| name | app-builder |
| description | Build and edit small, personal visual tools and artifacts — dashboards, trackers, calculators, data visualizations, charts, simple landing pages, and slide decks the user wants for THEMSELVES. This is the right skill whenever the user asks to "visualize this," "make a chart," or "build an artifact" for their own use, or to edit an app they already built here. Do NOT reach for a ui_show dynamic_page to fake an artifact — build a real persistent app here. NOT for complex, multi-user, or shippable products — those go to a real project folder with a coding agent (see Scope below). |
| metadata | {"emoji":"🛠️","vellum":{"display-name":"App Builder","activation-hints":["User asks to build a dashboard, tracker, calculator, data visualization, chart, simple landing page, or slide deck for their own use","User asks to visualize something, make a chart, or build an artifact — build a real persistent app here, never a ui_show dynamic_page","User asks to change, fix, restyle, or extend an app they already built in the sandbox — open it and iterate"],"avoid-when":["User wants a complex app, a multi-user app, or something to publish, deploy, or hand off to others — route to a local project folder + coding agent instead (see Scope)"]}} |
You build small, personal visual tools — dashboards, trackers, calculators, data visualizations, simple landing pages, and slide decks. These are quick, single-user tools the user wants for themselves, not products they ship to other people.
Load frontend-design first (skill_load("frontend-design")), then move fast: think, plan in one pass, pick a striking visual direction following that skill, and build it immediately. Don't ask permission to be creative — pick the colors, the layout, the atmosphere, the micro-interactions. Every tool gets its own identity: a plant tracker feels earthy and green, a finance dashboard precise and navy. They should feel designed, not generated.
Design quality is delegated to the frontend-design skill. You MUST call skill_load("frontend-design") before building anything, every time, and follow it completely. That skill owns the aesthetics (typography, color, motion); this skill owns the technical infrastructure (sandbox, data, widgets, lifecycle). Skipping the load gives generic, templated UI, which is a failed build.
Scope — what belongs here, what doesn't
Build here (the default — lean toward it): a tool the user wants for themselves. A dashboard, tracker, calculator, data viz, slide deck, or a simple landing page they'll use on their own. Personal and self-contained.
Does NOT belong here: anything complex, multi-user, or meant to be published, deployed, handed off, or shipped to other people. Sandbox apps are single-user, run only in this preview, and can't be exported or deployed. They're the wrong home for a real product.
When a request is for a shippable/complex app, don't build in the sandbox. Instead:
- Explain the approach in a sentence: a real product belongs in a project folder they own — version-controlled, deployable, shareable — and you'll build it with them as a coding agent, not inside a preview.
- Establish a project folder (propose a path, or use one they name).
- Hand off to a coding agent:
skill_load("acp") → acp_spawn({ task: "<what to build>", cwd: "<folder>" }) (agent defaults to claude), then follow the acp skill.
Triage on intent, not artifact type. A simple landing page is a personal build by default — it only becomes a handoff when the user signals they want to publish or share it. When the signal is weak, lean personal and just build. If you genuinely can't tell, ask exactly one short question.
Editing an existing sandbox app? Skip scope entirely — that's iteration. Resolve the app (see below), open it, and go to Iteration.
Resolving an app the user mentions
app_open takes an app_id, not a name:
- If the
app_id is already in your context, use it.
- Otherwise
app_list(query: "<what they said>") returns matches with app_id + name. app_list() with no query lists everything.
- One match → open it. Multiple → list them and ask which. None → say so, show what exists, offer to build it.
Filesystem layout
Apps live under /workspace/data/apps/:
/workspace/data/apps/
<slug>.json # App metadata
<slug>/
src/ # Source files (TSX) — what you write
dist/ # Compiled output — auto-generated by app_refresh
records/ # Data records (one JSON file per record)
<slug>.preview # Preview image (auto-generated)
Metadata fields: id, name, description, icon, schemaJson, createdAt, updatedAt, formatVersion, dirName. Records: { "id", "appId", "data": {...}, "createdAt", "updatedAt" } — the system auto-adds everything but data.
All new apps use formatVersion: 2 (multi-file TSX). No root-level index.html or pages/ — those are legacy.
⚠️ Correct source path is /workspace/data/apps/<slug>/src/. Never /workspace/apps/.
Responsive & design system
Every app works phone (~360px) to desktop (~1400px+). The <turn_context> block carries an interface: field: ios → mobile-first (design narrow first, body 17px); macos/web → desktop-first (multi-column, body 14px); absent → desktop-first unless the request implies phone use ("for my iPhone").
Universal baseline — every build, regardless of interface:
- Viewport meta:
width=device-width, initial-scale=1, viewport-fit=cover. Never user-scalable=no (blocks accessibility zoom).
- Pad the root with
env(safe-area-inset-*) so content clears the notch: padding-top: max(var(--v-spacing-lg), env(safe-area-inset-top)), mirrored for the other sides.
- Full-height containers use
100dvh, not 100vh.
- Form controls (
input/textarea/select) must be font-size: 16px+ or iOS Safari zooms on focus. Add inputmode (numeric/decimal/email/tel/url).
- Interactive elements ≥44×44pt (
.v-button already complies; custom controls set min-height: 44px). Gate hover behind @media (hover: hover).
- Fluid widths only —
%, fr, minmax, clamp(), never fixed px on containers. Size chart containers in vw/%. At narrow widths, collapse tables into stacked label-value cards.
Mobile-first extras (interface: ios): body --v-font-size-lg (17px); one column by default, multi-column only above @media (min-width: 720px); bottom-anchor the primary action (position: sticky; bottom: env(safe-area-inset-bottom)); bottom sheets instead of side modals.
Full detail when reachable: {baseDir}/references/RESPONSIVE.md.
A design-system CSS and widget library are auto-injected (inside a @layer, so your own styles always win). Use the --v-* variables and .v-* classes below — they switch light/dark automatically, no manual dark-mode CSS needed. Always use window.vellum.widgets.* chart functions instead of hand-coded SVG/CSS charts.
Design tokens (use these, don't invent hex values):
| Category | Tokens |
|---|
| Backgrounds | --v-bg, --v-surface, --v-surface-border |
| Text | --v-text, --v-text-secondary, --v-text-muted |
| Accent | --v-accent, --v-accent-hover |
| Status | --v-success, --v-danger, --v-warning |
| Spacing | --v-spacing-xxs(2) -xs(4) -sm(8) -md(12) -lg(16) -xl(24) -xxl(32) -xxxl(48) |
| Radius | --v-radius-xs(2) -sm(4) -md(8) -lg(12) -xl(16) -pill(999) |
| Shadows | --v-shadow-sm/md/lg |
| Typography | --v-font-family, --v-font-mono, --v-font-size-xs(10) -sm(11) -base(14) -lg(17) -xl(22) -2xl(26) |
| Animation | --v-duration-fast(.15s) -standard(.25s) -slow(.4s) |
| Palettes | --v-slate/emerald/violet/indigo/rose/amber-{950..50} |
| Constant | --v-aux-white (always #FFF both modes — text on filled/accent backgrounds) |
Utility classes: .v-button (.secondary/.danger/.ghost), .v-card, .v-list/.v-list-item, .v-badge (.success/.warning/.danger), .v-input-row, .v-empty-state, .v-toggle.
Theme in JS: window.vellum.theme.mode ('light'/'dark'); listen on window.addEventListener("vellum-theme-change", e => e.detail.mode).
For a custom branded look, write complete CSS with hardcoded colors + @media (prefers-color-scheme: dark) — don't mix --v-* auto-switching vars with hardcoded colors in the same element.
⚠️ Never hardcode color: white / #fff — use var(--v-aux-white) on filled/accent backgrounds, var(--v-text) / var(--v-text-secondary) on surfaces. Hardcoded white goes invisible on light surfaces.
Full detail when reachable: {baseDir}/references/DESIGN_SYSTEM.md. Note: in local dev these reference files live outside the app's sandbox and may not be readable — the essentials here are self-contained, so you can build without them.
Widget library (auto-injected)
CSS classes for standard patterns: .v-metric-card/.v-metric-grid (big-number stats), .v-data-table (sortable, sticky header, th[data-sortable]), .v-tabs, .v-accordion, .v-search-bar, .v-timeline, .v-action-list (rows with per-item actions), .v-card-grid, .v-progress-bar, .v-status-badge (.success/.error/.warning/.info), .v-stat-row/.v-stat, .v-tag-group, .v-avatar-row. Landing-page components: .v-hero/.v-hero-badge/.v-hero-subtitle, .v-section-header/.v-section-label, .v-feature-grid/.v-feature-card, .v-pullquote, .v-comparison (.before/.after), .v-page, .v-gradient-text, .v-animate-in. Domain widgets: .v-weather-card, .v-stock-ticker, .v-receipt, .v-invoice, .v-itinerary, .v-boarding-pass.
JS utilities at window.vellum.widgets.*:
vellum.widgets.sparkline("el-id", [10,25,15,30], { width:200, height:40, color:"var(--v-success)", fill:true });
vellum.widgets.barChart("el-id", [{label:"Jan",value:120},{label:"Feb",value:180,color:"var(--v-success)"}], { width:400, height:200, showValues:true, horizontal:false });
vellum.widgets.lineChart("el-id", [{label:"Mon",value:42},{label:"Tue",value:58}], { width:400, height:200, showDots:true, showGrid:true });
vellum.widgets.progressRing("el-id", 75, { size:100, strokeWidth:8, color:"var(--v-success)", label:"75%" });
vellum.widgets.formatCurrency(1234.56, "USD");
vellum.widgets.formatDate("2025-01-15", "relative");
vellum.widgets.formatNumber(1234567, { compact:true });
vellum.widgets.sortTable("table-id");
vellum.widgets.filterTable("table-id", "input-id");
vellum.widgets.tabs("tabs-id"); vellum.widgets.accordion("acc-id", { allowMultiple:true });
vellum.widgets.toast("Saved!", "success", 4000);
vellum.widgets.countdown("el", "2025-12-31T00:00:00Z", { onComplete:()=>{} });
Use custom HTML for novel/creative UIs (games, art tools); widgets for standard patterns; mix freely. Full list: {baseDir}/references/WIDGETS.md.
Build workflow
0. Preflight — optional profile switch
App builds are multi-step and benefit from a stronger model. If the active model profile looks weak for this work, you may offer to switch profiles first. Use the ui_show tool to ask, with surface_type: "confirmation" and await_action: true, so the user explicitly opts in before anything changes. Do not call the shell command assistant ui confirm for this — it can block the build flow before app work starts. If the user declines, just proceed on the current profile.
1 — Plan and build, fast
Think (what's the tool, who's the single user), plan in one pass (visual direction, minimal schema, core layout), then build. No wireframes, no mockups, no color questions. Make the creative calls yourself. Only ask a question when the request is genuinely ambiguous about what to build — and even then, prefer building something strong from context clues.
2 — Design the data schema (only if it persists data)
A JSON Schema for a single record. The system auto-adds id, appId, createdAt, updatedAt — define only user-facing fields. Keep it flat (string, number, boolean); encode nested data as JSON strings.
{
"type": "object",
"properties": {
"title": { "type": "string" },
"status": { "type": "string", "enum": ["todo", "doing", "done"] }
},
"required": ["title"]
}
Calculators, single-page tools, landing pages, and slide decks skip this — pass an empty schema_json or omit it.
3 — Create the app (scaffold, then expand)
⚠️ app_create is ONE-SHOT per build. Call it exactly once. After it returns an app_id, all further changes go through file_write / file_edit + app_refresh. To start over: app_delete(app_id) first, then a fresh app_create.
Apps are multi-file Preact + TSX projects; esbuild bundles automatically. Structure:
src/
index.html # Minimal shell that loads the bundle
main.tsx # Renders <App /> into #app
components/App.tsx # Top-level component
styles.css # Global styles (import from TSX)
import { render } from "preact";
import { App } from "./components/App";
import "./styles.css";
render(<App />, document.getElementById("app")!);
Scaffold-then-expand is the pattern for every non-trivial app. Cramming all files into one app_create blows the response token budget mid-emit:
app_create with a 4-file scaffold: src/index.html, src/main.tsx, a placeholder src/components/App.tsx (<div>Loading...</div>), and an empty src/styles.css. The placeholders make the first compile clean — a 2-file scaffold leaves broken imports.
file_write each real file, one per tool call, overwriting the placeholders and adding components.
app_refresh ONCE at the end to compile.
Allowed packages (esbuild-resolved, no CDN): date-fns, chart.js, lodash-es, zod, clsx, lucide (use lucide, NOT lucide-react).
Constraints: Preact not React. No CDN imports. No external fonts/images (system fonts, inline CSS/SVG). Responsive only, no fixed-pixel widths. The WebView blocks navigation — href and form action don't work.
⚠️ compile_errors in the app_create response is NOT a retry signal — the response also has an app_id, so the app was created. Proceed. Calling app_create again makes a duplicate.
app_create accepts EXACTLY these 7 keys — nothing else
name (optional — defaults to the preview title, else "New App"), description, schema_json, source_files, preview, auto_open, change_summary.
Anything else fails with Invalid input for tool "app_create": Unknown parameter "X". The retired keys models still reach for:
html — old single-file shortcut. Put your HTML inside source_files["src/index.html"].
pages — retired. Multi-page apps use TSX components under src/components/.
icon — NOT a top-level param. An emoji icon goes in preview.icon (e.g. preview: { title: "Bean Coffee", icon: "☕" }). For an AI-generated icon, call app_generate_icon(app_id, description) after the app exists.
- A file path as a top-level key (e.g.
"src/components/Header.tsx") — these go inside source_files, or in a file_write after app_create.
If a prior session in your context shows app_create({ html }) or app_create({ pages }), that example is outdated — ignore it.
// ❌ Wrong // ✅ Right
app_create({ app_create({
name: "Landing", name: "Landing",
html: "<!DOCTYPE...>" // INVALID source_files: {
}) "src/index.html": "<!DOCTYPE...>",
"src/main.tsx": "...",
"src/components/App.tsx": "...",
"src/styles.css": ""
}
})
Key notes: preview — always include, title required (plus optional subtitle, description, icon, up to 3 metrics). auto_open — always pass false so you don't get a duplicate preview card (Step 5 owns surfacing). change_summary — conventional commit message.
4 — Compile
app_refresh(app_id)
Call it ONCE, after ALL file writes — batching is required. If it fails, the response has error details; fix with file_edit, then app_refresh again.
5 — Show the preview card
app_open(app_id, open_mode: "preview")
⚠️ Don't skip this — without it the user has no Open button, just your text. It fires after all writes, so the card shows final content (this is why auto_open must be false). Don't use open_mode: "workspace" unless the user explicitly asks for the full panel.
6 — Iteration
Editing an existing app means reusing its app_id — never app_create. Resolve it from name if needed (see Resolving an app), open it so the live result is visible, then:
file_edit — targeted changes (styles, fixes, small features), full path /workspace/data/apps/<slug>/src/...
file_write — new files or full rewrites
app_update — rename or change description/schema, and/or rewrite files via source_files, in one call. It recompiles for you, so no separate app_refresh is needed. Use this instead of editing <slug>.json by hand.
- Full rebrand — still iteration, edit the existing files.
Then app_refresh(app_id) ONCE (unless you used app_update, which already recompiled). If the change is substantial, app_open(app_id, open_mode: "preview") for a fresh card; for small tweaks the existing card stays valid.
⚠️ skill_load("app-builder") is required before every app_* call (including the first app_create). The skill can auto-unload between turns; without the reload, app_refresh / app_open error with "not currently active." It's idempotent — call it every time.
Using your assistant's tools and data
The point of these apps is to put the user's own data and the assistant's capabilities behind a real interface. Apps reach the assistant backend through custom routes.
Call routes with window.vellum.fetch("/v1/x/...") — never raw fetch(). Raw fetch fails in the sandboxed origin. This is how an app reads and writes persistent records, runs server-side logic, and touches files.
async function loadRecords() {
const res = await window.vellum.fetch("/v1/x/my-route");
if (!res.ok) { window.vellum.widgets.toast("Couldn't load", "error"); return []; }
return res.json();
}
Always wrap calls in try/catch, check res.ok before parsing, and surface failures with a toast or inline error — never fail silently:
useEffect(() => {
window.vellum.fetch("/v1/x/items")
.then(res => res.ok ? res.json() : Promise.reject(res.status))
.then(setItems)
.catch(() => window.vellum.widgets.toast("Couldn't load", "error"));
}, []);
Writing a route handler. Routes are .ts/.js files in {workspaceDir}/routes/, served at /v1/x/<filename> (routes/items.ts → /v1/x/items; routes/bar/index.ts → /v1/x/bar). Write them with file_write before app_refresh. Each exports named functions per HTTP method (GET/POST/PUT/PATCH/DELETE), receiving the Web Request and an optional context. Full Node API access (fs, path, crypto), 30s timeout, hot-reloaded on change. No [id].ts dynamic segments — use query params.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
export const description = "Item CRUD — JSON file storage";
const FILE = join(process.env.VELLUM_WORKSPACE_DIR!, "data", "items.json");
const load = () => existsSync(FILE) ? JSON.parse(readFileSync(FILE, "utf-8")) : [];
const save = (x:unknown[]) => { mkdirSync(join(process.env.VELLUM_WORKSPACE_DIR!,"data"),{recursive:true}); writeFileSync(FILE, JSON.stringify(x,null,2)); };
export function GET(): Response { return Response.json(load()); }
export async function POST(req: Request): Promise<Response> {
const item = { id: crypto.randomUUID(), ...(await req.json()), createdAt: new Date().toISOString() };
const items = load(); items.push(item); save(items);
return Response.json(item, { status: 201 });
}
The optional context arg exposes daemon singletons — e.g. context.assistantEventHub.publish({...}) to push real-time events to connected clients (UI updates, navigation, notifications). It's immutable. Full guide + copyable examples (Focus Timer, Habit Tracker, Expense Tracker): {baseDir}/references/CUSTOM_ROUTES.md, {baseDir}/references/examples/.
Persistence options: localStorage for ephemeral UI state (filters, view modes, drafts); custom routes for persistent records and server-side logic. (window.vellum.data.* is deprecated — only for editing pre-existing legacy apps.)
Interaction standards
- Feedback for every action —
vellum.widgets.toast() after creates, deletes, updates, errors.
- Confirm destructive actions —
window.vellum.confirm(title, message) (returns Promise<boolean>) before deleting or resetting.
- Validate forms before submit, show errors inline, disable submit during async.
- Loading states — skeleton or spinner, never a blank screen.
- Designed empty states —
.v-empty-state when there's no data.
Keep the assistant aware
Wire window.vellum.sendAction() during the build so the assistant sees meaningful interactions. Reactive hooks trigger a response (form submissions, selections worth explaining); silent hooks (state_update) accumulate context without interrupting (tab changes, filter changes). Examples in {baseDir}/references/INTERACTION_HOOKS.md.
Actionable UI & links
For triage/bulk-action UIs: render a dynamic_page with selectable items + action buttons → user selects and clicks → UI sends surfaceAction with action ID + selected IDs → execute tools, ui_update, toast. Use window.vellum.confirm() for destructive actions. Make items clickable with vellum.openLink(url, metadata) (include metadata.provider and metadata.type).
Slides
Slide decks are a different domain — skip app patterns (contextual headers, search/filter, toasts, form validation, custom routes). Build navigation and layouts with custom HTML/CSS. Templates and principles in {baseDir}/references/SLIDES.md.
SKILL COMPLETE WHEN
Reference files
Read with file_read using the {baseDir}/references/... paths ({baseDir} resolves to this skill's directory):
RESPONSIVE.md — mobile vs desktop, universal baseline, safe areas
DESIGN_SYSTEM.md — token table, utility classes, theme detection
WIDGETS.md — widget classes, chart utilities, formatting helpers
CUSTOM_ROUTES.md — server-side persistence and custom API routes
examples/ — complete copyable example apps
INTERACTION_HOOKS.md — sendAction patterns, reactive vs silent
SLIDES.md — presentation slide design