Files
iceBear67andClaude Opus 5 051efe8fec grok-glance: web control plane for grok's /rc remote control
A single Go binary that grok dials out to over a WebSocket, and a HeroUI
web UI for driving the session it is attached to.

The roles are inverted relative to the terminal: over the /rc link grok
is the ACP Agent and glance is the Client. That makes glance a stock ACP
client and the web Stop button a real session/cancel rather than a
bespoke control message.

Both notification rails are mirrored. The stable session/update rail
carries correctness; x.ai/session_notification is presentation only and
degrades rather than erroring, because its ~60 variants are grok
internal and drift with every upstream sync. _meta is forwarded
byte for byte so viewers can dedup and order.

Permissions race: the terminal and any browser may answer, first
responder wins, and the loser's UI retracts by itself. All three
interaction methods go through that path, not just permissions.

Auth is TOTP only, with no accounts to have. A bootstrap token printed
at first start gates /setup, which is a 404 without it; state lives in
one 0600 JSON file and history in an in-memory ring, so there is no
database and no recovery story beyond deleting the file.

ARCHITECTURE.md covers the topology and the limits of that auth model;
CLAUDE.md covers building, the fakeagent loop, and the end-to-end
checklist that unit tests cannot replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:07:33 +00:00

17 KiB

grok-glance — architecture

grok-glance is remote control for grok: a self-hosted server plus web UI that mirrors a running terminal session and lets you drive it from a browser — send prompts, interrupt a turn, approve tool calls — without the terminal ceding anything.

It is one Go binary with the frontend embedded, one port, and no database.

   ┌────────────── your machine ──────────────┐        ┌──── anywhere ────┐
   │  grok TUI                                │        │                  │
   │    │                                     │        │                  │
   │    ├── AcpClientRx ──▶ tee ──▶ TUI       │        │                  │
   │    │                    │                │        │                  │
   │    │                    ▼                │        │                  │
   │    │              /rc bridge ═══ WSS ════╪══▶ glance ══ WSS ══▶ browser
   │    │                    │      (ACP)     │   (Go)     (glance     (HeroUI)
   │    └── AcpAgentTx ◀─────┘                │            envelope)   │
   └──────────────────────────────────────────┘        └──────────────────┘

Two links, two protocols, deliberately:

Link Protocol Auth Who dials
grok ↔ glance ACP over WebSocket, grok as the Agent Authorization: Bearer <api key> grok dials out
glance ↔ browser glance's own JSON envelope __Host-glance session cookie browser dials in

Why it is shaped this way

grok dials out

grok runs on a laptop, in a devcontainer, on a box behind NAT. glance runs where you can reach it. Making grok the dialer means remote control works without a port forward, a tunnel, or an inbound firewall rule on the machine that holds your source tree — the one place you least want an open port.

The consequence is that glance never initiates: an agent appears when it connects and disappears when it hangs up, and the session list is exactly the set of live sockets.

The roles are inverted relative to the terminal

Inside grok, the TUI is an ACP Client talking to the agent runtime. Over the /rc link grok presents itself as the Agent and glance is the Client.

That inversion is the whole trick. It means glance is a stock ACP client — it receives session/update and session/request_permission, it sends session/prompt and session/cancel — and needs no knowledge of grok's internals to be a second head on the same session. It also means the browser's Stop button is a real session/cancel, not a simulated keypress.

The browser is not an ACP peer

/api/ws speaks a small glance-specific envelope (see Browser protocol), not ACP. Translating once, server-side, keeps three things out of the frontend: JSON-RPC id correlation, the pending-interaction table, and the ring buffer. The browser sends {"type":"prompt", ...} and receives {"type":"frame", ...}; everything that must be exactly right about the ACP conversation is exactly right in one place, in Go, with tests.

No database

Transcript history is a per-agent in-memory ring (4096 frames, internal/hub/ring.go). A reload replays it; a server restart does not. This was a deliberate choice: the alternative is a store of every prompt, file path, diff and command output from every session, on disk, forever, guarded by one TOTP secret. The ring is bounded, unbackupable by construction, and enough for what the UI is actually for — watching the turn that is happening now.

What is persisted is only what cannot be re-derived: the TOTP secret, the cookie signing key, and API key hashes, in ~/.grok/glance/state.json (0600).


The grok side (/rc)

Implemented as patches 0003/0004 in the sibling newgrok/ tree. Summarised here because the two halves only make sense together; the authoritative comments are in newgrok/work/crates/codegen/xai-grok-pager/src/rc/.

The tee. The pager already holds an AcpAgentTx (to the agent) and an AcpClientRx (from it). Every inbound message passes through one function, so /rc inserts one call there: mirror a copy to the bridge, return the original untouched. Remote control needs no change to the agent runtime, the session actor, or the shell — which is what makes "does not disturb the session" true rather than aspirational.

Both notification rails are mirrored. Alongside the stable session/update, grok emits ~60 grok-specific variants as x.ai/session_notification — tool-call deltas, subagents, retries, turn boundaries. Forwarding only the standard rail would give glance a transcript with the streaming taken out. _meta (eventId, promptId, chunkId, isReplay) is forwarded verbatim so a viewer can dedup and order the stream the same way the TUI does.

Interactions are raced, not routed. The three reverse-requests a human must answer — session/request_permission, x.ai/ask_user_question, x.ai/exit_plan_mode — carry their own oneshot reply channel. The tee lifts the real sender out, hands the TUI a substitute, forwards a copy to glance, and gives the answer to whichever side replies first. The loser is told to retract its dialog. Neither side is privileged, and no configuration decides who may answer.

Failure is local-only. The bridge is its own task with its own reconnect backoff. If glance is down, unreachable, or killed mid-turn, the terminal session is unaffected — the tee degrades to a plain move the moment the bridge's channel closes.


The glance side

cmd/glance/main.go     serve | apikey add·list·rm | bootstrap | version
internal/
  acp/       JSON-RPC framing, the ACP subset glance needs, method predicates
  hub/       the live system: agents, browsers, ring buffer, interaction table
  auth/      TOTP enrollment and login, bootstrap gate, cookie signing
  state/     ~/.grok/glance/state.json (0600)
  httpapi/   routing, middleware, the two upgrades, the embedded SPA
web/         Vite + React 19 + Tailwind 4 + HeroUI 3

hub — where everything meets

Hub owns two maps: connected agents by API-key id, and connected browsers. Everything else is per-agent state on Agent (internal/hub/agent.go):

  • ring — the replay buffer. Frames are stored exactly as received, bytes unchanged.
  • interactions — open reverse-requests, keyed by grok's JSON-RPC id.
  • calls — glance's own outstanding requests, keyed by id, each with a buffered reply channel so a late response never blocks the reader goroutine.
  • outbound — a bounded queue drained by a single writer goroutine, because coder/websocket permits one concurrent writer and prompts, replies and pings all originate on different goroutines.

Fan-out is unfiltered. Every browser receives every agent's frames. At this scale (a handful of sessions, a handful of viewers) per-browser filtering would be one more thing to get wrong for no measurable gain; the frontend ignores frames for the agent it is not showing.

Backpressure is a disconnect, not a buffer. A browser that cannot keep up fills its 256-frame queue and is dropped; it reconnects into a fresh snapshot. An unbounded queue would turn one slow phone on hotel wifi into the server's memory problem, and a snapshot is cheaper than the backlog it would have replayed anyway.

Turn state is derived, not declared. classifyUpdate reads a single field — update.sessionUpdate — from either rail to decide whether a turn is running. Nothing else in the xAI rail's payload is interpreted, because those ~60 variants are internal to grok and drift with every upstream sync. The stable rail carries correctness; the xAI rail is presentation.

The permission race, end to end

grok ──▶ session/request_permission (id 7) ──▶ glance
                                                 │  ring? no. interactions[7] = …
                                                 └─▶ {"type":"interaction"} to all browsers

  … whichever happens first:

  browser answers  ──▶ Agent.Answer(7)  ──▶ JSON-RPC response id 7 ──▶ grok
                                        └──▶ {"type":"interaction_resolved", by:"browser"}

  terminal answers ──▶ grok sends x.ai/rc/interaction_cancelled(7)
                                        └──▶ {"type":"interaction_resolved", by:"terminal"}

Agent.Answer deletes from interactions under the lock and reports whether it was still there. A false return is not an error — it is the ordinary outcome of losing the race — and the browser that lost is sent by:"elsewhere" with "already handled elsewhere". That is why exactly one JSON-RPC response ever reaches grok for a given id, even when two browsers and a terminal all click at once.

A browser may also decline, which replies to grok with a JSON-RPC error. grok reads that as "glance is not answering this", leaves the terminal's dialog up, and the turn proceeds when the user answers there. Declining is not denying — denying is an ordinary answer with a reject option.

Requests glance will not serve

grok drives nothing on this link. An inbound request that is not one of the three interactions gets -32601 Method not found, including fs/* and terminal/*. glance has no filesystem to offer, and a fabricated success would leave grok acting on a lie. (In its default configuration the pager does not advertise those capabilities, so they should not arrive at all; the arm exists because "should not" is not "cannot".)


Browser protocol

One JSON object per WebSocket message, in both directions.

Browser → server (internal/hub/browser.go, command):

type Fields Meaning
list resend the agent list
subscribe agent send a full snapshot of one agent
prompt agent, text start a turn
cancel agent interrupt the running turn
answer agent, id, result answer an interaction
decline agent, id, reason hand it back to the terminal

Server → browser (Event): agents, snapshot, frame, interaction, interaction_resolved, notice, error.

Two details that matter:

  • prompt and cancel run detached. A prompt does not return until the turn ends, which can be many minutes. Waiting inline would stall the browser's whole command stream — including the Stop button it might need next. They run on their own goroutine under context.WithoutCancel, and progress arrives as mirrored frames like anything else.
  • snapshot is authoritative. It carries the ring, the open interactions, the session metadata and the turn state in one message. The frontend replaces state with it rather than merging, which is what makes a reload or a reconnect land on the server's view instead of a half-stale one.

Authentication

There is no username and no password. There is one authenticator app and one server.

Bootstrap. On first serve, glance mints a one-time token, prints it with a setup URL, and writes it to ~/.grok/glance/bootstrap.token (0600) — stderr for the terminal case, the file for the systemd case. /api/setup/* answers 404 unless the request carries a valid, unused token, and 404 again once enrollment succeeds. Without that gate, the window between "server starts" and "operator opens the browser" is a race anyone who can reach the port may enter.

Enrollment. /api/setup/begin returns a candidate secret and otpauth:// URI, which the page renders as a QR code. Nothing is persisted until /api/setup/complete verifies a code generated from that secret — a failed QR scan must not be able to lock the operator out of their own server. Success stores the secret, burns the bootstrap token, and issues a session cookie, because you have just proved you hold the authenticator and a login form one second later would ask for the same proof.

Login. Six digits, ±1 time step for clock skew. Failures are rate-limited (8 per 5 minutes, counted globally — the limiter protects the secret, not a user account). Accepted time steps are burned, so a code observed over a shoulder cannot be replayed inside its 30-second window.

Session cookie. __Host-glance — the prefix is a browser-enforced promise of Secure + Path=/ + no Domain, so a sibling host cannot set or overwrite it. The value is <expiry>.<hmac>, signed with a key in state.json: no session table, so a restart does not sign everyone out, and deleting state.json invalidates every outstanding cookie at once. HttpOnly, SameSite=Strict, 12 hours.

API keys. glance apikey add <name> prints glance_sk_… once; only a SHA-256 hash is stored. The key identifies one grok instance, and its id is the agent id — which is why reconnecting with the same key replaces the previous connection instead of accumulating a ghost session in the list.

What this model does not protect against

Stated plainly, because the threat model is small on purpose:

  • glance is a remote control for a shell agent. Anyone who can authenticate can approve arbitrary tool calls. It listens on 127.0.0.1 by default; exposing it should be a deliberate act, ideally behind a reverse proxy that terminates TLS.
  • No TLS of its own. --insecure-cookie exists for plain-HTTP localhost and is refused on a non-loopback address, because a session cookie without Secure on a real network is a credential in cleartext.
  • No account recovery. Losing the authenticator means deleting state.json — which also revokes every API key and session. glance bootstrap refuses to mint a second token once enrolled, since a re-enrollment path is exactly the door the bootstrap gate exists to keep shut.
  • One operator. There are no roles, no audit log, and no per-key permissions.

Content-Security-Policy

default-src 'self' with no unsafe-inline for scripts. glance renders agent output — file contents, command output, model text — and none of it is trusted markup. Plan content is rendered as preformatted text rather than parsed as Markdown for the same reason: it avoids shipping a parser and a sanitiser to display text an agent wrote.


Frontend

React 19 + HeroUI 3 (react-aria-components) + Tailwind 4, built by Vite into web/dist and embedded with //go:embed all:dist.

  • App.tsx gates on /api/statusSetup / Login / Console, and calls useTheme exactly once at the root (each call owns its own state — a second call would desync).
  • Console owns the single GlanceSocket, so moving between the session list and a session neither drops the connection nor re-requests a snapshot.
  • lib/acp.ts folds frames into a Transcript of seven item kinds (user, assistant, thought, tool, plan, notice, turn-end). Tool calls are updated in place by id, so a streaming tool_call_update refines the card that is already on screen.
  • components/PermissionDialog.tsx renders all three interaction types as cards, not modals — a modal that closes itself when the terminal wins the race is more jarring than a card doing the same, and cards stack when several are open.

The response payloads it builds are pinned to grok's Rust types, not guessed: {"outcome":{"outcome":"selected","optionId":…}} for permissions, {"outcome":"approved"} for plan mode, and for questions an {"outcome":"accepted","answers":…} envelope whose map is keyed by question text, in original order, values as arrays of labels, unanswered questions omitted — the exact construction xai-grok-pager/src/views/question_view.rs performs. Getting this wrong does not fail loudly; it fails as an agent that received an answer to a question nobody asked.

An unrecognised interaction method renders its raw params and offers only decline, which is the honest response to a request whose reply shape glance does not know.


Testing

internal/hub/hub_test.go drives the hub over real WebSocket connections with a fake grok and fake browsers. The behaviour that matters — an interaction reaching a browser, an answer reaching grok, two sides racing — lives in the interleaving of three goroutines, and a mocked transport would test the mock. It covers the permission round trip, terminal-wins retraction, browser-vs-browser arbitration, decline, ring replay on subscribe, method-not- found, reconnect-replaces-ghost.

internal/httpapi/server_test.go covers the auth boundary: the bootstrap 404, enrollment, rate limiting, cookie forgery, both upgrades, and the SPA fallback's refusal to swallow /api/*.

What tests cannot cover is the race against a real terminal. The end-to-end checklist in CLAUDE.md is not optional.