Skip to main content
loomcycle
§ release note

An interactive terminal in the Web UI — steer your agents mid-run, Claude-Code-style.

For most of loomcycle's life, the way you drove an agent from a human seat was indirect: spawn a run over HTTP, watch its SSE event stream go by, and if you wanted to redirect it mid-flight your options were "cancel and start over" or "build orchestration around it." That's fine for a production pipeline. It's awkward when you're developing an agent, when you want a real conversation with one, or when you want to course-correct a long-running session that's gone slightly off the rails.

The interactive terminal that landed at v0.26.0 and completed today at v0.29.0 closes that gap. Open the Web UI, navigate to /run, pick an agent, type a prompt. The agent streams back into a terminal. You can type a new instruction while it's still working and it shows up as the next user turn before the model's next call. You can answer its Yes/No questions inline. You can close the page, come back two hours later, and the run is still alive. There's a context-size gauge in the corner. Tool results collapse to one line by default. Everything you type is echoed in the transcript.

It looks like Claude Code looks. It feels like Claude Code feels. But it's running your agents on your loomcycle instance, with whatever providers and tools the operator wired up.

This post walks the four headline mechanisms, the polish layer that landed today, and three engineering moments worth naming — including one where the agent's tool-call protocol almost made mid-run steering impossible to do correctly.

What it looks like

A real session in the terminal, drained as the SSE events arrived:

ctx 12.4k / 200k (6%) ❯ analyze this CSV and tell me what's odd Bash: ls -la *.csv → orders_2026Q2.csv (4.2 MB) Read orders_2026Q2.csv (head -1) ▸ The file is a quarterly orders export with 18 columns; first oddity: the unit_price column has values in dollars AND cents … ❯ also check for duplicate order_ids » also check for duplicate order_ids Bash: awk -F, 'NR>1{print $1}' orders_2026Q2.csv | sort | uniq -d ▸ Found 47 duplicate order_ids. Sample: 8821-ACME, 8821-ACME … idle — waiting for operator input ❯ ok dump the dup list to /tmp/dups.txt and stop Bash: awk … > /tmp/dups.txt ▸ Done. 47 lines written to /tmp/dups.txt. Ready for the next instruction. idle — waiting for operator input

Two things in that transcript are new in v0.26-v0.29 and worth pointing at:

The four headline mechanisms (v0.26.0)

1. Mid-run steering — POST /v1/runs/{run_id}/input

