Agent sessions

An agent session lets an AI agent act for a person in one account.

Docs menu

The person chooses which apps and capabilities it can use and whether it can write. Each agent call is logged for the account. Calls that cannot be undone require a person's approval. Any account member can end a session immediately.

The same session works with Claude, ChatGPT, Grok, Codex, a curl script, or another MCP client. There is no per-vendor plugin or extension. Send the credential as Authorization: Bearer <secret> over HTTP or /mcp.

Getting a session

There are four ways to obtain a session:

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_... (POST /v1/_accounts/{id}/agent/enrollments; the console makes no codes: its Agents page connects an agent over MCP with OAuth, the row above). 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.
Signing in by email. The agent asks for a sign-in (agent.login.begin), the person approves it on a page with the code from their email, and the agent gets a refreshable session. See Signing in by email. substrio login --email does all of it. The CLI, and any agent that runs on the person's machine or can ask them for a code.

Redeeming a connect code

The code works once within agent_enroll_ttl_seconds. Redeeming it creates a bearer session unless the request specifies kind or sends a public_key (see Kinds). The response contains the session (id, kind, expires_at, the grant, and key or keys, shown once) and a next line explaining how to send the credential and how long it lasts. Unknown, used, and expired codes all return 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-mcp 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).

Signing in by email: agent.login

An agent without a credential requests sign-in, and the person approves it in their browser. They enter the code from their email once. The code signs the browser in to the dashboard and gives the agent a session. A new address receives an account and a free plan at the same time.

substrio login --email [email protected] runs the whole flow. When the browser does not finish, it exits with code 75 and the agent asks the person for the code, then runs substrio login --code <code>.

The flow

  1. The agent begins. agent.login.begin {email, agent_name?, loopback_port?} answers intent_id, intent_secret, match_code, url, dashboard_url, and expires_at. Nothing is mailed yet. The agent keeps intent_secret to itself and shows the person match_code and url. A CLI on the person's own machine first listens on 127.0.0.1 and passes that port as loopback_port.
  2. The person opens the page. url is https://substrio.com/start?intent=<intent_id>. The page reads agent.login.show {intent_id}: the address masked, the agent's name, the match code, the site key, and whether the code was mailed. The person checks that the match code is the one the agent showed.
  3. The bot check mails the code. agent.login.challenge {intent_id, turnstile_token} checks the token with Cloudflare, then mails the code through the identity app's own code request. A request mails once. A second challenge answers as the first did and sends nothing.
  4. The browser finishes. agent.login.finish {intent_id, code}, with no secret, signs the browser in through the identity app's auth.email.code_verify. A person with two-factor authentication completes it on the page (auth.mfa), then the page calls finish again with their new session. When the request names a loopback_port, the answer also carries a one-time grant and grant_expires_at.
  5. The agent gets its session. There are two ways:
    • Same machine. The page sends the browser to http://127.0.0.1:<loopback_port>/done?intent=<intent_id>&grant=<grant>. The CLI answers with a redirect to the dashboard and calls agent.login.finish {intent_id, intent_secret, grant}.
    • Any other case. The page tells the person to give the agent the same code if it asks. The agent calls agent.login.finish {intent_id, intent_secret, code}.

Before the redirect, the page probes http://127.0.0.1:<loopback_port>/alive with a one-second timeout. The CLI answers the browser's Private Network Access preflight for the platform origin alone (Access-Control-Allow-Origin the exact origin and Access-Control-Allow-Private-Network: true). If the probe fails, the page signs the browser in, opens the dashboard button, and tells the person to give the agent the same code if it asks.

The dashboard the CLI sends the browser to is the platform setting agent_login_dashboard_url, read from the request. It never comes from the query.

One code, two uses

The code can be used once by the browser and once by the agent, in either order. Each use is recorded, and a third returns LOGIN_CODE_SPENT. A grant permits one exchange within 60 seconds and requires the request's intent_secret. If the CLI never receives the grant, the agent can still use the code.

