.md file to compare - side-by-side diff against dashboard-builder
dashboard-builder
What it does for you
Helps build and update the screens in your dashboard.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 3/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/dashboard-builder/SKILL.md
present
state/lib/dashboard-builder.ts
present
state/bin/dashboard-builder/
not present
state/skills/dashboard-builder/AGENTS.md
present
how it runs - the shared frame every skill uses 2/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
This skill doesn't fix its own gaps yet.
state/log/evals.ndjson what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
how the work flows- step by step
what this step does
what this step does
what this step does
what this step does
SKILL.md- the skill, written out in plain English
Backed by: state/lib/dashboard-builder.ts
dashboard-builder
Legacy meta-skill that generated OpenUI Lang files for snappy-os skills before the 2026-05-11 live-composition cutover.
Current doctrine: reusable right-canvas surfaces are Live App / Artifact records. They are composed through compose_inline or exact Lang, persisted as artifact lang_body, and reopened through OpenArtifact. Do not create new per-skill resources/*.openui dashboards for normal product work.
Why it existed
Historically, each skill had a reusable visual surface: a default dashboard at resources/ui.openui, plus named surfaces such as resources/schedule.openui when a natural intent should open a specific canvas. Hand-writing those surfaces was:
- Error-prone (copy-paste bugs, stale templates)
- Maintenance-heavy (every query signature change breaks N dashboards)
- Repetitive (the same KPI patterns repeat: runs, score, last-run time)
This skill read a target skill's metadata (SKILL.md, AGENTS.md) and dispatch-log usage, then emitted a tailored OpenUI dashboard without human intervention. That file-backed path is now retired for new work. Keep this skill only for inspecting, migrating, or deleting legacy resources.
Current replacement
For any new or refined UI:
- Ground the request with current snappy-os state / tool results.
- Compose the surface through
compose_inlineby default, orLangwhen exact source is already authored. - Persist reusable UI as artifact
lang_bodywith title, intent, and follow-up metadata. - Reopen existing UI through
OpenArtifact/ Live Apps before patching it. - Patch only changed statements on follow-up turns.
Input
slug(string) - the skill folder name (e.g.ai-spend,dogfood-loop,linkedin-post)resourceName(string, optional) - legacy resource name such asui.openuiorschedule.openuiintent metadata(for legacy named surfaces) - old top comments:// surface,// intents,// response
Output
- Legacy maintenance only:
state/skills/<slug>/resources/<resourceName>- the generated OpenUI Lang resource (atomic write: temp + rename) - For current product work: an artifact record with
lang_body, not a skill resource file
Legacy maintenance steps
1. Validate the skill exists
const skillMdPath = join(cwd, "state/skills", slug, "SKILL.md");
if (!existsSync(skillMdPath)) {
throw new Error(`Skill not found: ${slug}`);
}
2. Parse the skill's frontmatter
Read SKILL.md, extract the YAML block (--- to ---), and parse:
name- the skill namedescription- what it doescategory- skill category (Channels, System, Memory, etc.)type- explicit type field (optional; can be "channel", "system", "reference", etc.)
3. Detect the skill's operational type
Use heuristics to choose the right dashboard template:
- Pipeline (steps, phases, processes): if category=
"Channels"OR description contains "send", "publish", "dispatch" →TEMPLATE_PIPELINE - System (infrastructure, admin, monitoring): if category=
"System"OR description contains "pipeline", "queue", "monitor" →TEMPLATE_SYSTEM - Channel (mail, Slack, etc.): if explicit
type: "channel"OR description contains activity/communication →TEMPLATE_CHANNEL - Basic (default for ambiguous): all others →
TEMPLATE_BASIC
The type detection code is in state/lib/dashboard-builder.ts as detectSkillType().
4. Compose the legacy dashboard, only when maintaining old resources
Pick the template based on detected type. For ui.openui, compose the default legacy skill dashboard. For named resources, compose the exact work artifact implied by the legacy intent metadata.
Legacy named resources used to start with:
// surface: Human surface name
// intents: phrase one; phrase two; phrase three
// response: Short middle-chat sentence when this surface opens.
Those comments are migration hints now, not the current routing contract. When migrating, carry the surface name, intents, and response into artifact metadata and store the source as lang_body.
Each default dashboard template:
- Queries
get_evalsfor the last 7 days of runs for this skill - Computes KPIs: run count, avg score, success rate, last run time
- Renders a data table with full run history
- For pipeline/channel types, adds extra visualizations (failure breakdown, recent activity)
All templates must still emit valid OpenUI Lang so they can be inspected or migrated safely.
5. Write the file atomically, legacy-only
const tmp = `${resourcesDir}/ui.openui.tmp.${process.pid}`;
writeFileSync(tmp, openui);
renameSync(tmp, outputPath);
Never leave a half-written resource file during legacy maintenance. If rename fails, the original is intact. Use writeOpenUiResource(slug, lang, { resourceName }) so validation, loader-linking, and atomic writes share one path.
Templates
Four templates, auto-selected by skill type:
TEMPLATE_BASIC
Minimum viable dashboard. Header + KPI row (runs, avg score, last run time) + full run table.
Used for: reference skills, utilities, anything that doesn't fit a stereotype.
TEMPLATE_PIPELINE
Pipeline/ETL-focused. Emphasizes success rate and failure breakdown.
Used for: skills with category: "Channels" or description containing "send", "publish", "queue", "process", "stage".
Extra visualizations:
- Success rate (%)
- Failure count
- Separate table of recent failures (score < 1.0) with primary_issue highlighted
TEMPLATE_CHANNEL
Activity-focused. Queries both evals AND recent-activity feed.
Used for: skills with type: "channel" or description containing "post", "message", "dispatch", "notify".
Extra visualizations:
- Recent activity from
get_recentfiltered to this skill's verb - Status (pass / needs-review based on last score)
TEMPLATE_SYSTEM
Infrastructure-focused. Minimal, clean, with implicit "this runs in the background" tone.
Used for: skills with category: "System" or type: "system", or description containing "monitor", "health", "admin".
Extra visualizations:
- Health indicator (good if avg score > 0.8, else review)
- Metadata card stating skill purpose
Invoking the skill
# Legacy inspection only: generate to stdout
npx tsx state/lib/dashboard-builder.ts ai-spend
# Legacy maintenance only: generate, then write through the contract helper
npx tsx state/lib/dashboard-builder.ts dogfood-loop > /tmp/dogfood-loop.ui.openui
npx tsx state/lib/openui-resource-contract.ts write-file dogfood-loop /tmp/dogfood-loop.ui.openui ui.openui
# Legacy comparison
npx tsx state/lib/dashboard-builder.ts ai-spend > /tmp/ai-spend.new
diff state/skills/ai-spend/resources/ui.openui /tmp/ai-spend.new | head -20
Critical rules
- Do not use this for new Snappy OS Live Apps. New surfaces use
compose_inline/Langand artifactlang_body. - Always check if a legacy
state/skills/<slug>/resources/<resourceName>already exists. If it does, warn the user and ask before overwriting unless the request is explicitly maintenance/regeneration. - Never regenerate if the file carries a
# DO NOT REGENERATEheader comment at the top. Respect manual customizations. - Atomic writes only for legacy maintenance. Temp file + rename. If the skill's resources folder doesn't exist, create it first (
mkdir -p). - Frontmatter plus artifact metadata is current truth. SKILL.md frontmatter determines ownership/domain. Artifact title, intent, and
lang_bodydetermine current Live Apps behavior. - All legacy templates must produce valid OpenUI Lang. Test with
findOpenUiResourceContractIssues()before writing. If the syntax is malformed, migrate/fix the Lang contract rather than adding server-side regex fallbacks. - Query() and Mutation() calls use the toolProvider contract. Use existing provider names from Snappy OS's
web/src/tool-provider.ts; add a provider before referencing a new Query.
When NOT to use this skill
- New dashboards, previews, galleries, command centers, or right-rail UIs: use
openui-lang/openui-appwithcompose_inline, artifactlang_body, andOpenArtifact. - Manually customized legacy dashboards: if a skill's ui.openui was hand-crafted and has domain-specific visualizations (e.g. custom math, specific field filtering), don't regenerate. Add a
# DO NOT REGENERATEheader comment so future agents skip it. - Raw React shape work: use
ui-components/shape-builderonly for rare customdefineComponentcomponents that cannot be composed from OpenUI primitives. Normal right-canvas work belongs in composed OpenUI Lang saved as artifactlang_body.
Eval
eval: auto-shape - the generated OpenUI is shape-checked by the right-panel Renderer at display time. If it fails to render, the audit agent (eval-watch-right-panel) emits a score row and surfaces the rendering error.
Manually run with: npx tsx state/lib/dashboard-builder.ts <slug> | head -1 to spot-check syntax.
AGENTS.md- what the AI loads when this skill comes up
Critical Rules
- Current product truth: new dashboards, previews, galleries, command centers, and right-rail UIs are Live App / Artifact records. Compose them through
compose_inlineor exactLang, persist as artifactlang_body, and reopen throughOpenArtifact. - Do not create new file-backed dashboards.
resources/ui.openui,resources/<name>.openui, and saved-surface registry comments are retired compatibility paths, not the default product contract. - Use this skill only for legacy maintenance. Valid uses: inspect an old resource, compare generated legacy output, migrate an old resource into artifact
lang_body, or delete stale loader references. - Respect manual legacy resources. If
state/skills/<slug>/resources/<resourceName>exists, check for# DO NOT REGENERATEheader in first 5 lines. Skip if present; warn before overwrite unless explicitly maintaining/regenerating legacy files. - Atomic writes only when maintaining legacy files. Use
writeOpenUiResource(slug, lang, { resourceName })so temp+rename and contract checks stay on one path. Never write directly to target path. - Legacy metadata is migration input. Old comments (
// surface,// intents,// response) should become artifact title, intent, and response metadata during migration. - Valid OpenUI Lang mandatory. Legacy resources must pass
findOpenUiResourceContractIssues()before write or migration. Renderer drift gets fixed in the Lang contract, not by adding server-side regex fallbacks. - Query/Mutation via toolProvider contract. Use
Query("get_evals", {slug, days: 7}, {evals: []}, 60)and existing providers from the Snappy OS web tool-provider module. Add the provider before referencing a new Query. Never inline raw SQL or non-Query() sources. - YAML parsing is regex-based.
parseFrontmatter()uses simple regex extraction. Unquoted colons or newlines in description field may fail - check SKILL.md frontmatter syntax. - Type detection is heuristic. Ambiguous category/description defaults to BASIC template (safe but may not be optimal). Add explicit
type: "system"ortype: "channel"to SKILL.md frontmatter if needed.
Commands
# Legacy inspection: generate to stdout
npx tsx state/lib/dashboard-builder.ts <slug>
# Legacy comparison: generate the default dashboard to stdout
npx tsx state/lib/dashboard-builder.ts <slug>
# Legacy-only: validate and atomically write an old OpenUI resource
npx tsx state/lib/openui-resource-contract.ts write-file <slug> /tmp/resource.openui ui.openui
npx tsx state/lib/openui-resource-contract.ts write-file <slug> /tmp/schedule.openui schedule.openui
# Test legacy syntax (first line only)
npx tsx state/lib/dashboard-builder.ts <slug> | head -1
# Compare legacy before/after
diff state/skills/<slug>/resources/ui.openui <(npx tsx state/lib/dashboard-builder.ts <slug>)
# List legacy OpenUI resources
find state/skills -path "*/resources/*.openui" | wc -l
# Find missing legacy default dashboards (audit only; do not backfill new ones)
for d in state/skills/*/; do [ ! -f "$d/resources/ui.openui" ] && echo "$(basename $d)"; done
Self-Test
- [ ] Confirm the request is legacy maintenance, not a new Live App request
- [ ] Skill exists check:
existsSync(join(cwd, "state/skills", slug, "SKILL.md")) - [ ] Parse frontmatter: name | description | category | type
- [ ] Detect type via
detectSkillType()heuristic when comparing legacy output - [ ] Query
get_evalsfor last 7 days, compute KPIs (runs | avg-score | success-rate | last-run-time) - [ ] Validate OpenUI Lang syntax before writing or migrating
- [ ] If migrating, map old
// surface,// intents,// responsecomments into artifact metadata - [ ] If writing a legacy file, write atomically (temp + rename)
- [ ] Do not add or backfill
resources/ui.openuias the current product path
Found a gap? Edit this file. <!-- footer-injection-point -->
api.ts- the code it can call
#!/usr/bin/env npx tsx
/**
* snappy-dashboard-builder/api.ts -- Auto-generate resources/ui.openui for any skill.
* Legacy module name; the output is a skill-owned OpenUI resource, not always
* a dashboard-shaped surface.
*
* The system extends itself: given a skill slug, read its SKILL.md + eval logs,
* then emit a tailored OpenUI Lang resource via LLM composition.
* Falls back to heuristic templates if the LLM call fails.
*
* Usage:
* npx tsx state/lib/dashboard-builder.ts <slug>
* npx tsx state/lib/dashboard-builder.ts ai-spend > /tmp/ai-spend.regen.openui
*
* Or import as module:
* import { generateDashboard } from "./dashboard-builder.ts";
* const openui = await generateDashboard("dogfood-loop");
*/
import { readFileSync, existsSync } from "fs";
import { join, resolve } from "path";
import { realpathSync } from "fs";
import { dispatchFor, readDefaultModel, readDispatchConfig } from "./dispatch.ts";
import { findOpenUiResourceIssues } from "./openui-resource-contract.ts";
interface SkillMeta {
name: string;
description: string;
category?: string;
type?: string;
}
// --- Workbench fallback templates (rewritten 2026-05-06) ---
// Workshop, not showroom. Composer + Mutation + Query + Pipeline note.
// PRODUCT.md bans KPI tile grids. Reference: state/skills/voice/resources/ui.openui.
const TEMPLATE_BASIC = (slug: string): string => `// surface: ${slug} workbench
// intents: ${slug}; ${slug} workbench; show ${slug}; show me ${slug}; open ${slug}; run ${slug}
// response: ${slug} workbench is open in the canvas.
$input = ""
$mode = "dry-run"
inputField = Input("${slug}-input", "What do you want ${slug} to do?", "text", null, $input)
modeField = Select("${slug}-mode", [
SelectItem("dry-run", "Dry-run only"),
SelectItem("apply", "Apply after preview")
], null, null, $mode)
// apply:false floor — Run posts the intent back through chat so the user
// consents by submitting; no \`<slug>_run\` Mutation is wired in toolProvider.
runBtn = Button("Run ${slug}", Action([@ToAssistant("run ${slug}: " + $input + " (mode=" + $mode + ")")]), "primary", "normal", "medium")
clearBtn = Button("Clear", Action([@Set($input, "")]), "secondary", "normal", "small")
composer = Card([CardHeader("Compose"), inputField, modeField, Buttons([runBtn, clearBtn])])
recent = Query("get_evals", {slug: "${slug}", days: 7}, {evals: []}, 60)
recentList = Card([
CardHeader("Recent runs", "Last 7 days from evals.ndjson"),
ListBlock(@Each(@Sort(recent.evals, "ts", "desc"), "e", ListItem(e.verb, e.notes, null, "Open", Action([@ToAssistant("inspect ${slug} run " + e.ts)]))))
])
note = TextContent("Pipeline: input -> chat dispatch -> ${slug} skill -> evals.ndjson row.", "small")
root = Stack([composer, recentList, note], "column", "m")
`;
const TEMPLATE_PIPELINE = (slug: string): string => `// surface: ${slug} pipeline
// intents: ${slug}; ${slug} pipeline; run ${slug}; show ${slug} pipeline; ${slug} status
// response: ${slug} pipeline workbench is open in the canvas.
$input = ""
$skip = ""
inputField = Input("${slug}-input", "Pipeline input", "text", null, $input)
skipField = Input("${slug}-skip", "Phases to skip (comma list)", "text", null, $skip)
// apply:false floor — Run posts the intent through chat; no \`<slug>_run\` Mutation is wired.
runBtn = Button("Run pipeline", Action([@ToAssistant("run ${slug}: " + $input + " (skip=" + $skip + ")")]), "primary", "normal", "medium")
composer = Card([CardHeader("Compose"), inputField, skipField, Buttons([runBtn])])
phases = Card([
CardHeader("Phases", "Static map of pipeline stages"),
Steps([
StepsItem("check", "preflight"),
StepsItem("run", "execute steps"),
StepsItem("emit", "write artifact + eval row")
])
])
recent = Query("get_evals", {slug: "${slug}", days: 7}, {evals: []}, 60)
failures = @Filter(recent.evals, "score", "<", 1.0)
failureList = Card([
CardHeader("Recent failures", "Phases that did not reach score 1.0"),
ListBlock(@Each(@Sort(failures, "ts", "desc"), "f", ListItem(f.verb, f.primary_issue, null, "Replay", Action([@ToAssistant("replay ${slug} failure " + f.ts)]))))
])
note = TextContent("Pipeline: input -> phase chain -> evals.ndjson row per phase. Skip phases for a partial run.", "small")
root = Stack([composer, phases, failureList, note], "column", "m")
`;
const TEMPLATE_CHANNEL = (slug: string): string => `// surface: ${slug} channel
// intents: ${slug}; send ${slug}; publish ${slug}; ${slug} channel; show ${slug}
// response: ${slug} channel workbench is open in the canvas.
$body = ""
$target = ""
$mode = "preview"
bodyField = Input("${slug}-body", "Message body", "text", null, $body)
targetField = Input("${slug}-target", "Recipient or channel", "text", null, $target)
modeField = Select("${slug}-mode", [
SelectItem("preview", "Preview only"),
SelectItem("apply", "Apply after review")
], null, null, $mode)
// apply:false floor — Send posts the intent through chat; no \`<slug>_send\` Mutation is wired.
sendBtn = Button("Send", Action([@ToAssistant("send ${slug} to " + $target + ": " + $body + " (mode=" + $mode + ")")]), "primary", "normal", "medium")
clearBtn = Button("Clear", Action([@Set($body, "")]), "secondary", "normal", "small")
composer = Card([CardHeader("Compose"), targetField, bodyField, modeField, Buttons([sendBtn, clearBtn])])
recent = Query("get_recent", {limit: 30}, {evals: [], total: 0, truncated: false}, 30)
slugRecent = @Filter(recent.evals, "skill", "==", "${slug}")
recentList = Card([
CardHeader("Recent activity", "Last 30 deliveries on this channel"),
ListBlock(@Each(@Sort(slugRecent, "ts", "desc"), "e", ListItem(e.verb, e.note, null, "Open", Action([@ToAssistant("inspect ${slug} delivery " + e.ts)]))))
])
note = TextContent("Pipeline: compose -> ${slug}_send mutation (apply=false previews; apply=true delivers) -> evals.ndjson row.", "small")
root = Stack([composer, recentList, note], "column", "m")
`;
const TEMPLATE_SYSTEM = (slug: string): string => `// surface: ${slug} console
// intents: ${slug}; ${slug} console; run ${slug}; check ${slug}; ${slug} status
// response: ${slug} console is open in the canvas.
$query = ""
queryField = Input("${slug}-query", "Inspect what?", "text", null, $query)
// apply:false floor — Run posts the inspection intent through chat; no \`<slug>_run\` Mutation is wired.
runBtn = Button("Run check", Action([@ToAssistant("check ${slug}: " + $query)]), "primary", "normal", "medium")
composer = Card([CardHeader("Compose"), queryField, Buttons([runBtn])])
state = Query("get_full_state", {}, {agents: [], skills: []}, 120)
recent = Query("get_evals", {slug: "${slug}", days: 7}, {evals: []}, 60)
agentsList = Card([
CardHeader("System state", "Live agents and skills snapshot"),
ListBlock(@Each(state.agents, "a", ListItem(a.name, a.status)))
])
eventsList = Card([
CardHeader("Recent events", "${slug} runs in the last 7 days"),
ListBlock(@Each(@Sort(recent.evals, "ts", "desc"), "e", ListItem(e.verb, e.notes, null, "Open", Action([@ToAssistant("inspect ${slug} event " + e.ts)]))))
])
note = TextContent("Pipeline: compose check -> ${slug}_run mutation -> get_full_state + evals refresh.", "small")
root = Stack([composer, agentsList, eventsList, note], "column", "m")
`;
// --- Skill Type Detection (fallback heuristic) ---
export function detectSkillType(skillMeta: SkillMeta): string {
const desc = (skillMeta.description || "").toLowerCase();
const cat = (skillMeta.category || "").toLowerCase();
if (skillMeta.type === "channel") return "channel";
if (skillMeta.type === "system") return "system";
if (skillMeta.type === "reference") return "system";
if (
skillMeta.name.includes("-tick") ||
desc.includes("tick") ||
desc.includes("step") ||
desc.includes("phase") ||
desc.includes("stage") ||
/(?<![a-z-])process(?![a-z-])/.test(desc)
) {
return "pipeline";
}
if (
cat === "channels" ||
cat === "integration" ||
skillMeta.name === "slack" ||
skillMeta.name === "gmail" ||
skillMeta.name === "email" ||
desc.includes("send") ||
desc.includes("publish") ||
desc.includes("dispatch") ||
desc.includes("inbox") ||
desc.includes("communication")
) {
return "channel";
}
if (
cat === "system" ||
cat === "admin" ||
cat === "memory" ||
desc.includes("pipeline") ||
desc.includes("queue") ||
desc.includes("monitor")
) {
return "system";
}
return "basic";
}
function generateFromTemplate(slug: string, meta: SkillMeta): string {
const skillType = detectSkillType(meta);
switch (skillType) {
case "pipeline": return TEMPLATE_PIPELINE(slug);
case "channel": return TEMPLATE_CHANNEL(slug);
case "system": return TEMPLATE_SYSTEM(slug);
default: return TEMPLATE_BASIC(slug);
}
}
export function generateTemplateContent(
slug: string,
meta: SkillMeta,
opts: {
resourceName?: string;
intentMetadata?: { surface: string; intents: string[]; response: string };
} = {},
): string {
let lang = generateFromTemplate(slug, meta);
if (opts.resourceName && opts.resourceName !== "ui.openui" && opts.intentMetadata) {
const { surface, intents, response } = opts.intentMetadata;
const lines = lang.split("\n");
lines[0] = `// surface: ${surface}`;
lines[1] = `// intents: ${intents.join("; ")}`;
lines[2] = `// response: ${response}`;
lang = lines.join("\n");
}
return lang;
}
// --- LLM Composition ---
const LANG_SYSTEM_PROMPT = `You compose live skill-owned OpenUI workbenches for snappy-os skills.
YOU ARE COMPOSING A WORKBENCH, NOT A SCOREBOARD.
A workbench is the surface where the operator DOES the skill's work. A scoreboard is a passive readout of metrics. Snappy-os bans scoreboards: a 0/0/0/0 KPI grid means the surface generated nothing useful for the user. Voice's saved surface (state/skills/voice/resources/ui.openui) is the canonical example. Read it before composing.
REQUIRED WORKBENCH STRUCTURE (every output, no exceptions):
1. Three-line metadata header at the top:
// surface: <slug-or-domain> workbench
// intents: <slug>; show <slug>; show me <slug>; <slug> workbench
// response: <slug> workbench is open in the canvas.
2. One or more $variables backing inputs ($input, $body, $target, etc.).
3. A composer Card (header "Compose") containing Input/Select/Checkbox primitives plus a primary Button whose Action is [@Run(<aMutation>)].
4. A live-data Card with at least one Query() — typed args, default value as 3rd arg, refresh interval (≤60s) as 4th arg, bounded by limit/days/skill.
5. A TextContent pipeline note at the bottom: "Pipeline: <input> -> <mutation> -> <evals.ndjson row>."
6. The final assignment is root = Stack([...], "column", "m").
BANNED:
- KPI tile grids (Stack of Cards each with TextContent("Runs", ...) + TextContent("" + N, "large-heavy")).
- Tables-of-zeros patterns where every Col binds to an empty Query result.
- Inner CardHeader literal that echoes the // surface: title (saved-surface-metadata lint failure — keep CardHeader scoped to state: "Compose", "Recent runs", "System state").
- Marketing / showroom copy. Workshop tone only. No emojis, no em dashes, no exclamation points.
AVAILABLE QUERY FUNCTIONS (read-only state):
- get_evals({slug, days}) -> {evals: [{ts, score, notes, primary_issue, ...}]}
- get_agents() -> {agents: [{name, status, lastRun, score, ...}]}
- get_skills() -> {skills: [{name, runs, score, lastRun, ...}]}
- get_recent({limit}) -> {evals: [{ts, skill, verb, score, note, primary_issue, ...}], total, truncated, last_updated}
- get_dispatch_log() -> {entries: [{ts, model, durationMs, ...}]}
- get_inbox_email({days}) -> {messages: [{from, subject, ts, ...}]}
- get_inbox_slack({}) -> {requires_channel_selection: true, channels: [{channel_id, channel_name, member_count}]} // step 1: list channels
- get_inbox_slack({channel: string}) -> {messages: [{channel_name, text, ts, ...}]} // step 2: fetch messages for a specific channel
- get_inbox_calendar({days}) -> {events: [{summary, start, location, ...}]}
- get_brain() -> {nodes: [...], edges: [...]}
- get_full_state() -> {agents: [...], skills: [...]}
- list_voice_outputs({limit}) -> {items: [...]}
- list_media({skill, kind, limit}) -> {items: [...], total, truncated}
PRIMARY-ACTION CONVENTION (apply:false floor):
Use Action([@ToAssistant("<imperative phrase> + " " + $variable + …")]) for the primary Run/Send/Draft button. This posts the intent back through chat where the user consents by submit; it does NOT call a Mutation.
Why: snappy-chat's toolProvider catalog wires only generic verbs (get_*, list_*, ask_operator, save_artifact, list_archetypes, etc.). Skill-specific Mutations like "<slug>_run", "voice_speak", "linkedin_draft" are NOT wired and will fail at render with tool-not-found.
Mutation() is reserved for verbs that ARE in the wired toolProvider set (e.g. Mutation("ask_operator", {...}), Mutation("save_artifact", {...})). Authoring against any other Mutation name is a bug.
LANG PRIMITIVES:
Card, CardHeader, Stack, Col, Table, BarChart, LineChart, AreaChart, PieChart, Tag, TagBlock, Button, Buttons, Callout, TextCallout, Steps, StepsItem, Tabs, TabItem, Accordion, AccordionItem, ListBlock, ListItem, MarkDownRenderer, Image, ImageBlock, Separator, TextContent, CodeBlock, Input, Select, SelectItem, SwitchGroup, SwitchItem, CheckBoxGroup, CheckBoxItem, Mutation.
Select uses SelectItem(value, label) — NOT SelectOption. Signature: Select(name, items, placeholder?, rules?, $binding).
No bare Checkbox — use SwitchGroup(name, [SwitchItem(label, description, name, defaultChecked?)], variant?, $binding) or two distinct Buttons (preview vs apply) for boolean intent.
OFFICIAL OPENUI SIGNATURES:
- Callout(variant, title, description) where variant is "info" | "warning" | "error" | "success" | "neutral".
- StepsItem(title, details). Details is required.
- Button(label, action, variant, size, density). Variant: "primary" | "secondary" | "tertiary".
- Input(id, placeholder, type, validator, $variable). 5th arg binds to a $variable for two-way state.
- Mutation(name, args). Plain identifier when assigned; runs only inside an Action([@Run(...)]).
LANG SYNTAX RULES:
- Every variable assignment uses =. Function calls use positional args.
- Stack([...], "column" | "row", gap, align, justify, wrap?). Horizontal Stack must pass wrap=true as the 6th arg.
- Use "column" for vertical direction. Never emit legacy "col".
- Filter/map: @Filter(arr, field, op, value), @Count(arr), @Each(arr, "name", expr).
- Supported @ built-ins ONLY: @Count, @Sum, @Avg, @Min, @Max, @First, @Last, @Filter, @Sort, @Round, @Abs, @Floor, @Ceil, @Each, @Run, @Set, @Reset, @ToAssistant, @OpenUrl.
- Never emit @If, @Coalesce, @Slice, @Get, @Distinct, @FormatTime.
- The final assignment MUST be root = Stack([...], "column", "m").
- Strings double-quoted; arrays use []; objects use {key: value}.
- Every Query() needs a default value as 3rd arg AND refresh interval (≤60s) as 4th arg.
OUTPUT RULES:
- Output ONLY the OpenUI Lang. Start with the // surface: header line.
- No markdown fences, no prose, no "Here is".
- Keep the body under 60 lines.
- End with root = Stack([...], "column", "m").`;
function buildUserPrompt(slug: string, meta: SkillMeta, skillBody: string): string {
return `THE SKILL:
name: ${slug}
category: ${meta.category || "unset"}
description: ${meta.description}
Body of SKILL.md (prose that defines behavior):
${skillBody.slice(0, 3000)}
YOUR JOB:
Compose the workbench for this skill. Read state/skills/voice/resources/ui.openui FIRST and use it as the structural pattern. The output MUST be a workbench (composer + primary @ToAssistant action + Query + pipeline note), never a scoreboard. The skill's category may be ${meta.category || "unset"} but the OUTPUT SHAPE is workbench regardless.
PRIMARY ACTION: a Button whose Action is [@ToAssistant("<imperative phrase including $vars>")]. The phrase becomes the chat prompt the user submits. NEVER author Mutation("${slug}_run") or any "${slug}_<verb>" name — those are NOT wired in the snappy-chat toolProvider and will render a red error block at runtime. Mutation() is reserved for the wired allowlist (ask_operator, save_artifact, list_archetypes, promote_openui_surface, etc.).
Pick Query sources that show what JUST RAN for this skill — typically get_evals(slug, days), get_recent(limit), get_full_state(), or get_inbox_email/get_inbox_telegram/list_media when the skill owns that channel. Bound every Query (limit, days, or filter to slug).
Composer must consent to side effects: include $apply or $dryRun toggles when the chat-dispatched action has external effect (channel send, publish). The toggle's value lives in the @ToAssistant string so the receiving turn sees the user's intent; default state is dry-run/preview. This is the apply:false floor.
Output ONLY the OpenUI Lang. Start with the // surface: header line. Keep the body under 60 lines. End with root = Stack([...], "column", "m").`;
}
export function validateLang(output: string): boolean {
const trimmed = output.trim();
if (!trimmed) return false;
// Must not start with backticks or explanation prose
if (trimmed.startsWith("```") || trimmed.startsWith("Here ") || trimmed.startsWith("I ")) return false;
// First non-comment line must contain = (variable assignment). Saved
// surfaces start with // surface / // intents / // response metadata.
const firstLine = trimmed
.split("\n")
.find(l => {
const s = l.trim();
return s.length > 0 && !s.startsWith("//") && !s.startsWith("#");
}) || "";
if (!firstLine.includes("=")) return false;
// Must contain root =
if (!trimmed.includes("root =")) return false;
// Must contain at least one Query call
if (!trimmed.includes('Query("get_')) return false;
const runtimeIssues = findOpenUiResourceIssues(trimmed);
if (runtimeIssues.length > 0) {
process.stderr.write(`[dashboard-builder] invalid OpenUI resource: ${runtimeIssues[0].detail}\n`);
return false;
}
return true;
}
function normalizeLangOutput(output: string): string {
const trimmed = output.trim();
const toolMatch = trimmed.match(/\[\[TOOL:Lang\]\]([\s\S]*?)\[\[\/TOOL\]\]/);
if (toolMatch?.[1]) return toolMatch[1].trim();
const fenceMatch = trimmed.match(/```(?:openui|lang)?\s*([\s\S]*?)```/i);
if (fenceMatch?.[1]) return fenceMatch[1].trim();
return trimmed;
}
async function callLLMViaDispatch(slug: string, systemPrompt: string, userPrompt: string): Promise<string> {
const axis = readDispatchConfig().subagent;
const modelLabel = axis.model === "auto" ? readDefaultModel().slug : axis.model;
process.stderr.write(`[dashboard-builder] composing ${slug} via ${axis.backend}/${modelLabel}\n`);
const dispatchOpts = {
prompt: userPrompt,
systemPrompt,
cwd: resolve(process.cwd()),
tools: ["read", "grep", "ls"],
timeoutMs: 180_000,
interviewMode: false,
};
let result = await dispatchFor("subagent", dispatchOpts);
// 2026-05-07 (post-sweep): subagent axis defaults to openai-codex/gpt-5.4
// and Robert's Codex credits are currently exhausted, so every subagent
// dispatch returns ok:true exit 0 with empty output. dashboard-builder
// then falls back to the (form-on-form) template, which the regen drainer
// mv-clobbers over rich existing surfaces. Add a one-shot fallback to
// openrouter+haiku when the primary returns empty — keeps the user's
// configured default in place for normal calls and only swaps in the
// fallback when the primary visibly failed. The CLI guard in this same
// file is the safety net for when this fallback ALSO fails.
if (!result.ok || !result.output.trim()) {
const primaryDetail = result.error || result.stderr || `exit ${result.exitCode}`;
process.stderr.write(
`[dashboard-builder] primary subagent dispatch (${result.provider}/${result.model}) ` +
`returned empty: ${primaryDetail}. Retrying via openrouter+haiku.\n`,
);
result = await dispatchFor("subagent", {
...dispatchOpts,
backend: "openrouter",
model: "anthropic/claude-haiku-4.5",
});
}
if (!result.ok || !result.output.trim()) {
const detail = result.error || result.stderr || `exit ${result.exitCode}`;
throw new Error(`${result.provider}/${result.model} failed: ${detail}`);
}
return normalizeLangOutput(result.output);
}
async function callLLM(slug: string, meta: SkillMeta, skillBody: string, systemPrompt = LANG_SYSTEM_PROMPT): Promise<string> {
const userPrompt = buildUserPrompt(slug, meta, skillBody);
return callLLMViaDispatch(slug, systemPrompt, userPrompt);
}
// --- Main Generator ---
// --- Dry-run overload (returns content without writing) ---
export async function generateDashboard(slug: string, opts?: { dryRun?: boolean }): Promise<{ content: string; written: boolean }>;
// Legacy overload: plain string return (backward-compat)
export async function generateDashboard(slug: string): Promise<string>;
export async function generateDashboard(slug: string, opts?: { dryRun?: boolean }): Promise<string | { content: string; written: boolean }> {
const dryRun = opts?.dryRun === true;
const _legacyStringReturn = opts === undefined;
return _generateDashboardImpl(slug, dryRun, _legacyStringReturn);
}
async function _generateDashboardImpl(slug: string, dryRun: boolean, legacyStringReturn: boolean): Promise<string | { content: string; written: boolean }> {
const skillDir = resolve(process.cwd(), "state/skills", slug);
const skillMdPath = join(skillDir, "SKILL.md");
if (!existsSync(skillMdPath)) {
throw new Error(`Skill not found: ${slug} (expected at ${skillMdPath})`);
}
const skillMdContent = readFileSync(skillMdPath, "utf8");
const meta = parseFrontmatter(skillMdContent);
// Strip frontmatter to get just the body prose
const skillBody = skillMdContent.replace(/^---\n[\s\S]+?\n---\n?/, "").trim();
// Try LLM composition first
let lang: string;
let fromTemplate = false;
try {
const raw = await callLLM(slug, meta, skillBody);
if (validateLang(raw)) {
lang = raw;
} else {
// Retry with a stricter system prompt
const stricterSystem = LANG_SYSTEM_PROMPT + "\n\nCRITICAL: Output ONLY OpenUI Lang. The first character must be a letter. No backticks, no prose.";
try {
const retryRaw = await callLLM(slug, meta, skillBody, stricterSystem);
if (validateLang(retryRaw)) {
lang = retryRaw;
} else {
process.stderr.write(`[dashboard-builder] dispatch output failed validation for ${slug}, using template fallback\n`);
lang = generateFromTemplate(slug, meta);
fromTemplate = true;
}
} catch {
process.stderr.write(`[dashboard-builder] dispatch retry failed for ${slug}, using template fallback\n`);
lang = generateFromTemplate(slug, meta);
fromTemplate = true;
}
}
} catch (e) {
process.stderr.write(`[dashboard-builder] dispatch call failed for ${slug}: ${(e as Error).message}, using template fallback\n`);
lang = generateFromTemplate(slug, meta);
fromTemplate = true;
}
if (legacyStringReturn) return lang;
return { content: lang, written: false, fromTemplate } as { content: string; written: boolean; fromTemplate: boolean };
}
// writeDashboard + isExistingRich retired 2026-05-11 - wrote into the
// now-retired state/skills/<slug>/resources/ui.openui tree via the
// openui-resource-contract writer. Generation still works via
// generateDashboard + generateDashboardContent (dry-run output to
// stdout); the regen path no longer touches the filesystem.
// compose_inline + artifact persistence replaced the per-skill
// saved-surface contract.
// Convenience: generate without writing, return content string only.
export async function generateDashboardContent(slug: string): Promise<string> {
const result = await generateDashboard(slug, { dryRun: true });
return (result as { content: string; written: boolean }).content;
}
function parseFrontmatter(content: string): SkillMeta {
const match = content.match(/^---\n([\s\S]+?)\n---/);
if (!match) return { name: "", description: "" };
const yaml = match[1];
const meta: SkillMeta = { name: "", description: "" };
const nameMatch = yaml.match(/^name:\s*(.+)$/m);
if (nameMatch) meta.name = nameMatch[1].trim();
const descMatch = yaml.match(/^description:\s*['""]?(.+?)['""]?\s*$/m);
if (descMatch) meta.description = descMatch[1].trim().replace(/^["']+|["']+$/g, "");
const catMatch = yaml.match(/^category:\s*(.+)$/m);
if (catMatch) meta.category = catMatch[1].trim();
const typeMatch = yaml.match(/^type:\s*(.+)$/m);
if (typeMatch) meta.type = typeMatch[1].trim();
return meta;
}
// --- CLI ---
if ((() => {
try {
return import.meta.url === `file://${realpathSync(process.argv[1])}`;
} catch {
return false;
}
})()) {
(async () => {
const slug = process.argv[2];
if (!slug) {
console.error("Usage: npx tsx state/lib/dashboard-builder.ts <slug>");
process.exit(1);
}
try {
// 2026-05-11: the isExistingRich CLI guard retired with the
// resources/ui.openui tree. The dispatch still falls back to the
// template stub when LLM credits are out; that's now the only signal
// the regen brief's downstream mv ever sees. compose_inline +
// artifact persistence replaced the per-skill saved-surface path.
const result = await generateDashboard(slug, { dryRun: true });
const toPrint = (result as { content: string }).content;
console.log(toPrint);
} catch (err) {
console.error("Error:", (err as Error).message);
process.exit(1);
}
})();
}
scripts- helper scripts it can run
prose-only skill - 4 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
no recent runs logged - the eval contract is declared but nothing has been graded yet