AgentServerClient
Low-level client for @cognipeer/agent-server. Chat and useChat create one from baseUrl; use it directly for your own UI. It implements ChatTransport, the interface every backend adapter implements.
Import
import { AgentServerClient } from "@cognipeer/chat-ui";Constructor
const client = new AgentServerClient({
baseUrl: "/api/agents", // required
authorization: "Bearer token", // optional
headers: { "X-Custom": "value" },
getHeaders: async () => ({ Authorization: `Bearer ${await freshToken()}` }), // before every request
fetch: (url, init) => fetch(url, { ...init, credentials: "include" }), // proxies, cookies, tests
rawUpload: true, // send files as themselves (default), false: base64 JSON
reconnect: { retries: 6, delayMs: 500, maxDelayMs: 8000 }, // followEvents
});getHeaders is merged over authorization and headers. agentId is accepted but not used by the client; conversations carry their agent.
Methods
Agents
const agents = await client.getAgents(); // GET /agents -> AgentInfo[]
const agent = await client.getAgent("support"); // GET /agents/:idConversations
const { conversations, total, limit, offset, hasMore } =
await client.getConversations({ agentId?, userId?, limit?, offset? });
const conversation = await client.createConversation({ agentId: "support", title?, userId?, metadata? });
const { conversation, messages, pendingActions, messagesPage } = await client.getConversation(conversationId);
const older = await client.getConversation(conversationId, { before: messages[0].id, limit?: 50 }); // the page just older than a message
await client.updateConversation(conversationId, { title?, metadata?, budget? });
await client.deleteConversation(conversationId);messages is the newest page of the history (agent-server sends limits.messagePageSize, 100 by default); messagesPage ({ limit, order, hasMoreBefore, hasMoreAfter }) says whether older messages exist, and before fetches them. useChat does this for you: see hasEarlierMessages and loadEarlierMessages. pendingActions lists the approvals and questions still waiting for an answer (agent-server 0.3.0+). conversation.runtime ({ status, stopReason?, lastEventSeq, pendingActionIds, activeRunId? }), conversation.budget and conversation.spentUsd come with the conversation and with each item of the list; a runtime or budget that is not well-formed is dropped.
Messages
const { messages, total, hasMore } = await client.getMessages(conversationId, { limit?, offset?, order? });
// JSON response
const { message, response, status, pendingActions } = await client.sendMessage(conversationId, {
message: "Hello!",
fileIds: ["file-1"], // files uploaded earlier with uploadFile
files: [{ name: "a.txt", content: "<base64>", mimeType: "text/plain" }], // or inline
metadata: {},
});
// Streaming: events arrive through callbacks
await client.sendMessageStream(
conversationId,
{ message: "Hello!" },
{
onStart: (event) => {},
onText: (chunk, fullText) => {},
onToolCall: (event) => {},
onToolResult: (event) => {},
onProgress: (event) => {},
onActionRequired: (event) => {}, // the turn paused for an approval or a question
onActionResolved: (event) => {},
onError: (error) => {}, // error.code when the event has one
onDone: (event) => {},
},
abortSignal // optional
);Approvals and questions
// Streams the continuation through options.callbacks
await client.resolveAction(conversationId, actionId, { type: "tool_approval", decision: "allow" }, {
stream: true,
callbacks: { onText: (chunk, full) => {}, onDone: (event) => {} },
signal: abortSignal,
});
// Or returns the JSON response
const { response } = await client.resolveAction(conversationId, actionId, {
type: "user_question",
answers: { "Which region?": "eu" },
}) ?? {};POST /conversations/:conversationId/actions/:actionId with { resolution, stream? }. See Approvals and Questions.
Regenerate, edit, feedback
// Run the newest answer again (POST /conversations/:id/messages/:messageId/regenerate); same streaming options as resolveAction
await client.regenerateMessage(conversationId, messageId, { stream: true, callbacks: { onText, onDone } });
// Replace one of the person's messages and run the turn again (POST .../messages/:messageId/edit { content, fileIds? })
await client.editMessage(conversationId, messageId, { content: "Show only last month" }, { stream: true, callbacks });
// Rate an answer (POST .../messages/:messageId/feedback { rating, reasons?, comment? }); rating: null withdraws it
const stored = await client.sendFeedback(conversationId, messageId, { rating: "down", reasons: ["inaccurate"], comment: "Wrong date" });regenerateMessage answers 409 conversation_busy while a turn runs and not_latest_message for an answer that is not the newest. sendFeedback resolves to the rating the server stored (or null after a withdrawal).
History: rename, pin, delete several, export
await client.renameConversation(conversationId, "Quarterly report"); // PATCH /conversations/:id { title }
// PATCH /conversations/:id { pinned }. Resolves with the pin the server stored (true / false), or undefined when it does not say.
// An unpin whose answer still shows a pin is checked by reading the conversation again.
const pinned = await client.pinConversation(conversationId, true);
// POST /conversations/bulk-delete { ids }, at most 100 per call (more are sent in turns); a server without the route is served one by one
const { deleted, failed } = await client.deleteConversations(["c1", "c2"]); // failed: { id, code?, message? }[]
// GET /conversations/:id/export?format=md|json|pdf&tools=0|1
const { blob, filename, contentType } = await client.exportConversation(conversationId, { format: "md", includeTools: false });exportConversation falls back to the server's print view (format=html) when a pdf request is answered 501 export_format_unsupported; the result's contentType is then text/html and Chat prints it. See Portal Experience.
Runs: cancel, follow, continue
// Stop the run on the server (POST /conversations/:id/cancel); the open approvals of a paused conversation are cancelled too
const { cancelled, runId, settled, cancelledActionIds, stopReason, runtime } = await client.cancel(conversationId);
// Replay the event log after `after`, then follow it live until aborted (GET /conversations/:id/events/stream)
const controller = new AbortController();
await client.followEvents(conversationId, {
after: conversation.runtime?.lastEventSeq,
signal: controller.signal,
types: ["agent.message", "session.status_idle"], // optional filter
onEvent: ({ seq, type, data, runId, createdAt }) => {},
onReconnect: (attempt) => {},
});
// Carry on a turn the usage limit stopped (POST /conversations/:id/continue)
await client.continueConversation(conversationId, { stream: true, callbacks: { onText, onDone } });
const reply = await client.continueConversation(conversationId); // JSON
// Raise or remove the limit (PATCH /conversations/:id { budget })
// The answer carries `continuable`: true only when a stopped turn can be resumed (call continueConversation then);
// false when the turn had finished its answer before the limit, so the raise only reopens the conversation
const updated = await client.updateBudget(conversationId, { maxCostUsd: 20, onReach: "pause" });
if (updated.continuable) await client.continueConversation(conversationId);
await client.updateBudget(conversationId, null);followEvents resolves when aborted. It opens the stream again from the last seq when the server closes it or the network drops (with backoff, up to reconnect.retries failed attempts in a row), and rejects with a refusal that retrying cannot fix (404, 403, ...). Events are { seq?, type, data, runId?, createdAt? } with the Console's snake_case data (tool_call_id, stop_reason, ...); agent.message.delta frames (live text) have no seq. Unknown types arrive too: ignore them. See the event types.
cancel answers 200, or 202 (settled: false) while the run is still winding down or runs on another node; nothing to cancel is cancelled: false, not an error. Errors carry code and details (budget_forbidden, invalid_budget, budget_reached with details.spentUsd / maxCostUsd, nothing_to_continue).
Files
// The file as itself: its bytes are the body, its own Content-Type (POST /files?name=&conversationId=)
const file = await client.uploadFile({
file: { name: "a.pdf", mimeType: "application/pdf", blob: fileOrBlob },
conversationId, // without it the file cannot be attached to a message
onProgress: ({ loaded, total }) => {}, // XMLHttpRequest, unless you gave your own `fetch`
signal: abortSignal,
});
// The JSON form
await client.uploadFile({ file: { name: "a.txt", content: "<base64>", mimeType: "text/plain" }, conversationId });
const { files, total, maxFiles } = await client.listFiles(conversationId); // GET /conversations/:id/files
const info = await client.getFile(fileId);
await client.deleteFile(fileId);
const url = client.getFileContentUrl(fileId); // GET /files/:id/content (needs the same credentials)
const shown = await client.getFileUrl({ id: fileId }, { disposition: "inline" }); // a URL an <img> can use
client.dispose(); // release the blob URLs getFileUrl madeuploadFile resolves to a FileAttachment (id, name, mimeType, size, expiresAt?, conversionStatus?). supportsRawUpload is true unless rawUpload: false; a blob given to a client that does not send raw bodies is read as base64.
getFileUrl is the file route itself when no credentials are configured. With authorization, headers, getHeaders or a custom fetch, the bytes are fetched with them and handed back as a blob URL, cached per file (the 200 most recently used are kept; dispose() releases them all, and useChat does so when a conversation is left). Only raster pictures keep their own type; any other blob is typed application/octet-stream, so a stored HTML or SVG file can not run on your page.
Errors
A request that fails rejects with an Error whose message is the server's, plus status (HTTP status) and code when the server sent one:
try {
await client.sendMessage(conversationId, { message: "Hello" });
} catch (error) {
if (error.status === 401) await refreshToken();
else if (error.code === "conversation_busy") { /* a turn is still running */ }
}Chat and useChat turn well-known codes into localized messages and recover from the 409 conflicts (a card waiting, a turn running) by fetching the conversation again; see Approvals and Questions.
Stream events
The streaming methods read Server-Sent Events whose data is a JSON StreamEvent (see Types): stream.start, stream.text, stream.thinking, stream.tool_call, stream.tool_result, stream.progress, stream.action_required, stream.action_resolved, stream.error and stream.done. Every frame may carry seq, its position in the conversation's event log, and stream.done may carry stopReason, runId, budget, spentUsd and warnings.
Cancelling
Pass an AbortSignal to stop reading a stream. This detaches the browser; a turn on agent-server 0.3.0+ goes on without it. To stop the run itself, call client.cancel(conversationId).
const controller = new AbortController();
setTimeout(() => controller.abort(), 30000);
try {
await client.sendMessageStream(conversationId, { message: "Long task..." }, { onText }, controller.signal);
} catch (error) {
if (error.name === "AbortError") console.log("Detached");
}Which methods a transport has
Besides the required core (getAgents, getConversations, createConversation, getConversation, deleteConversation, sendMessage, sendMessageStream), ChatTransport has optional methods; the UI offers a feature only where the method exists. AgentServerClient implements all of them:
resolveAction, uploadFile (+ supportsRawUpload), deleteFile, listFiles, getFileUrl, followEvents, cancel, continueConversation, updateBudget, regenerateMessage, editMessage, sendFeedback, renameConversation, pinConversation, deleteConversations, exportConversation.
The routes behind each are listed in Server routes.