When the agent uses the code before the browser does, the browser's later finish answers agent_first: true. The page then tells the person that their agent is signed in and can open the dashboard for them (substrio open --dashboard).

The session

The agent's session is a bearer session of the client substrio-cli, in the person's personal account. Its answer is agent.signup.finish's answer: account, app_defaults, next_steps, and session with key, refresh_token, and secret_expires_at. The status is 201 when the address got its account just now, and 200 when it had one.

  • The access secret lasts agent_login_access_seconds (default 3600).
  • agent.login.refresh {refresh_token} swaps the pair. It answers access_token, refresh_token, access_expires_at, and refresh_expires_at. The old refresh token is spent. For agent_refresh_retry_seconds (default 60) after the swap it answers the same pair again, so a client whose answer was lost can retry. After that it is refused, like every older token.
  • The session lasts agent_login_refresh_days (default 30), counted again from each refresh.
  • agent.session.revoke ends it at once, like any session.

Operations

Operation Route Credential
agent.login.begin POST /v1/_agent/login/begin none
agent.login.show POST /v1/_agent/login/show none
agent.login.challenge POST /v1/_agent/login/challenge none, with a Turnstile token
agent.login.finish POST /v1/_agent/login/finish none, or the person's session after two-factor authentication
agent.login.refresh POST /v1/_agent/login/refresh the refresh token in the body
auth.turnstile GET /v1/_auth/turnstile none: answers site_key and gate (on, closed, or off)

Every one runs on the platform host. None names an app.

Limits

  • One address may begin agent_login_ip_per_hour requests in an hour (default 10), and one network (an IPv4 /24 or an IPv6 /48) may begin agent_login_net_per_hour (default 30). Past either one, begin answers 429 RATE_LIMIT_EXCEEDED with Retry-After.
  • The platform host mails at most auth_code_mails_per_hour codes in an hour to people who are not signed in (default 1000), counting every caller.
  • A request lives agent_login_seconds (default 900).
  • A request takes five wrong codes. After that, it is refused and the agent begins again.
  • Every code mail on the platform host passes the bot check first. See The bot check on code mails.

Refusals

Code Status When What the agent does
TURNSTILE_REQUIRED 403 the challenge carried no token, or the gate is closed the page shows the check and sends its token
TURNSTILE_FAILED 403 Cloudflare refused the token, or it was made on another page or host the page resets the check and the person tries once more
TURNSTILE_UNREACHABLE 503 Cloudflare could not be reached or refused the platform secret try again in a moment
LOGIN_INTENT_UNKNOWN 404 no such request, or the intent_secret is wrong begin again
LOGIN_INTENT_EXPIRED 410 the request is older than agent_login_seconds begin again: substrio login --email
LOGIN_INTENT_NOT_CHALLENGED 409 finish came before the code was mailed the person opens url and passes the check
LOGIN_CODE_SPENT 409 the code served the browser and the agent already, or the agent twice nothing: the agent is signed in, or it begins again
LOGIN_GRANT_INVALID 404 the grant is not the one this request minted, or it was used ask the person for the code and finish with it
LOGIN_GRANT_EXPIRED 410 the grant is older than 60 seconds ask the person for the code and finish with it
LOGIN_REFRESH_REFUSED 401 the refresh token is spent (replaced more than agent_refresh_retry_seconds ago), expired, or revoked sign in again

A wrong code is 400 INVALID_ARGUMENT with reason WRONG and attempts_remaining, as auth.email.code_verify answers it.

Example

# The agent begins, listening on 127.0.0.1:49152.
curl -s https://mcp.substrio.com/v1/_agent/login/begin -H 'content-type: application/json' \
  -d '{"email":"[email protected]","agent_name":"laptop","loopback_port":49152}'
# {"intent_id":"5f0c...","intent_secret":"...","match_code":"K7Q-3MZ",
#  "url":"https://substrio.com/start?intent=5f0c...","dashboard_url":"https://substrio.com/app/","expires_at":1793000900}

# The person opens url, checks K7Q-3MZ, passes the check, and types the code. Their browser is signed in,
# and it opens http://127.0.0.1:49152/done?intent=5f0c...&grant=... on the agent's machine.

