Cogitator
Advanced

Multiple Users

Serve many users with one agent — each with their own threads, memory and tool credentials, and no way to read each other's conversations.

Overview

A production agent usually serves many people at once: a support bot for every customer, an assistant for every employee. Cogitator keeps them apart with one value — the userId of each run:

  • Threads belong to the user who started them. A client cannot read or continue another user's conversation by sending its threadId.
  • Memory (facts, semantic memory, knowledge graph nodes) can be private to a user or shared by everyone the agent serves.
  • Tools receive the user as context.userId, so they act with that user's data and credentials — including tools from your own MCP server, called with the user's token.
await cog.run(supportAgent, {
  input: 'Where is my order?',
  userId: session.userId,
  threadId: request.body.threadId, // from the client: checked against its owner
});

Threads

The run that creates a thread records its userId as the owner (thread.metadata.userId). When a run passes a threadId, the runtime checks it before loading any history:

ThreadRun userIdResult
does not existanycreated, owned by the run's user
owned by alicealicecontinues
owned by alicebob or nonefails with THREAD_ACCESS_DENIED (HTTP 403)
no ownernonecontinues
no owneralicefails with THREAD_ACCESS_DENIED
cannot be read (store down)anyfails with MEMORY_READ_FAILED, nothing overwritten
import { CogitatorError, ErrorCode } from '@cogitator-ai/core';

try {
  await cog.run(agent, { input, userId: 'bob', threadId: 'thread-of-alice' });
} catch (error) {
  if (error instanceof CogitatorError && error.code === ErrorCode.THREAD_ACCESS_DENIED) {
    // 403 — not this user's conversation
  }
}

Runs without a threadId get a fresh random thread, so there is nothing to check.

Paused runs

A run paused for approval belongs to its user too. cog.resume(agent, threadId, { userId, decisions }) with a thread id resumes only for the user who started the run, and fails with THREAD_ACCESS_DENIED for anyone else; resuming from a checkpoint object checks the user when you pass userId.

Shared threads

Some threads are meant to be shared: a group chat several users write to, or a thread id your server derives itself and never takes from a client. Pass threadAccess: 'shared' to skip the owner check. @cogitator-ai/channels does this for every run, since it derives thread ids from the chat it is in.

await cog.run(agent, {
  input: message.text,
  userId: message.authorId,
  threadId: `team-chat:${message.channelId}`,
  threadAccess: 'shared',
});

Threads created before

Threads created before ownership existed, or by runs without a userId, have no owner and are open only to callers without one. To hand such a thread to a user, set its owner:

await memory.updateThread(threadId, { ...thread.metadata, userId: 'alice' });

Your own endpoints

Code that reads threads directly — a "load conversation" endpoint, an export job — checks access with the same rule:

import { assertThreadAccess, ensureThreadAccess } from '@cogitator-ai/core';

const memory = await cog.getMemory(); // connects the configured adapter on first use
if (!memory) throw new Error('Memory is not configured');

const thread = await assertThreadAccess(memory, threadId, session.userId); // null if missing
const entries = await memory.getEntries({ threadId });

await ensureThreadAccess(memory, threadId, { agentId: agent.id, userId: session.userId }); // creates it for the user when missing

Server Adapters

Every server adapter takes the user from its auth function: agent runs and streams (REST and WebSocket), swarm runs, and the /threads/:id routes are all scoped to it. Another user's thread answers 403 THREAD_ACCESS_DENIED.

import { CogitatorServer } from '@cogitator-ai/express';

const server = new CogitatorServer({
  app,
  cogitator,
  agents: { support },
  config: {
    auth: async (req) => {
      const session = await sessions.verify(req.headers.authorization);
      return session ? { userId: session.userId } : undefined;
    },
  },
});

See Server Adapters for each framework's auth signature. The other entry points work the same way:

  • A2A — auth.validate returns { userId } instead of true; tasks, contexts and runs then belong to that user (A2A).
  • MCP — serveAgents(cog, agents, { transport: 'http', auth }) runs each tool call for the user auth resolves (Serving Agents).
  • Channels — the gateway passes the sender as userId and derives thread ids from the chat, with threadAccess: 'shared' (Gateway).

Memory

Facts, embeddings and knowledge graph nodes are scoped by metadata.userId. Memory with an owner goes only into that user's context; memory without one is shared by everyone the agent serves:

await postgres.addFact({
  agentId: support.id,
  content: 'Prefers to be contacted by email',
  category: 'preference',
  confidence: 0.9,
  source: 'user',
  metadata: { userId: 'alice' }, // only in Alice's context
});

await postgres.addFact({
  agentId: support.id,
  content: 'Support hours are 9:00–17:00 CET',
  category: 'policy',
  confidence: 1,
  source: 'system', // no userId: everyone's context
});

The runtime builds each run's context for its userId. The filter runs inside the vector search (filter: { userId }) on the in-memory, Postgres and Qdrant adapters, so other users' memories never crowd out the user's own. Configure what goes into the context with memory.contextBuilder:

const cog = new Cogitator({
  memory: {
    adapter: 'postgres',
    postgres: { connectionString: process.env.DATABASE_URL! },
    embedding: {
      provider: 'openai',
      apiKey: process.env.OPENAI_API_KEY!,
      model: 'text-embedding-3-small',
    },
    contextBuilder: {
      maxTokens: 8000,
      strategy: 'hybrid',
      includeFacts: true,
      includeSemanticContext: true,
    },
  },
});

Tools and Credentials

A tool learns who it acts for from context.userId:

const myInvoices = tool({
  name: 'my_invoices',
  description: "List the user's invoices",
  parameters: z.object({}),
  execute: async (_args, context) => billing.invoicesOf(context.userId!),
});

When the tools live on your own MCP server, give each user their own client, connected with their own token. The server's auth resolves the token to the user, and its tools see that user as context.userId:

async function supportFor(userToken: string): Promise<Agent> {
  const client = await MCPClient.connect({
    transport: 'http',
    url: 'https://api.example.com/mcp',
    headers: { Authorization: `Bearer ${userToken}` },
  });
  return support.clone({ id: support.id, tools: await client.getTools() });
}

const agent = await supportFor(session.mcpToken);
await cog.run(agent, { input, userId: session.userId, threadId });

Keep id: support.id when cloning, so the user's memory stays attached to the same agent. MCP Integration covers the server side, and examples/mcp/03-per-user-mcp.ts runs the whole flow.

Limits

  • Callers without a user. An auth that returns no userId (or an A2A validate that returns true) puts every caller in one space: they share the threads and tasks without an owner.
  • Tool results in memory. History stays in its thread, so it is isolated with the thread. Anything a tool writes elsewhere (your database, files, the knowledge graph without metadata.userId) is isolated only as far as the tool scopes it.

On this page