Skip to content

Dynamic Dashboards

A dashboard here is not a saved query. It is a natural-language brief handed to an agent, which collects the data with whatever tools it has — files, shell, git, MCP servers — and then decides how to show it, publishing a validated JSON document the UI renders as widgets.

The same machine produces a morning briefing, a communications board, and a system-status view. Only the brief and the tool allowlist differ.

A dashboard rendered from an agent-authored document

Metrics, a list, a chart, and a written summary — chosen by the agent that gathered them.

Open the Dashboards area and press + in the sidebar (or New Dashboard in the command palette). It asks one question: what do you want to see, and how often? Answer in your own words.

Every morning tell me what happened overnight — how many runs, how many failed, what needs my attention, and a short summary of what you’d do first.

That description goes to an authoring model, which returns a complete definition. It is written to <latchHome>/dashboards/<id>.md and the tab opens. Nothing runs yet — press ↻ Refresh to build it.

Name exact numbers and charts and you get slots: named widgets pinned on every build. Describe an outcome instead and you get no slots, leaving the agent to pick a presentation from whatever the data turns out to be. Authoring is a single completion rather than a tool loop, and it retries once if the reply isn’t usable JSON.

Your home ships with one already — Morning Briefing (dashboards/morning-briefing.md), seven slots over latchai_activity, no schedule. Building that one is the fastest way to see the whole loop.

Everything the agent is told lives in one markdown file: frontmatter for what the runtime must enforce, and a body that is the brief itself.

---
name: Morning Briefing
icon:
description: What happened overnight and what needs me today.
refresh: 0 7 * * * # cron expression; omit for manual-only
paused: true # only written while paused: holds the schedule, ↻ still builds
agent: analyst # optional: an agent you defined, for persona + model
layout: hybrid # pinned | dynamic | hybrid
tools: [read_file, list_dir, run_shell, latchai_activity]
widgets: [metric, list, line, markdown]
slots:
- overnight | metric | Runs overnight | 3
- attention | list | Needs your attention | 9
- spend | line | Token spend, 7 days | 6
---
Summarise what happened while I was asleep…

Slot entries are id | type | title | span, where type names an entry in the widget catalog and span is 1–12 grid columns. Slots are the consistency mechanism: they are what stops a daily briefing from rearranging itself every morning.

tools is an allowlist over the whole tool registry — omit it and the run gets every tool. widgets narrows the catalog the agent may draw from; omit it and it gets all of it, and a type named by a slot is always kept regardless. agent names an agent definition of yours, which supplies the persona, model, skills and turn budget for the run.

The file is the source of truth. Edit it by hand, keep it in git, or use the editor — every save snapshots the previous version into version history.

The tab toggles ▦ View ⇄ ✎ Definition the way a workflow toggles Canvas ⇄ JSON. The definition side leads with a plain-English box: type “drop the token count and make the list full width”, press ⌘⏎, and the same authoring endpoint rewrites the whole definition, preserving what the instruction didn’t touch. It replaces any unsaved edits in the form below, and the editor says so.

The dashboard editor

The brief up front; slots, tools, and widget types behind Advanced.

Below the box sit the brief, the name and icon, and a schedule builder. Layout, slots, tools, the widget allowlist and the orchestrator agent are behind an Advanced disclosure — refinement, not entry.

Widgets are data, not code. Each entry is <latchHome>/widgets/<type>.md:

---
name: metric
renderer: metric
label: Metric tile
span: 3
---
A single headline number, optionally with a change indicator and a sparkline.
Use for "how many" / "how much" answers you want readable at a glance.
data:
{ "value": 14, "unit": "runs", "delta": 3, "deltaLabel": "vs yesterday",
"trend": [9, 11, 8, 14], "tone": "ok" }

The body is the text the agent reads — editing it changes how every dashboard is built, which is where “always use a line chart for time series” is really configured. Twelve types are seeded into a new home: metric, line, bar, area, donut, table, list, links, markdown, timeline, status and mermaid. Settings ▸ Widgets edits them, with a live preview of the renderer against sample data. Any seeded type you delete comes back on the next start; one you edit is never overwritten.

Settings ▸ Widgets with a widget type open for editing beside its live preview

Settings ▸ Widgets: a widget type open for editing, with its live preview. renderer must name one of the renderers that exist in code — the same twelve names. A type naming a renderer that doesn’t exist is skipped with a console error rather than letting an agent publish into a void. You can add as many named types as you like (incidents, standup) as long as each binds to a real renderer; its data is then validated against that renderer’s schema.

layout What the run must produce
pinned Exactly the declared slots. Any other widget id is rejected — which can leave a hole.
hybrid Every declared slot, plus extra widgets when the data warrants.
dynamic No slots; the agent chooses the widgets, their types, order and spans.

