GraphSheets API

GraphSheets holds financial models as typed graphs. This API lets a program read them, evaluate them, and propose changes, which land marked for the person's review. It is the API a connected app reaches; the machine-readable description is /api/v1/openapi.json, and the catalog is /.well-known/api-catalog.

Signing in

OAuth 2.1 with PKCE; clients register themselves (RFC 7591). The authorization server's metadata is at /.well-known/oauth-authorization-server. A person may instead make a personal access token on their profile page and send it as Authorization: Bearer gsp_….

Agent accounts

An agent may have an account of its own, which owns its models and pays for its work. It has no person behind it and no password:

  1. POST /api/v1/agents/register with its name, its purpose, jwks: one to three public keys (EdDSA, ES256 or RS256), each with a kid, and acceptTerms: true, accepting the Terms of Service, and at least one working way to tell it things (its account's warnings, key changes): an ownerEmail (the person who answers for it), an agentEmail (its own inbox) and/or a webhookUrl, which must answer a test POST with a 2xx at registration. Each email is mailed its own link; its token, POSTed to /api/v1/agents/register/verify, confirms it, so an agent confirms its own inbox itself. The two are told different things, and only the owner's is shown on its profile. A confirmed email lets it join an organisation that claimed its domain. The answer gives the clientId it will sign in as, a statusUrl and the tokenEndpoint.
  2. Once it can be reached (a checked webhook, or the email confirmed), an administrator approves or declines — or it is approved then and there, where this workspace needs no approval for agents. GET the statusUrl to see which: awaiting_approval, approved, declined, suspended (by the administrator) or blocked (by its owner), with reachable.
  3. Once approved, it signs in at the token endpoint with grant_type=client_credentials, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion: A JWT signed with one of your registered keys (EdDSA, ES256 or RS256), its kid the key's: iss and sub your clientId, aud the tokenEndpoint, exp at most 300 seconds after iat, and a jti never used before. An access token for an hour, and no refresh token: sign a fresh assertion when it lapses. The authorization server's metadata does not list private_key_jwt, so that apps signing in for a person do not choose it.
  4. To replace its keys, PUT /api/v1/me/keys with the new jwks and an assertion signed with a key it holds now, its aud that route's address; a token alone is refused. Its owner, its own inbox and its webhook are told. GET /api/v1/me/keys lists the keys it holds.

An agent account has no free allowance: every model call, request and stored byte is paid from its credit, at GET /api/v1/me/credit. Where the server takes in-band payment, POST /api/v1/billing/topup answers 402 with an MPP or x402 challenge; by card, POST /api/v1/billing/checkout answers a Stripe Checkout url for whoever pays for the account to open, and the credit lands when Stripe confirms the payment.

Building a model

POST /api/v1/models with title, granularity (month, quarter, half, year or week), start (the first period's label: 2027-01, 2027-Q1, 2027-H1, 2027 or 2027-W01) and periods makes an empty model. Its contents arrive as change-sets: POST /api/v1/models/{uid}/changesets with the model's baseHash, a message, and ops. Each op states what it expects to find, so the order matters. For example:

{"op": "group.add", "group": {"path": "plan", "title": "Plan"}}
{"op": "node.add", "node": {"uid": "node_cups0001", "name": "plan.cups", "title": "Cups sold", "kind": "input",
  "unit": "cups", "inputValues": {"base": [18000, 21000, 24000]}}}
{"op": "node.add", "node": {"uid": "node_rev00001", "name": "plan.revenue", "title": "Revenue", "kind": "calculated",
  "unit": "USD", "formula": "plan.cups * plan.price"}}
{"op": "node.set_formula", "uid": "node_rev00001", "from": "plan.cups * plan.price", "to": "plan.cups * plan.price * 0.98"}
{"op": "values.set", "uid": "node_cups0001", "scenario": "base", "changes": [{"period": "2027", "from": 18000, "to": 19000}]}

The operations are: node.add, node.remove, node.rename, node.set_formula, node.set_kind, node.set_meta, values.set, actuals.set, constant.set, uncertainty.set, statistic.rename, group.add, group.remove, group.rename, group.set_meta, scenario.add, scenario.remove, scenario.rename, scenario.set_meta, ledger.add, ledger.remove, ledger.set_categories, ledger.splice_categories, ledger.set_date_field, ledger.set_span_field, ledger.set_union, entry.add, entry.remove, entry.set, model.set_meta, presentation.set, axis.migrate, unknown.set. A node's fields, and a whole model document (which POST /api/v1/models also takes, as document), are described by the JSON Schema at /api/v1/schemas/model.json.

MCP

The same API is served as an MCP server at https://mcp.graphsheets.ai/mcp, signed in the same way. Its server card is at https://mcp.graphsheets.ai/mcp/server-card, listed in the AI Catalog at /.well-known/ai-catalog.json.

A2A

The same connection answers the Agent2Agent protocol (1.0, JSON-RPC) at https://mcp.graphsheets.ai/a2a; its agent card is at /.well-known/agent-card.json. Each skill is one of the connector's read-only tools, asked for with a data part {"skill": "read-values", "arguments": {…}} and answered at once with a message.

Routes

MethodPathWhat it doesInputs (* required)Scope
GET/api/v1/meThe person the token acts for—models:read
GET/api/v1/modelsThe models the person can open?archived ?scopemodels:read
GET/api/v1/models/{uid}A model's document at its head?version ?structuremodels:read
GET/api/v1/models/{uid}/bundleA model as a bundle of files?versionmodels:read
GET/api/v1/models/{uid}/searchFind rows in a model by name or title?qmodels:read
GET/api/v1/models/{uid}/graphA model's dependency graph—models:read
GET/api/v1/models/{uid}/historyA model's versions, newest first?limit ?authorType ?node ?reviewmodels:read
GET/api/v1/models/{uid}/diffThe change between two versions of a model?from* ?to*models:read
GET/api/v1/models/{uid}/valuesEvaluated values of a model's rows?scenario ?nodes ?from ?to ?versionmodels:read
GET/api/v1/models/{uid}/explainHow a value is worked out, step by step?node* ?period* ?scenario ?depthmodels:read
POST/api/v1/models/{uid}/verifyRun a model's checks and lintJSON body (optional): opsmodels:read
POST/api/v1/models/{uid}/sensitivityHow outputs move with inputsJSON body: overrides* scenario outputs ops basemodels:read
POST/api/v1/models/{uid}/riskA Monte Carlo run over a model's uncertain inputs?scenario ?version
JSON body (optional): ops outputs
models:read
GET/api/v1/compareCompare two models?left* ?right*models:read
GET/api/v1/changesets/{id}A change-set and its review?rebasemodels:read
GET/api/v1/models/{uid}/agent-draftThe connection's working draft of a model—models:read
GET/api/v1/models/{uid}/private-scenariosThe person's own private scenarios on a model—models:read
PUT/api/v1/models/{uid}/private-scenarios/{name}Keep one of the person's private scenariosJSON body: scenario* inputs* constants*models:read
DELETE/api/v1/models/{uid}/private-scenarios/{name}Delete one of the person's private scenarios—models:read
POST/api/v1/modelsCreate an empty modelJSON body: title description granularity start periods document bundle policy asCopymodels:write
POST/api/v1/models/{uid}/cloneClone a model?version
JSON body: title*
models:write
POST/api/v1/models/{uid}/scenarios/forkFork a scenarioJSON body: name* from titlemodels:write
POST/api/v1/models/{uid}/changesetsPropose a change-set, landed marked for reviewJSON body: baseHash* message* ops* lintReasons suggested requestReview agent supersedes partsmodels:write
POST/api/v1/changesets/{id}/applyApply a change-set—models:write
POST/api/v1/changesets/{id}/reviewApprove or reject the connection's own proposalJSON body: decision* commentchanges:approve
PUT/api/v1/models/{uid}/agent-draftSave the connection's working draftJSON body: baseHash* ops* revision*models:write
DELETE/api/v1/models/{uid}/agent-draftDiscard the connection's working draft—models:write
GET/api/v1/market-data/providersThe market-data providers—data:fetch
GET/api/v1/market-data/searchSearch for a priced symbol?provider* ?qdata:fetch
GET/api/v1/market-data/historyA symbol's price history?provider* ?symbol* ?from* ?to*data:fetch
GET/api/v1/financials/providersThe company-filings providers—data:fetch
GET/api/v1/financials/searchSearch for a reporting company?provider* ?qdata:fetch
GET/api/v1/financials/factsA company's reported statement lines?provider* ?symbol* ?metric* ?frame* ?from* ?to* ?deriveQ4 ?deriveH2data:fetch
GET/api/v1/me/creditThe account's prepaid credit: balance, held, storage reserve, free—models:read
POST/api/v1/billing/topupTop up the account's credit in-band: answers 402 with an MPP/x402 challenge, then settles a credential sent in Payment-AuthorizationJSON body: dollars*models:read
GET/api/v1/models/{uid}/sharesWho a model is shared with (agent accounts)—models:read
POST/api/v1/models/{uid}/sharesShare a model with a person, by address (agent accounts)JSON body: email* rolemodels:write
DELETE/api/v1/models/{uid}/shares/{userId}Stop sharing a model with a person (agent accounts)—models:write
PATCH/api/v1/models/{uid}/ownerHand a model over to another account (agent accounts)JSON body: email*models:write
PATCH/api/v1/models/{uid}/organisationMove a model into one of your organisations, or out (agent accounts)JSON body (optional): orgId share restrictedmodels:write
PATCH/api/v1/models/{uid}Archive or unarchive a model (agent accounts)JSON body: archived*models:write
DELETE/api/v1/models/{uid}Delete a model (restorable for thirty days) (agent accounts)—models:write
GET/api/v1/orgsYour organisations (agent accounts)—models:read
GET/api/v1/orgs/{id}An organisation and its members (agent accounts)—models:read
PUT/api/v1/orgs/defaultChoose which organisation new models go to (agent accounts)JSON body: orgId*models:write
POST/api/v1/orgsStart an organisation (agent accounts)JSON body: name*models:write
PATCH/api/v1/orgs/{id}Rename an organisation or change its settings (agent accounts)JSON body (optional): name restrictByDefault memberInvitesNeedApproval agentJoinsNeedApprovalmodels:write
POST/api/v1/orgs/{id}/membersInvite somebody to an organisation (a member's invitation may wait for an administrator) (agent accounts)JSON body: email* rolemodels:write
PATCH/api/v1/orgs/{id}/members/{userId}Change a member's role (agent accounts)JSON body: role*models:write
DELETE/api/v1/orgs/{id}/members/{userId}Remove a member, or leave (agent accounts)—models:write
DELETE/api/v1/orgs/{id}/invitations/{email}Withdraw an organisation's invitation (agent accounts)—models:write
PUT/api/v1/orgs/{id}/house-styleSet an organisation's house style for documents (agent accounts)JSON body: houseStyle*models:write
POST/api/v1/orgs/{id}/joinJoin an organisation that claimed your verified contact address's domain; 202 when the join waits for an administrator (agent accounts)—models:write
POST/api/v1/billing/checkoutTop up by card: answers a Stripe Checkout url for whoever pays for the account to open; the credit lands when Stripe confirms the payment (agent accounts)JSON body: dollars* saveCard returnTo autoTopUpmodels:read
GET/api/v1/users/{id}/profileAn account's profile page, where its owner lets you see it: who it is and the models of theirs you may open (404 where withheld) (agent accounts)—models:read
PUT/api/v1/me/profileSet who may see your own profile page, and its picture (agent accounts)JSON body: visibility avatarmodels:write
GET/api/v1/me/keysThe public keys you sign in with (agent accounts)—models:read
PUT/api/v1/me/keysReplace your keys: the new JWK Set, and an assertion signed with a key you hold now, its aud this route's address. Your owner, your own inbox and your webhook are told (agent accounts)JSON body: jwks* assertion*models:write
POST/api/v1/agents/registerAsk for an agent account: its name, its purpose, its public keys, acceptTerms: true, and at least one of ownerEmail (who answers for it), agentEmail (its own inbox) or webhookUrl. Answers the client id it will sign in as, where to ask how the request stands, and approved where the workspace needs no approval for agents and it can already be reachedJSON body: name* acceptTerms* purpose* jwks* ownerEmail agentEmail contactEmail webhookUrlnone
GET/api/v1/agents/registrations/{id}Where an agent's request stands: awaiting_approval, approved, declined, suspended (by the administrator) or blocked (by its owner); whether a contact it gave has confirmed, and whether it can be reached yet (approval waits for that)—none
GET/api/v1/accessWho may come in without an administrator's approval: people and agents, each open (a verified address, or a reachable agent account, is admitted at once) or approval (an invitation, or the administrator's yes)—none
GET/api/v1/schemas/model.jsonThe model document's JSON Schema—none
GET/api/v1/billing/pricesThe price list: model list prices per million tokens and the premium discount on them, model markups by payer, the engine price list (per call, per 10,000 risk trials, per export, per data pull) and what is never metered, the people's free allowance, their starter credit and free first Builder turn, and the trial credit—none

Errors answer {"error": {"code", "message", "details"}}. Health: /api/v1/health.