app-builder
$
npx mdskill add vellum-ai/vellum-assistant/app-builderYou 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.
SKILL.md
.github/skills/app-builderView on GitHub ↗
---
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:
1. **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.
2. **Establish a project folder** (propose a path, or use one they name).
3. **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:
1. If the `app_id` is already in your context, use it.
2. Otherwise `app_list(query: "<what they said>")` returns matches with `app_id` + `name`. `app_list()` with no query lists everything.
3. 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.*`:
```javascript
// Charts — ALWAYS use these, never hand-code SVG/CSS charts (they handle bounds, scaling, dark mode)
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%" });
// Formatting
vellum.widgets.formatCurrency(1234.56, "USD"); // "$1,234.56"
vellum.widgets.formatDate("2025-01-15", "relative"); // "3d ago" ("short" → "1/15/25")
vellum.widgets.formatNumber(1234567, { compact:true }); // "1.2M"
// Behaviors
vellum.widgets.sortTable("table-id"); // wire th[data-sortable]
vellum.widgets.filterTable("table-id", "input-id"); // live text search
vellum.widgets.tabs("tabs-id"); vellum.widgets.accordion("acc-id", { allowMultiple:true });
vellum.widgets.toast("Saved!", "success", 4000); // success | error | warning | info
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.
```json
{
"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)
```
```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:
1. **`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.
2. **`file_write`** each real file, one per tool call, overwriting the placeholders and adding components.
3. **`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` (required), `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
- **Rename / metadata** — edit `/workspace/data/apps/<slug>.json` directly. Not a new app.
- **Full rebrand** — still iteration, edit the existing files.
Then `app_refresh(app_id)` ONCE. 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.
```tsx
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:
```tsx
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.
```typescript
// routes/items.ts
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
export const description = "Item CRUD — JSON file storage"; // optional, for `assistant routes list`
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
- [ ] Request was scoped: personal build (sandbox) or complex/shippable (handed off to a project folder + coding agent)
- [ ] **Sandbox path:** `app_create` returned an `app_id`; all files written via `file_write`; `app_refresh` ran ONCE clean; `app_open(open_mode: "preview")` rendered the card; user told what was built (3-6 bullets); iterations reflected live
- [ ] **Handoff path:** project folder established; coding agent spawned via `acp_spawn({ task, cwd })`; user told work continues in the folder
---
## 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
More from vellum-ai/vellum-assistant
- acpSpawn external coding agents via the Agent Client Protocol (ACP)
- amazonShop on Amazon and Amazon Fresh through your browser
- api-mappingRecord and analyze API surfaces of web services
- app-controlDrive a specific named macOS app via raw input bypassing the Accessibility tree
- assistant-migrationMigrate from ChatGPT, Claude, OpenClaw, Hermes, Manus, and other AI assistants into Vellum by inspecting their data exports, conversation archives, files, prompts, custom instructions, memory, saved memories, tools, GPTs, workflows, integrations, and relationships, then mapping as much as safely possible into Vellum primitives. Handles single-source and multi-source migrations with a unified, deduplicated inventory.
- chatgpt-importImport conversation history from ChatGPT into Vellum
- cli-discoverDiscover which CLI tools are installed, their versions, and authentication status
- computer-useControl the macOS desktop
- contactsManage contacts, communication channels, access control, and invite links
- conversation-launcherOffer the user several spin-off conversations as clickable buttons on a single persistent card. Each click spawns a fresh seeded conversation in the sidebar; the user keeps their place in the current conversation. Use when you want to branch into N focused threads (research directions, draft choices, pending replies, triage of N items) without losing the current context. Not for single-destination pivots — just reply inline.