REST API server infrastructure - a ready-made API layer for AI agents.
Documentation: https://docs.cognipeer.com/agent-server/guide/getting-started
- 🤖 Agent SDK Integration: Directly register agents built with
@cognipeer/agent-sdk - 🔧 Custom Handler Support: Integrate agents built with other libraries
- 🏠 Host-backed agents: serve an agent that runs in your own backend - it streams with typed events, pauses for approvals by returning
pendingActions, is cancelled through a signal and a hook, and the server keeps the messages, the events and the pause (registerHostAgent) - 💾 Multiple Storage: PostgreSQL, MongoDB (GridFS for files), SQLite and in-memory, all tested against one provider contract
- 🔐 Authentication & tenancy: token and JWT providers, or your own identity per request; conversations belong to their user, and a list that names no user is refused (
allowAnonymousListopts out) - 📚 Swagger UI: Automatic OpenAPI documentation
- 🌐 Framework Agnostic: Works with Express, Next.js, Fastify, and raw Node HTTP
- 🧩 Request Context & Dynamic Agents: Resolve user/tenant per request, serve a different agent list and storage scope per request
- 📁 Files the agent reads: upload a file as itself or as JSON; it is owned by the uploader, limited per agent, deleted after
retentionDaysor with its conversation, and its text (PDF and Office with@cognipeer/to-markdown) or picture reaches the agent - inline, in pages withsession_files_read, or as an image by reference - ✅ Approvals that hold up: several people can answer the actions of one pause at once without losing an answer, requests lapse (and are denied or dropped) instead of waiting for ever, you decide who may answer, edited arguments are checked against the tool's schema, and the paused message records who decided what
- 💰 Usage & budgets: per-turn usage from agent-sdk's ledger (a resumed turn is never counted twice), cost from your price list, a spend limit per conversation that stops a turn, waits for a raise, and carries on (
POST /continue) - 💬 A chat around the conversation: regenerate an answer, edit a message and resend, pin / rename / bulk-delete conversations, generated titles, starters and a welcome text, thumbs up / down with reasons, and export as Markdown, JSON or a print page - see Chat experience
- 🪟 Embeddable: per-request bearer auth (no cookies),
client_toolrequests that run in the embedding page, arateLimithook with release for stream caps, and anaudithook - see Embedding & limits - ⏯️ Runs that outlive the connection: a turn keeps running when the client disconnects; cancel it, follow it from another client (event log + SSE), one turn at a time per conversation across server instances
npm install @cognipeer/agent-serverYou also need to install the storage provider and framework you want to use:
# For PostgreSQL
npm install pg
# For MongoDB
npm install mongodb
# For SQLite (one process, one file)
npm install better-sqlite3
# For Express
npm install expressA complete runnable server - SQLite or MongoDB, an agent-sdk agent with approvals and files, a host-backed agent - is in examples/standalone.
import express from 'express';
import {
createAgentServer,
createPostgresProvider,
createExpressMiddleware,
} from '@cognipeer/agent-server';
import { createSmartAgent } from '@cognipeer/agent-sdk';
// Create storage provider
const storage = createPostgresProvider({
connectionString: 'postgresql://user:pass@localhost:5432/mydb',
});
// Create agent server
const agentServer = createAgentServer({
basePath: '/api/agents',
storage,
allowAnonymousList: true, // a quick start with no sign-in: see "Who may list" in Request Context
swagger: {
enabled: true,
path: '/docs',
title: 'My Agent API',
},
auth: {
enabled: false, // Disabled for now
},
});
// Register SDK agent
const myAgent = createSmartAgent({
name: 'Assistant',
model: myLLMModel,
tools: [...],
});
agentServer.registerSDKAgent('assistant', myAgent, {
description: 'A helpful assistant',
});
// Register custom agent
agentServer.registerCustomAgent('echo', {
processMessage: async ({ message }) => ({
content: `Echo: ${message}`,
}),
}, {
name: 'Echo Bot',
description: 'Echoes your messages',
});
// Create Express app
const app = express();
app.use(express.json());
// Connect to storage and start server
await storage.connect();
app.use(createExpressMiddleware(agentServer));
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log('Swagger UI at http://localhost:3000/api/agents/docs');
});// app/api/agents/[...path]/route.ts
import {
createAgentServer,
createMongoDBProvider,
createNextRouteHandlers,
} from "@cognipeer/agent-server";
const storage = createMongoDBProvider({
connectionString: "mongodb://localhost:27017/mydb",
});
const agentServer = createAgentServer({
basePath: "/api/agents",
storage,
allowAnonymousList: true, // a quick start with no sign-in: see "Who may list" in Request Context
swagger: { enabled: true },
});
// Register agents
// ...
// Connect to storage
await storage.connect();
// Export route handlers
export const { GET, POST, PATCH, DELETE, OPTIONS } =
createNextRouteHandlers(agentServer);| Method | Endpoint | Description |
|---|---|---|
| GET | /agents | List all agents |
| GET | /agents/:agentId | Agent details |
| GET | /conversations | List conversations |
| POST | /conversations | Create new conversation |
| GET | /conversations/:id | Conversation details and messages |
| PATCH | /conversations/:id | Update conversation (title, metadata, budget) |
| DELETE | /conversations/:id | Delete conversation |
| GET | /conversations/:id/messages | List messages |
| POST | /conversations/:id/messages | Send message |
| POST | /conversations/:id/actions/:actionId | Resolve a pending tool approval / question |
| POST | /conversations/:id/continue | Carry on a turn the budget stopped |
| GET | /conversations/:id/remembered-tools | Tools approved "for the session" |
| DELETE | /conversations/:id/remembered-tools/:tool | Forget one: it asks again |
| POST | /conversations/:id/cancel | Cancel the running turn (or the open approvals) |
| GET | /conversations/:id/events | The conversation's event log |
| GET | /conversations/:id/events/stream | Replay and follow the event log (SSE) |
| GET | /conversations/:id/files | The files the conversation holds |
| POST | /files | Upload a file (its bytes, or JSON) |
| GET | /files/:fileId | File metadata |
| GET | /files/:fileId/content | Download file |
| DELETE | /files/:fileId | Delete file |
| POST | /tasks | Create a background task |
| GET | /tasks | List tasks |
| GET | /tasks/:taskId | Task details (and result) |
| GET | /tasks/:taskId/status | Task status |
| GET | /tasks/:taskId/result | Task result |
| POST | /tasks/:taskId/cancel | Cancel a task |
| GET / POST | /tasks/:taskId/comments | Read / add comments (continue the task) |
GET /conversations and GET /tasks list one user's records and need a user (401 identity_required otherwise, unless allowAnonymousList: true).
The full reference is in docs/api/endpoints.md; the server serves its own OpenAPI document at {basePath}/docs/openapi.json.
GET /conversations?q=<text> finds conversations whose title or message text contains q (case-insensitive, literal: % and _ are ordinary characters). It takes the usual limit, offset, agentId and userId, is scoped like the plain list, and returns the newest activity first (pinned conversations get no lead). q is trimmed: shorter than 2 characters is no search, longer than 200 answers 400 invalid_request.
Each hit may carry searchMatch, and the response says how it searched:
{
"conversations": [{ "id": "c1", "title": "Budget", "searchMatch": { "field": "message", "messageId": "m7", "role": "assistant", "snippet": "…the budget is approved…" } }],
"total": 1, "limit": 20, "offset": 0, "hasMore": false,
"searchMode": "full"
}searchMode is full when the storage searched titles and messages (capabilities().search: the built-in memory, SQLite, PostgreSQL and MongoDB providers), or title when it cannot: the server then filters the titles of the caller's 500 most recently updated conversations. GET /agents publishes the same as features.conversationSearch ("full" | "title") so a client knows what to offer. A custom provider opts in by handling GetConversationsParams.search and reporting search: true.
import { createPostgresProvider } from "@cognipeer/agent-server";
const storage = createPostgresProvider({
// With connection string
connectionString: "postgresql://user:pass@localhost:5432/mydb",
// Or with separate parameters
host: "localhost",
port: 5432,
database: "mydb",
user: "user",
password: "pass",
// Optional
schema: "public", // Default: 'public'
tablePrefix: "agent_", // Default: 'agent_server_'
autoMigrate: true, // Default: true - auto-create tables
pool: {
min: 2,
max: 10,
},
});
await storage.connect();import { createMongoDBProvider } from "@cognipeer/agent-server";
const storage = createMongoDBProvider({
connectionString: "mongodb://localhost:27017/mydb",
database: "mydb", // Optional
collectionPrefix: "agent_", // Default: 'agent_server_'
autoIndex: true, // Default: true
});
await storage.connect();import { createSQLiteProvider } from "@cognipeer/agent-server";
const storage = createSQLiteProvider({
filename: "./data/agent-server.db", // ':memory:' for a throwaway database
});
await storage.connect();One process owns the file; several server instances need PostgreSQL or MongoDB. See docs/guide/storage.md for what each provider can hold.
import { createTokenAuthProvider } from "@cognipeer/agent-server";
const authProvider = createTokenAuthProvider({
// Static tokens
tokens: {
"my-api-key": "user-1",
"another-key": "user-2",
},
// Or custom validation
validateFn: async (token) => {
const user = await myDatabase.findUserByToken(token);
if (user) {
return { valid: true, userId: user.id };
}
return { valid: false, error: "Invalid token" };
},
});
const agentServer = createAgentServer({
// ...
auth: {
enabled: true,
provider: authProvider,
headerName: "Authorization", // Default
tokenPrefix: "Bearer ", // Default
excludeRoutes: ["/docs", "/docs/*"], // Routes accessible without auth
},
});import { createJWTAuthProvider } from "@cognipeer/agent-server";
const authProvider = createJWTAuthProvider({
secret: "my-jwt-secret",
algorithm: "HS256", // Default
issuer: "my-app", // Optional - for validation
audience: "my-api", // Optional - for validation
extractUserId: (payload) => payload.sub as string,
});The built-in JWT provider verifies the token's signature with the configured key (HS256/384/512, RS256/384/512 or ES256/384/512; the algorithm comes from the configuration, never from the
token) and checks expiry, issuer and audience. It holds one static key and has no JWKS support: for keys that rotate, verify in front of the server or write an AuthProvider with a JWT
library - see docs/guide/authentication.md.
You can integrate your own agent without using the SDK:
agentServer.registerCustomAgent(
"my-agent",
{
processMessage: async (params) => {
const { conversationId, message, files, state, metadata } = params;
// Your own AI logic
const response = await myAIService.chat(message, {
history: await getHistory(conversationId),
files,
});
return {
content: response.text,
files: response.attachments,
state: { ...state, lastMessageAt: new Date() },
usage: {
inputTokens: response.usage.prompt,
outputTokens: response.usage.completion,
totalTokens: response.usage.total,
},
};
},
},
{
name: "My Custom Agent",
description: "A custom AI agent",
version: "1.0.0",
metadata: { capabilities: ["chat", "files"] },
},
);An agent that runs in your own backend streams with typed events and returns a typed result; the server stores the messages, the pending actions, the events and the runtime status:
agentServer.registerHostAgent({
info: { id: "support", name: "Support", capabilities: { toolApproval: true } },
async invoke({ message, history, files, context }, { emit, signal }) {
for await (const chunk of backend.stream(message, { signal })) emit({ type: "text", text: chunk });
const run = await backend.finish();
return run.needsApproval
? { pendingActions: [{ id: run.requestId, type: "tool_approval", toolName: run.tool, args: run.args }] }
: { usage: run.usage };
},
async resolveAction({ actionId, resolution }, { emit, signal }) {
return { content: (await backend.answer(actionId, resolution, { signal })).text };
},
async cancel({ runId, reason }) { await backend.cancel(runId); },
});See docs/guide/host-agents.md.
An agent can let the person pick the model that answers. List the choices in AgentInfo.models (two or more; exactly one default):
server.registerSDKAgent("support", agent, {
models: [
{ key: "gpt-5", label: "GPT-5", description: "Best quality", default: true },
{ key: "gpt-5-mini", label: "GPT-5 mini", description: "Faster, cheaper", default: false },
],
}, {
// local runner: switch before the turn (the instance is shared, so serialise turns if you mutate it)
selectModel: async ({ model, conversationId, context }) => agent.setModel(model),
});GET /agentsandGET /agents/:idreturnmodels. A host agent (for example one backed by the Console) reports the list the caller may use.POST .../messages,.../regenerateand.../editacceptmodel. A key that is not in the agent's list answers 400model_not_allowed.- Host agents get it as
HostInvokeInput.model, custom handlers asProcessMessageParams.model, Chat Completions / Responses agents use it as the request's model, and SDK agents get theselectModelhook. A request withoutmodelleaves all of these unset (the agent's own model runs). - The answer's
metadata.modelholds the key it came from (the requested one, else the advertised default), for "answered with" footers.
- Stream. A host agent reports the model's reasoning with
emit({ type: "thinking", text }). The server sends it asstream.thinkingframes ({ thinking }) next tostream.text. It is live only and never part of the stored answer. - Hiding it.
AgentInfo.showReasoning(defaulttrue, also published byGET /agents): withfalsenostream.thinkingframe is sent. Token accounting is unaffected. - Effort.
POST .../messages,.../regenerateand.../editacceptreasoningEffort(none,minimal,low,medium,high; 400 otherwise). Host agents get it asHostInvokeInput.reasoningEffort, custom handlers asProcessMessageParams.reasoningEffort. A request without it leaves both unset.
Resolve who is calling (and anything else your host knows, e.g. tenant) once per
request. The result reaches agents, storage and resolveUserId:
const server = createAgentServer({
basePath: "/api/portal",
storage, // receives the context as its last argument on every call
resolveContext: async (req) => {
const session = await lookupSession(req.cookies.sid); // req.headers / req.query / req.mountParams too
return session ? { user: { id: session.userId }, data: { tenantId: session.tenantId } } : null; // null -> 401
},
// array, or resolved per request
agents: async (ctx) => loadAgentsFor(ctx.data.tenantId as string),
});With resolveContext set the resolved identity is authoritative: userId sent by
the client is ignored, and conversations owned by another user answer 403. The lists return the
resolved user's own, and a request with no user cannot list (401 identity_required; a single-user
server sets allowAnonymousList: true).
Unknown agents (not served to this request) answer 404. See
docs/guide/request-context.md.
import Fastify from "fastify";
import { mountOnFastify } from "@cognipeer/agent-server/fastify";
const app = Fastify();
// the prefix may be parameterised; params arrive as req.mountParams in resolveContext
mountOnFastify(app, server, { prefix: "/api/portal/:portalKey" });import http from "node:http";
import { createNodeHandler } from "@cognipeer/agent-server";
const handle = createNodeHandler(server); // serves under server basePath
http.createServer(async (req, res) => {
if (!(await handle(req, res))) { res.statusCode = 404; res.end(); }
}).listen(3000);Extend BaseStorageProvider (connection check + ids) and implement the _-hooks,
or implement the StorageProvider interface directly. Every hook receives the
per-request RequestContext as its last argument:
import { BaseStorageProvider, type RequestContext } from "@cognipeer/agent-server";
class MyStorageProvider extends BaseStorageProvider {
async connect() { this._connected = true; }
async disconnect() { this._connected = false; }
protected async _createConversation(id, params, ctx?: RequestContext) {
// e.g. stamp ctx?.data.tenantId so reads can be scoped
}
protected async _getConversation(id, ctx?: RequestContext) { /* scope by ctx */ }
// ...implement the other abstract methods (see docs/guide/custom-storage.md)
}const agentServer = createAgentServer({
// ...
cors: {
enabled: true,
origins: ["http://localhost:3000", "https://myapp.com"],
methods: ["GET", "POST", "PATCH", "DELETE"],
headers: ["Content-Type", "Authorization"],
},
});const agentServer = createAgentServer({
// ...
swagger: {
enabled: true,
path: "/docs", // Default: '/docs'
title: "My Agent API", // API title
version: "1.0.0", // API version
description: "AI Agent REST API",
},
});You can access the Swagger UI at {basePath}/docs.
The OpenAPI spec is available at {basePath}/docs/openapi.json.
- WebSocket support
- Rate limiting
- Request logging
- Metrics and monitoring
MIT