HQ turns are asynchronous: you post a message, it runs server-side on the agent’s machine, and you watch it unfold over a Server-Sent Events (SSE) stream. This is how you build a live chat UI or react to tool calls and artifacts as they happen.
The flow
Create or pick a conversation
Create one against an agent (agent_id from List agents):curl -X POST https://api.hq.zone/v1/api/conversations \
-H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
-d '{"agent_id":"<AGENT_ID>"}'
# → { "id": "<CONVERSATION_ID>", ... }
Open the event stream
Attach to the stream so you don’t miss early events:curl -N https://api.hq.zone/v1/api/conversations/<CONVERSATION_ID>/stream \
-H "Authorization: Bearer $HQ_TOKEN" -H "Accept: text/event-stream"
-N disables buffering so events print as they arrive. Post a message
curl -X POST https://api.hq.zone/v1/api/conversations/<CONVERSATION_ID>/messages \
-H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
-d '{"text":"What changed in revenue quarter over quarter?","idempotency_key":"<UUID>"}'
# → 202 { "accepted": true, "stream_url": "/v1/api/conversations/<id>/stream", "idempotent": false }
The response is immediate; the reply arrives on the stream you opened.
Reading the stream
Each event has a name, an id: cursor, and a one-line JSON payload:
event: text_delta
id: 7Z9aQ2
data: {"text":"Revenue rose 12% "}
event: text_delta
id: 7Z9aQ3
data: {"text":"quarter over quarter."}
event: final
id: 7Z9aQ4
data: {"text":"Revenue rose 12% quarter over quarter.","duration_ms":4120}
Append text_delta chunks to the active reply; final (or error) ends the turn. The full event vocabulary and scope rules are on Stream conversation events.
In JavaScript
EventSource can’t set an Authorization header, so read the stream with fetch and a streaming body reader (works in Node 18+ and modern browsers):
const base = "https://api.hq.zone";
const headers = { Authorization: `Bearer ${process.env.HQ_TOKEN}` };
const convId = "<CONVERSATION_ID>";
// 1. Post the message (returns immediately).
await fetch(`${base}/v1/api/conversations/${convId}/messages`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ text: "What changed in revenue quarter over quarter?", idempotency_key: crypto.randomUUID() }),
});
// 2. Read the reply off the SSE stream.
const res = await fetch(`${base}/v1/api/conversations/${convId}/stream`, {
headers: { ...headers, Accept: "text/event-stream" },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop(); // keep the trailing partial frame
for (const frame of frames) {
const ev = {};
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) ev.event = line.slice(6).trim();
else if (line.startsWith("data:")) ev.data = line.slice(5).trim();
// lines starting with ":" are heartbeats — ignore
}
if (!ev.event) continue;
const payload = ev.data ? JSON.parse(ev.data) : {};
if (ev.event === "text_delta") process.stdout.write(payload.text);
if (ev.event === "final" || ev.event === "error") return;
}
}
Track the last id: you saw and send it as the Last-Event-ID header when reconnecting — the server replays what you missed from the journal (or pass ?since_seq=<id> on a fresh attach). Always include an idempotency_key in the message body so a network retry never starts a duplicate turn.
Tips
- Open the stream early — attach before or right after posting so you catch the first
text_delta.
- One stream, many turns —
result/error ends a turn, not the stream. Keep it open and post again for the next turn.
- Stop a running turn with Interrupt the active turn.