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.

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:

ts
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();
FieldMeaning
statusOne run status: pending, running, success, error, timeout, interrupted, or cancelled
assistant_id, thread_idExact identifiers; combine them with any other filter
created_after, created_beforeCreation-time window: inclusive lower bound, exclusive upper bound
updated_after, updated_beforeLast-update window with the same half-open bounds
limit, offsetDefault 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:

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

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.

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:

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

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

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 ​