Runs
A run is one execution of your graph. Starting one is the single most common thing you'll do against skein, and the shape you pick — wait for it, stream it, or queue it — decides everything about how your client has to behave.
Pick a run mode
| You want | Use | You get |
|---|---|---|
| The answer, and you can hold a connection | client.runs.wait(threadId, …) | The final state, as JSON |
| Tokens as they're produced (a chat UI) | client.runs.stream(threadId, …) | An SSE stream |
| To return immediately and check back later | client.runs.create(threadId, …) | The Run row; work continues server-side |
| A one-shot call with no conversation to keep | The same three, with null for the thread | skein creates and owns the thread |
| To start many at once | client.runs.createBatch([…]) | Up to 100 runs per request |
// Wait for it
const state = await client.runs.wait(threadId, "agent", { input });
// Stream it
for await (const chunk of client.runs.stream(threadId, "agent", {
input,
streamMode: "messages",
})) {
// …
}
// Queue it, come back later
const run = await client.runs.create(threadId, "agent", { input });
for await (const ev of client.runs.joinStream(threadId, run.run_id)) console.log(ev);Background runs need somewhere to run. They execute on the server after your request returns, so they need a process that stays alive — which is why they don't work on serverless platforms. See what doesn't work on serverless.
Streaming is its own topic — the stream modes, reconnecting mid-stream, and joining from a second client are in streaming.md.
Search runs across threads
POST /runs/search returns run rows across threads without enumerating every thread. For example, find currently failed runs updated in the last hour using your deployment's normal auth headers:
const response = await fetch(`${apiUrl}/runs/search`, {
method: "POST",
headers: { "content-type": "application/json", ...authHeaders },
body: JSON.stringify({
status: "error",
updated_after: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
limit: 100,
offset: 0,
}),
});
if (!response.ok) throw new Error(`Run search failed: ${response.status}`);
const runs = await response.json();| Field | Meaning |
|---|---|
status | One run status: pending, running, success, error, timeout, interrupted, or cancelled |
assistant_id, thread_id | Exact identifiers; combine them with any other filter |
created_after, created_before | Creation-time window: inclusive lower bound, exclusive upper bound |
updated_after, updated_before | Last-update window with the same half-open bounds |
limit, offset | Default 100 and 0; limit must be 1–1000, offset a nonnegative integer |
Timestamps must include a timezone and are normalized to UTC. Invalid values or inverted windows return 400; equal bounds select no rows. Results are ordered by stored created_at DESC, then run_id DESC. A deployment's lower page cap still applies. To traverse pages, increase the offset by the number returned and stop on an empty page. The response is a plain Run[], without execution kwargs, retained results, or a total count. Existing thread-scoped listing keeps its behavior.
Search authorizes through threads:search and matches the ownership metadata stamped on each run. All filters, including server-enforced ownership, run inside the storage query before pagination. Custom auth engines should express access through the declared ownership filters. If a stricter custom matchesFilters callback rejects a returned row, the entire request returns 501; Skein does not expose that row or silently shorten the page. Custom stores without RunRepo.search also return 501. All bundled stores implement it.
The tested SDK versions 1.9.25 and 1.12.6 do not expose client.runs.search(). Use HTTP as above; the endpoint and its shared validator appear automatically in the deployment's generated /docs reference. Programmatic store/service queries use the exported RunSearchQuery with camel-case identifiers and time bounds (assistantId, threadId, createdAfter, createdBefore, updatedAfter, updatedBefore).
Multitask: what happens to the run already going
Send a second message before the first finishes ("double-texting") and something has to give. Pass multitask_strategy on the second run to say what:
| Strategy | The run already going | The new run | Reach for it when |
|---|---|---|---|
reject | Keeps going | Fails with 422 | The default. A second message is a bug or a double-click |
interrupt | Stops, keeps what it wrote | Starts now | The user changed their mind, and the partial answer is still worth keeping |
rollback | Stops, its writes discarded | Starts now | The user is correcting themselves — the abandoned turn should read as if it never happened |
enqueue | Runs to completion | Waits, then runs | Both messages matter and order is what you want |
await client.runs.create(threadId, "agent", { input, multitaskStrategy: "interrupt" });Things worth knowing before you pick:
rejectis the default, and apendingrun counts as busy. A run held byafterSeconds, or one sitting in the queue, will reject the next one just as a running one does. That surprises people — but work is scheduled on the thread.- The 422 is
thread_busy. Handle it distinctly from an ordinary validation failure: the client's move is to retry or to switch strategy, not to fix the request. enqueueis not strict FIFO unless you run at concurrency 1. Concurrent workers can observe queued runs in different orders. See thread dispatch.- It's decided atomically, so two instances racing the same thread cannot both win. You don't need a lock of your own.
A displaced run settles interrupted (under interrupt) or cancelled (under rollback), and one that never started executing sends no webhook.
Cancelling
await client.runs.cancel(threadId, runId); // action: "interrupt", the defaultaction | Effect |
|---|---|
interrupt (default) | Settles the run cancelled and keeps whatever it wrote |
rollback | Also discards its checkpoint writes and deletes the run row — the turn reads as never having happened |
WARNING
cancel takes these positionally, not as an options object. The signature is cancel(threadId, runId, wait?, action?), so cancel(tid, rid, { action: "rollback" }) binds your object to wait and silently performs an ordinary interrupt instead.
await client.runs.cancel(threadId, runId, true, "rollback"); // wait for it to stop, discard its writesThe third argument returns only once the run has actually stopped, rather than as soon as it's been marked.
To cancel in bulk, POST /runs/cancel takes { thread_id?, run_ids?, status? }, narrowest selector first: explicit ids, else one thread's inflight runs, else every inflight run on the server. Unknown ids are skipped rather than failing the sweep, and the response says what actually happened.
Bound a runaway run
A graph that hangs — a model call with no timeout of its own, a node that loops — holds a worker slot until the process restarts. Set --run-timeout <ms> or SKEIN_RUN_TIMEOUT_MS; the run aborts and settles as timeout.
Off by default, deliberately. A legitimate research or multi-step tool run takes minutes, so a default would turn slow-but-working into killed — the exact failure the timeout exists to prevent. Pick a number from your own graphs' worst honest case.
Individual run creation requests can additionally supply timeout_ms, an integer from 1 through 2,147,483,647 milliseconds. The duration starts at execution, so a queued delay does not consume it. The effective limit is the smallest of this duration, the deployment limit, and any remaining native deadline. Native start/resume and BullMQ command inputs inherit the same field. Omit it for the existing deployment behavior. GET /info.flags.per_run_timeout: true confirms support.
The installed LangGraph SDK does not serialize this additional field. Use a submission-scoped onRequest hook to add it to the prepared run body, or send the wire request directly:
await fetch(`${url}/threads/${threadId}/runs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ assistant_id: "research", input: {}, timeout_ms: 30_000 }),
});The console exposes this control under Advanced run options after capability confirmation.
Start a run later
afterSeconds holds a run before it starts — capped at 86400 (a day); for anything longer use a cron.
Use it on a background run. The queue holds it, so it costs nothing while it waits and, on Redis, survives a restart. On an inline wait/stream run the server holds your connection open instead, so a long delay will hit a proxy's idle timeout.
A delayed run is cancellable like any other, and counts as inflight the whole time it waits.
Fields where skein differs from LangGraph
Two defaults are deliberately not LangGraph's. If you're migrating, these are the ones to check:
| Field | skein | LangGraph | Why |
|---|---|---|---|
on_completion | keep | delete | A stateless run's thread stays inspectable afterwards |
on_disconnect | continue | cancel | A proxy timeout is indistinguishable from a real hang-up, and shouldn't kill a healthy run |
Pass the LangGraph value explicitly if you want its behavior. A client's explicit choice overrides skein's default. The legacy @langchain/langgraph-sdk/react hook requests cancellation on disconnect unless its stream is resumable. Dedicated framework bindings use thread subscriptions: closing a subscription does not cancel execution. Use their explicit stop action when cancellation is intended; see frontend SDKs.
One more worth knowing, though it matches LangGraph: naming a thread that doesn't exist is a 404 (if_not_exists: "reject"). Pass "create" to have the run bring the thread into existence instead — which is how you start a run keyed on an external identity (a phone number, a ticket id) without a round trip to create the thread first.
Don't start a run on a thread that's waiting for a human
interrupt() leaves a thread parked until someone answers, and the run that parked it is terminal — interrupted is a finished status, so the thread holds no inflight run. That means multitask_strategy cannot protect it: every strategy, including the default reject, arbitrates pending and running only. A plain start on an interrupted thread therefore succeeds, and the pending question is discarded with no error and no log line.
if_thread_status is how you say "only if nobody is waiting":
await fetch(`${url}/threads/${threadId}/runs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
assistant_id: "support",
input: { messages: [{ role: "human", content: text }] },
if_thread_status: ["idle", "error"],
}),
});
// → 409 { code: "thread_status_mismatch", details: { status: "interrupted" } }The check happens inside the driver's atomic create, alongside the one multitask_strategy: "reject" already uses — so two replicas racing the same thread cannot both win. An in-process mutex cannot give you this; the window is between processes.
The 409 carries the status it actually observed, so you can branch without a second read that might see a third value. The usual branch for an async chat channel is: refused with interrupted → resume the pending interrupt instead of starting a new run.
// The reply hours later is an answer, not a new conversation.
body: JSON.stringify({ assistant_id: "support", command: { resume: text } });It composes with multitask_strategy rather than overlapping it — that one guards pending/running, this one guards everything else. Omit the field and run creation behaves exactly as it always has.
A storage driver that does not implement the precondition answers 501 if_thread_status_unsupported rather than quietly falling back to a read-then-create, because a non-atomic fallback looks like it works right up until two replicas race. Both first-party drivers implement it.
Don't create the same run twice
A retrying caller — Stripe, GitHub, Twilio, or your own sweep — should not start a second run. Send an Idempotency-Key header and the original response replays:
// `runs.create` has no `headers` option — a `headers` key in the payload is silently dropped and you
// get a brand-new run every retry. A per-key client is the only way to send it.
const client = new Client({ apiUrl, defaultHeaders: { "Idempotency-Key": issue.id } });
await client.runs.create(threadId, "agent", { input });Streaming creates reject the header rather than ignoring it — an SSE response has no body to replay. Details: agent-protocol.md.
Reading a run back
| You want | Call |
|---|---|
| One run's row | client.runs.get(threadId, runId) |
| A thread's runs | client.runs.list(threadId) |
| To tail a run already in flight | client.runs.joinStream(threadId, runId) |
| To block until it settles, then get JSON | client.runs.join(threadId, runId) |
Joining a run that has already settled returns immediately, long after its frames have aged out — the wait is decided by the run row, not by the event stream. So a client that reconnects late still gets its answer.
To be told a run finished without holding anything open, use a webhook.
Working example
triage-agent dispatches one background run per issue, each on its own thread, with idempotent re-sweeps — and runs with no API key and no network.
See also
- Streaming — stream modes, reconnecting, joining
- Runs & Redis — run concurrency, the queue, scaling past one instance
- Human-in-the-loop — pausing a run for a person
- Crons — running a graph on a schedule
- Agent Protocol — every run endpoint and field