← ALL POSTS

Tips · Oct 10, 2026 · 12 min read

Multi-Tenant AI Agents: Give Every User Their Own Agent

IR
Illiana Reed
CEO & Product, camelAI
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 keyGood forWatch out for
Useruser-123Assistants that remember one personOne history that keeps growing
Threadthread-9f2cChat products, Slack threadsNo context across threads unless a tool supplies it
Ticket, PR or recordticket-4411Support triage, code reviewSeveral people may write to it
Team or orgteam-acmeA team assistant in a shared channelEvery 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.

PatterncamelRun featureYour app still…
Key agents by your idsagents.upsert(key, …)Derives keys after its own access checks
Per-agent history and filesOwn history and /workspace per agent; volumes shared only if mountedDecides what gets mounted where
Who the agent acts forsubject and context fixed at creation; user per runSets them from its authenticated session
Identity to toolsA runtime-signed token on every served tool callChecks it on every call
Credentials and model keysTools run in your code; key scopes hold customers' provider keysStores and looks up tool credentials
Spend and ratespendLimit per agent and run, usage.recorded, channel limitsSets budgets, quotas and billing
Deletion and exportagent.delete(), agent.history(), agent.filesCalls 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 });
  • upsert makes 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.
  • subject is whom the agent acts for, and context holds 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.
  • user names who sent this message, and tools receive it as identity.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):

FieldMeaning
userWhom to authorize as: the turn's actor, else the agent's subject
subjectWhom the agent acts for, set at creation
actorWho is acting in this turn: the run's user
contextClaims set at creation, such as { org }
originFor 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_exec sandbox 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: true makes 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 with spend_limit after 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, stream and prompt take their own spendLimit. 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.recorded webhook reports each model response's cost with the agent's subject, context, actor and keyScope, 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's onSend hook 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 with agent.history() and agent.files. The account export (GET /v1/account/export) covers your whole camelRun account, not one customer.
  • DELETE /v1/key-scopes/:scope removes 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 identity on 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-tenant CLAUDE_CONFIG_DIR and 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.

FROM THE TEAM BEHIND CAMELAI

Try what we're building.