eleven / ocho Public
π€ Shared remote Claude Code sessions β an Eleven group fronting a workstation
Date: 2026-08-05 Β· Status: accepted β open questions decided by Paul 2026-08-28 (see Β§ Decisions) Β· Scope: design only β no implementation yet
What this is
A group in Eleven becomes a shared front-end to Claude Code sessions running on a remote workstation (the motivating box: docs-t1). Multiple people share the sessions: the chat on the left is where you steer β @claude is an ordinary member of the chat β and the right-hand pane is a live view of the remote sessions. Agent activity is translated into Eleven primitives (typed events, cards, views, action lines, notices) rather than mirrored as a terminal.
The design uses almost entirely existing machinery β no new group type, no new E2EE exceptions, one new sidecar-shaped process and one new mini-app.
ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
β Chat β agents app (Interface pane) β
β β β
β Paul: @claude fix the β βΈ fix-login π messenger β
β flaky reminder test β running Β· turn 3 β
β @claude [fix-login]: β β go test ./internal/bot β
β Found it β the tickerβ¦ β βΈ ops-poke π₯ workstation β
β Ana: @claude also checkβ¦ β waiting for approval β
β β [Allow] [Deny] β
ββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββ
the shipped Chat | Split | widget-bar layout, nothing newTrust model (read this first)
- The E2EE rule holds untouched. The server relays opaque ciphertext; every
session.*event, prompt, approval, and view is encrypted under the thread key like any message. The server never learns what the sessions do, which repos exist, or that a box called docs-t1 is involved. - The bridge is a bot-model process (docs/bots.md): an E2EE member whose "device" is the workstation. It runs ON the box, beside the sessions, holds its member private key + the Anthropic access there, and is a pure outbound client β dials the instance and the Anthropic API, no inbound ports. Bot code never runs inside the lchat server process.
- Group membership is the consent moment. Everyone in the group can steer sessions, which means everyone in the group has a lever on the box β filesystem, git credentials, whatever the bridge's unix user reaches. This is the operator-only dev-bot tier (docs/bots.md tier 4): chat input is untrusted input to a machine with authority. The bridge's config (below) enumerates exactly what that authority is; adding a member is granting it.
- Flat authority for v1. Any member can prompt, approve, and interrupt. Roles (driver/observer) are layerable later as ordinary fold state (
agents.roleevents) without schema changes.
The pieces
1. The bridge β lchat agents run
A sibling subcommand of lchat bot run, reusing the Go sidecar runtime (WebSocket client, key unwrap, reconnect, trigger plumbing β all existing). It runs on the workstation and:
- joins the instance as the @claude member (one keypair, one roster row β the standard bot-member shape, not a charter persona);
- owns the Claude Code sessions: spawns them headless (
claude -p --output-format stream-json, resumable via--resume <session-id>; the Agent SDK is the same thing as a library), consumes the structured event stream β assistant text, tool_use blocks with full inputs, tool results, turn boundaries. No terminal emulator: nothing scrapes a TTY. The only terminal-ish work is stripping/basic-rendering ANSI colours out of Bash tool output (~50 lines, no cursor addressing exists in a-psession); - translates the stream into encrypted
session.*events (vocabulary below) posted into the group thread, and reads members' prompt/approval/interrupt events back off the same thread; - serializes turns per session (a session is one-turn-at-a-time), queues prompts that arrive mid-turn, and attributes prompts by speaker β "Paul: fix the flaky test" β so the model knows it's talking to a room.
Config, per group the bridge serves:
groups:
<thread-id>:
roots: ["~/github.com/elevenmessenger/messenger"] # scoped sessions live here
plain: false # whole-workstation sessions allowed?The config is the blast radius, written down in one place on the box.
2. Sessions β two flavors, chosen at spawn
- Scoped (default): bound to a directory β an allowed root, normally a fresh git worktree under it so parallel sessions don't trample each other. A scoped session gets that project's own
.claude/settings, CLAUDE.md, hooks, and permission config exactly as a human dev would β the repo's existing guardrails govern the remote session with zero new machinery. - Plain (opt-in via
plain: true): no pinning β cwd = home, the whole workstation at the bridge's unix user's reach. The "shared computer" mode for ops-ish, cross-repo work. Off by default because it hands every member the entire box.
Flavor is visible everywhere: carried on session.spawn, badged on the session's view ("π messenger @ fix-login" vs "π₯ full workstation") and on its chat card. Nobody wonders which kind of hands are on the box.
Birth is conversational: "@claude fix X" with zero live sessions spawns a scoped session in the group's default root; /claude new <name> names one; /claude new --plain <name> when the config allows. Session state persists on disk on the box (Claude Code's own session files), so a human on the workstation can claude --resume <id> from a real terminal after the bridge lets go β continuity is preserved; concurrent dual-driving is not pretended.
3. Chat β @claude plus focus-based routing
One bot member; the mental model is "a colleague in the chat who happens to be several sessions deep."
- Routing: the target is the active view. Each member's client knows which session view they have focused in the right pane; a message to @claude carries
target: <session-id>inside the encrypted payload. Focus is per-member β two people can steer two sessions from the same chat simultaneously. - Routing is visible at send time. When more than one session is live, the composer shows a chip β "β fix-login" β tappable to retarget without touching the pane.
- Replies pin the target. Every @claude bubble carries a subtle session tag; replying to a bubble routes to that session regardless of pane focus β this is also the mobile/chat-only answer. The tag works the other way too: tapping it focuses that session's view.
- Degenerate cases collapse. One live session: everything routes there, no chip. Zero sessions: @claude answers as the workstation ("no sessions running β want one?") and a prompt spawns one.
Output posts as @claude with the session tag on each bubble β the feed reads as one assistant working several desks, which is the truth.
4. The right pane β one app, one view per session
- One
agentsmini-app (web/static/apps/agents.js), chat-placed (places: ["chat"]), added to the group like any app β one glyph in the widget bar, rendering in the standard Interface pane. The shipped Chat | Split | widget-bar toggle IS the "chat left, session right" layout; nothing new is built for it. - Its Interface is the fleet board: one card per session (name, flavor badge, status, current activity line, waiting-on-approval chips), folded from durable
session.*events. - Per-session status rides the views engine (
views.js,view.create/view.updatefrom the bridge): one engine view per session. Views are declarative SCHEMA + DATA interpreted by both web and the Swift twin engine, so session boards render on iOS/Mac with no per-feature Swift. Chips on @claude's replies open the session's view;/viewslists them. - Fidelity is "board + drill-in" (option c). Tap a session to focus it: the durable board plus a live tail β tool-call chips ("Ran
go test ./..."), collapsed monospace output, file-edit lines, assistant prose β streamed as ephemeral events (relay-only frames, the existing primitive): live viewers see the chatter; nobody replays it; the encrypted log stores only what deserves permanence. The views engine cannot host the tail (views are durable documents; streaming throughview.updatewould bloat the log) β the tail is the app's own render over the ephemeral stream, web-first.
5. Event vocabulary
Durable (thread log; the fold state every member converges on):
| event | payload sketch | why durable | |---|---|---| | session.spawn | id, name, flavor, root/worktree, by | fleet board exists offline | | session.status | id, state (running/idle/waiting/dead), activity line | board truth | | session.prompt | id=target, text, by | the steering history IS the log | | session.turn | id, assistant text (final, per turn) | the conversation with the agent | | session.result | id, summary card (files changed, tests, links) | milestones as cards | | session.approval | id, request (tool, input digest), state, decided-by | audit trail of authority used | | session.end | id, reason, by | lifecycle honesty |
Ephemeral (relay-only; live tail + presence):
session.toolβ tool_use started/finished, truncated output chunkssession.streamβ assistant text deltas while a turn is in flight- typing-style "session is thinking" indicators
Streaming into chat has no partial-message primitive, so @claude's chat bubbles are post-per-turn (the durable session.turn), with the live delta visible in the drill-in tail instead β no post-then-edit churn in the feed.
6. Approvals
Claude Code's permission gate surfaces as a chip row β on the session's board and as a @claude chat card: "fix-login wants to run git push β [Allow] [Deny]". A tap emits an encrypted session.approval decision event; the bridge reads it and answers the gate. Flat authority: first decision wins, attributed in the event and shown in the feed ("Ana allowed git push"). Notices/push ride the existing E2EE notify path, so "waiting for approval" reaches pockets.
7. Liveness
Derived, never declared (the docs/bots.md pattern): the bridge heartbeats (ephemeral presence + a periodic durable status touch); a session whose bridge is gone must read as dead/stale in the board β "last seen 4m ago" β not linger as "running". A dead bridge silently eating prompts is the worst failure mode; staleness is always rendered from event age, client-side.
What the server gains
Nothing. Every piece rides existing surfaces: bot membership, thread events, ephemeral frames, the views engine, the mini-app framework, notify/push. The bridge is a new subcommand of the existing binary; the app is one new file under web/static/apps/.
Hard parts, named
- The approval round-trip β latency between the gate firing and a human tapping Allow; the session blocks meanwhile. Mitigations: push notices, per-session auto-allow lists (later; config-side, never chat-grantable).
- Log discipline β a busy session emits a lot; the durable/ephemeral split above is the design's load-bearing wall. Anything high-churn must be ephemeral or it bloats every member's encrypted log forever.
- Prompt-injection containment β session output is model output over untrusted repo/web content; it lands in chat as content, never as commands. The bridge only ever acts on typed events from members.
Deferred (deliberately)
- Driver/observer roles β layer later as
agents.rolefold state. - Fleet-across-groups overview β a "you"-placed app aggregating every workstation group; needs nothing new from this design.
- Attaching to a human's interactive TUI session β ruled out for v1 (tmux-scraping + keystroke injection fights the one-writer TUI; the honest handoff is
--resumeafter the bridge lets go). - Auto-allow permission profiles per session flavor.
- Native drill-in tail β boards are native-free via the views engine; the live tail ships web-first.
Decisions (Paul, 2026-08-28)
- Naming:
agentsstands.lchat agents run,apps/agents.js,session.*events, and the member reads as @claude. - The prompt IS the chat message. One
session.promptevent, two projections β the member's visible bubble in the feed and the bridge's steering input, tagged with the target. No separatechat.msg. - Closing a session closes its worktree. Sessions are closable from within Eleven, and
session.endis the cleanup moment: the bridge removes a scoped session's worktree then. The branch (any committed work) survives worktree removal as an ordinary git branch; only the checkout goes.