Web dashboard #
eukhe dashboard serves a web app (phone and desktop) for the session you use in the CLI.
- Chat with the newest root session or a selected one; CLI, Signal gateway and each tab share it.
- Live turn: phases, tool calls, tool output, speed.
- Controls: model, thinking level, service tier, queue, compaction, retry, settings (cvars),
bashapprovals. - Saved sessions: rename, fork, export, delete, new.
- Chat memory as a tree of dated lines, down to each message.
The app and fonts are in the binary; no network loads. Fonts: Inter, Ioskeley Mono (SIL Open Font License; fonts/inter-OFL.txt, fonts/IoskeleyMono-OFL.txt).
Change the app #
Source: crates/eukhe-dashboard/ui (TypeScript, Preact + Radix primitives, StyleX, Vite+). vp build writes crates/eukhe-dashboard/web, which is committed; build.rs embeds every file there, so cargo and Nix builds need no Node.
- Edit
ui/src(verbatim files such as fonts, icons:ui/public). make dashboard-ui: installs locked packages, runsvp check(format, lint, types),vp test run,vp build.- Commit
ui/andweb/together. CI rebuilds and fails ifweb/differs.
EUKHE_DASHBOARD=127.0.0.1:<port> npx vp dev # in ui/: dev server, proxies api/ws to a running dashboard; no Rust build
Tokens #
One source for every presentation value: ui/src/tokens.stylex.ts (colors, lengths, radii, type, weights, opacities, layers, motion).
-
Each color: one value in
lightPalette, one indarkPalette;colors,shadows,pagepair them withlight-dark(); the root'scolor-schemeselects. -
Never repeat a value. Lint rule
eukhe/token-literals(ui/lint/tokens.mjs) failsvp check(file, line) on a literal underui/src: color, non-zero length, duration, easing, or a number for z-index, font weight, opacity, line height, Radix offset. -
global.css(document root, selection, scroll bars) usespagevariables (--page-*);src/tokens.test.tsfails on a literal there. -
index.html,public/manifest.webmanifest,public/theme.jscannot read CSS and repeat the theme color;src/tokens.test.tschecks it equals the palette'sbg. -
bend/tokens.bendholds the same tables.laws/tokens.bend(T2):- each color token has a light and a dark value;
- both modes: text color vs ground meets minimum contrast (4.5:1 text, 3:1 captions on fills);
- layers strictly ordered;
- spacing scale, type sizes, line heights ascend.
src/tokens.test.tsrunsbridge/bridge_tokens.bendand checks its tables equaltokens.stylex.ts; skipped withoutbendunlessEUKHE_REQUIRE_BENDis set.
Run #
eukhe dashboard # print the URL, open the browser; Ctrl+C stops; starts a daemon if none runs
The URL ends with #token=<64 hex digits>: the pairing token (Pairing).
| Option | Effect |
|---|---|
--port <port> |
Listen port. Default: a free port |
--no-open |
Print the URL only |
--allow-host <name> |
Also accept this host name. Repeatable |
--rotate-token |
New pairing token before start; earlier links stop working |
--daemon-socket <path> |
Use the daemon on this socket |
Pairing #
No token, no action: a page cannot send a message, approve a bash step, change a setting, or read a chat.
- Token: 32 random bytes (256 bits), 64 hex digits, file
dashboard-tokenin the agent folder (~/.eukhe), mode0600. Stable across restarts, so bookmarks work. - In the URL fragment (
#token=…), which browsers never send: not in request lines, forwarder logs, orReferer. - The page stores the token in this site's browser storage, removes it from the address bar, never shows it.
- To open the browser, the command writes a small redirect page (mode
0600) and opens it: the token is not in process arguments visible to other users. - No or refused token: "Pairing needed". Open the printed link, or paste the link (or token) in the field and select Pair.
eukhe dashboard --rotate-token # new token, e.g. after losing a phone with the link
All earlier tokens die at once, also in a running dashboard: open pages get unpaired at their next request and show "Pairing needed".
Phone (tailscale serve) #
The dashboard listens on 127.0.0.1 only. tailscale serve gives HTTPS on your tailnet name, reachable only by your tailnet's devices.
eukhe dashboard --no-open --port 8377 --allow-host box.tail1234.ts.net # also prints the link for that name
tailscale serve --bg 8377 # forward HTTPS
tailscale serve --bg --set-path /eukhe 8377 # or: mount at a path (app uses relative URLs)
- On the phone open
https://box.tail1234.ts.net/#token=…(or/eukhe/#token=…). "Add to Home Screen" installs it; a home-screen app can have its own storage: on "Pairing needed", paste the link. --allow-hosttakes the nametailscale statusshows, withouthttps://or port. Without it, requests throughtailscale serveare refused.
Phone (poros) #
poros runs the dashboard with its own HTTPS tailnet link; no --allow-host:
poros eukhe dashboard
Open the printed Poros URL plus the #token=… part of the dashboard link. poros sets Host to the local address and names the HTTPS link in X-Forwarded-Host. The fragment stays in the browser; poros never sees the token.
Security #
Warning
Anyone with the pairing link can message the agent, which runs tools
with your user permissions. Keep it private; if leaked, run
eukhe dashboard --rotate-token.
- Each WebSocket pairs first: first frame holds the current token. Other first frame, no token, or stale token:
unpaired, close; no daemon start, connect or command. - Token rechecked before each frame of a paired socket; after rotation an old socket's next frame gets
unpairedand closes. - Constant-time comparison for tokens of the correct length. Token never in a log line, error, or page.
- HTTP routes only serve app files; no actions.
- Listens on
127.0.0.1only. Hostmust be127.0.0.1,localhost,[::1](any port) or an--allow-hostname; else403. Stops DNS rebinding.- WebSocket
Originmust match host and port ofHost, or of the lastX-Forwarded-Hosta forwarder adds (poros); missing or other origin:403. Web pages cannot addX-Forwarded-Hostto an upgrade, so other pages cannot use the socket. - CSP: only the app's own scripts, styles, fonts, workers, manifest, socket; images also
data:,blob:. No inline script or style. --allow-hostonly for a forwarder you control that keepsHost(tailscale servedoes).tailscale serve, nevertailscale funnel(public internet).- The socket refuses bridge and daemon-internal commands (Protocol).
laws/dash.bend: pairing gate rules;PROOF.bendproves them onbend/dash.bend. Bridge testpairing::tests::the_gate_pairs_as_its_modelruns real server and model on the same events.
Caching #
- Entity tag per file;
Cache-Control: no-cache: checked each load,304if unchanged. - No service worker; an older build's worker and cache are removed at the next load.
Protocol #
One WebSocket (api/ws, relative to the page); each frame one JSON text message. Protocol 3.
Pairing frames #
Page's first frame: {op: "pair", token} (token from the link). Answer:
| Frame | When |
|---|---|
hello {protocol: 3, build, ops} |
Token current; requests can follow |
unpaired {error} |
First frame not a pairing, no token, or stale token. Socket closes next. Nothing ran |
10 seconds to pair. Later, any frame with a stale token gets unpaired; socket closes.
Server frames #
| Frame | When |
|---|---|
hello {protocol: 3, build, ops} |
First frame after pairing. build changes with the eukhe build or a web file |
reply {id, data} |
Request succeeded |
failure {id, error} |
Request failed; also an invalid frame (id 0 if none) |
event {event, cursor?} |
One bound-session event. cursor = {generation, sequence} when the daemon gives a sequence |
approval {request} |
Bound session's bash asks to run one step. request = {requestId, step, command, effects}; effects ⊆ read, write, exec, net, outside, protected, secret |
resync {reason} |
Bound session closed, moved worker, or lost its worker link. Reopen |
unavailable {error} |
Daemon unavailable. Socket closes next |
unpaired {error} |
Token rotated. Nothing ran. Socket closes next |
Requests #
{id, op, ...fields}. Requests run concurrently; each answer carries its id. Events never wait for requests.
| Op | Fields | Reply data |
|---|---|---|
sessions |
none | Saved root sessions, newest first: [{id, path, cwd, title, modified}] |
open |
sessionId? or path?, resume? |
{session: {id, path}, state, summary, messages, cursor, replay} |
new |
cwd? (absolute folder) |
As open |
prompt |
text, mode (steer or followUp), admissionId?, images?: [{data, mimeType}] |
null |
abort |
none | null |
memory |
node?: {id, count} |
One level of the chat memory tree |
run |
command |
The daemon answer's data |
export |
format (html or jsonl) |
{name, mimeType, data}: bound session's file, data base64 |
delete |
sessionId |
{deleted}: id of the deleted saved root session |
rename |
sessionId, name |
{renamed, name}: the hosting worker renames it; with no worker, the saved file is renamed |
openwithoutsessionId/path: newest root session; none exists: a new one.resume= cursor of the page's last event; thenreplayiscomplete(messagesnull),partialorunavailable. Withoutresume,replayisnull.- The
openreply precedes post-snapshot events; events in the snapshot are not resent. promptwith the sameadmissionIdis sent once; the server makes an id if the page sends none.export: the daemon writes into a new private temp folder (mode0700); the server reads the file, removes the folder.deleterefuses a session open in another client (tab, CLI, Signal gateway):the chat is open in another window: close it there first. Else stops its worker, if any, and deletes it. A tab bound to it releases it first; open another session next.
run #
command: a daemon command, type in snake case, fields in camel case, no activeSessionId (the server sets the bound session's id, overriding the page).
Allowed (list: crates/eukhe-dashboard/src/command_policy.rs):
- bound-session controls and inspection: model, thinking, service tier, steering and follow-up modes, auto-compaction, auto-retry, cvars, compaction, retry, queue,
bashapproval answers, session name, fork, trees, prompts, memory, statistics; - catalog commands
list_saved_sessions,rename_saved_session,cron_list,heartbeats_list, sent without session id (daemon answers for all sessions).
Refused with the dashboard does not run <type>:
- bridge-owned:
create,attach,detach,prompt; export_html,export_jsonl(write to anyoutputPath; useexport);delete_saved_session(cannot stop the worker; usedelete);- worker, supervisor and daemon commands such as
shutdown.