Tips · Oct 10, 2026 · 12 min read
Multi-Tenant AI Agents: Give Every User Their Own Agent

TL;DR: Multi-tenant AI agents give every customer, user, ticket or thread its own agent, with its own history and files, while one backend serves every agent's tools. To make that safe, key agents by your own ids, pass a verified identity to every tool call, authorize in your app instead of the prompt, scope credentials and spend per tenant, and make deletion cascade. camelRun handles the agent side with keyed upsert, runtime-signed identity, key scopes and spend limits. Your tool code still decides what each user may touch.
When you add an AI agent to an app with many customers, you rarely end up with one agent. You end up with thousands of small ones: one per user, per support ticket, per Slack thread or per pull request. Each keeps its own conversation and files and may live for months, and all of them call the same tools: your app's APIs.
That raises questions a single-agent demo never faces. Whose data can this agent read? Whom is it acting for? Whose key pays for the model? What happens to its history when the customer leaves? This guide gives the vendor-neutral answers first, then shows how camelRun implements them. For the wider build choices, start with how to build an AI agent.
One AI agent per user, thread or ticket
Pick the record your product already has that matches one conversation's scope.
| Agent per… | Example key | Good for | Watch out for |
|---|---|---|---|
| User | user-123 | Assistants that remember one person | One history that keeps growing |
| Thread | thread-9f2c | Chat products, Slack threads | No context across threads unless a tool supplies it |
| Ticket, PR or record | ticket-4411 | Support triage, code review | Several people may write to it |
| Team or org | team-acme | A team assistant in a shared channel | Every member sees the whole conversation |
The last two rows lead to a design rule: treat everyone who can talk to an agent as potentially able to learn anything in its context, even if your UI hides earlier messages. Keep private data in agents that belong to one person.
Multi-tenant agent architecture: the general patterns
These hold whether you write your own loop, run a harness in containers, or use a hosted runtime.
1. Key every agent by an id you already have
Derive each key from your own records (org, user, thread) and use get-or-create semantics, so a retried request or a second server finds the same agent instead of making a duplicate. Recomputable keys mean no mapping table: to find a customer's agents, list their users or threads in your database and recompute the keys. Never build a key straight from request input: check that the caller may use the record, then derive the key.
2. Keep history and files per agent
Each agent needs its own transcript and working files. Make sharing opt-in and read-only: a handbook every agent can read is fine, while one customer's uploads visible to another customer's agent is a breach. Watch for state that leaks through the host. Anthropic's guide to hosting the Claude Agent SDK warns that in a shared container, settings and memory files "can leak one tenant's context into another tenant's session."
3. Separate who the agent works for from who is talking
Every turn has two identities. The subject is the customer or user the agent works for, fixed at creation. The actor is whoever sent this message, which changes per turn in a shared agent. Both should come from your authenticated session and travel outside the prompt, where neither the user nor the model can rewrite them.
4. Authorize every tool call in your app, never in the model
The model is not a security boundary. Tool arguments are whatever the model chose, and the model reads untrusted text all day: user messages, web pages, ticket bodies, PR comments. A prompt-injected agent will happily ask for customerId: "someone-else". So:
- Take who is asking from the verified identity, never from the arguments.
- Check ownership of every object the arguments name, on every call. Row-level checks belong in the tool, not the system prompt.
- Give each agent only the tools its tenant needs.
- Require human approval for destructive or expensive actions.
- Make side effects idempotent, so a retried call can't refund twice.
5. Keep credentials server-side and per tenant
Tool credentials, such as a customer's CRM OAuth token, stay in your backend, and tools look them up from the verified identity. They never appear in the prompt or the arguments. For model keys, decide who pays: you, with usage metered per tenant, or each customer with their own provider key, which you store per tenant and route each call to.
6. Cap every tenant's spend and rate
Agents loop, and loops burn money. Set budgets per agent and per run, rate-limit senders, check quotas before a message reaches the agent, and meter usage by tenant so billing and abuse detection read the same numbers.
7. Plan for most agents being asleep
With 10,000 customers you might have 10,000 agents and a few dozen working at once. Holding a process or container per tenant while it waits is the expensive way to build this. Keep state outside the process, wake agents on messages, schedules and webhooks, run one turn at a time per agent, and serve tools statelessly so any instance can answer any agent. Our post on durable AI agents covers sleeping and waking in depth.
8. Make deletion and export cascade
When a customer, user or thread is deleted, delete its agents, their histories and files, and any per-tenant keys. An export request means finding every agent the customer owns and pulling its history and files. Both are easy only if keys come from your ids.
How camelRun implements multi-tenant AI agents
camelRun is camelAI's hosted runtime for AI agents. You define each agent's model, instructions, tools and channels; camelRun runs the loop, keeps each agent's history and files, and wakes it when there is work.
A note on terms: in camelRun's docs, a tenant is your camelRun account, not your customer. Every API access check compares an agent's account with the caller's, and the runtime's tests check that one account can't list, read, prompt or delete another's agents. Your customers all live inside your account, so you separate them with the features below plus your tool code.
| Pattern | camelRun feature | Your app still… |
|---|---|---|
| Key agents by your ids | agents.upsert(key, …) | Derives keys after its own access checks |
| Per-agent history and files | Own history and /workspace per agent; volumes shared only if mounted | Decides what gets mounted where |
| Who the agent acts for | subject and context fixed at creation; user per run | Sets them from its authenticated session |
| Identity to tools | A runtime-signed token on every served tool call | Checks it on every call |
| Credentials and model keys | Tools run in your code; key scopes hold customers' provider keys | Stores and looks up tool credentials |
| Spend and rate | spendLimit per agent and run, usage.recorded, channel limits | Sets budgets, quotas and billing |
| Deletion and export | agent.delete(), agent.history(), agent.files | Calls them when its records change |
A per-user AI agent, or one per conversation
Key agents by your ids and upsert on each request (from the multi-user guide):
const agent = await agents.upsert(`thread-${thread.id}`, {
definition: SUPPORT_DEFINITION, // tools served by your backend, see below
subject: thread.ownerId, // whom the agent acts for
context: { org: thread.orgId }, // claims your tools authorize against
});
const run = await agent.run(message, { user: currentUser.id });
upsertmakes the agent once and returns the same one afterwards, from any process. Keys are 1 to 80 letters, digits,_and-, and a keyed agent lives until you delete it.subjectis whom the agent acts for, andcontextholds claims your tools authorize against (at most 4 KB). Your API key sets both at creation. Neither the agent's own token nor the model can change them, and an upsert that changes them on an existing key gets a 409.usernames who sent this message, and tools receive it asidentity.actor. Set it from your session, never from the request body.
Serve tools once, for every customer's agents
A multi-user backend runs many instances, so it serves tools over HTTP instead of attaching them to one process. Each call carries a token the runtime signs, naming the agent, whom it acts for and who is acting. The token lasts two minutes and is bound to your server's URL, so one endpoint can answer every customer's agents.
These tools from camelRun's examples/team-todos.ts scope everything by that identity (tool and schema come from @camelai/run; todos is the app's data):
const tools = {
list_todos: tool({
description: "The current user's to-dos on their team",
input: schema.Object({}, { additionalProperties: false }),
// Who is asking comes from the runtime's signed token, never from the model's arguments.
execute: (_args, { identity }) => todos.filter(todo => todo.owner === identity!.user && todo.team === identity!.context.team),
}),
complete_todo: tool({
description: "Mark one of the current user's to-dos done",
input: schema.Object({ id: schema.String() }, { additionalProperties: false }),
execute: ({ id }, { identity }) => {
const todo = todos.find(entry => entry.id === id && entry.owner === identity!.user && entry.team === identity!.context.team);
if (!todo) throw new Error(`You have no to-do ${id}`);
todo.done = true;
return todo;
},
}),
};
The only thing complete_todo takes from the model is an id, and it refuses that id unless the to-do belongs to the calling user on the agent's team. That check is the tenant boundary. (The example names its claim team; the multi-user guide uses org. Use whatever your data model calls it.)
Serve them with your camelRun account id as tenant. serveTools comes from @camelai/run/server; Python has serve_tools:
// tenant: yours (GET /v1/me). Other tenants' agents can reach this URL too, claiming any user: their tokens are refused.
export default { fetch: serveTools(tools, { runtime: "https://run.camelai.com", tenant: "acme" }) };
Don't skip tenant. Any camelRun account can create agents with any subject and point them at your URL, and the runtime signs their tokens too; only the tenant check tells your agents from theirs. Behind a TLS-terminating proxy on Node, pass your public URL as origin to nodeListener so the token's audience matches.
Then name the server in a definition (SUPPORT_DEFINITION above is this definition's id):
const definition = await agents.runtime.upsertDefinition("support", {
name: "Support",
mcpServers: [{ name: "app", url: "https://app.example.com/mcp", auth: { type: "runtime" } }],
});
What a tool reads from context.identity (full list in Tools):
| Field | Meaning |
|---|---|
user | Whom to authorize as: the turn's actor, else the agent's subject |
subject | Whom the agent acts for, set at creation |
actor | Who is acting in this turn: the run's user |
context | Claims set at creation, such as { org } |
origin | For channel messages: the channel, conversation and sender |
Four more boundaries come with this:
- Channels. Slack, Discord, Telegram and GitHub channels give each conversation its own agent and pass the sender in
origin. Map that sender to one of your users before a tool grants anything. - Model-written code. The
js_execsandbox runs JavaScript that reaches only the agent's own tools and files, with no network, so scripts go through the same tool checks. - Retries. Every tool call carries
context.idempotencyKey, the same on each attempt. A call whose answer was lost is never re-sent; the model is told the outcome is unknown. Durable history isn't exactly-once execution, so key side effects by it. - Approvals.
needsApproval: truemakes a person approve each call. By default only the person whose message started the turn (plus a definition's approvers) may answer, and the model can't.
Show each user only their own agent
Browsers never get your API key. Your server mints a browser token for one agent after its own access check. The token can only read that agent's events, state, history and inputs, lasts 900 seconds by default, and can't send messages; those go through your server. createAgentHandler in @camelai/run/server packages this as one route: its authorize callback returns the signed-in user, and by default each user and thread gets its own agent.
Per-customer model keys with key scopes
If customers bring their own provider keys, give each a key scope (Models and keys):
PUT /v1/key-scopes/org_abc123/providers/openrouter
{"apiKey": "sk-or-…", "baseUrl": "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/openrouter",
"headers": {"cf-aig-authorization": "Bearer <gateway token>"}}
Then pass keyScope when the agent is made, or later through PATCH /v1/agents/:id/configuration; the agent's own token can't change it. Each model call uses the scope's key for that provider, else your account's key, else (on prepaid accounts) the platform's key charged to your credit. So if a customer's scope lacks a provider, those calls land on your bill. Keys are read at each call, so a rotated key reaches every agent in the scope within five seconds. Scope keys carry no platform token charge; agent time is still billed.
Spend limits, metering and rate limits
- Per agent.
spendLimit: { usd }on upsert, or through the configuration PATCH, sets one agent's model budget from then on. Setting it restarts the count at zero, so avoid resetting it on every request. At the limit, new runs get 402; a running turn ends withspend_limitafter the model response that crosses the limit and its tool calls finish. It can therefore exceed the threshold by that response's cost. Only your API key can set it, and the production checklist asks for it on agents that act for untrusted users. - Per run.
run,streamandprompttake their ownspendLimit. Pass a request's remaining allowance here without resetting the agent's accumulated spend; enforce any shared customer budget in your app. - What it covers. Spend limits count model cost, on whoever's key. Agent time ($0.01 per agent-hour of active run time) is billed to your account separately.
- Metering. The
usage.recordedwebhook reports each model response's cost with the agent'ssubject,context,actorandkeyScope, enough to meter per customer. - Rate. Channels have allowlists, a per-sender limit (10 messages a minute by default) and a daily cap (1,000 turns per channel by default). In your own app,
createAgentHandler'sonSendhook can refuse a message for quotas or moderation.
Thousands of agents, most of them asleep
camelRun is built to run thousands of agents at once. Agents sleep between runs; a sleeping agent costs only storage ($0.10 per GB-month), and a message, schedule, channel event or webhook wakes it. You pay $0.01 per agent-hour of active run time plus model usage.
- The number of agents awake at once is set per deployment; accounts on free credit get 2 until their first purchase. Past the limit, the least recently active idle agent is stopped to make room, and with none idle the request gets a 429.
- Each agent runs one turn at a time. Messages sent meanwhile queue (up to 32 open requests per agent) or join the running turn.
- A handler that upserts an agent without local tools follows it read-only, so any number of serverless instances can run agents. Agents woken by schedules or channels should use served tools, since nobody may be connected to answer attached ones.
Deletion and export
agent.delete()deletes the agent, its history and its files. The hosted runtime purges the data within about a minute, and the next upsert with that key makes a fresh agent. Do it when the user or thread is deleted.agents.runtime.listAgents()returns each agent with its key, so you can match a customer's agents against keys recomputed from their users and threads. Export them withagent.history()andagent.files. The account export (GET /v1/account/export) covers your whole camelRun account, not one customer.DELETE /v1/key-scopes/:scoperemoves a customer's stored provider keys.- Copies outside camelRun are yours to delete: database rows, stored webhook payloads, tool-server logs.
Tenant isolation for tools is your app's job. camelRun tells each tool call who it is for, with a signature you can verify. It does not decide what that person may see or change. Your tools must check
identityon every call, look records up by it, and never take a user or customer id from the model's arguments.
When to choose a different setup
- Each tenant's agent needs a Linux machine. camelRun's sandbox runs JavaScript only: no shell, no Python, no package installs, no network. Agents that build code, run tests or call arbitrary CLIs need a container or sandbox per tenant. If you run the Claude Agent SDK that way, Anthropic's hosting guide lists the multi-tenant settings:
settingSources: [],CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, a per-tenantCLAUDE_CONFIG_DIRand working directory, and per-tenant egress rules. Our guide to hosting the Claude Agent SDK covers the options. - You want agent code inside your own Cloudflare Worker. Cloudflare's Agents SDK addresses each agent instance by a unique id, such as a user id or ticket number, and gives each instance its own embedded SQLite database.
- You're comparing hosted options. See AI agent runtimes and hosting platforms compared.
If the concern is where agent data lives, camelRun's runtime is open source (AGPL-3.0, with MIT SDKs) and self-hosts with one Docker container plus Postgres.
Try camelRun
camelRun gives each customer, user, ticket or thread its own keyed agent and passes a signed identity to your tools, for $0.01 per agent-hour of active run time plus model usage. The quickstart gets a first agent running, and the multi-user guide puts together the pieces in this post.