Guides
Build a Multi-User Server on Alter
Front Alter with your own hosted service — many users, per-user identity, the credential never in your process.
Most integrations start as a single script with one grant. A multi-user server is the next shape: one hosted service (an API, an MCP server, a worker pool) that acts on behalf of many end users, each under their own provider credential. This guide is the broker pattern — how to resolve the right user’s grant per request and call as them, with the credential staying inside Alter.
By the end you have a request handler that: reads identity from the request, resolves the grant
that user delegated to your agent, and runs the provider call through proxy_request so the credential is injected server-side and never touches your process.
The model
Section titled “The model”end user ──(their JWT)──▶ your server (holds the agent key) ──▶ Alter ──▶ provider • your server holds NO provider credential; it reads user identity from the request • it acts AS your agent, on behalf of that user: agent.list_grants(end_user_token=<their jwt>) → the grant they delegated agent.proxy_request(grant_id=…, user_token=<their jwt>, …) • Alter injects the credential, runs the call, and audits agent_id + user_idTwo things make this safe and per-user:
- User identity comes from the authenticated request, never a model-controlled argument. The end user’s JWT arrives through the framework’s authentication path. The agent key stays in the server’s secret configuration; never accept it from a public request. A caller — or a prompt injection — must not be able to pick whose credential runs.
- The JWT is a lookup key, not authorization. A user only appears in
list_grants(end_user_token=…)because they previously delegated a grant to your agent (see Prerequisite). Runtime resolves the grant from the (agent, user) pair.
Prerequisite: each user delegates once
Section titled “Prerequisite: each user delegates once”Per-user identity means each user has their own credential and delegates it to your agent. For a managed secret that is a one-time, operator/App-side Connect step:
session = await app.create_managed_secret_connect_session( template_slug="stripe-api-key", delegated_agent_id=AGENT_ID, user_token=user_jwt, # the consenting user's IDP JWT)# Send the user to session.connect_url to consent. Afterwards your agent can# resolve their grant by their JWT.The agent can also ask for itself, with its own key and no agent id:
await agent.create_managed_secret_connect_session(template_slug="stripe-api-key", user_token=user_jwt).
The session is always for the calling agent. The two differ in one way: when the user holds nothing
that could be delegated to the agent, the App call raises ConnectConfigError with code
no_eligible_grants and creates no session, while the agent’s call creates the session anyway and
the Connect page tells the user why after they sign in, so an agent never learns what a user holds.
For OAuth, the equivalent is create_connect_session(..., agent=<agent-id>). Either way, until a
user has delegated, your agent resolves no grant for them — a clean “not delegated” error, not
someone else’s credential.
Only the user can approve, so the server holding the session token — and the user’s JWT — cannot
approve on their behalf. For a managed secret, the Connect page has the user sign in through the
app’s identity provider in that browser before it offers Approve. The identity provider therefore
needs its sign-in client configured (Approving Connect delegations);
without it, create_managed_secret_connect_session() raises ConnectConfigError with code
idp_signin_not_configured. For OAuth, connecting the account at the provider is the user’s
proof; only approving on an account they already connected asks for the identity-provider
sign-in, and without a sign-in client the session sends them through the provider instead.
Deliver connect_url to the user’s own browser — a link or popup on the web, and the system
browser (never an embedded WebView) in a native app.
Per-request handler
Section titled “Per-request handler”Resolve the user’s grant from their JWT, then call as them. Pass both grant_id and
user_token: the pair is what the backend authorizes and records on-behalf-of, so a grant can’t
be exercised as the wrong user.
from alter_sdk import Agent, HttpMethod, PendingApproval
async def handle(agent: Agent, user_jwt: str, body: dict): # 1. Resolve the grant THIS user delegated to THIS agent (scoped by their JWT). # Skip terminal-status grants and page through if the user has many. grant_id = None offset = 0 while grant_id is None: page = await agent.list_grants(end_user_token=user_jwt, status="active", offset=offset) for g in page.grants: # A user-DELEGATED managed-secret grant only. An agent-OWNED grant is # returned for every user (it has no end-user dimension), so picking it # would run one shared credential for all and defeat per-user identity. if getattr(g, "grant_kind", None) != "managed_secret": continue if getattr(g, "access_via", None) != "ms_delegation": continue if g.managed_secret_slug.startswith("stripe"): grant_id = g.grant_id break if grant_id or not page.has_more: break offset += len(page.grants) if grant_id is None: return {"error": "not_delegated"} # the user hasn't delegated — never a fallback
# 2. Call as them. The credential is injected in Alter's backend, not here. result = await agent.proxy_request( HttpMethod.POST, "https://api.stripe.com/v1/refunds", grant_id=grant_id, user_token=user_jwt, json=body, context={"tool": "refund"}, ) if isinstance(result, PendingApproval): return { "status": "approval_required", "approval_id": str(result.approval_id), "urls": [gate.approval_url for gate in result.gates], } return {"status_code": result.status_code, "body": result.body_json()}import { Agent, HttpMethod, PendingApproval } from "@alter-ai/alter-sdk";
async function handle(agent: Agent, userJwt: string, body: Record<string, unknown>) { // 1. Resolve the grant THIS user delegated to THIS agent (scoped by their JWT). let grantId: string | undefined; let offset = 0; while (!grantId) { const page = await agent.listGrants({ endUserToken: userJwt, status: "active", offset }); // A user-DELEGATED managed-secret grant only — an agent-owned grant would // resolve for every user and defeat per-user identity. const g = page.grants.find( (x) => x.grantKind === "managed_secret" && x.accessVia === "ms_delegation" && x.managedSecretSlug.startsWith("stripe"), ); if (g) grantId = g.grantId; if (grantId || !page.hasMore) break; offset += page.grants.length; } if (!grantId) return { error: "not_delegated" };
// 2. Call as them. The credential is injected in Alter's backend, not here. const result = await agent.proxyRequest({ method: HttpMethod.POST, url: "https://api.stripe.com/v1/refunds", grantId, userToken: userJwt, json: body, context: { tool: "refund" }, }); if (result instanceof PendingApproval) { return { status: "approval_required", approvalId: result.approvalId, urls: result.gates.map((gate) => gate.approvalUrl), }; } return { statusCode: result.statusCode, body: result.bodyJson() };}What a hardened broker gets right
Section titled “What a hardened broker gets right”- Reuse the client for the server’s configured agent key. Constructing an
Agentsets up an HTTP client; create it during service startup and close it during shutdown. Do not construct clients from caller-supplied agent keys. - Resolve to user-DELEGATED grants only.
list_grants(end_user_token=…)returns the agent’s own managed-secret grants too (they have no end-user dimension, so the JWT doesn’t filter them) — select only grants withaccess_via == "ms_delegation", or an agent-owned credential runs for every user and per-user identity collapses. - Resolve to
"active"grants only, and treat multiple matches as ambiguous. Never let a revoked grant shadow a live one, and don’t silently pick the first of several — surface it and narrow byaccount/label. Seelist_grants. - Sanitize errors before returning them. Map the SDK’s typed exceptions to safe messages for the caller; don’t forward raw exception text.
- Handle HITL. A grant with an approval policy returns a
PendingApproval— surface every gate URL (N-of-N policies return more than one) and resolve out of band, never block the request. - The destination-host allowlist is enforced by Alter. Configure the hosts each managed secret may call; a call to an un-allowlisted host is blocked before injection.
Verify per-user isolation
Section titled “Verify per-user isolation”Prove the property that makes this a multi-user server, not a shared one: two delegated users
run under two different credentials, and one user’s grant is rejected when called as another. Run
alter verify --runtime for the calls, and assert two users resolve to two
different provider identities and that a cross-user call fails.
Related
Section titled “Related”- Calling APIs —
requestvsproxy_request, and every parameter. - Connect & grants —
list_grants, delegation sessions. - Errors — the exceptions to map to safe messages.
- Expose credentials to Claude Code — the MCP-server variant of this shape.