Interactive agentic sessions, now on every adapter.
Yesterday's v1.1.0 post shipped Filesystem Volumes, the runtime-level fix for the multi-ensemble shared-jail problem the launch-week Paca conversation surfaced. That covered the workspace side: each ensemble gets its own isolated filesystem, the legacy jail retires, ephemeral volumes auto-purge.
Today's v1.1.1 covers the other half of what an external product needs to drive a loomcycle agent: the conversation itself.
v1.1.1 ships RFC AI: interactive agentic sessions over gRPC and the TypeScript / Python adapters. A 3rd-party app can now start an interactive run, push operator messages into it mid-flight (steering), survive client disconnect, and re-attach by run_id from a fresh process or device. All through the same official client surfaces that already handle non-interactive runs.
Below: what was missing, what changed, what reuses what, and what an "external app drives a loomcycle interactive session" actually looks like in code now.
The gap: interactive power locked behind one client
The interactive terminal is one of loomcycle's most capable surfaces. It shipped over v0.26 → v0.30 and has been load-bearing ever since. An interactive run does four things a one-shot run doesn't:
- It parks at
end_turninstead of terminating. The loop blocks inparkForInput, emitsEventAwaitingInput, and waits for the next operator message instead of shutting down. - It accepts mid-run steering. An operator can push a new instruction into a running loop; the loop drains the steer queue at the top of each iteration (not mid-tool-call, so a
tool_use/tool_resultpair is never split). - It survives client disconnect. The run executes in a detached goroutine under
context.WithoutCancel; closing the SSE stream doesn't cancel anything. - It's re-attachable by
run_id.GET /v1/runs/{run_id}/stream?from_seq=Nreplays the persisted transcript from a sequence cursor, then live-tails. Cross-replica steer routing is inherited free (theSteerCoordinatordispatches over the backplane to the run's owning replica). Walk away on your laptop, rejoin on your phone two hours later. Same conversation.
That whole machine was reachable only through the embedded Web UI, which hand-rolls six raw HTTP calls in web/src/api.ts. The official integration surfaces (@loomcycle/client on npm and the Python gRPC client) exposed a one-shot model: start a run, stream events to completion, or append a turn to a session. Neither could:
- Start a run with the
interactiveflag (noparkForInput, noEventAwaitingInput). - Inject an operator steering message into a live run.
- Re-attach to a detached run by
run_id. - Observe the parked (
awaiting_input) state cleanly.
A 3rd-party app that wanted the loomcycle interactive experience had to reverse-engineer the raw HTTP dance the Web UI does, or go without. Two products I've talked to in the past month independently asked for this. Time to ship it as a first-class capability.
The structural asymmetry that mattered
Two adapter surfaces, two gaps with different shapes.
The TS client (HTTP+SSE) is mostly a client-side gap: every endpoint the Web UI uses already shipped on the server. POST /v1/runs already accepts the interactive flag. POST /v1/runs/{id}/input already accepts steers. GET /v1/runs/{id}/stream already does replay + live-tail. The TS client just didn't expose any of it. Pure client-side work.
The gRPC server had a deeper problem: the steer registry and the re-attach tail were owned by the HTTP Server struct, not by the transport-shared Connector that gRPC dispatches through. A gRPC steer call would have had nowhere to route to. The gRPC server held a connector.Connector and a runner.Runner but no steer.Registry at all. The asymmetry was real and load-bearing: you couldn't just "add an RPC."
The fix: lift the shared engine onto the Connector
Three small shared server changes, then a thin wire surface per transport.
S1: self-sufficient re-attach
The re-attach tail (streamRunEvents in internal/api/http/run_stream.go) used to skip user_input rows when replaying the persisted transcript. That was fine when the only client doing re-attach was the same Web UI tab that had originally driven the conversation: the operator's own turns were already echoed in the browser's local state, replay would have been duplication.
It is not fine for a cold client. A user resuming a conversation on a different device needs to see their own prior turns too, not just the agent's responses to them. So S1 makes the re-attach stream self-sufficient: it replays the operator's user_input rows as steer frames with source="replay", the cold client reconstructs the whole conversation, and the Web UI de-dupes the replays against its optimistic local echo (because the Web UI's local turns also carry a sequence number that matches the persisted row's seq).
The streamRunEvents function was also refactored to a visitor pattern. HTTP and gRPC now share one engine.
S2: Connector-lift
SteerRun + StreamRunEvents (+ RunEventVisitor) added to the Connector. Additive, mirroring the v0.33.0 CompactRun lift and the existing StreamUserRunStates visitor pattern. The gRPC server now reaches the same in-process steer registry an HTTP-started run registered in. Cross-replica routing is inherited free (the SteerCoordinator hops to the owning replica regardless of which transport initiated the push). handleRunInput on the HTTP side now dispatches through SteerRun too, so both transports share one path.
S3: interactive through gRPC + the wire surface
Proto additions: RunInput and StreamRun RPCs, an interactive field on RunRequest, AwaitingInput and UserInput Event payloads. eventToProto maps the new variants. The runner already knew what interactive meant; the one-field plumb through runInputFromProto was all that was missing.
The trust posture: source is server-stamped, never wire-trusted (a malicious gRPC client cannot claim its steer came from the operator). Tenant opaque-404 preserved. Scope gates: RunInput → runs:create, StreamRun → runs:read.
What "drive a loomcycle interactive agent" looks like now
Both adapters expose the lot. The TS client also ships a high-level InteractiveSession helper that ports the Web UI's useRunStream orchestration so you don't have to assemble the events / send / cancel triple yourself.
TypeScript
import { LoomcycleClient, InteractiveSession } from "@loomcycle/client";
const client = new LoomcycleClient({ baseUrl, token });
// Start an interactive session
const session = new InteractiveSession(client, {
agent: "code-reviewer",
prompt: "Review the open PRs and pick one to start with.",
});
// Drive it
for await (const event of session.events()) {
if (event.type === "text") console.log(event.text);
if (event.type === "awaiting_input") {
// The agent parked at end_turn. Push the next instruction.
await session.send("Let's look at #142 first.");
}
}
// ... or walk away
session.detach();
// Later, from a fresh process / device:
const resumed = await client.streamRunByID(session.runId, { fromSeq: 0 });
for await (const event of resumed) { /* same conversation, reconstructed */ }
Python
from loomcycle import Client
client = Client(base_url, token)
# Start an interactive run
run = client.run_streaming(
agent="code-reviewer",
interactive=True,
prompt="Review the open PRs and pick one to start with.",
)
# Drive it
for event in run:
if event.type == "text":
print(event.text)
if event.type == "awaiting_input":
client.run_input(run.id, "Let's look at #142 first.")
# Re-attach by run_id
for event in client.stream_run(run.id, from_seq=0):
pass
What's in the box
| Adapter | v1.1.0 | v1.1.1 |
|---|---|---|
TypeScript (@loomcycle/client) | 57 methods | 61 methods + InteractiveSession helper |
Python (loomcycle) | 40 RPCs | 42 RPCs (run_input + stream_run) |
Both adapters realign to 1.1.1 (the loomcycle line) so they actually publish together. The TS adapter also ships the previously-skipped v0.35.0 Volume surface (the v1.1.0 release was a Go-only one; the npm publish was waiting on this lockstep alignment).
Reuse over reinvention
The load-bearing fact about this whole release: the enforcement, parking, steering, cross-replica routing, and re-attach engines didn't change. They were proven over five months by the Web UI. v1.1.1 changes where they're reachable from, not how they work.
The same shape RFC AH (Filesystem Volumes) used for resolveInsideRoot: Volumes only changed which root got passed in, not how containment worked. The same shape RFC L (multi-tenant authz) used for the caller-authoritative host policy. The same shape RFC Z (context-transform plugins) used for the contextplugin chain. New wire surfaces, same hardened core. The trust posture didn't slip.
I tested the same way: go build ./..., go test ./..., gofmt -l . all clean. Proto regenerated with pinned protoc plugins (zero-diff toolchain gate). New Go tests for the S1 self-sufficient conversion (fail-before verified), the gRPC RunInput happy + error-mapping + empty cases (with server-side source-stamping asserted), and the gRPC StreamRun end-to-end with typed payloads. Python pytest 110 passing (3 new). TS npm test 221 passing (6 new). Security-weighted code review across tenant isolation, source-stamping, the visitor refactor, the Web UI dedupe, and the scope gates.
The Paca story, two releases in
Two weeks ago a conversation with the Paca maintainer surfaced what an external product would need from loomcycle to host real multi-agent work. The integration the maintainer was implementing went on hold; the productive output was the gap list. Two items mattered:
- Per-ensemble workspace isolation. Two agents in the same runtime should not see each other's files. Shipped as RFC AH in v1.1.0 yesterday.
- Adapter-driven interactive sessions. An external app should be able to start an interactive loomcycle agent, drive it as a conversation, let the user walk away and come back. Shipped as RFC AI in v1.1.1 today.
Combined: an external product (Paca-shaped or any other) can now create an ephemeral workspace for an agent run, clone a repo into it, start the agent in interactive mode, drive the conversation through @loomcycle/client or the Python adapter, let the user disconnect or switch devices, re-attach by run_id later, and when the run completes the ephemeral volume auto-purges. Zero loomcycle-specific reverse engineering required.
The Paca integration itself is still on hold while the maintainer absorbs the multi-agent ensemble shape (his existing architecture is single-agent-per-feature; the shift takes time). But the runtime side is no longer the blocker. Any product that wants this shape can pick it up today.
How to actually use it
# Upgrade
brew upgrade loomcycle # macOS / Linux
docker pull denngubsky/loomcycle:1.1.1
# Adapters (both 1.1.1, same line as the runtime)
npm install @loomcycle/[email protected]
pip install loomcycle==1.1.1
# Then drive an interactive session (see the TS / Python snippets above)
Full release notes for v1.1.1 in REVISIONS.md. The RFC's design narrative (Section §2's anchor list of what the implementation reuses unchanged, §4's three shared server changes, §6's per-adapter surface) lives in the loomcycle-internal RFC archive. The public companion is PR #518 (one combined effort per the maintainer's choice, with focused commits per layer).
Companion reading: v1.1.0: Filesystem Volumes (the workspace half of the integration story), the original interactive terminal release (the plumbing v1.1.1 lifts onto every adapter), the v1.0 release (where the substrate-completeness work the v1.1.x line builds on lives).