API Reference - Chats
Chats are user-driven conversations with a single agent, persisted as top-level entities (not nested under a workspace). Each chat runs over a WebSocket stream. Unlike a workspace session a chat never parks: ask_user and the _approval gate soft-yield, ending the turn conversationally so the human's next message resolves the call (the pending call is held in pending_tool_call).
Turn mechanics, the agent switcher, compaction, and streaming.
Endpoints
| Method | Path | Summary |
|---|---|---|
| POST | /v1/chats |
Create a new chat bound to an agent |
| POST | /v1/chats/{chat_id}/agent |
Switch the chat's current agent |
| GET | /v1/chats |
List chats (paginated) |
| GET | /v1/chats/{chat_id} |
Get a chat by id |
| DELETE | /v1/chats/{chat_id} |
End (or hard-delete) a chat |
| GET | /v1/chats/{chat_id}/messages |
List messages on a chat |
| GET (ws) | /v1/chats/{chat_id}/ws |
Stream messages and events over WebSocket |
POST /v1/chats
Creates a new chat. The agent reference is validated at creation time; a missing agent returns 404.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
agent_id |
string | yes | Id of an existing agent |
curl -X POST https://primer.example/v1/chats \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent-assistant"}'import httpx
r = httpx.post(
"https://primer.example/v1/chats",
headers={"Authorization": f"Bearer {token}"},
json={"agent_id": "agent-assistant"},
)
r.raise_for_status()
chat = r.json()const r = await fetch("/v1/chats", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_id: "agent-assistant" }),
});
const chat = await r.json();Response: 201
| Field | Type | Notes |
|---|---|---|
id |
string | Server-assigned id, prefixed chat- |
agent_id |
string | Echoed from request |
status |
string | active or ended |
last_seq |
integer | Starts at 0; increments per message |
title |
string or null | Set on the first user turn; null until then |
created_at |
string | ISO-8601 timestamp |
Errors: 404 agent not found, 422 missing agent_id.
POST /v1/chats/{chat_id}/agent
Switches the chat's current agent mid-conversation. The agent and its system
prompt are resolved fresh at the start of every turn from chat.agent_id, so
updating that field is the entire switch: the next turn runs under the new
agent with the full prior conversation as shared context. History is never
rewritten - only the system prompt and tool set change from the next turn on.
If the chat is paused on a pending gate (an ask_user question or a tool
_approval), that gate is auto-rejected before the switch so the append-only
message log stays valid: a rejected tool_result plus a cancelled row are
appended and pending_tool_call is cleared. Switching to the agent the chat is
already bound to is an idempotent no-op that returns the unchanged chat.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
agent_id |
string | yes | Id of the agent to switch this chat to |
curl -X POST https://primer.example/v1/chats/chat-abc123/agent \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent-researcher"}'import httpx
r = httpx.post(
"https://primer.example/v1/chats/chat-abc123/agent",
headers={"Authorization": f"Bearer {token}"},
json={"agent_id": "agent-researcher"},
)
r.raise_for_status()
chat = r.json()const r = await fetch("/v1/chats/chat-abc123/agent", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_id: "agent-researcher" }),
});
const chat = await r.json();Response: 200 - the updated Chat object, with agent_id set to the new
agent. Switching to the same agent returns the chat unchanged (idempotent).
Errors: 404 chat not found or target agent not found, 409 the chat has
ended, 422 missing or empty agent_id.
GET /v1/chats
Lists chats with optional agent filter and pagination.
Query parameters:
| Param | Type | Notes |
|---|---|---|
agent_id |
string | Filter to one agent |
limit |
integer | Page size |
offset |
integer | Page offset |
cursor |
string | Cursor token for cursor-mode pagination |
curl "https://primer.example/v1/chats?agent_id=agent-assistant&limit=20&offset=0" \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get(
"https://primer.example/v1/chats",
headers={"Authorization": f"Bearer {token}"},
params={"agent_id": "agent-assistant", "limit": 20, "offset": 0},
)
r.raise_for_status()
page = r.json()const params = new URLSearchParams({
agent_id: "agent-assistant", limit: 20, offset: 0,
});
const r = await fetch(`/v1/chats?${params}`, {
headers: { "Authorization": `Bearer ${token}` },
});
const page = await r.json();Response: 200 - {"items": [Chat, ...], "total": N}
GET /v1/chats/{chat_id}
Returns a single chat by id.
curl "https://primer.example/v1/chats/chat-abc123" \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get(
"https://primer.example/v1/chats/chat-abc123",
headers={"Authorization": f"Bearer {token}"},
)
r.raise_for_status()
chat = r.json()const r = await fetch("/v1/chats/chat-abc123", {
headers: { "Authorization": `Bearer ${token}` },
});
const chat = await r.json();Response: 200 - Chat object. Errors: 404 not found.
DELETE /v1/chats/{chat_id}
Ends a chat (soft delete by default) by transitioning it to status: "ended". A second DELETE on an already-ended chat returns 409. Pass ?force=true to hard-delete the row and all messages.
Response: 200 - Chat with status: "ended". Errors: 404 not found, 409 already ended.
GET /v1/chats/{chat_id}/messages
Lists stored messages for a chat. Returns 404 if the chat does not exist (probe-resistance: the endpoint does not reveal whether an id has zero messages vs. does not exist).
Query parameters:
| Param | Type | Notes |
|---|---|---|
after_seq |
integer | Return only messages with seq > after_seq |
before_seq |
integer | Return only messages with seq < before_seq |
limit |
integer | Page size |
offset |
integer | Page offset |
cursor |
string | Cursor token for cursor-mode pagination |
curl "https://primer.example/v1/chats/chat-abc123/messages?after_seq=0&limit=50" \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get(
"https://primer.example/v1/chats/chat-abc123/messages",
headers={"Authorization": f"Bearer {token}"},
params={"after_seq": 0, "limit": 50},
)
r.raise_for_status()
page = r.json()const params = new URLSearchParams({ after_seq: 0, limit: 50 });
const r = await fetch(`/v1/chats/chat-abc123/messages?${params}`, {
headers: { "Authorization": `Bearer ${token}` },
});
const page = await r.json();Response: 200 - {"items": [...], "total": N}. Errors: 404 chat not found.
GET (ws) /v1/chats/{chat_id}/ws
WebSocket endpoint for streaming chat messages and events in real time. Connect once and receive all messages appended after the supplied cursor.
Query parameters:
| Param | Type | Notes |
|---|---|---|
cursor |
integer | Resume from this seq value; messages with seq > cursor are replayed then streamed live. Defaults to 0 (full replay). |
Authentication uses the same bearer token or session cookie as the REST surface. Pass it via a query param or a first-frame protocol message depending on your WebSocket client.
The server emits JSON frames per message. On reconnect, supply the highest seq you received as ?cursor=<seq> to avoid replaying already-seen messages.
Example connection (pseudocode)
# wscat (npm i -g wscat):
wscat -c "wss://primer.example/v1/chats/chat-abc123/ws?cursor=0" \
-H "Authorization: Bearer $TOKEN"import asyncio
import httpx
import websockets
async def stream():
uri = "wss://primer.example/v1/chats/chat-abc123/ws?cursor=0"
async with websockets.connect(
uri, extra_headers={"Authorization": f"Bearer {token}"}
) as ws:
async for frame in ws:
print(frame)
asyncio.run(stream())const ws = new WebSocket(
"/v1/chats/chat-abc123/ws?cursor=0",
);
ws.addEventListener("message", (evt) => {
const frame = JSON.parse(evt.data);
console.log(frame);
});Error envelopes
All error responses use RFC 7807 problem details:
# Example 404 response body:
# {
# "type": "/errors/not-found",
# "title": "Not Found",
# "status": 404,
# "detail": "chat not found"
# }# Inspect r.json()["type"] after a non-2xx response:
# "/errors/not-found" : chat or agent does not exist
# "/errors/conflict" : illegal state transition (e.g. DELETE on ended chat)
# "/errors/validation-error" : missing required field (e.g. no agent_id)// On error, r.ok === false and r.json() returns:
// { type: "/errors/not-found", title: "...", status: 404, detail: "..." }
// Common types: /errors/not-found, /errors/conflict, /errors/validation-error