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.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.
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.