| 1 | # π€ Shared remote Claude Code sessions β an Eleven group fronting a workstation |
| 2 | |
| 3 | **Date:** 2026-08-05 Β· **Status:** accepted β open questions decided by Paul |
| 4 | 2026-08-28 (see Β§ Decisions) Β· **Scope:** design only β no implementation yet |
| 5 | |
| 6 | ## What this is |
| 7 | |
| 8 | A group in Eleven becomes a shared front-end to **Claude Code sessions running |
| 9 | on a remote workstation** (the motivating box: docs-t1). Multiple people share |
| 10 | the sessions: the chat on the left is where you steer β **@claude is an |
| 11 | ordinary member of the chat** β and the right-hand pane is a live view of the |
| 12 | remote sessions. Agent activity is translated into Eleven primitives (typed |
| 13 | events, cards, views, action lines, notices) rather than mirrored as a |
| 14 | terminal. |
| 15 | |
| 16 | The design uses almost entirely existing machinery β no new group type, no new |
| 17 | E2EE exceptions, one new sidecar-shaped process and one new mini-app. |
| 18 | |
| 19 | ``` |
| 20 | ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ |
| 21 | β Chat β agents app (Interface pane) β |
| 22 | β β β |
| 23 | β Paul: @claude fix the β βΈ fix-login π messenger β |
| 24 | β flaky reminder test β running Β· turn 3 β |
| 25 | β @claude [fix-login]: β β go test ./internal/bot β |
| 26 | β Found it β the tickerβ¦ β βΈ ops-poke π₯ workstation β |
| 27 | β Ana: @claude also checkβ¦ β waiting for approval β |
| 28 | β β [Allow] [Deny] β |
| 29 | ββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββ |
| 30 | the shipped Chat | Split | widget-bar layout, nothing new |
| 31 | ``` |
| 32 | |
| 33 | ## Trust model (read this first) |
| 34 | |
| 35 | - **The E2EE rule holds untouched.** The server relays opaque ciphertext; |
| 36 | every `session.*` event, prompt, approval, and view is encrypted under the |
| 37 | thread key like any message. The server never learns what the sessions do, |
| 38 | which repos exist, or that a box called docs-t1 is involved. |
| 39 | - **The bridge is a bot-model process** (docs/bots.md): an E2EE member whose |
| 40 | "device" is the workstation. It runs ON the box, beside the sessions, holds |
| 41 | its member private key + the Anthropic access there, and is a **pure |
| 42 | outbound client** β dials the instance and the Anthropic API, no inbound |
| 43 | ports. Bot code never runs inside the lchat server process. |
| 44 | - **Group membership is the consent moment.** Everyone in the group can steer |
| 45 | sessions, which means everyone in the group has a lever on the box β |
| 46 | filesystem, git credentials, whatever the bridge's unix user reaches. This |
| 47 | is the operator-only dev-bot tier (docs/bots.md tier 4): chat input is |
| 48 | untrusted input to a machine with authority. The bridge's config (below) |
| 49 | enumerates exactly what that authority is; adding a member is granting it. |
| 50 | - **Flat authority for v1.** Any member can prompt, approve, and interrupt. |
| 51 | Roles (driver/observer) are layerable later as ordinary fold state |
| 52 | (`agents.role` events) without schema changes. |
| 53 | |
| 54 | ## The pieces |
| 55 | |
| 56 | ### 1. The bridge β `lchat agents run` |
| 57 | |
| 58 | A sibling subcommand of `lchat bot run`, reusing the Go sidecar runtime |
| 59 | (WebSocket client, key unwrap, reconnect, trigger plumbing β all existing). It |
| 60 | runs on the workstation and: |
| 61 | |
| 62 | - joins the instance as the **@claude member** (one keypair, one roster row β |
| 63 | the standard bot-member shape, not a charter persona); |
| 64 | - owns the Claude Code sessions: spawns them **headless** (`claude -p |
| 65 | --output-format stream-json`, resumable via `--resume <session-id>`; the |
| 66 | Agent SDK is the same thing as a library), consumes the **structured event |
| 67 | stream** β assistant text, tool_use blocks with full inputs, tool results, |
| 68 | turn boundaries. **No terminal emulator**: nothing scrapes a TTY. The only |
| 69 | terminal-ish work is stripping/basic-rendering ANSI colours out of Bash tool |
| 70 | output (~50 lines, no cursor addressing exists in a `-p` session); |
| 71 | - translates the stream into encrypted `session.*` events (vocabulary below) |
| 72 | posted into the group thread, and reads members' prompt/approval/interrupt |
| 73 | events back off the same thread; |
| 74 | - serializes turns per session (a session is one-turn-at-a-time), queues |
| 75 | prompts that arrive mid-turn, and **attributes prompts by speaker** β |
| 76 | "Paul: fix the flaky test" β so the model knows it's talking to a room. |
| 77 | |
| 78 | Config, per group the bridge serves: |
| 79 | |
| 80 | ``` |
| 81 | groups: |
| 82 | <thread-id>: |
| 83 | roots: ["~/github.com/elevenmessenger/messenger"] # scoped sessions live here |
| 84 | plain: false # whole-workstation sessions allowed? |
| 85 | ``` |
| 86 | |
| 87 | The config is the blast radius, written down in one place on the box. |
| 88 | |
| 89 | ### 2. Sessions β two flavors, chosen at spawn |
| 90 | |
| 91 | - **Scoped** (default): bound to a directory β an allowed root, normally a |
| 92 | **fresh git worktree under it** so parallel sessions don't trample each |
| 93 | other. A scoped session gets that project's own `.claude/` settings, |
| 94 | CLAUDE.md, hooks, and permission config exactly as a human dev would β the |
| 95 | repo's existing guardrails govern the remote session with zero new |
| 96 | machinery. |
| 97 | - **Plain** (opt-in via `plain: true`): no pinning β cwd = home, the whole |
| 98 | workstation at the bridge's unix user's reach. The "shared computer" mode |
| 99 | for ops-ish, cross-repo work. Off by default because it hands every member |
| 100 | the entire box. |
| 101 | |
| 102 | Flavor is **visible everywhere**: carried on `session.spawn`, badged on the |
| 103 | session's view ("π messenger @ fix-login" vs "π₯ full workstation") and on its |
| 104 | chat card. Nobody wonders which kind of hands are on the box. |
| 105 | |
| 106 | Birth is conversational: "@claude fix X" with zero live sessions spawns a |
| 107 | scoped session in the group's default root; `/claude new <name>` names one; |
| 108 | `/claude new --plain <name>` when the config allows. Session state persists on |
| 109 | disk on the box (Claude Code's own session files), so a human on the |
| 110 | workstation can `claude --resume <id>` from a real terminal after the bridge |
| 111 | lets go β continuity is preserved; concurrent dual-driving is not pretended. |
| 112 | |
| 113 | ### 3. Chat β @claude plus focus-based routing |
| 114 | |
| 115 | One bot member; the mental model is "a colleague in the chat who happens to be |
| 116 | several sessions deep." |
| 117 | |
| 118 | - **Routing: the target is the active view.** Each member's client knows which |
| 119 | session view they have focused in the right pane; a message to @claude |
| 120 | carries `target: <session-id>` **inside the encrypted payload**. Focus is |
| 121 | per-member β two people can steer two sessions from the same chat |
| 122 | simultaneously. |
| 123 | - **Routing is visible at send time.** When more than one session is live, the |
| 124 | composer shows a chip β "β fix-login" β tappable to retarget without |
| 125 | touching the pane. |
| 126 | - **Replies pin the target.** Every @claude bubble carries a subtle session |
| 127 | tag; replying to a bubble routes to that session regardless of pane focus β |
| 128 | this is also the mobile/chat-only answer. The tag works the other way too: |
| 129 | tapping it focuses that session's view. |
| 130 | - **Degenerate cases collapse.** One live session: everything routes there, no |
| 131 | chip. Zero sessions: @claude answers as the workstation ("no sessions |
| 132 | running β want one?") and a prompt spawns one. |
| 133 | |
| 134 | Output posts as @claude with the session tag on each bubble β the feed reads |
| 135 | as one assistant working several desks, which is the truth. |
| 136 | |
| 137 | ### 4. The right pane β one app, one view per session |
| 138 | |
| 139 | - **One `agents` mini-app** (`web/static/apps/agents.js`), chat-placed |
| 140 | (`places: ["chat"]`), added to the group like any app β one glyph in the |
| 141 | widget bar, rendering in the standard Interface pane. The shipped |
| 142 | Chat | Split | widget-bar toggle IS the "chat left, session right" layout; |
| 143 | nothing new is built for it. |
| 144 | - Its Interface is the **fleet board**: one card per session (name, flavor |
| 145 | badge, status, current activity line, waiting-on-approval chips), folded |
| 146 | from durable `session.*` events. |
| 147 | - **Per-session status rides the views engine** (`views.js`, |
| 148 | `view.create`/`view.update` from the bridge): one engine view per session. |
| 149 | Views are declarative SCHEMA + DATA interpreted by **both web and the Swift |
| 150 | twin engine**, so session boards render on iOS/Mac with no per-feature |
| 151 | Swift. Chips on @claude's replies open the session's view; `/views` lists |
| 152 | them. |
| 153 | - **Fidelity is "board + drill-in" (option c).** Tap a session to focus it: |
| 154 | the durable board plus a **live tail** β tool-call chips ("Ran `go test |
| 155 | ./...`"), collapsed monospace output, file-edit lines, assistant prose β |
| 156 | streamed as **ephemeral events** (relay-only frames, the existing |
| 157 | primitive): live viewers see the chatter; nobody replays it; the encrypted |
| 158 | log stores only what deserves permanence. The views engine cannot host the |
| 159 | tail (views are durable documents; streaming through `view.update` would |
| 160 | bloat the log) β the tail is the app's own render over the ephemeral |
| 161 | stream, web-first. |
| 162 | |
| 163 | ### 5. Event vocabulary |
| 164 | |
| 165 | Durable (thread log; the fold state every member converges on): |
| 166 | |
| 167 | | event | payload sketch | why durable | |
| 168 | |---|---|---| |
| 169 | | `session.spawn` | id, name, flavor, root/worktree, by | fleet board exists offline | |
| 170 | | `session.status` | id, state (running/idle/waiting/dead), activity line | board truth | |
| 171 | | `session.prompt` | id=target, text, by | the steering history IS the log | |
| 172 | | `session.turn` | id, assistant text (final, per turn) | the conversation with the agent | |
| 173 | | `session.result` | id, summary card (files changed, tests, links) | milestones as cards | |
| 174 | | `session.approval` | id, request (tool, input digest), state, decided-by | audit trail of authority used | |
| 175 | | `session.end` | id, reason, by | lifecycle honesty | |
| 176 | |
| 177 | Ephemeral (relay-only; live tail + presence): |
| 178 | |
| 179 | - `session.tool` β tool_use started/finished, truncated output chunks |
| 180 | - `session.stream` β assistant text deltas while a turn is in flight |
| 181 | - typing-style "session is thinking" indicators |
| 182 | |
| 183 | Streaming into *chat* has no partial-message primitive, so @claude's chat |
| 184 | bubbles are **post-per-turn** (the durable `session.turn`), with the live |
| 185 | delta visible in the drill-in tail instead β no post-then-edit churn in the |
| 186 | feed. |
| 187 | |
| 188 | ### 6. Approvals |
| 189 | |
| 190 | Claude Code's permission gate surfaces as a chip row β on the session's board |
| 191 | and as a @claude chat card: "fix-login wants to run `git push` β [Allow] |
| 192 | [Deny]". A tap emits an encrypted `session.approval` decision event; the |
| 193 | bridge reads it and answers the gate. Flat authority: first decision wins, |
| 194 | attributed in the event and shown in the feed ("Ana allowed `git push`"). |
| 195 | Notices/push ride the existing E2EE notify path, so "waiting for approval" |
| 196 | reaches pockets. |
| 197 | |
| 198 | ### 7. Liveness |
| 199 | |
| 200 | Derived, never declared (the docs/bots.md pattern): the bridge heartbeats |
| 201 | (ephemeral presence + a periodic durable status touch); a session whose bridge |
| 202 | is gone must **read as dead/stale** in the board β "last seen 4m ago" β not |
| 203 | linger as "running". A dead bridge silently eating prompts is the worst |
| 204 | failure mode; staleness is always rendered from event age, client-side. |
| 205 | |
| 206 | ## What the server gains |
| 207 | |
| 208 | Nothing. Every piece rides existing surfaces: bot membership, thread events, |
| 209 | ephemeral frames, the views engine, the mini-app framework, notify/push. The |
| 210 | bridge is a new subcommand of the existing binary; the app is one new file |
| 211 | under `web/static/apps/`. |
| 212 | |
| 213 | ## Hard parts, named |
| 214 | |
| 215 | - **The approval round-trip** β latency between the gate firing and a human |
| 216 | tapping Allow; the session blocks meanwhile. Mitigations: push notices, |
| 217 | per-session auto-allow lists (later; config-side, never chat-grantable). |
| 218 | - **Log discipline** β a busy session emits a lot; the durable/ephemeral split |
| 219 | above is the design's load-bearing wall. Anything high-churn must be |
| 220 | ephemeral or it bloats every member's encrypted log forever. |
| 221 | - **Prompt-injection containment** β session output is model output over |
| 222 | untrusted repo/web content; it lands in chat as content, never as commands. |
| 223 | The bridge only ever acts on typed events from members. |
| 224 | |
| 225 | ## Deferred (deliberately) |
| 226 | |
| 227 | - **Driver/observer roles** β layer later as `agents.role` fold state. |
| 228 | - **Fleet-across-groups overview** β a "you"-placed app aggregating every |
| 229 | workstation group; needs nothing new from this design. |
| 230 | - **Attaching to a human's interactive TUI session** β ruled out for v1 |
| 231 | (tmux-scraping + keystroke injection fights the one-writer TUI; the honest |
| 232 | handoff is `--resume` after the bridge lets go). |
| 233 | - **Auto-allow permission profiles** per session flavor. |
| 234 | - **Native drill-in tail** β boards are native-free via the views engine; |
| 235 | the live tail ships web-first. |
| 236 | |
| 237 | ## Decisions (Paul, 2026-08-28) |
| 238 | |
| 239 | 1. **Naming: `agents` stands.** `lchat agents run`, `apps/agents.js`, |
| 240 | `session.*` events, and the member reads as @claude. |
| 241 | 2. **The prompt IS the chat message.** One `session.prompt` event, two |
| 242 | projections β the member's visible bubble in the feed and the bridge's |
| 243 | steering input, tagged with the target. No separate `chat.msg`. |
| 244 | 3. **Closing a session closes its worktree.** Sessions are closable from |
| 245 | within Eleven, and `session.end` is the cleanup moment: the bridge removes |
| 246 | a scoped session's worktree then. The branch (any committed work) survives |
| 247 | worktree removal as an ordinary git branch; only the checkout goes. |
| 248 | |