Build a multi-user Claude Agent SDK app that acts as each of your users
Follow the official Agent SDK examples and every user's agent runs on one shared token, which is usually yours. Here is how to bind each tool call to the signed-in user instead, using AuthKit and Pipes relay.
A multi-user agent fails in a way no demo will show you. You put one Jira token in .env, wire it into a Claude Agent SDK tool, and the agent works, for you. Then the second user runs it, and it reads your issues, your pipeline, your Slack. Nothing throws, the run is green, and the audit trail says one service account did all of it.
This is not a hypothetical, and it is not a skill issue. GitGuardian's 2026 secrets report counted 24,008 unique secrets sitting in MCP-related config files on public GitHub, 2,117 of them live, and traced the pattern to tutorials that tell developers to put credentials in config. The Agent SDK's own documentation is part of that pattern: the custom tools page never mentions identity, multi-tenancy, or request scoping, and the MCP page demonstrates Authorization: Bearer ${process.env.API_TOKEN} before dismissing OAuth in a single sentence. Per-user credential vaults do exist in Anthropic's Managed Agents, but they inject into remote MCP servers and do nothing for the in-process tools you define with createSdkMcpServer.
So if you are building in-process tools for more than one person, the identity plumbing is yours to write. This post is about where it goes.
Where the identity has to live
Prompting will not fix this, because the problem sits below the model. There is exactly one design rule that matters, and it is structural rather than behavioral:
The acting user is a closure variable, never a tool parameter.
Build the tool surface per request, capture the user once when you build it, and expose no field in any input schema through which the model could name a different user. A prompt-injected model can then emit anything it likes and still cannot change whose access a call runs with, because there is nowhere to put the lie. Compare that to a userId argument on a tool, validated in the handler: now every handler is a place where the check can be forgotten, and the model is holding a knob it should never have been offered.
Everything else in this post is a consequence of that one rule.
What you will build
Five files, in this order. Each one is short, and the whole thing runs behind a single Next.js route.
app/api/agent/route.ts, to resolve who is asking from the AuthKit sessionlib/relay.ts, to call a provider as that user without holding their tokenlib/agent-tools.ts, to build the tool surface per request with the user closed overlib/agent.ts, to take away every tool the agent has no business holdinglib/audit.ts, to record what happened under the person's name
Step 1: Resolve the acting user server-side
withAuth() from @workos-inc/authkit-nextjs reads the user out of the AuthKit session. The return type is a discriminated union, UserInfo | NoUserInfo: signed out, you get { user: null } and nothing else, so accessToken and the rest are absent too. Passing ensureSignedIn: true redirects to sign-in instead of returning null. Session management comes from the AuthKit proxy, authkitProxy in proxy.ts on Next.js 16+ or authkitMiddleware in middleware.ts on 15 and earlier, and the library installs alongside the WorkOS Node SDK, which is a peer dependency. Note that withAuth() reads request headers the proxy sets, so it only works with the proxy active.
organizationId comes from the same call, but it is optional: it is populated from the org_id access token claim, so a session that was not authenticated under an organization simply does not have one. That matters more than it looks, because audit log events are organization-scoped. An agent run with no organization is a run you cannot write an audit trail for, so fail it closed rather than discovering the gap later in your logs:
user.id comes from the encrypted session cookie. It does not come from the request body, a query string, or anything the model produced earlier in the loop. That is the whole tenant boundary, so it is worth being boring about.
Step 2: Call the provider without holding the token
Each user's provider grant lives in their Pipes connected account, which handles the OAuth flow, credential storage, and refresh. Scopes are configured per provider integration rather than per request and are granted when the user connects, which makes that dashboard field the outer wall of everything the agent can ever do.
Pipes offers two ways to use that grant. The credential-vending endpoint hands your code the user's token; relay has WorkOS make the call and return the provider's response, so the token never enters your environment. The docs put it plainly: access tokens for trusted infrastructure and long-running syncs, relay for agents, sandboxes, and untrusted runtimes. An agent runtime reads untrusted text for a living, so relay it is.
I have written up relay's request and error surface in detail already, so here is only what you need to build the tool layer. Delegated access for AI agents covers why an agent should not hold the token at all, 402, not 401 covers the authorization-required response, and the no-session tutorial covers the full header and error inventory for a scheduled worker.
The short version: send your request to https://api.workos.com/relay with your WorkOS API key in Authorization, the target URL in X-Relay-URL, and the user in X-Relay-User. Relay accepts any HTTP method and passes it through. Add X-Relay-Organization when the connection is organization-scoped, and omit it when it is not. Every proxied response carries X-Relay-Upstream-Status, so its presence tells you the provider answered and its absence tells you relay did.
Three notes for production. A user who never connected, whose grant was revoked, or whose token can no longer be refreshed gets a 402 with relay_authorization_required, deliberately not a 401 or 403 so you cannot confuse it with a WorkOS auth failure or a passthrough. Treat authorization_url as optional: it is null for API key and client-credentials connections, and also null when a provider's OAuth configuration is incomplete. And split the rest of the error surface rather than collapsing it as this helper does, because the codes mean different things: relay_upstream_error (502) is worth a retry, relay_credential_error (502) is not, and relay_credential_invalid (400) is a different problem again.
Relay is in early access. Contact WorkOS support by email or Slack to enable it for your environment.
Step 3: Mint the tool surface per request
A custom tool in the Agent SDK is a name, a description, a Zod raw shape that types the handler's args, and an async handler. createSdkMcpServer wraps them into a server that runs in-process rather than as a subprocess, and the key you give it in mcpServers becomes the {server_name} segment of each fully qualified tool name, mcp__{server_name}__{tool_name}. Note that it is the mcpServers key that is used, not the server's own name field.
Build that server per request, with the acting user closed over:
That Slack validator is not defensive boilerplate. Slack answers missing_scope, not_in_channel, and invalid_auth with HTTP 200 and {"ok": false, "error": "..."}, so a handler that checks only response.ok tells the model the message posted, returns a green run, and writes an audit event for a Slack post that never happened. That is the exact failure this post opens with, reproduced by the framework rather than by a shared token. Check the body.
Three provider details worth their lines. Jira OAuth 2.0 (3LO) calls go through api.atlassian.com rather than your-domain.atlassian.net, using /ex/jira/{cloudid}/{api}, and you get the cloudid from /oauth/token/accessible-resources on that same host. The search endpoint is /rest/api/3/search/jql; the older /rest/api/3/search was fully retired in October 2025 and now returns 410, so treat any snippet using it as dead code. Pagination on the new endpoint uses nextPageToken rather than startAt, there is no total, and page size is advisory, so follow the token rather than trusting maxResults. HubSpot's search is a POST to the date-versioned object path, currently /crm/objects/2026-03/deals/search, capped at 200 results per page, returning dealname, amount, closedate, pipeline, and dealstage among its defaults.
readOnlyHint: true on the two read tools lets the runtime call them in parallel with other read-only tools, and it defaults to false. Returning isError: true lets you compose the message the model reads rather than surfacing a raw exception, so one user's revoked Slack grant degrades a run instead of ending it.
Step 4: Take away the tools it should not have
Scoping an agent to least privilege is not one setting. It is four, and they fail differently.
- The grant. Whatever scopes the provider integration requests at connect time is the ceiling. A read-only agent asking for write scopes has already lost the argument.
- The route. Relay only reaches a supported provider's allowlisted hosts, only over HTTPS, and it does not follow redirects. A confused tool cannot be talked into calling an arbitrary endpoint with someone's credential attached. Check the allowlist when you write a tool:
jiraresolves toapi.atlassian.comandhubspottoapi.hubapi.com, butslackisslack.comonly.api.slack.comis not allowlisted and returns400 relay_invalid_url. - Availability. Passing
tools: []in the query options removes every built-in, leaving only your MCP tools. An agent that reads Jira has no business holding Bash. - Permission. Tools named in
allowedToolsrun without a prompt. For a headless agent, pair the allowlist withpermissionMode: 'dontAsk', which denies anything unlisted outright instead of prompting a human who is not there.
Two things about that config are easy to get wrong. allowedTools stops mattering the moment you set permissionMode: 'bypassPermissions', which approves every tool including Bash, Write, and Edit no matter what your allowlist says. And a call auto-approved by an allow rule skips your canUseTool callback entirely, so a policy check placed there is silently bypassed for exactly the tools you pre-approved. If you need a check to run on every call, use a PreToolUse hook: hooks run first in the permission evaluation, and a hook deny holds even under bypassPermissions.
Pin your SDK version and test the availability layer rather than trusting a blog post about it, including this one. The distinction between which options remove a tool from Claude's context and which options merely gate approval has moved between versions, and it is load-bearing for the argument above. tools: [] removing built-ins is stable; the rest deserves a test in your own repo.
The fifth wall is not yours at all. Because each call carries the user's own credential, the provider enforces its own model on top of everything above. Atlassian is explicit about it: "the permissions held by the user an app is acting for always constrain the app, regardless of the app's scopes."
Step 5: Audit the person, not the service account
An agent that acts as fifteen people needs an answer to "who did this," and the answer should not be the name of your service account. WorkOS Audit Log events give you an actor and a target shape you control, which is more work than reading a claim out of a token response and worth it.
Configure the allowed event schemas before emitting anything, either in the dashboard or with createSchema(), or the call fails. The actor here is the person rather than the agent, because that is the question an auditor asks first, and the run ID rides along as both a target and a metadata field so one session can be reconstructed from the trail.
Pass the idempotency key explicitly. The raw API will derive one from the event content if you omit it, but @workos-inc/node fills the gap first with workos-node-${randomUUID()}, so a keyless retry with an identical payload creates a duplicate event. Two more limits worth knowing before you design your event shape: context.location is required, metadata takes at most 50 keys with values up to 500 characters and no nesting, and retention is 30 days by default, raisable to 365 per organization through the retention API. Actor and target objects also accept name and their own metadata, which is usually a better place for a human-readable label than the event metadata.
Optional: Give admins something to turn off
Per-user grants solve the wrong problem for an IT admin, who wants a lever over the whole company. Organization-scoped providers in Pipes let one organization override three things: whether a provider is enabled, the scopes it requests, and the OAuth credentials it uses. That is the difference between "our users can connect Jira" and "our users connect Jira through our OAuth app, read-only, and HubSpot is off."
Admins configure it through the drop-in Pipes Admin widget, gated by the widgets:pipes:manage permission, or through the management API:
One line for the onboarding runbook: for a provider set to use organization credentials, that organization's users cannot connect until the admin supplies a client ID and secret.
What this design does not fix
- Identity binding stops impersonation, not misuse. Scopes are granted at connect time, not per task, so an agent that gets confused still operates at the full width of what the user authorized. Narrow the integration's scopes rather than trusting the loop to behave. This is the objection to pre-empt if you post this design anywhere developers are reading: the credential half is close to a solved problem, and the unsolved half is enforcement between the agent's decision and the action firing.
- The WorkOS API key stays in the runtime. Relay removes provider tokens from the calling environment, not that key, which authenticates every WorkOS call for the environment. Inject it at request time rather than baking it into agent-visible code or prompts.
- Relay is a proxy with limits. Bodies are forwarded byte-for-byte up to 5 MB and the upstream timeout is 30 seconds, which is fine for tool calls and wrong for a bulk export. That is what the credential-vending path and a trusted worker are for. Note also that "returned verbatim" is true of status and body but not of every header: hop-by-hop headers and
Set-Cookieare stripped. - Revocation is fast on your side and slower on the provider's.
deleteUserConnectedAccount({ userId, slug })removes the stored access and refresh tokens and returns 204, after which the next relayed call comes back 402. It does not revoke access at the provider, and the user may still need to disconnect your app there. Note the parameter isslug, notprovider, which differs fromconfigureOrganizationProvider. - The cloudId shortcut is wrong for multi-site customers.
jiraCloudId()takes the first site in the response. Atlassian also warns that theidis not unique across container types, and the endpoint tells you nothing about the user's permissions within a site, so pin the site explicitly once you have a customer with two.
The test that matters is not the happy path. Sign in as a second user, run the same prompt, and check that the run touched nothing belonging to the first. Then read the audit log and check that it names a person rather than a robot.
Prove it with a second user
The happy path proves nothing here, because the failure this post is about does not throw. Every check below is one that a green run can still fail:
- Sign in as a second user and run the same prompt. Confirm the run touched nothing belonging to the first. This is the whole test; the rest are details of it.
- Revoke one user's Slack grant mid-session. The run should degrade to a reconnect message and keep going, not die on a stack trace and not silently skip the step.
- Post to a channel the user is not a member of. The tool must report failure. Slack answers 200, so this is the check that proves your body validation is wired up rather than just written.
- Read the audit log. It should name a person, a target, and a run ID you can reconstruct a session from, not a service account.
- Try a session with no organization. The request should be refused, because an unaudited agent run is not a feature.
Do all five as a different user than the one who built it. The bug in a multi-user agent is almost never in the code path you wrote; it is in the assumption that there is only one of you.
The agent will still get things wrong. The difference is that when it does, the damage is bounded by one person's grant, and the audit log says whose. That is the whole return on the closure variable at the top of this post.
If the other half of your workload has no signed-in user at all, a nightly job or a queue consumer, the same relay call works with a stored grant and no session. That is the companion tutorial.
Build this on WorkOS
Four pieces do the work in this post.
- AuthKit resolves who is asking.
- Pipes holds each user's grant and refreshes it.
- Relay makes the call without handing your runtime the token.
- Audit Logs records who did what, under their own name.
AuthKit, Pipes, and Audit Logs are self-serve. Relay is in early access, so email or Slack support to have it turned on for your environment before you start wiring tools against it.
AuthKit is free up to 1 million monthly active users, which is more headroom than you need to build this and prove it with a second user. Create a WorkOS account, connect your first provider, and give your agent an identity that is not yours.