# The agent exchanges the grant.
curl -s https://mcp.substrio.com/v1/_agent/login/finish -H 'content-type: application/json' \
  -d '{"intent_id":"5f0c...","intent_secret":"...","grant":"..."}'
# {"account_id":7,...,"session":{"key":"substrio_ab_...","refresh_token":"...","secret_expires_at":1793004500}}

# If the browser never reached it, the agent asks the person for the code and finishes with that instead.
curl -s https://mcp.substrio.com/v1/_agent/login/finish -H 'content-type: application/json' \
  -d '{"intent_id":"5f0c...","intent_secret":"...","code":"482913"}'

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, and all of them leave the session list.
  • 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, such as collection.delete, account.app.delete, or a domain attach, does not run immediately. A person in the account approves or denies it. After approval, the agent 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 configured Substrio 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. An approval can authorize one call. It is marked spent before execution. A repeated request cannot use that approval to run the call again.

An approval is bound to one call by one session. It covers the operation, app, route, query, and body identifying the target. Another call requires a separate approval. An approval expires after agent_approval_ttl_seconds (600 by default). Until then, a repeated spent or denied call is refused without asking the person again. After expiry, the same call requests approval again.

A call whose target does not exist opens no approval. The target is resolved first, and the call returns the same missing-target response it would without approval. Nothing is added to pending approvals. Caller refusals, such as no standing in the account or a key where a person is required, also return before approval. A pending approval is found as before; the approved call resolves its target once when it runs.

Operation Target Answer when it is not there
account.app.delete the account's app TENANT_NOT_FOUND (404), naming the slug
account.member.remove the account's member ACCOUNT_MEMBER_NOT_FOUND (404)
account.key.revoke the account's key ACCOUNT_KEY_NOT_FOUND (404)
agent.session.revoke an agent session the caller sees 404 "No such agent session"
featured.disable the featured app FEATURED_APP_NOT_FOUND (404)
subscriptions.cancel the person's subscription SUBSCRIPTION_NOT_FOUND (404)
collection.delete the collection 404
namespace.delete the collection's namespace 404
secret.delete the app's secret SECRET_NOT_FOUND (404, field key)
user.delete the app's person 404 (a person already erased softly can still be erased for good)
file.delete the file FILE_NOT_FOUND (404)
tenant.domain.detach a domain attached to the app 404
tenant.key.revoke an earlier key of the app TENANT_KEY_NOT_FOUND (404); the current key is refused (400)
backup.restore, backup.clone_into the app's backup (and the app it goes into) FILE_NOT_FOUND (404); a backup still being made is not refused

schema.delete is not in the table: deleting a schema that is not there already answers success, so there is nothing to refuse.

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 configured by the server operator. 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) return pages oldest first. A member sees their agents' sessions and approvals; an admin or owner sees the account's. Both require a person signed in as themselves. An account key cannot list either and receives 403 INSUFFICIENT_PERMISSIONS over HTTP and MCP.

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.

The session list holds live sessions only. A session leaves it the moment it ends, in the same commit as its end:

  • revoked (agent.session.revoke, or agent.session.revoke_all), with every session it delegated to;
  • idle past its limit, or past its absolute end (expires_at): platform upkeep ends it as expired on its next pass, even when no request used it. An OAuth connection's session has neither: it lasts until you or its client revokes it (docs/mcp.md, "OAuth on the platform host").

Each session also carries stale_refreshes and stale_refresh_at: for an OAuth connection, how many refresh tokens older than the one it replaced last were presented and refused (each for its one request; the connection kept working), and when the last came (seconds; 0 for none, and for every other kind of session). Tokens you did not expect there are a reason to revoke it. Every refused refresh is also a row on the session's log (operation oauth.token, outcome refused) with oauth_error, oauth_reason and oauth_client (docs/mcp.md, "OAuth on the platform host").

An ended session's call log is still read by its id (agent.session.log, kept for agent_log_retention_seconds), and a second agent.session.revoke of it answers 404.

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.