Authored definitions get hybrid when they have slots and dynamic when they don’t; the authoring model never picks pinned. Select it yourself in the editor when the board’s shape must not vary.

publish_dashboard validates every widget before it lands and returns the failures to the model as the tool result, so a wrong shape gets fixed on the next turn instead of silently vanishing. A widget is rejected when:

  • it doesn’t parse as a widget at all (no id, no type, no data);
  • the dashboard is pinned and the id isn’t a declared slot;
  • it fills a declared slot with the wrong type;
  • its type isn’t in the catalog;
  • its data doesn’t match the renderer’s schema.

Everything that survives is stamped with its resolved renderer, the slot’s title and span when it didn’t supply its own, and a timestamp. Stamping the renderer onto the stored document is what lets an old board still paint correctly after you edit or delete a catalog entry.

Two escape hatches keep one failure from taking the board down: a widget may be published with status: "error" and an error string (its data isn’t schema-checked, because a placeholder has none), and widgets merge by id across calls, so an agent is told to publish each section the moment its data is in hand rather than gathering everything first. Passing replace: true drops whatever wasn’t in that call instead of merging.

During a dashboard run, every tool result is archived in full before it is truncated for the model, and the model sees a [source: tN] marker appended to it. The prompt tells it to carry those refs onto the widget they fed:

{
"id": "overnight",
"type": "metric",
"title": "Runs overnight",
"summary": "14 runs, 3 failed — all three in build-pipeline.",
"data": { "value": 14, "unit": "runs", "tone": "warn" },
"sources": [{ "ref": "t3", "label": "latchai_activity · last 12h", "tool": "latchai_activity" }]
}

Those refs render as a footer under the widget; clicking one opens the archived payload exactly as the tool returned it. A path opens the file in the editor, a url opens the browser. Every widget also carries a one-to-three-line summary saying what it shows — that is what a follow-up conversation reads, so it is written to carry the finding rather than describe the chart.

A build drives the same tool loop everything else does, so it appears in the Run Monitor, the run log and the token report for free — under the run id dash-<id>-<timestamp> and the workflow id dashboard:<id>. Widgets land via dashboard.published events as the agent produces them, so an open board fills in live. A run gets 60 turns unless its agent definition says otherwise, and publish_dashboard is force-added to its allowlist however narrow tools was.

A build starts from an empty board, so a run must republish everything it wants shown. A run that publishes nothing at all fails rather than blanking the dashboard, which is what stops a broken brief from quietly erasing this morning’s briefing.

refresh: arms a cron schedule alongside your workflow triggers, re-armed whenever the dashboards/ directory changes. in the sidebar or ↻ Refresh in the header builds on demand.

⏸ Pause in a scheduled dashboard’s header (or the sidebar row) writes paused: true into the definition: the cron is not armed, the row dims and the tab shows a paused badge, and still builds on demand — a disabled workflow’s exact shape. ▶ Resume clears it. A revision made through the prose brief inherits the current pause state unless it says otherwise, so an AI rewrite can’t quietly resume a board you parked.

Only one build of a dashboard runs at a time: a scheduled refresh that comes due while the previous build is still going is skipped and logged, and a manual POST /api/dashboards/<id>/run returns 409 rather than starting a second one. The UI treats that 409 as “already building” and just keeps showing the spinner.

With a dashboard tab on screen, the chat dock binds the turn to that board: the assistant gets the brief, every widget’s summary and displayed data, and an index of the sources — labels and refs, never payloads. dashboard_source fetches one full payload on demand, which is how “where did that 14 come from?” gets a real answer rather than a re-derivation.

It also holds publish_dashboard, so “make the failures list full width” re-renders the live board. A publish from chat starts from the current document rather than an empty one, so changing one widget is an edit, not a rebuild.

What Where
Definition — the brief <latchHome>/dashboards/<id>.md
Widget catalog — what the agent may draw with <latchHome>/widgets/<type>.md
Document — one build’s output <latchHome>/runs/dashboards/<id>/<ts>.json
Source archive — every tool call’s full payload <latchHome>/runs/dashboards/<id>/<ts>.sources/
Vault export <latchHome>/workspace/dashboards/<date>-<id>.md

The source archive exists so a number on a dashboard can be traced back to the exact tool output it came from; each payload is capped at 256KB and none of it is ever loaded wholesale into a model’s context. The twenty most recent documents per dashboard are kept with their archives; older ones are pruned after a build. The vault export is a permanent markdown note per build day — charts become tables, so it stays greppable.

Every board renders in the phone app too, in one column, filling in live as an agent collects. All twelve widget types are redrawn for a thumb, and charts reuse the workbench’s own Chart.js renderer. Refresh from the phone needs Let paired phones start work turned on in Settings ▸ Remote Control; without it the board is read-only and waits for its next scheduled build.