The endpoint-by-endpoint contract for driving CRHQ sessions from your product — creation, turns, messages, streaming, status and errors.
Everything below works with a scoped API key (sessions:write / sessions:read). Base URL is your instance (https://<instance> or http://127.0.0.1:3456 on-host).
POST /api/sessions — create + configure without spending tokens; persist the id in your own DB transaction first (crash-safe).PATCH /api/conversations/:id and POST /api/conversations/:id/agent — title, model, agent.POST /api/agent — start the first turn./metadata for status.POST /api/sessions/:id/message for follow-ups.POST /api/sessions{ "sessionId": "session-1785…-abc", // optional — server generates if absent
"name": "Intake — Acme Corp", // optional
"agent": "prd-builder", // optional; must be a registered, active agent
"model": "opus" } // optional; validated against the model catalog
Responses: 201 {"success":true,"sessionId":"…","created":true} · 200 …"created":false (idempotent re-POST — provided fields are applied). A retry is always safe. Bad model or unknown agent → 400 with a readable message. Session ids are yours to generate — any string works.
POST /api/agent{ "instruction": "…", "sessionId": "session-1785…-abc" }
Two possible outcomes:
{ "success": true, "status": "processing", "queued": false, "processed": true, … }
{ "success": true, "status": "queued", "queued": true,
"messageId": "msg-…", "queuePosition": 1, "queueStatus": { … } }
queued:true means the session was busy — the message runs after the current turn ends. Concurrency is guaranteed: two calls can never start two simultaneous turns on one session.
The retry contract — read this twice. If you retry an instruction (network timeout, worker crash) and get queued:true, the queued copy is a duplicate that will run again later. Void it: DELETE /api/conversations/:sessionId/queue/:messageId. Rule of thumb: queued:true on a retry means "already running — drop the duplicate and keep waiting."
A user-stopped session responds queued:true, parked:true, reason:"recipient_stopped_by_user" — the message waits for a human to resume that session.
POST /api/sessions/:sessionId/message{ "content": "…" }
Same processed / queued / parked contract as POST /api/agent. The session must exist (404 otherwise).
PATCH /api/conversations/:sessionId with { "name": "…" } and/or { "model": "…" } (validated).POST /api/conversations/:sessionId/agent binds a registered agent — its instructions, skills and platform context are assembled server-side into every subsequent turn.GET /api/conversations/:sessionId/metadata{ "metadata": { "status": "idle" | "running" | "error" | "interrupted" | "stopped", … } }
Returns 404 for unknown ids — this is the canonical liveness endpoint. The transcript route below returns 200-empty for unknown ids by default; add ?strict=1 if you use it for liveness.
GET /api/conversations/:sessionId?limit=N&offset=MPaginated message history plus metadata. Add &strict=1 to get 404 on unknown ids.
wss://<instance>/?sessionId=session-1785…-abc (header: x-api-key: crhq_sk_…)
→ send: { "action": "subscribe", "sessionId": "session-1785…-abc" }
→ receive events keyed on "type": text, tool_use, session_run_status, processing_queued, …
Subscribing requires sessions:read; starting a turn over WS (action: "instruction") requires sessions:write. Recent events are buffered and replayed on reconnect; reconnection logic is your responsibility.
| Signal | Meaning | What to do |
|---|---|---|
401 | Unknown, revoked or missing key | Stop; the key may have been revoked in Settings |
403 | Valid key, insufficient scope or off-limits endpoint (message names the reason) | Fix the call or request a broader scope — don't retry as-is |
404 | Unknown session (/metadata, or ?strict=1) | Treat as not-running / deleted |
400 | Validation error (bad model, unknown agent, missing field) | Fix the request — messages are human-readable |
queued:true | Session busy; message queued for after the current turn | On a retry: void the duplicate |
If your key is pinned to specific agents (recommended — see Scoped API Keys):
POST /api/sessions without an agent binds your first allowed agent automatically.403.POST /api/conversations/:id/agent with an agent outside your list → 403.403.K="crhq_sk_…"; B="https://<instance>"
SID="session-$(date +%s000)-smoke1"
curl -s -X POST $B/api/sessions -H "x-api-key: $K" -H 'Content-Type: application/json' \
-d "{\"sessionId\":\"$SID\",\"name\":\"smoke\"}" # → 201 created:true
curl -s -X POST $B/api/agent -H "x-api-key: $K" -H 'Content-Type: application/json' \
-d "{\"sessionId\":\"$SID\",\"instruction\":\"Reply exactly: OK\"}" # → processing
sleep 20
curl -s $B/api/conversations/$SID/metadata -H "x-api-key: $K" # → status: idle
curl -s "$B/api/conversations/$SID?limit=5" -H "x-api-key: $K" # → transcript incl. "OK"
curl -s $B/api/settings/agents -H "x-api-key: $K" # → 403 — the wall works