Build a chat app
Give every user their own always-on agent. The simplest thing to build on the Agent API, and where most teams start.
The pattern is four calls: create one instance per user at signup, start one session per chat thread, list sessions for the sidebar, and fetch one session for the open thread.
hermes-chat: this guide as a working app
Everything on this page, runnable: create instances from a table, stream replies token by token, list and reopen threads, cancel a turn. Express plus vanilla JS, no build step. Clone it, add your key, npm start.
One key, two base URLs
Info
Two base URLs, one key, two headers: https://api.pusk.ai manages instances and takes the key as Authorization: Bearer; each instance serves its own chat API at https://{instanceId}.pusk.app (the id is the hostname) and takes the same key as X-Pusk-Key, leaving Authorization free for your own app. See Core concepts.
The snippets below use a small shorthand client so the calls stay readable. api is the hosting API; agentOf(id) is one user's agent.
const HOSTING_HEADERS = {
Authorization: `Bearer ${process.env.PUSK_API_KEY}`,
"Content-Type": "application/json",
};
const AGENT_HEADERS = {
"X-Pusk-Key": process.env.PUSK_API_KEY,
"Content-Type": "application/json",
};
function makeClient(base, headers) {
return {
get: (path) => fetch(base + path, { headers }).then((r) => r.json()),
post: (path, body) =>
fetch(base + path, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
}).then((r) => r.json()),
};
}
const api = makeClient("https://api.pusk.ai", HOSTING_HEADERS);
const agentOf = (id) => makeClient(`https://${id}.pusk.app`, AGENT_HEADERS);
The shape of it
One instance per user, on signup
When a user signs up, create one instance for them, tagged with your own user id. That instance is their agent from then on.
const inst = await api.post("/v1/instances", { user: "u_882", name: "chat-u_882", budget: { credit_micros: 1000000 }, }); await db.users.update("u_882", { instanceId: inst.id }); // inst.id is bare, e.g. "ab12cd34ef", and doubles as the hostname: // https://ab12cd34ef.pusk.appOmitting
templategives youpusk-hermes, the default, on the default 2 vCPU / 4 GB RAM / 6 GB disk shape, billed from your workspace wallet (see Billing). Each create uses the template's newest published image; for a fleet where every signup must get the identical image, pass a version-pinned template instead ("template": "pusk-hermes@<tag>"). Thebudget.credit_microsfield grants one-time managed-spend headroom so the agent's LLM calls work from the first message; without it the per-instance budget defaults to $0.The call is synchronous and returns
201withstatus: "running": the instance's computer is up. The agent inside is still booting, usually seconds but up to a few minutes on a cold host, so before the first message pollGET /v1/healthon the instance URL until it answers with"ok": true(see Health and version). Storeinst.idon the user row.A session per chat thread
Each thread is a session on the user's instance. Send the first turn to the instance URL with no
session_id; the reply mints one. Store it on your thread row, then sendsession_idplus the newinputon every later turn. The session keeps the full history, so you never resend a transcript.const agent = agentOf(user.instanceId); // new thread: first turn, no session_id const first = await agent.post("/v1/responses", { input: "Research the top 3 EV makers, write a memo.", }); await db.threads.create({ userId: "u_882", sessionId: first.session_id }); // first.session_id, e.g. "7f3e0b6c52a949d2b1c4a8e9d0f31726" // later turns: session_id and the new input only const reply = await agent.post("/v1/responses", { session_id: thread.sessionId, input: "Make it shorter, add a quote.", }); render(reply.output_text);Tip
Stream every reply so the UI fills in as the agent reasons, calls tools, and writes. Set
stream: trueand read the Server-Sent Events; see Streaming for the full event list and a client parser.List threads for the sidebar
GET /v1/sessionson the instance URL lists the harness's sessions, newest first, without history.const { agent, data } = await agentOf(user.instanceId).get("/v1/sessions"); // Hermes: [{ id, title, model, message_count, started_at, last_active, preview }] // timestamps are epoch millisecondsOn Hermes the list already carries a
title, and you can set it withPATCH /v1/sessions/{id}({ "title": "..." }); the first user message usually makes a good default. Harnesses that do not store a title answer405; for those, keep titles in your own database keyed bysession_id.Load a thread when it opens
GET /v1/sessions/{id}returns the session with its full transcript inhistory, in order.const session = await agentOf(user.instanceId).get( `/v1/sessions/${thread.sessionId}` ); // session.history: [{ id, session_id, role, content, thinking?, created_at }] // session.active_response_id: the running response's id, or null when idleRender each message by
role(user,assistant, orsystem). Ifactive_response_idis set, a turn is still running and its messages are not inhistoryyet, so reattach withGET /v1/responses/{id}/streamto render it live (this is how a page reload mid-turn recovers the stream). When a user deletes a thread,DELETE /v1/sessions/{id}removes it; see Sessions.
Handle a busy session
A session runs one response at a time. Posting a new turn while one is in flight returns 409:
{
"error": {
"code": "session_busy",
"message": "A response is already running on this session.",
"hint": "Reattach with GET /v1/responses/{response_id}/stream, cancel it, or start another session.",
"response_id": "c91d2a7e84f04b6f9a3d5e1c0b87f4a2"
}
}
error.response_id is the running response, so even a client that lost its state can reattach to it or cancel it. Three good ways to handle it in a chat UI:
- Disable the composer while a turn runs, and re-enable it when the reply arrives (the non-streaming call returning, or the terminal streaming event).
- Offer a stop button that calls
POST /v1/responses/{id}/cancelon the instance URL. Withstream: truethe first event,response.created, hands you the response id immediately, which is what makes the button possible. Cancel is best effort: the response ends withstatus: "cancelled", and whatever the agent already did is not undone. - Reattach instead of erroring: on a 409,
GET /v1/responses/{response_id}/streamreplays the running turn from its start and follows it live. Iferror.response_idis absent (a rare race, or an older gateway), readactive_response_idfromGET /v1/sessions/{id}instead.
The hermes-chat example wires up the first two: the composer locks while a turn is in flight, and the stop button cancels it.
Other threads are unaffected: each session has its own lock, so one user can run turns in several threads at once.