Upgrading to 0.3.0
How to move a server from @cognipeer/agent-server 0.1.x or 0.2.x to 0.3.0: what to check first, what changes behaviour, and what is new. The full list of changes is in the release notes.
0.3.0 is mostly additive. The pause protocol (approvals and questions) does nothing for an agent that never pauses, and every new route, setting and field is optional. What can bite an existing server is the handful of behaviour changes below, most of them security hardening and storage parity.
Before you upgrade
- Back up the database. The SQLite and PostgreSQL providers upgrade their schema once, on connect (to version 5, under an advisory lock on PostgreSQL). The only data the upgrade deletes is the older results of a task that had several. MongoDB gets new collections and indexes.
- Check what lists conversations. A request that names no user is now
401 identity_required(Identity and listing). - Check JWTs. Tokens must now be signed with the key you configure (Authentication).
- Check who relies on a disconnect. A client that closed the connection to stop a turn now has to call
POST /conversations/:id/cancel(Streaming and runs). - Check file retention. Files now expire after 30 days unless you set
files.retentionDays: 0(Files). - Use agent-sdk 0.10.6 or later if you offer a Stop button: earlier SDKs let the model finish (and bill) after a cancel.
Behaviour changes at a glance
| Area | Before | Now | Do this |
|---|---|---|---|
| Listing | GET /conversations and GET /tasks answered everyone's records to a request that named nobody | 401 identity_required unless the request names a user | Resolve a user for every request (resolveContext, token auth, auth.resolveUserId), or set allowAnonymousList: true on a single-user server |
| Client-named identity | ?userId= and a userId in a create body were honoured with token auth | Ignored whenever the server established an identity; used only when the request carries none | Stop sending them; use resolveContext |
| JWT | createJWTAuthProvider read the claims without verifying the signature | Verifies the signature (HS/RS/ES), exp, nbf, iss, aud; throws without a secret | Configure the real key and algorithm; tokens signed with another key now fail |
| Auth without provider | auth.enabled: true without provider ran unauthenticated | createAgentServer throws | Add a provider, or set enabled: false and use resolveUserId / resolveContext |
| Generated tokens | tok_ plus base64 of the user and the time | tok_ plus 256 random bits; only a hash is kept, in memory; optional tokenTtlMs | Re-issue the tokens generateToken made; the old shape is gone |
| Disconnect | A streamed turn stopped when the client went away | The turn runs to its end and is stored | Call POST /conversations/:id/cancel to stop |
| Concurrent turns | A second message or answer started an overlapping turn (or was 409 action_not_pending) | 409 conversation_busy (error.details.activeRunId) | Wait for the turn, or cancel it |
| Approval deadlines | expiresAt was only passed through | After it the request answers 410 action_expired and hitl.onTimeout applies (default deny, after hitl.timeoutMinutes, default 1440) | Set hitl.timeoutMinutes: 0 to keep requests open for ever, as before |
| History | GET /conversations/:id returned the first 100 messages | The newest page of 100, oldest first within it | Nothing for conversations up to 100 messages; page with before / after for longer ones |
| Agent history | The agent saw only the first 100 messages (after about 50 turns the newest message never reached the model) | The agent gets the whole conversation | Nothing |
| Files expire | Files lived until their conversation was deleted | expiresAt = upload + files.retentionDays (30) and the sweep deletes them | Set retentionDays: 0 to keep them. Files uploaded before 0.3.0 have no expiry |
| Files and ownership | A file could be attached to any conversation; GET /files/:id answered to anyone | Only the uploader sees a file; a message attaches only files of its own conversation uploaded by the caller; a file without conversationId cannot be attached | Upload with conversationId |
limits.maxFiles | Files per conversation | Files one message attaches; a conversation holds files.maxFiles (10) | Set files.maxFiles for the old meaning |
| Inline files | SendMessageRequest.files got throwaway ids (404 when fetched) | They are stored (owned, in the conversation, under the upload limits) and carry real ids | Nothing |
FileRecord.mimeType | Kept as declared | Stored without parameters (text/plain; charset=utf-8 becomes text/plain); application/octet-stream is guessed from the extension | Nothing |
| Forms | A multipart/* upload stored the whole envelope as the file | 415 unsupported_media_type | Send the bytes as the body, or use the JSON form |
| Downloads | The file name and type went into headers as they were | Sanitised, X-Content-Type-Options: nosniff, Cache-Control: private, no-store; ?disposition=inline for pictures, PDF and plain text | Nothing |
| Conversation metadata | A client could set metadata.taskId and source: "task" | These, and the new pinned, pinnedAt and titleGenerated, belong to the server: a client can neither set nor drop them | Use PATCH { pinned } and PATCH { title } |
| SDK usage | Raw ledger, or empty | The usage of the run (a resumed turn no longer repeats the calls before the pause), with cachedInputTokens and costUsd when known | Re-read any code that added usage up |
Handler state | Written in the middle of a turn | Written with the rest of the turn, and cannot erase the server's own keys | Nothing |
| Assistant messages | metadata.status and stopReason were not on every message | Always present; an assistant message that stopped before saying anything (cancelled, no text) is left out of the history the agent is given | Nothing |
| Next.js adapter | Read the body before authenticating | Authenticates first and refuses a body over limits.maxBodySize with 413 | Set limits.maxBodySize when agents are built per request |
| Fastify adapter | A body over the limit answered Fastify's FST_ERR_CTP_BODY_TOO_LARGE | 413 { error: { code: "PAYLOAD_TOO_LARGE", message } } | Update clients that matched Fastify's shape |
GET /files/:id | Ambiguous in the reference | Answers the file record itself; only the upload wraps it as { file } | Nothing |
Identity and listing
The identity the server established always wins, in this order: context.user from resolveContext; the token user, then auth.resolveUserId; and only when the request carries no identity at all (an open demo server) the userId the client names. Listing needs one of them:
// a server with real users
createAgentServer({ basePath: '/api', storage, resolveContext: (req) => ({ user: { id: userIdFrom(req) }, data: {} }) });
// a local tool or a demo: everybody is the same person
createAgentServer({ basePath: '/api', storage, allowAnonymousList: true });A context that carries only a tenant ({ data: { tenantId } }, no user) is not enough: resolve a user for every request, even a service user per tenant. Details: Request Context.
Authentication
createJWTAuthProvider({ secret })verifies the signature with the key and algorithm you configure (HS256by default; the PEM public key assecretfor RS* and ES*). It never takes the algorithm from the token. There is no JWKS support: for keys that rotate, verify in front of the server or write anAuthProvideron a JWT library (Authentication).- The user of a token is taken from
sub, thenuserId, thenuser_id(a non-empty string); setextractUserIdfor anything else.
Streaming and runs
- A turn is a run that outlives its HTTP request: closing the tab, a dropped connection or a proxy timeout no longer stops it. See Runs, Cancel & Reattach.
- Frames carry
seq;stream.donecarriesstopReasonandrunId; a client that loses a message stream resumes withGET /conversations/:id/events/stream?after=<seq>. - A non-streamed request to an SDK agent now passes
onEventandcancellationTokentoinvoke, so tool calls reach the event log whether or not the request streams. ProcessMessageParams.conversationIdof a task is now the id of the task's own conversation (it wastask_<id>).
Files
- Uploads can be the file's own bytes:
POST /files?name=...&conversationId=...with the file'sContent-Type. The JSON form keeps working. - Attach by id:
fileIdson the message. Both are checked against the agent's settings before the turn starts. - What the agent reads, images, retention and the per-agent
filessettings: Files. - The Node handler's default body limit follows the largest file any agent accepts (
server.maxUploadBytes()); the Fastify plugin registers its own buffer parsers for the types Fastify has none for.
Storage
All four built-in providers now pass one contract suite against real databases, which changed behaviours that used to differ:
- Rows created in the same millisecond keep insertion order; a new message bumps its conversation's
updatedAteverywhere. - Records read back the same way:
undefinedandDates in JSON fields are normalised asJSON.parse(JSON.stringify())would,files: []reads as no files, an absentuserId/titlereads asundefined, taskcommentsis always an array. - Deleting a conversation deletes its files and their content in every provider (SQLite, PostgreSQL and MongoDB used to keep them); deleting a message keeps its files.
- Saving a file is all-or-nothing. A file for a conversation or message that does not exist is a
404(it was a500on SQL, accepted on memory and MongoDB), and so is a message for an unknown conversation. - A task has one result:
createTaskResultreplaces the existing one in every provider. - PostgreSQL: the migration adds
tasks.comments, per-tableseqcolumns and prefix-qualified index names (old unprefixed ones are dropped when they belong to this prefix's tables); an idle-client error no longer crashes the process; message text that is valid JSON no longer comes back as a number or object. - MongoDB: file content moves to GridFS (content written by older versions in
<prefix>file_contentsis still read), so files are no longer limited to 16 MiB; the client connects withignoreUndefinedandpromoteBuffers; a conversation state overmaxStateBytes(12 MiB by default) is refused instead of failing later; indexes cover the sort keys (older single-field indexes they replace are dropped). - Work that belongs to no request (recovery, pruning) calls the storage provider with the context
{ data: { system: true } }. A custom provider that scopes byctx.usermust let those calls through. Everything new in the interface is optional, so a 0.2.x provider keeps working; see Custom Storage. - The expiry sweep calls
getConversationswithactionDueBefore; a storage wrapper that logs or failsgetConversationswill see it.
Tasks
- A task runs in a conversation of its own (
task.conversationId); a task with input files has it from the start and its files are checked against the agent'sfilessettings. - New status
cancelled,waiting_for_inputwithpendingActions,POST /tasks/:taskId/cancel,GET /tasks?conversationId=, and409 task_busy/action_pendingon a comment. See Task System. - A task left
runningby a process that is gone is failed ("The server stopped while the task was running").
From 0.1.x
Everything in 0.2.0 is additive, so a 0.1.x server needs the changes above and nothing more. What 0.2.0 added, and fixed:
- Request context:
resolveContext(req), the context in dynamic agents, handlers (params.context), SDK agents,auth.resolveUserIdand every storage call (Request Context). - Dynamic agents:
config.agentsas an array or a per-request function. - Node and Fastify adapters:
createNodeHandler,@cognipeer/agent-server/node,@cognipeer/agent-server/fastify(Fastify & Node HTTP). maskServerErrors: hide the text of every error that is not an explicit 4xx.- Fixes: a conversation or task without an owner is refused (
403) whenresolveContextis set;limitis capped at 200 and a negativeoffsetbecomes 0; an SDK agent that answers without streaming still ends the stream with astream.text(isFinal: true); a rejected streaminginvokeends the stream withstream.errorinstead of hanging; errors thrown as plain{ statusCode, code, message }objects keep their status; everyexportsentry has adefaultcondition, so TypeScript compiled to CommonJS no longer fails withERR_PACKAGE_PATH_NOT_EXPORTED(the package itself is ESM-only:require()needs Node 20.19+ or 22.12+).
If you copied code from older documentation
Some older examples used names that do not exist. The package exports createInMemoryProvider (not createMemoryProvider) and generateOpenAPISpec(config, agents) (there is no agentServer.getOpenAPISpec()), and a message is streamed with "stream": true (the field is stream, not streaming). An error answers { "error": { "code", "message", "details" } }, not { "error": "..." }.
What is new in 0.3.0
| Feature | Guide |
|---|---|
| Tool approvals, questions and client tools; requests that lapse; who may answer; edited arguments checked | Approvals & Questions |
| Detached runs, cancel, one turn at a time, the event log, reattach and the snapshot frame, recovery | Runs, Cancel & Reattach |
| Pricing, per-conversation budgets, continue | Usage & Budgets |
| Files the agent reads: inline, in pages, images by reference; raw uploads, ownership, retention | Files |
| Host-backed agents | Host-backed agents |
| Regenerate, edit, pin, rename, bulk delete, generated titles, starters, feedback, export | Chat experience |
Bearer auth for embedded chats, rateLimit and audit hooks | Embedding & limits |
| Storage contract suite, capabilities, GridFS, schema versioning | Storage Providers |
Paged history, orderBy=updatedAt, GET /conversations/:id/files, server.getRoutes(), the standalone example | REST Endpoints, Examples |
Verify the upgrade
- Start the server against a copy of the database and watch the log: the schema upgrade runs once on connect.
GET /agentsshowscapabilities.attachmentsandmetadata.experiencefor each agent.- List conversations as a real user, and as a request with no identity (expect
401 identity_required). - Send a message with
stream: true, close the connection mid-answer, and checkGET /conversations/:idreportsruntime.status: "running"and thenidle. - If you use files: upload one with
conversationId, attach it byfileIds, and read itsexpiresAt.
If something does not behave as described, Troubleshooting lists the common causes.

