grok-glance
A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what an agent is doing from your phone — and tap approve or deny when it wants to run something risky.
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files, or drive a session.
┌────────────────────────────┐
│ ● grok-glance 2 sessions│
├────────────────────────────┤
│ Waiting on you 62s │
│ Bash · in remote-grok │
│ rm -rf ./dist │
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
│ [ Deny ] [ Approve ] │
├────────────────────────────┤
│ remote-grok working │
│ ~/src/remote-grok │
│ Last asked: fix the flaky │
│ ⟳ Read 4s │
│ server/src/state.ts │
│ 12 tools 0 failed 0 ✗ │
├────────────────────────────┤
│ Activity │
│ ● Read state.ts 14:22 │
│ ● Bash npm test 14:21 │
└────────────────────────────┘
Requirements
- Node.js 20 or newer, and npm.
- Grok Build.
- For phone access: Tailscale on both the machine and the phone. See Why Tailscale — a LAN IP genuinely cannot work.
Install
git clone <this repo> grok-glance
cd grok-glance
npm install && npm run build
The build produces dist/server (the daemon) and dist/web (the dashboard). Both are required;
the daemon serves the dashboard itself.
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so
for a local checkout the shortest path is a one-entry catalog. Create
.grok-plugin/marketplace.json in a directory that contains your checkout:
{
"name": "local",
"description": "Local plugins",
"owner": { "name": "me" },
"plugins": [
{
"name": "grok-glance",
"description": "Passkey-guarded phone dashboard for Grok Build.",
"category": "monitoring",
"source": { "type": "local", "path": "./grok-glance" }
}
]
}
…then add that marketplace and install grok-glance from Grok Build's /plugin interface.
Once installed, the daemon starts by itself: the SessionStart hook boots it in the background on
the first session after installation.
Get it onto your phone
The daemon binds to 127.0.0.1 only and never opens a port to your network. Tailscale Serve
publishes it inside your tailnet with real TLS:
tailscale serve --bg 127.0.0.1:8791
tailscale serve status # note the https://<box>.<tailnet>.ts.net URL
Tell grok-glance which origin it is being served on — this is also the WebAuthn relying-party ID, so it has to be exact:
node bin/glance set-origin https://<box>.<tailnet>.ts.net
Enrol the phone
node bin/glance enroll
That prints a URL and an 8-character code, good for 10 minutes, single use:
Open this on your phone:
https://mybox.tailnet-1234.ts.net/?enroll
Enrollment code: K7QM4RTX
Valid for: 10 minutes (single use)
Open the URL on the phone, type the code, tap Create passkey, and confirm with Face ID / a fingerprint / the device PIN. From then on the phone unlocks the dashboard with that passkey and nothing else gets in.
Repeat for each device you want. node bin/glance devices lists them; node bin/glance revoke <id-prefix> removes one (and kills its live session immediately).
What the dashboard shows
- Now — the workspace, its state (working / waiting on you / idle / error / ended), the last thing you asked, the tool currently running with a live elapsed timer, and running counts of tools, failures and denials.
- Sessions — one row per live session when there is more than one; tap to filter.
- Activity — a timeline of prompts, tool calls with durations, failures, permission denials, notifications, subagents, compactions, session start/end.
- Pending approvals — a card per waiting tool call, with the command, a countdown, and two large buttons.
Updates arrive over Server-Sent Events. The server sends whole snapshots rather than deltas, so a phone that slept through twenty events still wakes up showing the truth.
Remote approve / deny
Off by default. Turn it on from the phone's settings panel, or:
node bin/glance approval risky # Bash, Write, Edit, MultiEdit, NotebookEdit
node bin/glance approval all # every tool call — noisy
node bin/glance approval off
Defaults worth knowing:
| Behaviour | Default | Changeable from the phone | Why |
|---|---|---|---|
| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. |
| On timeout | allow | yes | Flip to deny if you would rather fail closed. |
| Timeout | 90s | no — edit config.json |
The hook's own timeout is 125s; raising this past that would just make the hook give up first. |
| Risky-tool pattern | ^(Bash|Write|Edit|MultiEdit|NotebookEdit)$ |
no — edit config.json |
Shown on the phone but not editable: a typo'd regex would silently change what gets gated. |
This is a convenience gate, not a security boundary. Every failure path is fail-open: daemon down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls actually blocked, use Grok Build's own permission settings.
CLI
bin/glance is plain Node with no dependencies. Run it as node bin/glance <command>.
| Command | What it does |
|---|---|
status |
Running? On which origin, with how many devices? |
up |
Start the daemon in the background |
serve |
Run it in the foreground (for debugging) |
stop |
Stop it |
logs |
Last 60 lines of the daemon log |
enroll |
Mint a one-time enrolment code and URL |
set-origin <url> |
Set the public https origin and RP ID |
devices |
List enrolled devices |
revoke <id-prefix> |
Revoke a device |
approval <off|risky|all> |
Set the approval policy |
sync-hooks |
Rewrite hook URLs after changing the port |
Files and configuration
Everything lives in ~/.grok/glance (mode 0700), or $GLANCE_HOME if you set it:
| File | Contents |
|---|---|
config.json |
Port, bind host, public origin, RP ID, approval settings |
credentials.json |
Enrolled passkeys — credential IDs, public keys, counters. No secrets of yours. |
auth-sessions.json |
Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
secret.key |
32-byte HMAC key used to sign session cookies |
admin.token |
Rotated every daemon start; authenticates the CLI |
events.jsonl |
Append-only event log, one JSON object per line, rotated at 5 MB |
daemon.log |
Daemon stdout/stderr |
Three environment variables override config.json, which is mostly useful for testing a second
instance without touching your real one:
| Variable | Effect |
|---|---|
GLANCE_HOME |
Where all of the above lives. Read by the daemon, the CLI, and the hook scripts. |
GLANCE_PORT |
Port to listen on (and, for the CLI and hooks, to talk to) |
GLANCE_ORIGIN |
Public origin, as if set with set-origin — but not persisted |
To change the port, edit config.json, then run node bin/glance sync-hooks so the hook URLs in
hooks/hooks.json match. Restart the daemon afterwards.
Security notes
Network surface. The daemon listens on 127.0.0.1:8791 and makes no outbound connections of
its own. Three classes of caller:
| Path | Caller | Authentication |
|---|---|---|
/hook/record, /hook/approve |
Grok Build's hooks, from this machine | none — loopback only |
/api/*, /events |
the dashboard | passkey session cookie + CSRF header |
/local/* |
the glance CLI |
rotating admin token from admin.token |
/local/* is token-gated rather than "is it from localhost", because tailscale serve proxies
remote traffic to 127.0.0.1 — the daemon cannot tell a local caller from a tunnelled one by
address alone.
Session cookie is HttpOnly, SameSite=Strict, HMAC-signed, and Secure whenever the request
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
last 30 days; revoking a device drops its session at once.
Cross-site defence. Every POST must carry exactly content-type: application/json, which is
not a CORS-safelisted type, so a hostile page cannot post here without a preflight that is never
answered. /api/* additionally requires an x-glance-csrf header. The pages themselves are served
with a strict CSP (script-src 'self', no framing, no form actions) and no-store.
User verification is required, for both enrolment and sign-in: the phone asks for a biometric or PIN every time, so a stolen unlocked phone is not automatically a way in. Passkeys are created as resident keys, so the phone offers the right one without you typing a username.
Rate limits. Authentication attempts are capped (40 per 5 minutes), enrolment attempts more
tightly (12 per 5 minutes, and a code burns itself after 5 wrong guesses). The limiter is keyed
globally on purpose: behind a tunnel every request arrives from 127.0.0.1, so per-IP buckets
would be one bucket wearing a hat.
What crosses the wire. Tool names, truncated arguments, prompt first lines, file paths,
durations, and exit statuses — a summary, not a transcript. Known secret shapes are redacted before
storage: sk-/rk-/pk- keys, xai- keys, GitHub and Slack tokens, AWS access key IDs, JWTs,
PEM private-key blocks, Authorization:/Bearer headers, and password/secret/token/api_key
assignments. This is a filter, not a guarantee — a secret in an unusual shape will show up in the
timeline. Treat the dashboard as being as sensitive as your terminal.
Threat model. grok-glance assumes the machine it runs on is trusted. It protects against
someone else on your tailnet, or a browser tab you left open, reaching the dashboard. It does not
protect against a local attacker who can read ~/.grok/glance — with admin.token they can enrol
their own device.
Why Tailscale, and not just the LAN IP?
WebAuthn will not run outside a secure context, and — separately — a bare IP address cannot be a
relying-party ID. So http://192.168.1.20:8791 can never hold a passkey, no matter what the
browser is willing to render. You need a hostname with valid TLS. Tailscale Serve gives you one for
free, with the tunnel closed to everything outside your tailnet.
Any other route to a real https hostname works too: set origin in config.json (or use
set-origin) to whatever your reverse proxy terminates on. If you put grok-glance behind a proxy
reachable from the public internet, the passkey gate is the only thing standing in front of it.
Changing the origin changes the RP ID, and passkeys are bound to the RP ID — existing devices stop working and must be enrolled again.
Hook wiring
hooks/hooks.json subscribes to all 14 lifecycle events. Passive events use type: "http": they
POST straight into the daemon with no process spawn, so they cost close to nothing per tool call
and quietly do nothing when the daemon is down.
Two exceptions:
SessionStartrunsbin/glance-up.mjs, which is what boots the daemon.PreToolUseis wired twice — anhttpentry that records every call for the timeline, and acommandentry matching only^(Bash|Write|Edit|MultiEdit|NotebookEdit)$that runsbin/glance-approve.mjs. PreToolUse is the only blocking event, and a command hook is the only documented way to return a deny decision, so the gate has to be a spawned process; keeping the match narrow means the cost is paid only for calls that could actually need a tap.
The hook scripts use nothing but the Node standard library and always exit 0 unless they are deliberately denying.
Deliberately omitted
Not oversights — decisions:
- Sending prompts or steering the session. Hooks cannot inject input, so a phone-to-agent channel would need a second transport and a much larger threat model.
- Full transcripts and tool output. Summaries only. Streaming assistant text through a phone would mean shipping your codebase through it.
- Push notifications. Needs a VAPID key, a service worker, and a subscription store, for a dashboard you open on purpose.
- Multi-user accounts and roles. One user, several devices.
- Diff views, file browsing, cost/token charts, log search, session resume. All out of scope for a glance.
- Editing the risky-tool pattern from the phone. Shown but not editable: a typo'd regex there
would silently change what gets gated. Edit
config.jsoninstead.
Licence
MIT.