Interrupt the LLM, keep the chat.
Every serious chat surface has an Esc key. You typed a question, the model went off in the wrong direction, you can already see it heading somewhere useless, and you want it to stop, right now, without losing everything you built up in the conversation before that point. Claude Code has Esc. ChatGPT has a stop button. LoomBoard has an Esc key too, but until v1.22.0 pressing it did the wrong thing.
The wrong thing was: the only stop primitive loomcycle had was cancel the run. Cancel the run stopped the model generation, but it also terminated the session. The transcript closed. The interactive parking state disappeared. The context that let you continue the chat was gone. Pressing Esc worked in the sense that the model stopped talking, but the price was starting a whole new conversation. That is not what a user pressing Esc means.
v1.22.0 splits stop the turn from stop the run. A turn is one round trip through the loop: one model call, plus any tool calls that call spawned, ending either at a text-only assistant message or the next round of tool_use. A run is the whole conversation, potentially many turns. RFC BH adds an endpoint that stops the current turn while leaving the run parked at awaiting_input, so the session, transcript, and context stay intact and the operator can send the next message and continue.
The short version. POST /v1/runs/{run_id}/cancel with an optional {reason} stops the current turn of an interactive run and parks it at awaiting_input. The loop keeps the partial output and synthesizes cancelled by operator tool_results for any in-flight tool_use so the next turn's history stays valid. A new turn_cancelled SSE frame (distinct from the run-level cancelled) announces the stop. Run-cancel versus turn-cancel is double-guarded on ctx.Err() == nil AND the ErrTurnCancelled cause, so a real run-cancel is never misread as a turn-stop. A pending interruption (a Question tool call waiting on the human) can now be declined via POST .../interrupts/{id}/resolve with {disposition: "declined"}, and the waiting Question tool returns a non-error "proceed" result rather than an error-shaped cancelled. Cross-replica cancels are owner-routed by runs.replica_id (a new internal/turncancel registry + coord.TurnCancelCoordinator). Every transport picked it up: gRPC CancelTurn + ResolveInterrupt RPCs, @loomcycle/client.cancelTurn() / cancelInterrupt(), Python cancel_turn / resolve_interrupt / cancel_interrupt. HTTP and gRPC share extracted connector cores. No schema migration (the new declined interrupt status is an additive TEXT value). @loomcycle/client + Python bump to 1.22.0 for the adapter methods.
Turn versus run
The two concepts sound similar. Distinguishing them at the wire level is the whole point of this release.
A run is the top-level unit an operator kicks off with POST /v1/runs. It has an ID, a session ID, a tenant, a user, a model, a set of tools, a budget. It ends when the loop reaches a terminal state (completed, cancelled, failed) or when an operator explicitly ends it. Ending a run releases the session, closes the transcript stream, and returns the run's final state. Whole-run cancel is destructive.
A turn is one iteration of the loop inside that run. The loop sends the current transcript plus system prompt to the model, gets back either a text-only response (turn ends, session parks at awaiting_input for the next user message) or one or more tool_use blocks (turn continues into the tool-execution phase, then loops back to the model with tool_results, until the model emits a text-only end). A run always contains at least one turn; interactive runs can contain arbitrarily many across the session's lifetime.
Turn-cancel stops the current turn. Everything the model has already streamed stays on the transcript. Any tool_use blocks that had already fired get synthesized cancelled by operator tool_result blocks so the transcript's tool-call / tool-result pairing stays valid. The run doesn't end; it parks at awaiting_input, exactly the state it would be in if the model had emitted a normal end-of-turn text response.
The endpoint
POST /v1/runs/{run_id}/cancel with a JSON body containing an optional reason. Returns {run_id, stopped, parked}. Scope runs:create. Idempotent: calling it twice on an already-parked turn is a no-op, not a 409.
POST /v1/runs/run_abc123/cancel
Authorization: Bearer <operator token>
Content-Type: application/json
{"reason": "operator pressed Esc"}
→ 200 OK
{"run_id": "run_abc123", "stopped": true, "parked": true}
Interactive runs only. A batch run (one that runs to completion without human interaction) returns 409: turn-cancel is not the shape for those; whole-run cancel is. This is deliberate. A batch run has no awaiting_input state to park to, so turn-cancel has no meaningful destination.
Synthesized tool_results and history validity
This is the load-bearing correctness detail. If the model was mid-turn, it may have already emitted one or more tool_use blocks and those tools may or may not have started executing. Some may have completed already; some may be in flight; some may not have started yet. If turn-cancel just stopped the loop and left those tool_use blocks in the transcript without matching tool_result blocks, the next turn's history would be malformed. The provider would reject the next call because tool_use without tool_result is a protocol violation on most providers.
So the loop synthesizes tool_result blocks for any tool_use that didn't complete before the cancel arrived. The synthesized result carries cancelled by operator as its content. To the model on the next turn, the transcript reads like: I asked to call a tool, the tool ran, the tool's answer was "the operator cancelled this." That's a coherent conversation state the model can respond to without protocol errors.
Tools that had already completed keep their real tool_result. Tools that were in flight get their in-flight work cancelled via the per-turn cancel context and their result gets synthesized. Tools that hadn't started yet get their result synthesized without ever running. All three cases produce a valid tool_use / tool_result pairing in the transcript.
Double-guard: ctx.Err() AND ErrTurnCancelled
A subtle mistake the implementation had to avoid: mistaking a real run-cancel for a turn-stop. Both signals travel through Go's context cancellation machinery. If the loop looks at only ctx.Err() to decide whether to park or terminate, a whole-run cancel could accidentally park the run at awaiting_input instead of ending it, and the operator would find a "cancelled" run still consuming resources instead of released.
The v1.22.0 implementation double-guards. Turn-stop is: ctx.Err() != nil AND the underlying cause is ErrTurnCancelled (a sentinel the turn-cancel machinery sets on its child cancel context). Run-cancel is: ctx.Err() != nil AND the cause is anything else (typically context.Canceled from the parent run context). The loop's terminal path checks the cause explicitly. A run-cancel context propagates through the per-turn cancel context (which is a child of the run context), so whole-run cancel still cascades correctly; it just gets classified as run-cancel, not turn-stop, at the loop's exit point.
The regression test set exercises every corner: turn-stop while the model is streaming, turn-stop while a tool is executing, turn-stop with a queued tool_use, run-cancel of an already-parked turn, run-cancel of a turn that just turn-stopped, run-cancel of a turn currently in a turn-stop drain. Each has a distinct exit shape.
The turn_cancelled SSE frame
A new SSE event type. turn_cancelled announces the stop to any client subscribed to the run's event stream. It carries the reason (if the operator provided one) and the run ID. Distinct from the run-level cancelled event so a UI can render the two differently: turn-stop is "your Esc took effect, keep going," run-cancel is "this conversation ended."
A UI that wants to be strict can key off the event type. A UI that just wants to know "the stream stopped" can treat both as end-of-stream. LoomBoard renders them differently: turn_cancelled shows a small "stopped" chip in the message; run cancelled shows the session as ended.
Interruption decline
The second half of RFC BH is a companion primitive: declining an Interruption. loomcycle's Interruption tool lets an agent pause mid-turn and ask the human a question ("what should I do here?"). Before v1.22.0, the only ways to resolve one were to send an answer via POST .../interrupts/{id}/resolve {answer}, or to cancel the whole run. Neither was right for the "actually, agent, just proceed without an answer" case.
v1.22.0 adds a third disposition: declined.
POST /v1/runs/{run_id}/interrupts/{id}/resolve
{"disposition": "declined"}
→ 200 OK
{"interrupt_id": "...", "status": "declined"}
Declining skips option validation (an answer would have needed to match one of the offered options; a decline doesn't). It writes a new terminal declined status to the interrupt row. And critically, the waiting Question tool call in the agent returns a non-error "proceed" result: to the model, it reads as "the human chose to skip this question," not "the run was cancelled." Distinct from the error-shaped run-cancel result.
Both dispositions (answered and declined) share the same wire path. The disposition field selects. That means one endpoint, one handler, and one adapter method that clients can invoke either way.
Cross-replica routing
A turn-cancel request can land on any replica of a multi-replica loomcycle deployment, but the target run is executing on only one specific replica. If the request lands on a replica that isn't running the run, the cancel needs to be forwarded to the owner. Without that, an operator's Esc would land on a random replica and silently do nothing.
The forward is owner-routed by runs.replica_id, a field the store already tracks. A new internal/turncancel registry holds active turn-cancel contexts on each replica; a new coord.TurnCancelCoordinator dispatches by replica ID. The registry lookup and the coordinator dispatch are cheap: constant-time by run ID. The pattern mirrors the existing steer coordinator (RFC AI's mid-run steering), so the plumbing is shared with a primitive that already handled the same problem.
Interruption decline gets cross-replica for free via a different mechanism. Declines are shared-store writes plus a poll backstop on the waiting Question tool: the agent's tool poll checks the interrupt row's status, sees declined, and returns the proceed result. That works from any replica because both writes and polls hit the same store. No coordinator needed.
Every transport
Turn-cancel and interruption-decline landed on every transport in the same release. The HTTP and gRPC handlers share extracted connector cores, so it's one implementation with two wire wrappers.
- gRPC. New
CancelTurnandResolveInterruptRPCs on theRunsservice. - TypeScript.
@loomcycle/clientgainscancelTurn()andcancelInterrupt(). Same call shapes as the other run operations. - Python. The Python adapter gains
cancel_turn,resolve_interrupt, andcancel_interrupt. Python previously had no answer-resolve method at all, so it gets that too. - MCP. The MCP
runsmeta-tool exposes both ops for tenant admins operating loomcycle from another agent.
Adapter versions bump to 1.22.0. The bump is not because the wire shape changed in any breaking way; it's the standard "adapter version tracks the runtime it targets."
What LoomBoard does with it
LoomBoard's Esc key was the front-end driver for RFC BH. Before v1.22.0 the desktop chat app's Esc handler called run-cancel, which was wrong for the reason described above. The v0.1.3 LoomBoard release (companion to loomcycle v1.22.0) switched Esc over to turn-cancel: press Esc while a turn is streaming, the model stops, any in-flight tools get their synthesized results, the transcript renders a "stopped" chip on the message, and the input box lights up ready for the next message. Same conversation, same tools, same context, next turn.
A second LoomBoard behavior fell out of interruption-decline: an operator can now click through an Interruption dialog without answering. The old dialog forced the operator to pick one of the offered options; the new one has a "skip" button that maps to disposition: declined. Agents that raise a Question can be told "just proceed" rather than getting an answer forced.
What else shipped in v1.22.0
Two smaller pieces landed alongside RFC BH. Worth naming because they compose with the turn-cancel work.
RFC BG P1: model_pattern. A models: alias can now use model_pattern: "claude-haiku-*" instead of a pinned model:. The resolver picks the newest listed model matching the glob against the provider's live catalog, so the config never needs a manual model-id bump when a provider ships a new snapshot. Anthropic exposes no -latest aliases, so this is the only clean way to auto-track a family. A generic numeric-run comparator orders dotted (qwen3.6 < qwen3.10), hyphenated, and timestamped (...-4-5-20251001) ids correctly. Byte-identical when no pattern is configured.
Bundled chat agents. chat/medium and chat/local gain the HTTP tool so they can call a JSON API endpoint directly rather than only fetching pages. chat/local compacts earlier (autocompact_at_pct: 70). And a new chat/local-small agent runs on a lighter local-small local model for cases where 6-9 tok/s on qwen3.6 is too slow.
What's next
A few things are queued for the RFC BH follow-ups. First, a batch equivalent: for a headless run that's mid-turn, an operator wants a "stop this turn cleanly and continue with a different prompt" path that isn't turn-cancel-plus-park (batch runs don't park). Second, a fine-grained tool-only cancel: stop the in-flight tools without stopping the model generation. Third, an "Esc twice" convention that translates to whole-run cancel on the second press. All three are UX-shaped decisions, not primitive-shaped ones; the primitive is done.
Companion reading: config-driven providers (RFC BF) that landed in v1.21.0, prerequisite for the local-GPU chat agents that made turn-cancel important; LoomBoard v0.1.2 ships for how the Esc key threads through the four packagings; agent teams (RFC AP + RFC BD) for the interruption + park semantics turn-cancel builds on; and the operator reference for the runs:create scope and the SSE event schema.