Agent sessions

An agent session lets an AI agent act for a person in one account. The person chooses what it may do: which apps, which capabilities, and whether it may write. Every call the agent makes is logged for the account. Calls that cannot be undone wait for a person to approve them. Any member of the account can end a session at once.

Docs menu

Any agent works the same way: Claude, ChatGPT, Grok, Codex, a curl script, or any MCP client. There is no plugin, extension or per-vendor setup. The credential is one string, sent as Authorization: Bearer <secret>, over plain HTTP or over /mcp.

Getting a session

There are three ways:

How Who uses it
OAuth on the platform host. The client finds the server from the /mcp 401 and registers itself. The person signs in, picks what to grant, and the client gets a short access token with a refresh token. See mcp.md. Chat apps (ChatGPT, Claude) through the Substrio connector, and MCP clients that sign in through a browser. No code is needed.
A connect code. A member makes a one-time code, substrio_ae_... (the console's Agents page, "Connect an agent", or POST /v1/_accounts/{id}/agent/enrollments). The agent redeems it once: the MCP tool substrio_connect, POST /v1/_agent/exchange, or substrio connect. Coding agents and scripts (Claude Code, Codex, Cursor, curl) a person sets up by hand.
Delegation. An agent with a session makes a narrower child session for a sub-agent (agent.session.delegate). Agents that start other agents.

Redeeming a connect code

The code works once, for agent_enroll_ttl_seconds. Redeeming it makes a bearer session unless the request names a kind or sends a public_key (see Kinds). The answer is the session (id, kind, expires_at, the grant, key or keys, shown once) and next: one line that says how to send the credential and how long it lasts. Unknown, used and expired codes answer alike: AGENT_CODE_INVALID.

A chat app (ChatGPT, Claude). Add the Substrio connector with https://mcp.substrio.com/mcp and sign in. The connector is already a session; a code given to it is not needed.

Over MCP, with no key. Every /mcp (the platform host's and each app's) runs one tool for a request with no credential, or with one that proves nothing (an ended session): substrio_connect. It is in no tools/list, and initialize and tools/list without a credential stay the OAuth 401. A caller that is already connected gets AGENT_ALREADY_CONNECTED, and its code stays unused.

POST https://mcp.substrio.com/mcp
Content-Type: application/json

{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
 "params": {"name": "substrio_connect", "arguments": {"code": "substrio_ae_..."}}}

The tool result holds the same answer as the HTTP call below.

Over HTTP.

curl -X POST https://mcp.substrio.com/v1/_agent/exchange \
  -H 'Content-Type: application/json' -d '{"code": "substrio_ae_..."}'
{"id": "...", "kind": "bearer", "expires_at": 1793170000, "account_id": 7, "user_id": 3, "role": "owner",
 "apps": [], "capabilities": [...], ..., "key": "substrio_ab_...",
 "next": "Keep key, shown only once. Send Authorization: Bearer <key> on every request to this host over plain HTTPS (/v1/...; the guide is /llms.txt), or connect this host's /mcp with that header. ..."}

With the CLI. substrio connect redeems the code (read from stdin with -, or as the argument), saves the session as profile agent (or --profile NAME) in the owner-only profiles file, and prints the MCP setup. It never prints the code or the secret, except with --show: stdout then holds the secret alone, so one line redeems a code into Claude Code:

claude mcp add --transport http substrio https://mcp.substrio.com/mcp \
  --header "Authorization: Bearer $(npx @substrio/cli connect substrio_ae_... --show)"
Code Status When What to do
AGENT_CODE_INVALID 401 the code is unknown, already used or past its life ask your owner for a new code
AGENT_ALREADY_CONNECTED 409 substrio_connect was called on a connection that already acts for your owner keep using that connection; the code was not used

An agent with no key at all signs its owner up or in by a mailed code (agent.signup.start, agent.signup.finish; see agents.md). The account it makes holds 500 MB until its owner signs in once with Google, GitHub or another provider; the agent's console link never opens it (authentication.md).

Kinds

Kind What the agent holds When to use it
bearer (default) One static secret, substrio_ab_... Any client. Short-lived and revocable at once.
ratcheted A key that changes on every request. Each reply carries the next key in Substrio-Next-Keys. Hardening for an agent that can keep the latest key.
key_bound A key pair. Each request carries a proof signed by the agent's private key (RFC 9449 style). Hardening for an agent that can sign requests.
link A link, https://<host>/v1/_agent/link/substrio_al_...: the secret is in the URL's path. An agent that can only open links (ChatGPT on the web). Made only by opening GET /v1/_agent/go/<code> (agents that can only open links); lives as a bearer session does.

Ask for a kind with "kind" on exchange or delegate. If you send a public_key and no kind, you get a key_bound session. Otherwise the session is bearer. link is not asked for: an exchange or delegate that names it is INVALID_ARGUMENT (field kind).

Bearer sessions

  • The credential: substrio_ab_ followed by the session id and a 32-byte random secret, 76 characters in all. It is shown once.
  • What is stored: only the secret's SHA-256 digest.
  • How long it lasts:
    • At most agent_bearer_max_seconds (default 3600) from when it was made.
    • It also ends after agent_bearer_idle_seconds (default 900) with no request.
    • Both are platform settings.
    • A session made by OAuth on the platform host keeps the OAuth lifetimes instead, and its secret is replaced on each refresh (substrio_ar_..., stored as a digest on the same record; see mcp.md).
  • What it may do: the same grant and limits as any agent session:
    • the account, and its apps (or all of them);
    • its capabilities;
    • the member's role, at most what it was granted;
    • a request rate and a request budget.
  • Revoking: agent.session.revoke ends the session at once. Its children end with it.
  • A wrong secret is refused like no key at all. It also spends one of the session's rate tokens, so guessing runs no faster than the session's own rate.

Using a session on /mcp

Both the platform host's /mcp and an app's /mcp accept an agent session.

  • The credential is checked once per HTTP request. The tool's grant is checked on each tools/call.
  • tools/list shows only the tools the grant allows.
  • A tool outside the grant is refused. So is an app the session does not reach.
  • A ratcheted session's next key comes back in the HTTP header Substrio-Next-Keys of the MCP reply, whatever the JSON-RPC message says. This includes refusals.

Approvals

A call that cannot be undone (an Irreversible operation, such as collection.delete, account.app.delete or a domain attach) does not run at first. A person of the account decides it, and the agent then repeats the call.

  1. The tool answers with the error APPROVAL_REQUIRED. The answer carries:
    • approval: the approval's id (also in the Substrio-Approval response header on HTTP);
    • approve_url: a page where the person approves or denies it, at the platform's configured origin (agent_approval_origin, below). With no origin configured, there is no approve_url: the id alone, and a person approves over the API.
  2. The agent shows the person approve_url. The person opens it, signs in, sees which agent asks, the tool, the app and a short form of the call, and approves or denies it. A person can also decide it over the API: POST /v1/_accounts/{id}/agent/approvals/{approval}/approve (or /deny).
  3. The agent repeats the same call. It may name the approval, or not:
    • nothing at all: the call finds its own approval, because the id is made from the session and the exact call. This is what a chat client (the ChatGPT connector) does, since it sends no header and no _meta;
    • the Substrio-Approval header;
    • params._meta["substrio/approval"] of the tools/call.
  4. The approved call runs exactly once. The approval is marked spent before the call runs, so a crash at that moment leaves the call unrun, never run twice.

An approval names one call by one session: the operation, the app and every part of the request that names its target (the route, the query, the body). Any other call asks for an approval of its own. An approval lapses after agent_approval_ttl_seconds (600 by default). Until then, a spent or denied call stays refused when it is repeated: a person is not asked again, and a client that retries does not run the call twice. After it lapses, the same call asks again.

A call whose target is not there opens no approval. account.app.delete resolves its app before an approval opens: a slug the account has no app by (none at all, or another account's) answers what the same call answers with no approval needed, TENANT_NOT_FOUND (404) naming the slug, and nothing is added to the approvals waiting. A refusal of the caller itself (no standing in the account) is answered the same way, before any approval. An approval already waiting is found as before, and its approved call resolves the app once, when it runs.

Agent keys

An account key a person makes (at substrio.com/keys, or with account.key.create) and hands to an agent meets the same approvals. A key's kind is agent unless the person made it automation (the API reference, "Account keys"); every key made before kinds existed is an agent key. An unscoped agent key's Irreversible call, on HTTP or on /mcp, in a batch or as a destructive SQL statement, answers APPROVAL_REQUIRED with approval and approve_url, and runs once after a person approves it, exactly as an agent session's call does. These differ:

  • The key asks under its own id, made from the key itself: its approvals are its own, apart from every other key's and every session's. On the approval list its session is that id and its agent is empty.
  • The key acts as no member, so only an admin or owner of the account decides its approvals; a developer member cannot.
  • A key presents nothing: it repeats the same call, and the call finds its own approval.

An automation key's calls run at once. Only a person signed in as themselves makes one: a key, an agent session or the system key asking for it is refused 403 KEY_AUTOMATION_NEEDS_PERSON at once, before any approval opens (no person could approve it). A key scoped to apps is a customer of each and reaches no control operation, so it asks for nothing.

The approval origin

agent_approval_origin is a platform setting: in substrio.conf, or set on a running server with platform.setting.set (PUT /v1/_platform/settings/agent_approval_origin). Its value is empty, or a web origin: https:// or http://, a host, an optional :port, and no path, such as https://mcp.substrio.com (255 bytes at most). Anything else is refused: 400 INVALID_ARGUMENT. The link is always built from this setting, never from the Host a request came in with, so a forged Host cannot point a person at another site.

agent_approval_origin = https://mcp.substrio.com

The approval page

approve_url is <agent_approval_origin>/oauth/consent?approval=<id>&account=<account>. The page is served by the platform beside the OAuth consent page, with the same sign-in (password, then the authenticator code when the person has one) and the same lockdown (its script by hash, no framing). The person signs in at the identity app. Then the page reads the account's waiting approvals (GET /v1/_accounts/{id}/agent/approvals) and decides the one in the link. Only a person signed in as themselves decides: an admin or owner of the account, or the member the agent acts as. An agent session, an agent token or an account key never can.

agent.approval.list names, for each waiting approval:

Field What it is
id the approval id
session the agent session that asks
agent that session's name (empty once the session ended)
operation the operation, such as collection.delete
app the app the call names (absent for an account call)
summary a short form of the call (at most 240 bytes, ... when cut); the approval binds the whole call
reason why it needs a person, such as container_destroyed
state pending
created_at, expires_at when it was asked for, and when it lapses (seconds)

Refusals

Every refusal is a 403 with its own code. Each one but APPROVAL_INVALID names the approval.

Code When What the agent does
APPROVAL_REQUIRED the call opened a new approval show the person approve_url, then repeat the call once they approve
APPROVAL_PENDING the call's approval is asked for and no one decided it yet the same: approve_url and the id are in the answer again
APPROVAL_DENIED a person denied the call stop; the repeat stays refused until the approval lapses
APPROVAL_SPENT the call already ran with its approval nothing: it ran once; an identical call can ask again after the approval lapses
APPROVAL_EXPIRED the presented approval lapsed before it was used repeat the call without the id to ask again
APPROVAL_MISMATCH the presented approval names another call or another session repeat the exact call that asked for it, or the call without an id to ask for its own
APPROVAL_INVALID the presented id is no approval repeat the call without it
KEY_AUTOMATION_NEEDS_PERSON account.key.create asked for kind automation and the caller is not a person signed in as themselves a person makes the automation key; an agent keeps its agent key

Example: a chat client

The ChatGPT connector, signed in by OAuth on mcp.substrio.com, deletes a collection. It sends only its bearer token.

{"jsonrpc":"2.0","id":7,"method":"tools/call",
 "params":{"name":"substrio_collection_delete","arguments":{"name":"orders"}}}

The tool's result is an error:

{"error":"This call is irreversible and a person must approve it: show the person approve_url, and once they approve, repeat the same call",
 "code":"APPROVAL_REQUIRED","status":403,"retryable":false,"next_action":"request_permission",
 "approval":"q3Xv0bS1o2C9mW4k8Zt7Ya",
 "approve_url":"https://mcp.substrio.com/oauth/consent?approval=q3Xv0bS1o2C9mW4k8Zt7Ya&account=12"}

The assistant says in the chat: "Deleting orders cannot be undone. Approve it here: https://mcp.substrio.com/oauth/consent?approval=...". The person opens the link, signs in and presses Approve. The page answers: "Approved. Go back to the chat and ask the agent to make the same call again: it runs once." The person says "done", and the assistant sends the identical tools/call again. The collection is deleted. If the assistant sends it a third time, it gets APPROVAL_SPENT, and nothing runs.

Listing sessions and approvals

agent.session.list (GET /v1/_accounts/{id}/agent/sessions, MCP substrio_agent_session_list) and agent.approval.list (GET /v1/_accounts/{id}/agent/approvals, MCP substrio_agent_approval_list) answer a page at a time, oldest first, in the one paging shape every list uses. A member sees the sessions and approvals of their own agents; an admin or owner sees the account's. Both are read by a person signed in as themselves: an account key (the key an agent holds) lists neither, and is answered 403 INSUFFICIENT_PERMISSIONS over HTTP and MCP alike, so the agents a person let in are never read by an agent.

Argument What it does
limit the most rows on the page, 1 to 4294967295; absent: every row left
cursor the next_cursor of the page before, sent back unchanged
app only that app's rows: its slug, as account.app.list names it

With app, a session is listed when its grant reaches the app: the grant names it, or names no app at all (then it reaches every app). An approval is listed when the call it waits for runs on that app; an account call (no app) is listed only without app. A decided approval leaves the list at once; one past its expires_at is not listed.

Each answer carries next_cursor: a string while more rows follow, null on the last page. Every page is read from the account's own list in key order, so a page of N costs one seek and N steps, wherever the cursor is.

GET /v1/_accounts/7/agent/sessions?app=acme-web&limit=2
  -> {"sessions":[{...},{...}],"next_cursor":"0000000069fd2c80a1b2..."}
GET /v1/_accounts/7/agent/sessions?app=acme-web&limit=2&cursor=0000000069fd2c80a1b2...
  -> {"sessions":[{...}],"next_cursor":null}

Over MCP, substrio_agent_session_list and substrio_agent_approval_list take the same limit, cursor and app.

Refusals are 400 INVALID_ARGUMENT, with the argument in field:

field When
limit 0, or past 4294967295
cursor not a next_cursor of an agent list (another kind of list's cursor, any other spelling); a cursor is a place in the list (a row's time and id), so it resumes under any app
app not an app of this account

Without a credential

A call to /mcp with no credential, or with a credential that is not valid, gets a 401. The reply carries three things:

  • the OAuth discovery header (WWW-Authenticate with resource_metadata);
  • error.data.sign_up: "/llms.txt", where an agent learns how to sign up;
  • error.data.connect, one line on redeeming a connect code from your owner: POST /v1/_agent/exchange {"code":"..."} on that host, or the tool substrio_connect on that /mcp with no credential.