Skip to content

Threads & runs

A thread is a conversation. A run is one execution of an agent over that conversation. Everything you do with the runtime API is some combination of the two, so it is worth being precise about which is which.

A thread holds message history and state. It does not belong to an agent — you can run different agents over the same thread, and they each see everything said before. A run, by contrast, is a single request-and-answer cycle: it takes the thread’s current state, appends your input, executes the agent (possibly calling tools several times), and writes the result back to the thread.

Terminal window
curl -s -X POST "$KOVAC_BASE/api/agent/threads" \
-H "Authorization: Bearer $KOVAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'

An empty body is fine; a thread needs nothing to exist. The response carries the thread_id every later call uses.

Threads are owned by the identity that created them, not merely by the tenant. An API key sees the threads it created and no others — including threads created by a different key in the same tenant, and threads created by a person signed into the platform UI. This is deliberate: a service holding a key should not be able to read the conversations of the humans in its tenant, and two services sharing one tenant should not see each other’s. It does mean that if you want one logical conversation across two processes, they must share a key.

GET /api/agent/threads/{thread_id}/state returns the thread’s current state without running anything — the messages so far, and the checkpoint they belong to.

Two endpoints start a run. They do the same work and differ only in how you get the result.

POST /threads/{thread_id}/runs returns immediately with a run record. You then poll GET /threads/{thread_id}/runs/{run_id} until it finishes. Use this when there is nobody watching — a scheduled job, a webhook handler, anything where a half-finished answer is not useful.

POST /threads/{thread_id}/runs/stream keeps the connection open and streams events as they happen. Use this whenever a person is waiting, and for anything long enough that you want to show progress.

Both take the same body:

{
"assistant_id": "...",
"input": { "messages": [{ "role": "user", "content": "..." }] },
"stream_mode": ["messages", "values"]
}

input.messages is the new turn. You do not resend the history — the thread already has it.

A run request may carry config and context, but some keys inside them are not yours to set: collections, trust_tier, guardrails, max_steps, tools, memory, require_approval, follow_ups, and workflow for a workflow. The platform overwrites them with the agent’s own stored values before the run starts, on every run, whether or not you sent them.

This is the mechanism behind the ceilings described in Agents & assistants. It is not validation — there is no error to catch, because a supplied value is replaced rather than rejected. If you set collections on a run and notice the agent still searching its configured set, that is the reason.

The stream is text/event-stream. Each event has a name, a JSON payload and an id.

Two are always sent, whatever you asked for:

  • metadata — once at the start, carrying the run_id.
  • end — once at the finish, carrying {"status": "success"} or {"status": "error"}.

A final reply may carry suggested next questions in response_metadata.kovac_follow_ups (see Agents & assistants); the reply’s text is the same with or without them.

The rest depend on stream_mode, and the mode you request is not always the event name you get:

  • messages produces messages/partial (token chunks as the model produces them, including tool calls as they are assembled), messages/complete (a message finished) and messages/metadata (per-message run detail). Use these for a live UI.
  • values produces values — the complete thread state after each step. Simpler to consume if you only want the final answer: keep the last one you receive.
  • updates produces updates — just what changed at each step, rather than the whole state.

Omit stream_mode entirely and you get values. Ask for the modes you want and ignore the rest; requesting both messages and values is common and costs little.

A run that fails emits an error event before end, and the HTTP response is still 200 — the request succeeded, the run did not. See Errors & limits.

A workflow run adds one more mode: custom produces custom events carrying {"type": "kovac_progress", ...}, the progress described under Progress of a workflow run.

Long silences are normal — an agent thinking, or a slow tool — so the stream sends periodic keepalive comments to stop intermediate proxies from dropping an idle connection. A well-behaved SSE client ignores them already.

By default, disconnecting cancels the run. Send "on_disconnect": "continue" if you would rather it finish anyway, which is what you want when the client is a browser tab a user might close.

The chat in the console works this way: a run started there keeps going if the page is reloaded or the connection drops, and the chat picks it up again when the conversation is opened. Stop cancels the run.

A run that reaches a tool or handover that needs approval ends with status interrupted. Read the thread’s state to see what it is waiting for: each pending interrupt’s value.action_requests lists the calls, with name, args and a one-line description.

Resume it with a run on the same thread that carries a command instead of input, with one decision per action, in order:

{"command": {"resume": {"decisions": [{"type": "approve"}]}}}

{"type": "approve", "always": true} also stops asking for that tool or handover for the rest of the thread. {"type": "reject", "message": "…"} denies it; the agent reads the message as the tool’s result and carries on. Anyone who can continue the thread can decide.

With several pending interrupts (parallel workers each pausing), LangGraph takes a map of interrupt id to {"decisions": [...]} as the resume value. Each interrupt’s id comes from the thread state.

A run that pauses ends interrupted, but so does a cancelled run. Check the thread’s own status before resuming: it is interrupted while it waits. A resume on a thread that is no longer waiting is refused with a 400.

Plain input sent to a paused thread does not resume it: it starts a new turn and closes the waiting call.

POST /threads/{thread_id}/runs/{run_id}/cancel stops a run in progress. GET /threads/{thread_id}/runs lists the runs on a thread, and GET /threads/{thread_id}/runs/{run_id} fetches one, which is the polling target for the non-streaming path.

Deleting a thread (DELETE /threads/{thread_id}) removes its history. Runs do not outlive their thread.

A workflow that runs for minutes reports where it is. Ask for stream_mode: ["custom"] (next to any others) and each custom event with "type": "kovac_progress" is one of two shapes:

{"type": "kovac_progress", "kind": "step", "step": "find", "title": "Find the documents", "state": "started", "status": null}
{"type": "kovac_progress", "kind": "container", "step": "each", "title": "Each document", "container": "for_each", "done": 3, "total": 10, "failed": 1, "of_at_most": false, "active": ["Статини.pdf"], "running_steps": ["read"], "state": "running"}

A step event comes when a top-level step or agent starts (state: "started") and ends ("finished", with status ok or error). A container event describes a for_each or loop: items done, the total (for a loop, its max_iterations with of_at_most true), how many iterations failed, the labels of the items in flight (at most five) and the steps of its body that are working now. Events are sent at most four times a second.

A client that polls instead of streaming reads the latest state, with the same API key:

Terminal window
curl -H "Authorization: Bearer $KOVAC_API_KEY" \
https://YOUR-HOST/api/manage/workflows/$WORKFLOW_ID/runs/$RUN_ID/progress
{"state": "running", "step": "each", "title": "Each document",
"container": "for_each", "done": 3, "total": 10, "failed": 1,
"of_at_most": false, "active": ["Статини.pdf"], "running_steps": ["read"],
"updated_at": "2026-10-19T10:00:00Z"}

state is running or finished, and a finished run keeps how its last container ended. A run that has reported nothing yet answers {"state": "none"}; a run of another workflow is a 404. active holds item labels only on your own runs, or for an editor of the workflow or a workspace admin; anyone else gets the counts and an empty list. The row is kept for 30 days.

Beyond the messages, a finished run leaves a trace: the model calls, the tool calls, the retrieved passages before and after reranking, and any guardrail decisions. You do not fetch those over this API — they are on the agent’s Traces tab. See Read traces and evals.

Next: Skills.