Customizing the dashboard (ui.json)
The Neo control-plane dashboard (neo ui, or a running daemon's dashboard — see daemon.md) ships with a set of built-in pages (Overview, Tasks, Sessions, Channels, Telemetry, Memory, Chat, …). On top of those, you — or Neo itself, on request — can declare extra pages from a small JSON file: ui.json. This is the mechanism behind "hey Neo, add a dashboard widget for X": Neo edits the file with its normal file tools, and every open browser tab picks up the change within about a second, with no refresh and no code.
This doc is the reference for both the owner and Neo. The companion skill (templates/skills/customize-ui/SKILL.md) teaches Neo the edit-validate-tell workflow; this doc is what that skill points back to for the full widget vocabulary.
Where it lives
ui.json lives at <config dir>/ui.json — resolved as defaultUiConfigPath(getConfigDir()) in runtime/control-plane/ui-config.ts, i.e. ~/.neo/config/ui.json under the default NEO_HOME layout (same getConfigDir() convention daemon.md/channels.md use for other per-install config paths). Every reader of this file — the HTTP route, ui validate, ui show, and the daemon's own hot-reload watcher — resolves the same path, so there is exactly one file to edit regardless of which host (neo ui standalone or neo daemon run) is serving the dashboard.
The file does not need to exist. With no ui.json present, the dashboard falls back to a small built-in default page (see Safety model below) — there's nothing to break by editing a file that isn't there yet.
The workflow
- Edit
ui.jsonwith a normal file write (Neo: thewrite-filetool; a human: any text editor). It must be valid JSON matching the schema below — always write the whole file, not a patch; there's no partial- update mechanism. - Run
neo ui validateto self-check before telling the owner it's done. Fix any reported error paths. - That's it. The dashboard picks up the change live: every open browser tab re-fetches
GET /api/ui-configwhen it receives aui_config_changedevent on its existing SSE connection, typically within ~1 second of the file write. No manual refresh, no restart, no extra command.
You never need to tell the owner to reload the page.
Safety model: last-good semantics
This is the property that makes self-editing safe to hand to an LLM: a broken edit never blanks the dashboard.
- No file yet → the dashboard renders the small built-in default page.
- First-ever file is broken (bad JSON or a schema violation) → same built-in default page, plus a banner listing what's wrong.
- A previously-valid file becomes broken → the dashboard keeps rendering the last valid version it had (not the default, not a blank page) — plus the same "your ui.json is broken" banner with per-error paths.
- Fixed again → the next reload picks up the corrected file and the banner disappears.
There is no failure mode where a typo blanks a page a user is actively looking at, and no failure mode where a broken file crashes a browser tab. The worst case of a bad edit is: the banner appears, and the dashboard keeps showing whatever it showed before.
One more graceful-degradation layer, at the widget level rather than the page level: a table's row action can reference a command name that doesn't exist (a typo, or a command that was renamed/removed). That renders as an inline "unknown command: <name>" message on that one button — it does not fail the page or the rest of the widgets.
Run neo ui validate any time to check the current file without needing a browser at all — see Command reference below.
$row.* interpolation only — no expression language
Table row actions bind to the command-RPC surface (command, args, positionals). Argument values are either a literal scalar (a fixed argument that doesn't vary per row, e.g. always passing --force: true) or a string matching the exact pattern $row.<fieldPath> (e.g. $row.id, $row.entry.id), which is interpolated by splitting on . and indexing into the row object at render time.
This is a safety property, not a convenience choice. Neo edits this file with ordinary file tools, so the interpolation surface has to be inert data that a typo can't turn into code execution. Anything that isn't an exact $row.* match — ${row.x}, $row.id + 1, $row["id"], a bare $row or $row., arithmetic, function calls, bracket indexing — is not an error; it's just treated as an ordinary literal string value, never evaluated. There is no interpreter for this string anywhere in the codebase.
Widget vocabulary (v1 — 7 renderable types + 1 placeholder)
Every widget lives inside a page's widgets array and shares two base fields: id (string, unique within the page — also the React key and the identifier used in ui validate's error paths) and an optional title (heading rendered above the widget).
The document shape:
{
"schemaVersion": 1,
"pages": [
{
"id": "my-page",
"title": "My Page",
"widgets": [ /* ... */ ]
}
]
}Custom pages are additive — they appear in the dashboard nav after the built-in pages, at #/custom/<page-id>. They do not replace or hide any built-in page.
table
A fetched table, optionally kept live by an SSE topic, with optional per-row action buttons. Mirrors the built-in Tasks/Channels pages.
{
"id": "running-tasks",
"type": "table",
"title": "Tasks",
"endpoint": "/api/tasks",
"columns": ["id", "description", "status"],
"topic": "task_changed",
"rowActions": [
{
"label": "Delete",
"command": "tasks delete",
"positionals": ["$row.id"],
"confirm": true
}
]
}endpoint— aGET /api/*path returning a JSON array of objects.columns(optional) — which fields to render, in order. Omitted ⇒ every key of the first row.topic(optional) — an SSE hub topic (see Event topics below) that triggers a re-fetch when it fires. Omitted ⇒ fetch-on-mount only.rowActions(optional) — each one is{label, command, args?, positionals?, confirm?}.commandmust be a name that exists in the liveGET /api/commandscatalog at render time — this is checked on mount, and a command that isn't found rendersunknown command: <name>inline instead of a dead or crashing button.args/positionalsentries are each either a$row.*reference or a literal (see above).confirm: trueshows a confirmation dialog before sending the request — independent of (and in addition to) the command's own server-side confirm gate, which is authoritative and can't be bypassed by omitting this.
chart
A bar chart fed by one endpoint. Mirrors the Telemetry page's bar charts.
{
"id": "cost-by-model",
"type": "chart",
"title": "Cost by model",
"endpoint": "/api/telemetry/cost-by-model",
"labelField": "model",
"valueField": "estimatedUsd"
}endpoint— aGET /api/*path returning a flat array of objects.labelField/valueField— which fields of each row become the bar's label and numeric value.topic(optional) — re-fetch trigger, same astable.
stat-grid
A small grid of named numbers/strings pulled from one object endpoint, with dotted-path field access. Mirrors the Telemetry page's totals band.
{
"id": "daemon-stats",
"type": "stat-grid",
"title": "Daemon",
"endpoint": "/api/overview",
"stats": [
{ "label": "Version", "field": "version" },
{ "label": "Running tasks", "field": "counts.runningTasks" },
{ "label": "Recent sessions", "field": "counts.recentSessions" }
]
}endpoint— aGET /api/*path returning a single JSON object.stats— one or more{label, field}pairs.fieldmay be a dotted path into the fetched object (e.g.budget.remaining). A path that doesn't resolve renders—rather than crashing.topic(optional) — re-fetch trigger, same astable.
form
Binds to a command name from the live GET /api/commands catalog — a config-driven instance of the existing command-form renderer, not a new form engine.
{
"id": "set-model",
"type": "form",
"title": "Set default model",
"command": "config set",
"defaults": {
"positionals": ["providers.defaultModel"]
}
}command— must exist in the live catalog at render time (dynamic, so this is a render-time check, not a config-parse-time one). A missing command renders an inline error card, not a crash.defaults(optional) — pre-filled{flags?, positionals?}, literals only (there's no row context in a bare form, so no$row.*here).
markdown
Static content, authored inline — no fetch, no live updates.
{
"id": "notes",
"type": "markdown",
"content": "# Heads up\n\nThis page is **auto-generated**. See `notes.md` for details.\n\n```\nneo ui show\n```\n"
}The supported subset is intentionally small (hand-rolled, no markdown library, matching the zero-new-deps rule) — exactly:
- Headings:
#,##,### - Fenced code blocks:
``` **bold**`inline code`- Blank-line-separated paragraphs
Nothing else. No lists, no links, no tables, no nested inline formatting. Anything outside this subset renders as literal text — it's a subset renderer, not a fallback-to-partial-support one. It is also XSS-safe by construction: content is tokenized into real elements, never rendered via innerHTML, so a stray <script> tag in content shows up as inert visible text.
log-tail
A scrolling list fed by one SSE hub topic. Mirrors the built-in Logs page.
{
"id": "channel-log",
"type": "log-tail",
"title": "Channel activity",
"topic": "channel_activity",
"maxEntries": 200
}topic— the SSE hub topic to listen on. There is no historical backfill — this widget only shows entries that arrive after it mounts (the built-in Logs page has the same limitation; no read model exists for "logs so far").maxEntries(optional, default 500) — caps retained entries.
chat
A marker referencing the existing built-in chat panel — not a declarative widget in its own right, just a way to include the real chat UI alongside other widgets on a custom page.
{ "id": "chat-panel", "type": "chat", "title": "Ask Neo" }No other fields. This is not reimplemented per-widget — it renders the same component the built-in Chat page uses.
plugin
Renders a widget contributed by an installed, trusted plugin — a sandboxed iframe running the plugin's own JS bundle, talking back to the dashboard through a small, capability-limited bridge (neo.read() / neo.runCommand()). See plugin-widgets.md for the full authoring guide: how a plugin declares a widget, the bundle contract, the bridge API, the trust gate, and the security model.
{ "id": "my-plugin-widget", "type": "plugin", "pluginId": "some-plugin", "widgetId": "some-widget" }If the plugin isn't trusted yet, the dashboard shows an explanatory card with the exact neo plugins enable <id> command to run — it never attempts to fetch/run an untrusted bundle. If the plugin or widget id isn't found, it shows a distinct "not found" message instead.
Useful endpoints (read models)
Any of these can back a table/chart/stat-grid widget's endpoint:
| Endpoint | Shape | Notes |
|---|---|---|
/api/overview | object | version, running-task/session counts |
/api/tasks | array | live on task_changed |
/api/sessions | array | disk-backed session browse list |
/api/delegations | object or {available:false} | daemon-only |
/api/channels | object or {available:false} | daemon-only |
/api/memory | array | memory entry index |
/api/daemon/status | object | health/tier/budget snapshot |
/api/telemetry/summary | object | budget + spend totals |
/api/telemetry/cost-by-model | array | {model, estimatedUsd, …} |
/api/telemetry/tool-usage | array | {tool, count, …} |
/api/telemetry/errors | array | recent tool/provider errors |
/api/commands | array | the live command-RPC catalog (what a form/row-action command must match) |
GET /api/ui-config itself returns {config, source, errors?} — this is what ui show and the dashboard's own hot-reload consumer read; you won't usually need to call it directly.
Command reference
Both commands are read-only (no confirm needed) and work without a running daemon or control plane — they resolve the file from disk directly.
neo ui validate [--path <file>]
Parses ui.json against the schema and reports every violation with its JSON path.
- A missing file reports
valid: true, zero errors — there's nothing to validate (the built-in default silently applies). - A present-but-broken file reports
valid: falseplus a list of{path, message}entries, e.g.pages[0].widgets[1].endpoint: Required. - This is a report, not an action: the JSON/RPC envelope is always
{ok: true, data: {valid, errors}}, even whenvalid: false— a bad file is a normal, expected finding, not a request failure. Only the CLI process's own exit code goes non-zero onvalid: false, so it's script-gateable:
neo ui validate || echo "ui.json has errors"Human-mode output:
$ neo ui validate
~/.neo/config/ui.json: invalid (1 error):
pages[0].widgets[1].endpoint: Requiredneo ui show [--path <file>]
Prints the resolved config — exactly what the dashboard would render right now — plus its source: 'file' (a valid on-disk file), 'default' (no file, or a first-ever-broken file, so the built-in default applies), or 'last-good' (the on-disk file is currently broken, but a previously-valid version is still being served). When source isn't 'file', errors lists what's wrong with the on-disk file, even though a config is still shown.
$ neo ui show
source: file
{
"schemaVersion": 1,
"pages": [ ... ]
}Both commands accept --path <file> to point at a ui.json somewhere other than the default location (mostly useful for testing a draft file before copying it into place).
Current limits (v1)
- Additive, not a replacement. Custom pages appear alongside the built-in pages; there's no way to hide or reorder the built-in ones from
ui.json. - 8 renderable widget types, including
plugin(Wave E) — see plugin-widgets.md for its own limits (v1 bridge API is read + runCommand + resize only, no dynamic capability negotiation). markdownis a small hand-rolled subset, not full CommonMark — see above. No lists, links, or tables.- Row-action commands are checked live, not at parse time.
ui validateonly checks the JSON shape; it doesn't know whetherrowActions[].commandnames a command that currently exists. A missing command degrades to an inline per-button error in the dashboard, not a validation failure. - No
ui initcommand yet. There's no command that writes the built-in default to disk as a starting point — start from the worked examples above, orneo ui showto see the built-in default's exact shape and copy it.