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).
- At most
- 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.revokeends 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/listshows 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-Keysof 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.
- The tool answers with the error
APPROVAL_REQUIRED. The answer carries:approval: the approval's id (also in theSubstrio-Approvalresponse 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 noapprove_url: the id alone, and a person approves over the API.
- 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). - 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-Approvalheader; params._meta["substrio/approval"]of thetools/call.
- 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
- 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
sessionis that id and itsagentis 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-Authenticatewithresource_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 toolsubstrio_connecton that/mcpwith no credential.