The core new wire surface (#430). An in-flight run gets a per-run buffered channel in internal/steer (mirroring the existing internal/cancel registry, depth 16 by default). The endpoint pushes a message; the loop drains it at the top of its next iteration and appends it as a user turn before the next provider call. A new EventSteer SSE event ("steer", not "user_input" — that name was taken by the persisted transcript event) renders the operator's input in the live transcript.

Single-replica returns 404 from a steer that lands on a non-owning replica. The cross-replica SteerCoordinator (v0.26.1, #434) routes by runs.replica_id, mirroring the cancel coordinator's shape — the owning replica dispatches to its local registry, never re-broadcasts (the "cancel storm" lesson we already learned).

2. Persistent interactive runs — park at end_turn

The "always-on terminal agent" half (#431). With RunOptions.Interactive and a steer queue attached, the loop no longer terminates when the model emits end_turn. It emits a new EventAwaitingInput and parks until a steering message arrives (append + continue) or ctx is cancelled. A heartbeat ticker pulses OnHeartbeat while parked, so the staleness sweeper doesn't reap the idle run.

Paired with per-agent unbounded_iterations (#429), which lifts the default 16-iteration soft-cap for LLM agents — the same exemption provider: code-js already had — keeping the 1<<20 hard ceiling as a runaway backstop. Cancel becomes the stop; LLM runs have no wall-clock timeout. The flag round-trips through every AgentDef mirror (F14/F40 chain) so it survives the content-addressed substrate.

3. Inline interruption answers

Pre-v0.26, when an agent raised a question via the Interruption tool, the operator had to leave the live run for the separate /interrupts inbox to answer — the terminal just went silent. The run's SSE stream already emitted interruption_pending with question/options/context/priority before the agent blocked. #428 wires that into the terminal: an inline prompt appears (option buttons for fixed-set asks, free-text box for open ones); the answer goes to the existing resolve endpoint; the parked run wakes on the same open stream.

The operator stays in the terminal. The conversation reads naturally.

4. The terminal itself — always-on prompt with state-routed send

The frontend half (#432). The between-turns "continue" box became an always-on terminal prompt. useRunStream.send(text) routes by state: while the run is live it steers (POSTs to /v1/runs/{id}/input on the SAME open SSE stream); otherwise it continues the session as a fresh turn. The placeholder reflects the mode. The inline interruption prompt takes precedence when one is pending.

A "interactive session" checkbox on the run form sets StartRunRequest.interactive so the loop parks at end_turn instead of terminating. The transcript renders the steer event as a distinct row (» {text}) and the awaiting_input banner as idle — waiting for operator input.

Three engineering moments worth naming

The orphaned-tool_use problem

If you append a user-turn (a steer) at the wrong moment, every Anthropic-shaped provider 400s your run with "tool_use without tool_results."

A naive steer implementation would drain its queue at any iteration boundary — including the one between an assistant tool_use turn and the corresponding tool_results user turn. But every Anthropic-family provider requires those two to be adjacent. Slipping a steer-turn between them orphans the tool_use, and the next provider call returns 400 with a "missing tool_result for tool_use" error. The whole run dies on what looks like an unrelated provider issue.

The fix in PR #430: drainSteer runs at the top of each iteration — after a tool round has completed, before the next provider call. The loop also yields consecutive user turns (the tool-result user-turn followed by the steer user-turn), which every provider accepts. A dedicated test, TestRun_DrainSteer_OrderingWithToolResults, fails-before on the naive placement: it confirms that a steer queued during a tool round is delivered after the tool_results land, not before.

Worth keeping in mind any time you're injecting bytes into an LLM conversation that isn't your own: provider message ordering is part of the contract, not just the content.

The detach-from-request problem

An interactive run was bound to its HTTP request — closing the browser tab cancelled the request context, which cascaded into the loop's context, which terminated the parked run.

The operator couldn't leave the /run page and come back. The whole point of "interactive" is that you can. The fix in #440 needed two pieces:

  1. Detach the loop from the request ctx. The interactive loop now runs in a background goroutine under a ctx derived with context.WithoutCancel(r.Context()) — it keeps the request's values (auth principal, tenant), but is not cancelled on client disconnect. The goroutine owns its teardown; the run stops only via the cancel registry. The handler's defers get neutralized by a hand-off flag.
  2. Stream by tailing the store, not by writing live. The handler can't keep writing to a ResponseWriter after the request returns (Go's net/http won't let it). So for interactive runs, the loop persists events to the store, and the handler streams by tailing a new store method, GetRunEventsSince(runID, afterSeq, limit), that does incremental reads via an indexed events_by_run_seq column. A new endpoint, GET /v1/runs/{run_id}/stream, replays from ?from_seq then live-tails.

The result: the operator closes the tab; the loop keeps running; on reload, the terminal re-attaches and replays the transcript from the persisted store. The agent never noticed.

The cross-replica problem (already solved)

Once a run survives client disconnect, it might end up on a replica different from the one the next operator action lands on. The fix here is short because we already built it for cancel: #434's SteerCoordinator is a near-mechanical copy of the existing CancelCoordinator — same shape (route by runs.replica_id to the owning replica, await ack, never re-broadcast). The pattern earned its keep.

The polish layer (v0.27 → v0.29)

The core mechanisms shipped in v0.26.0. The polish that makes the terminal feel finished arrived across the next three releases:

Context-size gauge in the header (v0.29.0)

#450 adds a per-run context indicator: ctx 47.2k / 200k (24%) with a colored bar — amber > 70%, red > 90%. "Context used" is computed honestly as input + cache_read + cache_creation tokens (the true prompt footprint; input_tokens alone undercounts under prompt caching). When the provider doesn't report a context window (Ollama, for instance), the gauge shows just the absolute size without a bar — better than a fake percentage.

The plumbing for it is small but worth naming: providers.Usage gained an additive optional MaxContextTokens field (json max_context_tokens, omitempty), stamped on each per-iteration EventUsage from opts.Provider.Capabilities().MaxContextTokens. Set on the iteration's usage event only, not on the run's total accounting, so run-final usage byte-stable. @loomcycle/client 0.26.0 picks up the field.

User-message echo (v0.29.0)

A subtle but irritating omission: the operator's typed prompt was invisible in the live transcript. The initial prompt is persisted as a user_input event, but that event was filtered from the live SSE tail (in nonStreamableEventTypes) — so the operator never saw what they typed. Steer events emitted as a transcient steer frame; continuations didn't echo at all.

The fix is client-side: useRunStream now pushes a synthetic user_echo transcript entry on its own sends. Seeded in start() with the initial prompt; appended in send() (steer) and sendMessage() (continuation). Rendered as ❯ {text}, visually distinct from agent text and from a drained » {text} steer frame. No wire change. Re-attach is safe: attach() wipes client state, and the persisted user_input is off the live tail, so no double-render.

Tool-result collapse + terminal polish (v0.27)

Pre-polish, every tool result rendered as a one-line truncated string with no way to expand. Now tool results render with a one-line summary plus a caret; click to expand to the full output. Errors start open (actionable). Tool calls themselves collapse the same way. Operator-input messages render in full (only tool results collapse by default). Auto-tail when within 80px of the bottom; otherwise the terminal stays where you scrolled it. Multi-line input via shift+enter.

Agent editor sampling controls + advanced overlay (v0.29.0)

With per-agent sampling.temperature shipping in v0.28.0, the agent create/fork modal needed UI controls for it — otherwise operators were dropping into raw JSON to tune. #449 adds dedicated inputs for temperature, top_p, top_k, frequency_penalty, presence_penalty, seed, and stop. Blank means "unset, inherit"; "0" means "explicit 0.0" (so deterministic temperature stays distinct from the default).

Plus a scoped advanced JSON/YAML overlay box (via js-yaml) for the long-tail fields without dedicated controls — channels, interruption blocks, *_def_scopes, etc. Shallow-merged over the structured overlay at submit; advanced wins per-key with a non-blocking collision warning. Empty box never blocks; malformed YAML blocks with an inline error. This deliberately differs from the v0.10.4 whole-overlay catch-all we removed in v0.11.6: system_prompt stays in its own textarea (no newlines-in-JSON to escape), and the box is optional, so neither original pain point applies.

Soft-reclaim of retired agent names (v0.29.0)

Adjacent operator-experience fix (#452): retiring an agent only flipped a flag — it never cleared the active pointer and never freed the name. A retired agent stayed listed, the active pointer could still reference a retired def (and was served to runs!), and the name couldn't be reclaimed.

Now: AgentDefSetRetired is transactional and clears the agent_def_active pointer in the same transaction when retiring the currently-active def. lookup.resolveDynamic skips a retired active row as defense-in-depth. AgentDefNameSummary gains LiveVersionCount + ActiveRetired, surfaced in the Library UI as an "inactive" badge (every version retired, reclaimable) or "active retired" (corrupt-legacy active pointer). The create modal's name-collision check loosens: a name with no live active version is reclaimable, so a retired agent no longer blocks recreating its name with a wider tool ceiling (a fork can't widen allowed_tools; a fresh create can).

Context op=self reports the resolved provider + model (v0.27.0)

A small but useful introspection: #439 makes Context op=self return the resolved driver id and model name alongside the existing identity bundle. Plumbed per-iteration (not once at run start) so a mid-run provider fallback — which swaps opts.Provider / opts.Model in place — is reflected truthfully. Useful for the per-agent sampling work in particular, since Anthropic drops temperature + top_p under a thinking block — an agent's self-report can name what's actually being used.

What this unlocks

The substrate becomes a development surface, not just a production runtime. Pre-interactive-terminal, the operator's relationship with the runtime was production-shaped: spawn, watch, cancel. The Web UI's /run page now gives the same operator a Claude-Code-shaped relationship: paste a prompt, watch tools fire, course-correct, answer questions, leave, come back. The agent that ships to production is the agent the operator just had a conversation with. The development loop is short.

It's also the operator-facing companion to the substrate work we've been shipping across the rest of v0.26→v0.30. Self-evolving agents needed parked-interactive-run semantics as its primitive — the breeder snapshots and resumes from a parked state across an instance boundary. Agent ensembles needed runs that don't terminate at end_turn — a Channel.await consolidator is a parked run waiting for fan-in. The interactive-terminal feature didn't just give the operator a new toy; it codified the "parked run, drained at iteration boundaries, woken by an external event" contract that several other substrate features build on.

One residual worth naming honestly: cross-replica re-attach (a viewer on replica A re-attaching a run owned by replica B) is deferred. The shared event store means the events are visible, but the live tail today reads the owning replica's in-memory state. The fix is a small DB-backed tail or a backplane fan-out — a separate small RFC. The current single-replica re-attach already covers the common case (the operator coming back from another machine, or a different browser tab, on the same loomcycle instance).

Try it

Latest release is v0.29.0 (today). Pull from the Homebrew tap or run the example:

# brew tap
brew install denn-gubsky/loomcycle/loomcycle
loomcycle init --with-token   # writes auth.env at mode 0600
loomcycle                      # boots on 127.0.0.1:8787

# then open http://127.0.0.1:8787/ui/run
# (the topbar will route you through /ui/login first; paste the token from auth.env)

Pick an agent. Toggle "interactive session." Type a prompt. Steer it. Answer its questions. Leave the page. Come back. Watch the gauge climb.

Companion reading from the operator-via-MCP series: exp1+2 — tool access and interruption (where the Interruption tool was first exercised through Claude Code, before the inline-answer terminal existed) · exp6 + exp6.5 — self-evolving agents with snapshot/resume (where parked interactive runs become a substrate primitive) · exp5 — agent ensembles (parallel parked agents on fan-in channels).