OR Key
drop another .md file to compare - side-by-side diff against dashboard-builder

dashboard-builder

Helps build and update the screens in your dashboard.
personal 2 files

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.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

eval modeauto-shape
categorySystem
stages5

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.

The skill
state/skills/dashboard-builder/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/dashboard-builder.ts present
code the skill can run
Reusable code this skill can call when it needs to.
Scripts
state/bin/dashboard-builder/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/dashboard-builder/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

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.

makes the work The worker
inferred
# Legacy inspection from a command
No worker named, so the first command in the skill is treated as the worker.
checks the work The reviewer
inferred
shape gate an automatic check
The check is an automatic pass or fail on the shape of the result, run separately from the work itself.
frame
learns Self-correction
not present

This skill doesn't fix its own gaps yet.

tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson auto-shape runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
No must-not-break rules called out for this skill. Anything important lives in the writeup below.

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.

  1. Loading feedback rows…

how the work flows- step by step

1 stage
Validate the skill exists
```typescript
2 stage
Parse the skill's frontmatter
Read SKILL.md, extract the YAML block (--- to ---), and parse:
what this step does
Read SKILL.md, extract the YAML block (--- to ---), and parse: - name - the skill name - description - what it does - category - skill category (Channels, System, Memory, etc.) - type - explicit type field (optional; can be "channel", "system", "reference", etc.)
3 stage
Detect the skill's operational type
Use heuristics to choose the right dashboard template:
what this step does
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**
4 generator
Compose the legacy dashboard, only when maintain
Pick the template based on detected type. For ui.openui, compose the default legacy skill dashboard. For named resources
what this step does
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: 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_eva
5 generator
Write the file atomically, legacy-only
```typescript
what this step does
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.

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:

  1. Error-prone (copy-paste bugs, stale templates)
  2. Maintenance-heavy (every query signature change breaks N dashboards)
  3. 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:

  1. Ground the request with current snappy-os state / tool results.
  2. Compose the surface through compose_inline by default, or Lang when exact source is already authored.
  3. Persist reusable UI as artifact lang_body with title, intent, and follow-up metadata.
  4. Reopen existing UI through OpenArtifact / Live Apps before patching it.
  5. 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 as ui.openui or schedule.openui
  • intent 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 name
  • description - what it does
  • category - 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_evals for 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_recent filtered 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

  1. Do not use this for new Snappy OS Live Apps. New surfaces use compose_inline / Lang and artifact lang_body.
  2. 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.
  3. Never regenerate if the file carries a # DO NOT REGENERATE header comment at the top. Respect manual customizations.
  4. Atomic writes only for legacy maintenance. Temp file + rename. If the skill's resources folder doesn't exist, create it first (mkdir -p).
  5. Frontmatter plus artifact metadata is current truth. SKILL.md frontmatter determines ownership/domain. Artifact title, intent, and lang_body determine current Live Apps behavior.
  6. 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.
  7. 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-app with compose_inline, artifact lang_body, and OpenArtifact.
  • 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 REGENERATE header comment so future agents skip it.
  • Raw React shape work: use ui-components / shape-builder only for rare custom defineComponent components that cannot be composed from OpenUI primitives. Normal right-canvas work belongs in composed OpenUI Lang saved as artifact lang_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

  1. Current product truth: new dashboards, previews, galleries, command centers, and right-rail UIs are Live App / Artifact records. Compose them through compose_inline or exact Lang, persist as artifact lang_body, and reopen through OpenArtifact.
  2. 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.
  3. 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.
  4. Respect manual legacy resources. If state/skills/<slug>/resources/<resourceName> exists, check for # DO NOT REGENERATE header in first 5 lines. Skip if present; warn before overwrite unless explicitly maintaining/regenerating legacy files.
  5. 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.
  6. Legacy metadata is migration input. Old comments (// surface, // intents, // response) should become artifact title, intent, and response metadata during migration.
  7. 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.
  8. 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.
  9. YAML parsing is regex-based. parseFrontmatter() uses simple regex extraction. Unquoted colons or newlines in description field may fail - check SKILL.md frontmatter syntax.
  10. Type detection is heuristic. Ambiguous category/description defaults to BASIC template (safe but may not be optimal). Add explicit type: "system" or type: "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_evals for 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, // response comments into artifact metadata
  • [ ] If writing a legacy file, write atomically (temp + rename)
  • [ ] Do not add or backfill resources/ui.openui as 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

rubric auto-shape no rubric declared
recent no runs actor/auditor: unverifiable
deps none declared

no recent runs logged - the eval contract is declared but nothing has been graded yet