33 — Admin console
One binary, one URL, full control. The web admin console (/admin on the
secured server) is the canonical management surface for vak:
observation, operation, and interaction against the same audited core the
TUI and desktop use.
Status: shipped. All endpoints live behind the standard auth stack; nothing here bypasses permission checks or brokered tools.
Goals
- Single deployment: the SPA is embedded in
vak-server(include_dir) — no separate web tier, no node at runtime. - Canonical client: run prompts, steer active runs, fan out best-of-N, answer approvals, switch modes — from any browser on the LAN.
- Real-time by default: a global event hub feeds SSE for event-driven views; control-plane snapshots use a bounded refresh interval because service-manager probes and durable ledgers are read as a coherent sample.
- Operations first: a single evidence-backed control plane makes server, CorePool, channels, runs, approvals, services, delivery and doctor state navigable from posture → subsystem → resource → incident.
- Rebuildable intelligence: SQLite FTS5 index over JSONL ledgers.
JSONL remains the source of truth; dropping
store.dbis always safe.
The Operations Center is part of this same embedded shell. It is a bookmarkable, evidence-first control plane over the server, CorePool, gateway, channels, work, delivery, automations, providers, incidents, and Doctor checks; it is not a second source of operational truth.
Architecture
crates/vak-admin-ui SolidJS + Vite + TS SPA (built dist committed)
crates/vak-client-ui the WORKSPACE client, served at /app by the same
process (docs/design/48-web-client.md); a separate
surface with a different job, sharing this auth
crates/vak-store rusqlite FTS5 rebuildable index (new crate)
crates/vak-server/src/events.rs global hub (tokio::broadcast)
crates/vak-server/src/admin.rs /admin/api/* data plane
crates/vak-server/src/admin_ui.rs /admin static asset serving
Event hub
EventHub wraps one tokio::broadcast::Sender<SystemEvent> (capacity
4096; slow consumers receive an explicit Lagged frame and reconnect).
Initialized once per process via init_global(); emitters are fire-and-
forget and never block producers.
Event families: agent lifecycle (run summaries), session lifecycle (created / entry appended), config changes, gateway inbound, approval requested/granted/denied, security (auth failures, rate limits), provider errors, heartbeat.
The SSE endpoint GET /admin/api/events streams them all. Browsers
subscribe with EventSource; auth rides the session cookie because
EventSource cannot send headers.
Operations URL contract
The Operations Center uses hash routes so a browser refresh never loses the operator's place. Its context bar writes the selected workspace and time window into the query string and every Operations link carries those values:
| Route | Purpose |
|---|---|
#/operations | Posture and topology overview |
#/operations/work | Sessions, active runs, approvals, scheduled work |
#/operations/runtime | Service manager, CorePool, action receipts |
#/operations/channels | Gateway bindings and delivery outbox |
#/operations/automations | Durable scheduled task definitions and state |
#/operations/providers | Effective provider/model route and provenance |
#/operations/incidents | Durable correlated incident queue and history |
#/operations/work/runs/:id | Run ledger and provider receipt trail |
#/operations/channels/:target | Binding identity, route, and dependencies |
#/operations/channels/delivery/:job | Outbox record, replay, and verification |
#/operations/incidents/:id | Incident timeline, correlation, and Doctor |
The browser can therefore move from posture → subsystem → resource → incident → raw evidence without losing scope. Detail screens use actual server responses; historical or unavailable records are labelled rather than filled with placeholders.
Store (FTS5 index)
- One SQLite DB at
cache_home()/store.db, WAL mode, schema-versioned. entriestable: entry_id, session_id, project_hash, parent_id, ts, kind (header/message/compaction/receipt/goal), role, provider, model, tool_name, content_text, is_error.entries_fts: FTS5 withporter unicode61; BM25 ranking +snippet().- Indexes ALL content blocks — tool commands, tool results, thinking — not just prose. This is the main recall win over the in-memory scanner.
- Lifecycle: background full rebuild at server start; per-session import
after create and after each completed run; synchronous re-import when a
transcript is read with
refresh=true(safe: reads never conflict with the ledger writer's exclusive lock).
Auth (cookie + bearer)
Same token, two channels:
Authorization: Bearer <token>— CLI/desktop/service clients.- Cookie
vak_session— browser flows.POST /auth/loginvalidates the token withsubtle::ConstantTimeEqand sets an HttpOnly SameSite=Strict cookie (lifetime from[server] session_ttl_hours, default 168h).Secureis set only behind real TLS ([server] public_urlis https, or a proxy sendsX-Forwarded-Proto: https) — aSecurecookie delivered over plain http is silently discarded by the browser, so setting it unconditionally would make every loopback login appear to succeed and then never persist.POST /auth/logoutclears it;GET /auth/sessionreports whether one exists.
This login is shared with the workspace client at /app
(docs/design/48-web-client.md). It used to live at /admin/login, which was
removed when /auth/* replaced it rather than kept alongside: two
endpoints against one cookie is two contracts that must agree forever
(AGENTS.md invariant 30).
Auth-exempt paths: /health, the static SPA shell + assets (no data),
POST /auth/login, GET /auth/session, and the /app shell. Every data route requires the token. Failures append
to the security-events log AND emit to the hub (live alerting).
Rate limiting applies to all POSTs including /auth/login — brute force
is bounded by [gateway.rate_limit].
Beyond the token, two checks gate every request (invariant 34): the Host
header must be loopback or listed in [server] trusted_hosts, and a
state-changing request carrying a cookie must also carry an Origin we
recognise. A request with no Origin at all is not a browser mutation
(curl, the CLI, a bridge) and must satisfy the bearer check instead.
API surface (/admin/api/*)
Bedrock model access status
GET /providers/bedrock/models returns discovered model IDs and, when the
native AWS check is available, a typed availability array. Entries include
agreement, authorization, entitlement, and regional status, an optional
agreement error, and invokable. The response may include
availability_error when the control-plane check cannot run. Settings renders
these states and disables models whose full availability is not confirmed;
discovery alone is never treated as proof of invocation access.
| Endpoint | Verb | Purpose |
|---|---|---|
/sessions | GET | Session catalog (GROUP BY over index) |
/sessions/:id/transcript | GET | Paginated entries; refresh=true re-imports first |
/search?q= | GET | FTS5 BM25 search w/ snippets, role/kind/project filters |
/approvals | GET | Pending gates across all live sessions (oldest first) |
/bestofn | GET | Active candidate runs |
/events | GET | SSE stream of SystemEvents |
/security | GET | Security-events audit log, kind-filterable |
/ops/center | GET | Unified operational posture and evidence snapshot |
/ops/incidents | GET | Folded durable incident history |
/ops/actions | GET | Operation receipts and post-action verification |
/ops/outbox | GET | Durable delivery records and retry/dead-letter state |
/ops/outbox/:job_id/replay | POST | Replay one non-delivered outbox job through the canonical delivery path |
/store/rebuild | POST | Full index rebuild (mutation ⇒ POST) |
/store/import/:id | POST | Import one session's JSONL |
/config | GET | Effective config snapshot, including provider/model provenance |
/config/global | PATCH | User-level defaults inherited by project workspaces |
/config/mcp/global | GET, PUT | Shared user MCP registry; values never expose secrets |
/gateway/status | GET | Default route + provenance, binding contracts, stale reasons, allowlist |
/gateway/bindings/:key | PATCH | Set provider/model pair, or {} to inherit workspace default |
/gateway/bindings/:key/rotate | POST | Detach session; preserve ledger; create fresh on next inbound |
/gateway/bindings/:key | DELETE | Remove binding and override; preserve session ledger |
/memory (secured shared route) | GET, POST | List or append workspace/profile memory notes |
/memory/:note_id (secured shared route) | PATCH, DELETE | Amend or explicitly forget one scoped note |
/memory/cleanup (secured shared route) | POST | Remove abandoned memory artifacts and empty workspace directories; never notes |
/sessions/:id/workers (secured shared route) | GET | List live child agents for a session |
/sessions/:id/workers/:child/steer (secured shared route) | POST | Queue steering text for a live child, parent-scoped |
/sessions/:id/workers/:child/stop (secured shared route) | POST | Cancel a live child, parent-scoped |
Answering approvals, running prompts, steering, cancelling, mode changes,
config patches, inbox acks reuse the EXISTING secured routes. Config patches
are durable workspace mutations: the server resolves provider/model into one
complete pair, writes the pair atomically, then hot-applies one atomic Core
route. New-session admission in other local Core processes refreshes that
persisted pair, so desktop, server, CLI, and inheriting channels converge
without a restart. Source metadata and a deterministic route revision let
clients distinguish project config, global config, environment, and
runtime/scoped overrides. Explicit runtime pins remain non-global.
The refresh also covers max turns, theme, MCP servers, and hooks. A persisted
permission-mode difference is routed through the revoke path (cancel main and
side runs, deny pending approvals) before the new mode is exposed.
(/sessions/:id/approvals/:req, /sessions/:id/run, /sessions/:id/ steering, /sessions/:id/cancel, /config/mode, PATCH /config,
/inbox/:id/ack). The console is just another client of the same contract.
Memory and worker lifecycle routes are also secured shared routes rather
than duplicated under /admin/api; the Admin SPA uses the same authorization
and workspace-scope enforcement as desktop and gateway clients.
Console views
-
Home — the operator's first screen, answering four questions in order and nothing else: is the system healthy, what is waiting on me, what is running, what is it costing.
- Readiness ring — eight arcs, one per subsystem (doctor, model route,
permissions, chat gateway, channels, delivery, approval gates, budget),
each drawn from that subsystem's own probe. A probe that did not answer
is
unknownand is excluded from the healthy count rather than counted as healthy; a subsystem the operator switched off (off) is excluded too, and the count says how many. State is carried by colour, stroke pattern, and a printed word, never by colour alone. - Attention queue — one severity-ranked list merging every signal in
the product that blocks, asks, or drifts: held approval gates, failed
doctor checks, open incidents, dead-lettered and queued deliveries,
budget position, chats knocking at the allowlist, a stopped gateway
unit, drifted bindings, configuration warnings, provider errors and
rate limits seen on the hub, overdue scheduled jobs, best-of-N drafts,
unread inbox, skill proposals, security events, and unfinished setup
steps (whose own
repairstring is the row's detail). Incidents whose fingerprint Home already states in its own words are dropped so one stuck queue does not read as three problems. Empty is a measured claim: it names how many probes produced it and when they were sampled. - Approval gates — the one thing Home answers rather than links to, since a gate is a run that has already stopped.
- Right now — live runs and their state, CorePool occupancy, service state, the next scheduled job, server version and uptime.
- Pulse — event rate and family mix off a 30-minute ring buffer of hub arrivals kept separately from the 60-item display feed, because a rate computed from the display buffer pins itself exactly when the system gets busy. The rate names the window it is over and never divides by less than a minute.
- Spend today — settled cost against the admission cap with an 80% mark, burn rate, when the cap lands at that rate, top model and provider, a 14-day trend, budget alerts, and the count of dispatches carrying no price — which makes the headline figure a floor, stated as such.
- Recent sessions and the live event stream.
Home samples
/ops/centeron the same bounded interval the Operations Center uses and reads everything else off the event hub. Sampling it also reconciles the durable incident ledger, so incidents open and resolve while an operator sits on Home rather than only while Operations is open. Home is a projection, not a second source of truth: every figure resolves to a probe, a ledger, or a manager state, and every row links to the screen that owns it. - Readiness ring — eight arcs, one per subsystem (doctor, model route,
permissions, chat gateway, channels, delivery, approval gates, budget),
each drawn from that subsystem's own probe. A probe that did not answer
is
-
Operations — first-class control plane with Posture, Live work, Runtime & pools, Channels & delivery, Automations, Providers, and Incidents routes. The Posture view renders a selectable topology (server → gateway/CorePool → work/delivery), live run and service cards, and an inspector. Deeper routes expose approval gates, manager-backed service actions, pool idle state, route provenance, durable outbox replay, security evidence, and doctor checks. Empty or unavailable data is labelled explicitly; the page does not infer health from a missing record. The context bar keeps workspace and time-window scope in the URL, alongside connection, approval, incident, search, and Doctor affordances. Internal drill-down links carry that context into run, binding, delivery, and incident detail pages. Each detail page links the next evidence layer and stops at the raw transcript, receipt, manager probe, or outbox record rather than inventing a summary. Resolved incidents remain visible as history; operation mutations show their durable receipt and verification state.
-
Commitments — the portfolio (
docs/design/47-commitment-kernel.md). Four questions in the order an operator asks them: what does this agent owe, what will it work next and why, what is stuck and on what, and can I believe the ones it says are finished. Rows rather than cards, because this is a ledger: the operator scans a column and compares across rows, and cards would let four commitments fill a screen that should hold thirty.- Evidence meter — the signature element, and the one fact no other
agent surface shows. The satisfaction lattice is drawn: four segments
filled to the level actually achieved, a rule beneath the segment for the
level this work is held to, and the shortfall in the accent, because a
gap blocking a closure is exactly what the single accent colour is
reserved for. Work closed on the model's own say-so and work closed on a
check the runtime ran look identical in an ordinary status column and are
not the same claim. Colour is never the only channel: fill differs too,
and the component carries an
aria-labelnaming both levels in words. - Priority — the scheduler's arithmetic, decomposed into its named components on click, and its withholding reason in words when a commitment cannot be worked. In a product whose thesis is auditability, "why did it pick that one" must not be the single unanswerable question.
- Close — a refused closure answers 409 and the message names the evidence that was missing, surfaced verbatim. The operator learns the closure invariant by hitting it rather than by reading about it.
- Comparison columns drop progressively as width runs out; evidence goes last, because it is the column that carries the idea.
- Evidence meter — the signature element, and the one fact no other
agent surface shows. The satisfaction lattice is drawn: four segments
filled to the level actually achieved, a rule beneath the segment for the
level this work is held to, and the shortfall in the accent, because a
gap blocking a closure is exactly what the single accent colour is
reserved for. Work closed on the model's own say-so and work closed on a
check the runtime ran look identical in an ordinary status column and are
not the same claim. Colour is never the only channel: fill differs too,
and the component carries an
-
Sessions — filterable catalog; "+ New session"; per-row Archive/ Unarchive, Delete (archived only, soft — the ledger file is kept), and Export
.md, plus a bulk "Delete all archived". A workspace column distinguishes rows this console instance can mutate (its own bound workspace) from rows it can only read (another project the store also indexes) —attach/run/diff/receipts/archive/delete/export all resolve a session's ledger file under this process's ownsessions_home/sessions/<hash(cwd)>/, never another workspace's. -
Transcript — role-railed entries, tool badges, error highlighting, kind/role filters, pagination, Live tail toggle (session-scoped SSE → debounced refetch), Cancel button while a run is active, and the composer: Enter-to-send prompts, mid-run sends become steering, ×1–×4 selector fans out best-of-N candidates. A live-workers panel lists child session id, elapsed time, and parent-scoped Steer/Stop actions; child ledgers remain available after completion or cancellation.
-
Search — debounced global FTS5 with
<mark>highlighted snippets; click-through to transcripts. -
Inbox — attention entries with unread badge (30 s poll) and acks.
-
Security — color-coded audit trail with per-kind filters.
-
Gateway — four sub-routes, one per operator task; see below.
-
Settings — provider/model editor (dirty-tracked), permission-mode cards (ReadOnly / WorkspaceWrite / FullAccess with consequences stated), gateway status, index rebuild, sign out.
-
Memory — workspace and global profile notes with provenance, inline amend/explicit forget, effective search/write/reflection status, and a confirmed cleanup action for abandoned lock/temp artifacts.
The Operations Center's mutation contract is deliberately auditable. Service
actions and delivery replays return a receipt id plus before/after verification
and append the receipt to operations/actions.jsonl. Current probe candidates
are folded into operations/incidents.jsonl, where repeated observations are
grouped by fingerprint, disappearance records resolution, and reappearance
reopens the same incident. The Admin UI displays both ledgers and links them
back to the affected run, binding, or outbox record.
Gateway information architecture
The gateway area grew one panel at a time — routing summary, Core pool, bot tokens, pending channels, registered channels — until it was one page an operator had to scroll to form a mental model of. It did not survive contact with a real operator: a bot token was pasted into the routing-key field, because a credential input and a routing-key input sat two boxes apart on the same scroll.
It is now split by task, each with its own hash route, listed in the sidebar as sub-rows while the section is open and in a tab bar above the view:
| Route | Task | Shape |
|---|---|---|
#/gateway (Chats) | "What channels do I have, and what do they do?" | Scan-first table; a row expands into the full route/access editor |
#/gateway/connect | "I need to add a new bot" | Three-step guided sequence with live state per step |
#/gateway/credentials | "Set or clear a bot token" | Credentials only — no routing field on the screen |
#/gateway/routing (Defaults & status) | "Is this healthy?" | Routing defaults, provenance, Core pool |
Rules the split enforces:
- A credential and a routing key never share a screen. Bot tokens live
under Credentials (and inside step 1 of Connect, which is the same
component). The manual routing-key registration is demoted to a
<details>on Channels and says in its own copy that a token does not go there. The surface prefix is still validated against the known bridges. - Onboarding reads as a sequence, not a pile. Connect's three steps —
set the credential, message the bot, review and approve — report their own
state from the backend (
GET /config'schat_surfacesfor token presence, the allowlist for pending chats) rather than asking the operator to track where they are. The current step carries the accent border; finished steps dim. - Ongoing management is a list, not a form. Channels is a table of channel, surface, workspace, effective route, effective permission, and status (labelled chat / app / project / model / can do / status, with reduced and new session next for the two edge states). Editing is a row expansion, so the settled state reads first and the form appears on demand. Every prior control — save route, rotate now, edit access, revoke access, remove binding, approve/deny — is preserved unchanged.
- Empty states teach. A fresh install's Channels screen explains what a channel is and what the three steps are, and offers the Connect action; a cold Core pool says why cold is normal.
Loading is skeleton rows and blocks, not a spinner dropped into content. No
new tokens or colors: the split reuses panel, table, chip, binding-*,
and the DESIGN.md status vocabulary. The documented :focus-visible accent
ring, themed scrollbars, and tabular numerals were specified in DESIGN.md but
had never reached the console; they ship here.
Toasts surface high-signal events everywhere (runs finished, gates waiting, security alerts); the sidebar dot shows hub connection state with exponential-backoff reconnect.
Extensions: visibility and scope
The Gateway split answered "who may talk to this agent, and under what
permission". Extensions answers the same question for everything else that
widens what the agent can do. #/integrations is four hash routes —
#/integrations (Connected apps), /skills (Skills), /hooks
(Automations), /tasks (Scheduled tasks) — presented in
a tab bar and as sidebar sub-rows, structurally identical to the Gateway
section so the two governance surfaces read as one system.
Each screen is a scan-first table with its add-form demoted to a
<details>, matching the Channels pattern: what is configured reads first,
the form appears on demand.
What backs the scope column
Nothing here is inferred where it could be read.
- Permission rules come from
GET /admin/api/config'spermissionsobject (allow/ask/deny), added for this view. The console parses each string with the same grammar asvak_permission::rules::Rule::parse— a+/?/-prefix selects allow/ask/deny, a bare rule allows,Tool(glob)scopes to matching arguments andToolcovers every call. - Which rules reach an MCP server follows the engine's own matching:
arg_candidatesrenders an MCP call as the literalserver/tool, so a rule reaches a server when it is blanket (mcp) or when the server half of its pattern globs to that name. - The fallthrough is the mode arm of
PermissionEngine::evaluate.mcpis neither a read nor a write tool, so an uncovered call isallowunder full-access,denyunder read-only,askunder workspace-write. The UI names that decision rather than leaving the cell blank. - MCP network and environment are on
McpServerConfig(network,env), both editable from the console (register/edit form).envvalues round-trip verbatim throughGET/PUT /config/mcp— usually a${VAR}reference resolved from the credential store at process launch rather than a literal secret, since that is how config.toml already stores them, and the value is exactly what a filesystem-reading operator could already see. GET /config/mcpandGET /config/hooksreport the project's own.vak/config.tomlonly, never the merged effective set (state.core's global+project view). A channel/hook editor'sPUTalways resubmits whatever the matchingGETreported; reporting the merged view would hand back an inherited global server or hook, which the very next save would write into the project file as if it were the project's own — silently forking an MCP server, and (worse, since[[hooks]]merges byVec::extendrather than a name-keyed map) duplicating a hook on every edit cycle, compounding without bound and re-firing a side-effecting command once per copy. A user-scope entry is still available to the workspace at runtime; it is simply not listed or editable from this screen, and the panel copy says so.- A hook's
enabledflag is real, not a display convenience: disabling one keeps its definition in.vak/config.toml(enabled = false) rather than deleting it, andvak_core::build_hooks_fromskips disabled entries when it builds the liveHookDeflist for a turn. Toggling it, and editing a hook in place (event/matcher/command/timeout), are bothPUT /config/hooks— there is no per-hook endpoint, so every one of these writes the whole (project-scoped) list back. - Hook scope is the hook's own matcher, which
vak-coreparses into aRulebeforevak-hooksmatches it. It prints verbatim, and a matcher that does not parse is flagged rather than hidden. - Skill provenance comes from
GET /skills, extended withpathand ascopeofworkspace(<cwd>/.vak/skills) oruser. Known gap:vak_core::skills::discoversorts by name and dedups same-named entries before scope is attached — a project skill silently shadows a user-level one of the same name (deliberate precedence, matching MCP servers and custom commands), but unlike custom commands (which document "names must be unique after precedence dedup," docs/design/09) nothing here discloses that the shadowed one exists.GET /skillscannot report it becausediscover()has already dropped it; a fix belongs there, not in the admin projection. - Channel capability overlays are edited from the Gateway channel editor and pending-approval form. They support inherited or restrictive built-in-tool, MCP server/tool, skill-visibility, and hook patterns. The Admin UI sends the overlay with the allowlist mutation; the runtime stores it on the binding and applies the same policy before tool dispatch. Skills are visibility only, and secrets remain workspace-owned.
What is deliberately not shown
- Live MCP tool lists.
McpManagerspawns a server lazily, per run; the server process holds no persistent client to enumerate. Listing a server's real tools means starting it, which is a side effect an admin read should not have. A route that does this deliberately is a separate decision, not something to fake with a config echo. - Rule editing. The rule lists render read-only in Settings. A console
session that can rewrite
allowcan widen its own reach; that belongs in the file and the trust prompt. - A per-tool effective decision. A rule may cover only some of a
server's tools (
mcp(github/read_*)), so a single verdict per server would be a lie. The matching rules and the fallthrough are shown side by side instead, with the precedence stated: among rules matching one call, deny outranks ask outranks allow, and config order never decides it.
An older server omits permissions entirely. The console renders "scope not
reported" in that case, never an empty rule list — the difference between an
unknown scope and an unrestricted one is the whole point of the screen.
Paths and tables at scale
A filesystem path in a table cell is not free text. word-break: break-all
in the Channels workspace column split /Users/nisheethranjan/Projects/ vakcoder across three lines mid-word; every row became a different height
and the column read as damage.
Paths render through one primitive (truncatePath / PathCell) with three
properties:
- Whole segments are dropped, never split. The head is spent first
because workspaces share their
/Users/<name>prefix and differ in the tail. - The budget is characters, not segments. A segment-counted result can
still overflow its column and be clipped from the right by CSS, throwing
away the very tail that distinguishes two workspaces. Fitting a character
budget means the CSS ellipsis is only a backstop for a single segment
wider than the whole column.
PathCellderives itsmax-widthfrom the same budget, so the two can never disagree. - Nothing is lost. The full path is on the
titleand is selectable.
Applied wherever a path sits in a constrained cell: Channels, Core pool,
skill sources, Home's workspace facts and run list, the routing summary,
Best-of-N repos.
.wrap remains for genuinely free text, but breaks at spaces before it
breaks a word.
Tables that scroll keep their headers: .table carried overflow: hidden
purely to round two corners, which made it a scroll container and silently
disabled position: sticky on thead th. Corners are rounded per-cell now.
Chips no longer wrap, so one two-word label cannot make its row taller than
its neighbours.
Measured at 100 rows against real payload shapes: uniform row height, sticky header holding after a 1200px scroll, client-side filter at under 4ms, no horizontal body overflow.
Known limit. GET /admin/api/gateway/allowlist and the bindings in
GET /admin/api/gateway/status return everything in one response with no
limit/offset/q. The table handles a hundred rows comfortably and the
filter is client-side over the full set, which is honest while the API is
unpaginated. Several thousand channels would want server-side paging and
search first; a paginated UI has not been built over an API that cannot
page, because it would have to invent the pages.
Plain language, and the controls that carry it
The console's audience is not only the person who wrote the config. Every screen was reviewed against one question: does this ask the reader to know a syntax, an internal name, or a Rust identifier in order to act? Where the answer was yes, the wire form stayed exactly as it was and the presentation changed.
Two rules govern the rewrite:
- The wire shape is never bent to fit the wording. No endpoint, config
key, glob grammar, cron dialect, or serde tag changed.
read-onlyis stillread-only; the console prints "Look, don't touch" and keeps the raw value in atitleso anyone matching the UI against a log or a config file still can. - A picker offers what exists, and never eats what doesn't. The
capability pickers are built from live data —
GET /config/mcp's servers,GET /config/skills,GET /config/hooks— and anything already in a stored list that no longer matches an offered option is rendered as a removable chip rather than dropped on save. Editing a channel cannot silently discard a pattern the picker does not understand.
src/controls.tsx holds the shared pieces:
AccessPickerreplaced eight free-text glob fields on the channel access editor.allowis tri-state on the wire (nullinherits,[]blocks everything, a list limits), which is read back as three words: Everything the workspace allows / Only what I pick / None. Because "limit to nothing yet" and "block all" are the same[], the control remembers the operator's choice locally — deriving the mode from the wire alone would snap the picker back to "block all" the instant they switched to "limit". Deny stays available, behind a disclosure, over the same options.ScheduleBuilderemits the cron string the tasks API already accepts, from a preset list and a time field.parseSchedulerecognises the shapes it emits, so an existing task opens on the preset that produced it rather than on "custom"; anything else round-trips through the raw field untouched.describeSchedulegives the tasks table its English.MatcherBuildercovers the hook matcher grammar's common cases — every call, one tool, one tool with an argument pattern — and hands anything else back to a raw field. A matcher that does not parse is still flagged, because a hook that will not load is exactly what an operator needs to see.BUILTIN_TOOLSnames the eight built-ins by what they do. The permission engine matches tool names case-insensitively, so the lowercase wire name is what gets stored either way.
Vocabulary is shared across screens on purpose: what the channel editor calls
"connected apps" and "automations" is what the sidebar calls them, and what a
permission rule is said to grant. modeLabel prints the same three phrases
wherever a permission mode appears, in either casing the server may send
(read-only from the gateway, ReadOnly from GET /config).
Chips carrying a phrase rather than a label use .chip-phrase, which drops
the global text-transform: lowercase and white-space: nowrap — a sentence
is not a status label, and truncating one mid-word helps nobody.
Invariants
- JSONL is truth.
store.db*are derived artifacts: excluded from checkpoints, safe to delete, rebuilt automatically. - Listing never resolves. Cross-session approval listing displays; answering stays scoped to the owning session's endpoint.
- Mutations are POSTs. Anything that changes state must never be triggerable by a prefetcher.
- No secrets in the shell. The SPA carries no token; auth lives in the HttpOnly cookie; the login page stores nothing.
- Built assets are committed.
crates/vak-admin-ui/distis embedded at compile time socargo buildneeds no node; regenerate withcd crates/vak-admin-ui && npm install && npm run build. - A route is a pair. Admin APIs never persist or hot-apply a provider independently from its model. Binding changes mark old contracts stale; they never rewrite a session header.
Testing
- Rust unit tests cover store schema/import/search/idempotency, hub delivery, login→cookie→authenticated-call flow, unauthenticated 401s, POST-only mutations, approvals aggregation.
- End-to-end smoke: boot
vak serve --port P, verify health open, shell serves unauthenticated, API 401 without auth, login sets working cookie, SSE delivers a triggered security event in real time, refresh= true surfaces just-appended entries.