Skip to content

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 wantUseYou get
The answer, and you can hold a connectionclient.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 laterclient.runs.create(threadId, …)The Run row; work continues server-side
A one-shot call with no conversation to keepThe same three, with null for the threadskein creates and owns the thread
To start many at onceclient.runs.createBatch([…])Up to 100 runs per request
ts
// 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:

StrategyThe run already goingThe new runReach for it when
rejectKeeps goingFails with 422The default. A second message is a bug or a double-click
interruptStops, keeps what it wroteStarts nowThe user changed their mind, and the partial answer is still worth keeping
rollbackStops, its writes discardedStarts nowThe user is correcting themselves — the abandoned turn should read as if it never happened
enqueueRuns to completionWaits, then runsBoth messages matter and order is what you want
ts
await client.runs.create(threadId, "agent", { input, multitaskStrategy: "interrupt" });

Things worth knowing before you pick:

  • reject is the default, and a pending run counts as busy. A run held by afterSeconds, 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.
  • enqueue is 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

ts
await client.runs.cancel(threadId, runId); // action: "interrupt", the default
actionEffect
interrupt (default)Settles the run cancelled and keeps whatever it wrote
rollbackAlso 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.

ts
await client.runs.cancel(threadId, runId, true, "rollback"); // wait for it to stop, discard its writes

The 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:

FieldskeinLangGraphWhy
on_completionkeepdeleteA stateless run's thread stays inspectable afterwards
on_disconnectcontinuecancelA 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 terminalinterrupted 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":

ts
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.

ts
// 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:

ts
// `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 wantCall
One run's rowclient.runs.get(threadId, runId)
A thread's runsclient.runs.list(threadId)
To tail a run already in flightclient.runs.joinStream(threadId, runId)
To block until it settles, then get JSONclient.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