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.
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. Several queued runs on one thread are dequeued together and race for the thread. See head-of-line blocking.- 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.
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 behaviour. Note useStream sends on_disconnect: "cancel" on every submit unless the stream is resumable, so with a browser client closing the tab does stop the run — skein's default only applies to callers that don't send the field.
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