Skip to content

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 ​

typescript
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:

FieldMeaning
method, pathHTTP method and the route path relative to the server (/conversations/abc/messages)
headerslower-cased header names
cookiesparsed Cookie header
query, bodyparsed query string / JSON body
useruser from the token auth provider, if both are used
mountParamsparams of a dynamic mount prefix (Fastify /p/:key, Express /p/:key/agents)
rawthe framework's native request (Express req, NextRequest, IncomingMessage, FastifyRequest)

Rules:

  • It runs for every route except the public Swagger routes. A pre-resolved context passed to server.handleRequest/handleRoute skips it.
  • Returning null answers 401. Throw { statusCode: 403, code, message } to answer with another status.
  • The resolved identity is authoritative. userId in 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 as req.user and is the fallback when the resolver returns no user.

Which identity counts ​

When a request carries more than one idea of who is asking, the server's own wins, in this order:

  1. with resolveContext: context.user (nothing the client sends changes it);
  2. the token user (auth.enabled), then auth.resolveUserId;
  3. only when the request carries no identity at all (an open demo server, allowAnonymousList): the userId the 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.

typescript
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 person

allowAnonymousList: 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:

SituationAnswer
A conversation, task or file belongs to someone else403 FORBIDDEN (a file: 404, the same as a file that does not exist)
With resolveContext, a conversation or task that carries no owner403 - it is not open to every signed-in caller
A list request that names no user401 identity_required (unless allowAnonymousList)
An agent that is not served to this request404, 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:

  1. A server per tenant. Give each tenant its own database, or share one with tablePrefix (PostgreSQL, SQLite) or collectionPrefix (MongoDB), and its own createAgentServer. 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.
  2. One server, user ids that cannot collide. Put the tenant in the user id that resolveContext returns (for example acme: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.
  3. A scoped storage provider. Extend BaseStorageProvider and filter by ctx.data.tenantId in 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 ​

ConsumerHow
Dynamic agents functionfirst argument
Custom AgentHandler.processMessageparams.context
Host-backed agentinput.context of invoke and resolveAction (Host-backed agents)
SDK agentagent.invoke(params, { context, ... }) (extra key, ignored by agents that don't read it)
auth.resolveUserId{ headers, query, body, context }
Storage providerlast argument of every data method
Anywhere in the requestgetRequestContext() (AsyncLocalStorage; also valid inside SSE streams)

Dynamic agents ​

typescript
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.agents is 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:

typescript
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 ​

SymptomCause and fix
401 identity_required on GET /conversationsThe 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 requestresolveContext returned null. Log what req.headers, req.cookies and req.mountParams hold; header names are lower-cased.
403 FORBIDDEN on a conversation you createdThe 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 effectBy design: the identity the server established wins. It is only taken on a server with no identity at all.
404 for an agent that existsThe 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 nothingA 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 ​

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub