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>
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, becausecoder/websocketpermits 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:
promptandcancelrun 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 undercontext.WithoutCancel, and progress arrives as mirrored frames like anything else.snapshotis 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.1by default; exposing it should be a deliberate act, ideally behind a reverse proxy that terminates TLS. - No TLS of its own.
--insecure-cookieexists for plain-HTTP localhost and is refused on a non-loopback address, because a session cookie withoutSecureon 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 bootstraprefuses 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.tsxgates on/api/status→ Setup / Login / Console, and callsuseThemeexactly once at the root (each call owns its own state — a second call would desync).Consoleowns the singleGlanceSocket, so moving between the session list and a session neither drops the connection nor re-requests a snapshot.lib/acp.tsfolds frames into aTranscriptof seven item kinds (user,assistant,thought,tool,plan,notice,turn-end). Tool calls are updated in place by id, so a streamingtool_call_updaterefines the card that is already on screen.components/PermissionDialog.tsxrenders 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.