Users and spend
Multi-tenant AI agents: one per user with camelRun
Build multi-tenant AI agents on camelRun: an agent per user or conversation, tools that know whom each call is for, and per-user limits and keys.
View as Markdown · Docs index for coding agents
Most applications give each user, or each conversation, an agent of its own, and serve every agent's tools from one backend. This page puts the pieces together: keys, identity, served tools, and per-user limits and keys.
One agent per user or conversation
Key agents by your own ids, and upsert on each request:
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 });subjectandcontextare set when the agent is made, with your API key. Neither the agent's own token nor the model can change them. Your tools get them asidentity.subjectandidentity.context.usernames who sent this message. The model sees who sent it, in a block only camelRun can write, and tools get it asidentity.actor(andidentity.user). Useuser: { id, name }to give the model a display name too. Names are the sender's own and never proof of anything, and camelRun tells the model so.- An agent shared by several people shares its whole conversation with all of them. Keep private data in agents of one person each.
Serve the tools from your backend
A multi-user backend runs many instances, so its tools are served over HTTP rather than attached to one process. Each call carries a signed token saying which agent it is for and who is acting, so one endpoint answers every user's agents safely:
const tools = {
list_todos: tool({
description: "The current user's to-dos",
input: schema.Object({}),
execute: (_args, { identity }) => db.todos({ user: identity!.user, org: identity!.context.org }),
}),
};
// 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" }) };const definition = await agents.runtime.upsertDefinition("support", {
name: "Support",
mcpServers: [{ name: "app", url: "https://app.example.com/mcp", auth: { type: "runtime" } }],
});Authorize every call as identity.user within identity.context, never from
the model's arguments. That identity is only meaningful within your own tenant,
which is why serveTools requires tenant. See Served tools and
Identity.
Your request handlers can be serverless: an agent upserted without local tools follows its stream read-only, so any number of instances can run agents at once.
Show each user their agent
Mint a browser token per user and let the browser read the agent directly, or
use createAgentHandler, which does this for you. Send
messages through your server.
Spend limits and keys per user
- To charge each person (by
subject) for what their agents spend, with a balance, daily free allowances and a markup, see End-user credits. spendLimit: { usd }caps what one agent may spend on model calls from now on. Set it on upsert or withPATCH /v1/agents/{id}/configuration. Setting it starts counting from zero, so you can set the remaining allowance before each run. An agent at its limit gets 402 for new runs, and a running turn ends with the run failed asspend_limit.- A key scope per customer holds that customer's own provider keys, so
their agents call providers with them:
PUT /v1/key-scopes/{scope}/providers/{provider}, thenkeyScopeon upsert. See Key scopes. modelHeaderslabel each model call, for example for a gateway's per-conversation logs.- The
usage.recordedwebhook reports each model response's cost with the agent'ssubject,context,actorandkeyScope, so you can meter per user. - Workspaces group agents and definitions by project or use case, with usage per workspace. They group resources inside your one account; they don't separate users.
Match messages to your records
run(text, { metadata }) attaches your own key-value data to the message (at
most 16 string values). The stored message and its run carry it in history,
events and webhooks; the model never sees it. Pass idempotencyKey to choose the
run's id, so a retried request never runs twice, and to find the run later.
Bring in existing conversations
A conversation that began elsewhere can continue on an agent made with its
history: initialMessages on POST /v1/agents (createAgent({ initialMessages }),
or an upsert's first create; Python agents.upsert(key, initial_messages=[…])).
Messages are Pi messages, kept as they
are, with what a message leaves out filled in:
user(content a string, or text, image and file blocks),assistant(text, thinking andtoolCallblocks, with itsusage,providerandmodelif you have them) andtoolResult(toolCallId,toolName, content).compactionSummary(summary,tokensBefore): the model sees this summary instead of every message before it. Put it just before the first message it keeps; only the last counts. History still shows every message.
From another API's format
importMessages (instead of initialMessages) takes {format, messages, model?}
and converts the conversation to Pi messages, tool calls and results included:
format | messages |
|---|---|
anthropic | Messages API messages: text, images, tool_use, tool_result, thinking and redacted_thinking |
openai-responses | Responses API input items: messages, function_call, function_call_output and reasoning |
openai-chat | Chat Completions messages, tool_calls and tool results included |
await agents.upsert(`thread-${thread.id}`, { importMessages: { format: "openai-chat", messages: thread.messages, model: "openai/gpt-6-sol" } });model names the model that wrote the assistant turns. Its reasoning (signed
thinking, encrypted reasoning items) goes back as it came to that same model;
any other model gets it as text, or not at all. System and developer messages
are left out: the agent's own prompt replaces them. Images must be base64 (or
data: URLs); nothing is fetched. Python takes import_messages={…}, and
toPiMessages(input) (TypeScript) converts one yourself, to see what an agent
will begin with.
Import rules
History is imported only when the agent is made: an agent that has one keeps
it, and an upsert with the same key makes the agent once. GET …/history shows
it at once. A tool call with no result gets one saying so, and tools the old
conversation called need not exist. If the history is larger than the model's
context, the first run compacts it before calling the model, charged as any
compaction.
A refused import makes no agent. INVALID_HISTORY (400) names the message and
what it lacks, and past 16 MB of JSON it is HISTORY_TOO_LARGE (413). Send the
most recent messages that fit, after a compactionSummary of the rest, and
upload images as files first to keep them out of those 16 MB.