Request Context & Dynamic Agents
Embed the server in a multi-tenant host: resolve the caller once per request, serve a per-request agent list and let storage scope its data. Everything here is optional - without resolveContext and with a plain agents array nothing changes.
New in 0.2.0, tightened in 0.3.0
resolveContext, dynamic agents and the storage context arrived in 0.2.0. 0.3.0 makes the identity the server established win over one the client names, and makes listing fail closed: GET /conversations and GET /tasks answer 401 identity_required to a request that names nobody unless allowAnonymousList is set. See Upgrading to 0.3.0.
When to use it
- Your users are already signed in to your application and you want the agent API to trust that identity (a session cookie, a bearer token you verify), not a user id the browser sends.
- Different tenants, portals or plans see different agents.
- You need to scope what is stored per tenant.
If a static token per user is enough, the token and JWT providers are simpler.
resolveContext
import { createAgentServer } from '@cognipeer/agent-server';
const server = createAgentServer({
basePath: '/api/portal',
storage,
resolveContext: async (req) => {
const session = await sessions.get(req.cookies.sid);
if (!session) return null; // -> 401 AUTHENTICATION_ERROR
return {
user: { id: session.userId },
data: { tenantId: session.tenantId }, // arbitrary host data, never sent to clients
};
},
});req is a ContextRequest:
| Field | Meaning |
|---|---|
method, path | HTTP method and the route path relative to the server (/conversations/abc/messages) |
headers | lower-cased header names |
cookies | parsed Cookie header |
query, body | parsed query string / JSON body |
user | user from the token auth provider, if both are used |
mountParams | params of a dynamic mount prefix (Fastify /p/:key, Express /p/:key/agents) |
raw | the framework's native request (Express req, NextRequest, IncomingMessage, FastifyRequest) |
Rules:
- It runs for every route except the public Swagger routes. A pre-resolved
contextpassed toserver.handleRequest/handleRouteskips it. - Returning
nullanswers 401. Throw{ statusCode: 403, code, message }to answer with another status. - The resolved identity is authoritative.
userIdin a create-conversation body or?userId=in a list query is ignored; a single conversation or task owned by another user (or by anyone, for an anonymous context) answers 403. The lists are covered in Who may list. - Token auth (
auth.enabled) still runs first; its user is available asreq.userand is the fallback when the resolver returns nouser.
Which identity counts
When a request carries more than one idea of who is asking, the server's own wins, in this order:
- with
resolveContext:context.user(nothing the client sends changes it); - the token user (
auth.enabled), thenauth.resolveUserId; - only when the request carries no identity at all (an open demo server,
allowAnonymousList): theuserIdthe client names in a create-conversation body or a list query.
So with token auth, ?userId=other on a list and userId: "other" in a create body are ignored: the token user's own conversations are listed, and a conversation is created for the token user. Without any identity a client names itself, which is all an anonymous server can offer - do not rely on it to keep users apart.
Who may list
GET /conversations and GET /tasks return the records of one user: the resolved one. A request that names nobody - no token user, no context.user, no resolveUserId result and, without resolveContext, no ?userId= - would list the conversations of everyone, so the server refuses it: 401 identity_required. That includes a context that carries only a tenant ({ data: { tenantId } }, no user): resolve a user for every request, even a service user per tenant.
createAgentServer({ basePath: '/api', storage, resolveContext }); // lists need resolveContext to return a user
createAgentServer({ basePath: '/api', storage, allowAnonymousList: true }); // a single-user tool or a demo: everybody is the same personallowAnonymousList: true lets an anonymous request list everything. Use it only when that is the point of the server.
The built-in storage providers keep every user's data in one namespace: they filter by user, not by tenant. A multi-tenant host either gives each tenant its own database / tablePrefix (a server per tenant), or scopes in its own storage provider using the context (ctx.data.tenantId) - see Custom Storage.
Tenancy
Conversations, tasks and files belong to a user. The server enforces that on every route, and it fails closed:
| Situation | Answer |
|---|---|
| A conversation, task or file belongs to someone else | 403 FORBIDDEN (a file: 404, the same as a file that does not exist) |
With resolveContext, a conversation or task that carries no owner | 403 - it is not open to every signed-in caller |
| A list request that names no user | 401 identity_required (unless allowAnonymousList) |
| An agent that is not served to this request | 404, also for an existing conversation whose agent is no longer visible |
The server does not know tenants: the built-in providers keep every user's rows in one namespace. Pick one of the patterns:
- A server per tenant. Give each tenant its own database, or share one with
tablePrefix(PostgreSQL, SQLite) orcollectionPrefix(MongoDB), and its owncreateAgentServer. The strongest isolation and the simplest to reason about; mount the servers under different prefixes and pick the one that serves a request from the URL. - One server, user ids that cannot collide. Put the tenant in the user id that
resolveContextreturns (for exampleacme:alice). Tenants cannot see each other's conversations, tasks or files because the owner differs. Nothing in the database separates them, so it is a convention, not a boundary. - A scoped storage provider. Extend
BaseStorageProviderand filter byctx.data.tenantIdin every_hook (Custom Storage). Work that belongs to no request - the recovery sweep and event pruning - reaches the provider with{ data: { system: true } }and no user, so let those calls through.
Per-tenant agents are a separate matter: return them from config.agents(ctx) (Dynamic agents).
Where the context goes
| Consumer | How |
|---|---|
Dynamic agents function | first argument |
Custom AgentHandler.processMessage | params.context |
| Host-backed agent | input.context of invoke and resolveAction (Host-backed agents) |
| SDK agent | agent.invoke(params, { context, ... }) (extra key, ignored by agents that don't read it) |
auth.resolveUserId | { headers, query, body, context } |
| Storage provider | last argument of every data method |
| Anywhere in the request | getRequestContext() (AsyncLocalStorage; also valid inside SSE streams) |
Dynamic agents
const server = createAgentServer({
basePath: '/api',
storage,
resolveContext,
agents: async (ctx) => {
const rows = await db.agentsFor(ctx.data.tenantId as string);
return rows.map((row) => ({
info: { id: row.key, name: row.name, description: row.description },
sdkAgent: buildAgent(row, ctx), // closures may capture the context
}));
},
});config.agentsis an array (registered once) or a function (resolved per request and memoised for that request).- Agents added with
registerSDKAgent/registerCustomAgent/... stay available; a dynamic registration with the same id wins. GET /agents,GET /agents/:id,POST /conversations, sending a message (also streaming) and tasks all use the per-request registry. An agent that is not served to the request answers 404 - also for an existing conversation whose agent is no longer visible.- Programmatic access:
await server.getAgentsFor(ctx),await server.getAgentFor(id, ctx).
Storage and the context
All StorageProvider methods take an optional trailing ctx?: RequestContext:
getConversation(id: string, ctx?: RequestContext): Promise<Conversation | null>;
getMessages(conversationId: string, params?: GetMessagesParams, ctx?: RequestContext): Promise<...>;See Custom Storage for implementing a scoped store.
A turn runs detached from the request that started it, but with that request's context: it reaches the agent, the handler, the storage calls the turn makes and getRequestContext() just as before, even after the client has gone away. Work that belongs to no request - the recovery sweep that closes the runs of a dead process and fails stale tasks, and the pruning of old events - runs with the context { data: { system: true } } and no user. A store scoped by ctx.user should let those calls through (or set runs.recoverOnStart: false and runs.sweepIntervalMs: 0, and call server.recoverStaleRuns() from a context of your own).
Streaming note
If an SDK agent returns its final answer without streaming any text (for example a tool agent running non-streaming), the server emits it once as a stream.text event (isFinal: true) before stream.done, so chat UIs never end with an empty message.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
401 identity_required on GET /conversations | The request names no user. Return { user: { id } } from resolveContext for every request (a context with only data: { tenantId } is not enough), or set allowAnonymousList: true on a server that is single-user by design. |
401 AUTHENTICATION_ERROR on every request | resolveContext returned null. Log what req.headers, req.cookies and req.mountParams hold; header names are lower-cased. |
403 FORBIDDEN on a conversation you created | The user id from resolveContext changed between requests (for example a refreshed token that carries a different sub). Conversations belong to the user id. |
?userId= or a userId in a create body has no effect | By design: the identity the server established wins. It is only taken on a server with no identity at all. |
404 for an agent that exists | The per-request agents function did not return it for this context (a conversation of an agent that is no longer visible answers 404 too). |
| Lapsed approvals end as cancels, or recovery does nothing | A per-request registry or a scoped storage must also resolve for the sweep's context (data.system). Or set runs.recoverOnStart: false and runs.sweepIntervalMs: 0 and call server.recoverStaleRuns() from a context of your own. |
Next Steps
- Authentication - token and JWT providers
- Embedding & limits - bearer auth for chats in other sites
- Custom Storage - a provider scoped by tenant

