Living UI Creator

SkillDev tools

Create Living UI applications (PocketBase backend, React kit frontend). Scaffolds, develops, validates, and launches local web apps with persistent state and realtime UI.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Living UI Creator skill

What this skill tells your AI

The instructions your AI receives, as published by craftos-dev/craftbot in skills/living-ui-creator/SKILL.md and read by ahel’s review.

A Living UI is a self-contained local web app: one PocketBase process (data, auth, realtime, custom verbs) serving a React frontend built from a preset kit. You declare schema, compose UI, wire verbs — the platform owns the rest.

Step 0: Have a registered project (MANDATORY FIRST)

  1. Task instruction contains Project ID + Project Path → the project is already scaffolded. Use those values. Skip scaffolding.
  2. No Project ID in your instruction (user asked in a regular chat) → call living_ui_scaffold(name, description, auth_mode) — it scaffolds AND dispatches the build to the project's dedicated session. Tell the user the build started, then end your turn. Do NOT build in the chat session.

Pick auth_mode from requirements: none (personal local tool — default) or multi-user (accounts; the kit's LoginGate wraps the app automatically).

The ownership rule (the gate enforces this)

Edit ONLY:

PathPurpose
frontend/src/app/all UI code
pb/pb_migrations/schema — one NEW migration per change
pb/pb_hooks/ops.pb.js + new *.pb.js / *.js modulescustom verbs + their helpers
operations.jsondeclarations for those verbs (non-system entries)
LIVING_UI.mdyour plan/context/index — keep current

NEVER edit frontend/src/kit/, frontend/src/main.tsx, frontend/src/config.gen.ts, pb/pb_hooks/_system.pb.js, manifest.json, or build configs — the validation gate hashes them and fails the build if they changed. Need a variant of a kit component? Wrap it in app/:

// frontend/src/app/components/DueBadge.tsx
import { cn } from '../../kit/index.ts';
export function DueBadge({ overdue }: { overdue: boolean }) { /* compose */ }

Before coding

  1. If reference/requirements.md starts with MARKETPLACE DECISION: install <app-id> — do NOT build. Call living_ui_marketplace_install(app_id=..., name=..., description=..., will_adapt=<true if the decision line says adapt: yes>). It installs INTO this project (same tab and id — never a duplicate).
    • adapt: no — the install completes the build and the system announces it; do NOT send your own summary and do NOT call notify_ready or walk_verify. End the run.
    • adapt: yes — after the install, apply ONLY the adaptations listed under ## Adaptations (modify flow: edit → living_ui_notify_readyliving_ui_walk_verify). If the list says "none specified", ask the user what to change (a FINAL send_message) instead of guessing. The user explicitly chose reuse over a fresh build — never rebuild what was just installed, even if a later trigger asks you to "continue" it.
  2. Read agent_file_system/GLOBAL_LIVING_UI.md — colors, fonts, enforced rules.
  3. Read {project_path}/LIVING_UI.md and reference/requirements.md. The creation wizard interviewed the user and synthesized requirements.md — it is the binding spec: implement it exactly and mirror its checklist into LIVING_UI.md. If it is absent, build from the project description; only ask the user (a FINAL send_message, continue_work=false) when something is genuinely blocking and you cannot reasonably decide it yourself.
  4. Any feature need data from outside the app? Check, then research. FIRST check the [INTEGRATIONS this app can use] block already in your context — if a connected integration's action covers the feature (email = send_gmail), use bridge.callAction; nothing to research. Only for THIRD-PARTY public APIs: research like an engineer — endpoint, auth, response shape, limits. Spawn a research_agent; never write an integration hook from memory.
    • User named an API/service → research it. If it needs a key, tenant URL, or account detail you cannot find online, ask the user (final send_message) and build the rest of the app while waiting.
    • No API named → research candidates and pick a keyless public API yourself (e.g. Open-Meteo for weather). Choosing the source is your engineering call — no user round-trip.
    • Nothing usable exists → build the honest empty/offline state and REPORT the blocker in your final message. Mock or generated data is forbidden unless requirements explicitly ask for demo data.
  5. A Living UI build is substantial work — the standard run protocol applies as-is (scope, plan, execute, verify, deliver); this skill adds nothing to it. reference/requirements.md is the binding spec verification checks against; mirror the feature checklist in LIVING_UI.md.

Per feature: schema → verbs → UI

Schema — add a new file in pb/pb_migrations/. Never edit AND never rename or delete a migration that has been applied (i.e. after any successful launch): the filename is the identity in the live database. Renaming one makes every boot re-run its "new" replacement into the existing schema — PocketBase exits before serving anything and the app cannot start until the original filename is restored. Fixing a migration's mistake = writing a NEW migration that alters the collection. The ONLY top-level call is migrate(upFn, downFn) — the down/rollback function is the second argument. A top-level rollback(...) does not exist and panics the whole PocketBase process at load. Follow the starter migration's pattern exactly: field types, autodate created/updated, and rules matching the project's authMode (manifest.json): '' open rules for none; @request.auth.id != "" (or owner-scoped owner = @request.auth.id with a relation to users) for multi-user.

Seeding records in a migration: new Record(...) takes the Collection OBJECT — never an id string. Passing someCollection.id nil-panics PocketBase internally and can WEDGE the process (alive, silent, never serving). The gate kills and reports it, but write it right:

const locations = app.findCollectionByNameOrId('locations'); // the OBJECT
const record = new Record(locations);
record.set('city_name', 'Manchester');
app.save(record);

Relation fields — the #1 migration mistake: collectionId must be the target collection's ID, never its name. Save the target collection first, then reference it:

const words = new Collection({ name: 'words', /* … */ });
app.save(words);
const reviews = new Collection({
  name: 'reviews',
  fields: [
    { name: 'word', type: 'relation', required: true,
      collectionId: app.findCollectionByNameOrId('words').id, cascadeDelete: true },
    /* … */
  ],
});
app.save(reviews);

Custom verbs — anything beyond CRUD is a routerAdd route in pb/pb_hooks/ops.pb.js PLUS a matching entry in operations.json (see the working items.clear-done example). The gate fails ops without routes and warns about routes without ops. Mark data-deleting ops "destructive": true. Plain CRUD needs no verb — the PB API and the kit hooks already cover it.

Request bodies in hooks: e.requestInfo().body ONLY (a pre-parsed object). toString(e.request.body) reads a Go stream as EMPTY — your handler will 400 on every request and the error will falsely blame the client.

Naming: kebab-case everywhere, all three places must agree — the op name in operations.json, the routerAdd path in pb_hooks, and every frontend call: "plan.generate"/api/ops/plan-generatefetch('/api/ops/plan-generate'). Pick the names once, before writing any of the three.

Load-time calls must survive an EMPTY database. A fresh app has no records: never call ops or filtered queries at page load that 400 without data — gate them behind existence checks (e.g. only call plan ops after a profile exists). The launch verifier fails the app on any first-paint console error.

External data (third-party APIs) — Living UIs CAN call the internet, from hooks only (never the frontend: browser CORS breaks and keys would be visible). Use $http.send.

THE #1 HOOK TRAP — handlers run in ISOLATED VMs. Code inside a routerAdd/cronAdd/onRecord* callback cannot see file-level consts or functions: it throws X is not defined at REQUEST time, which the gate (registration-time only) cannot catch. Share logic via a plain .js module and require() it INSIDE each callback — module scope IS visible within the module:

// pb/pb_hooks/weather.js — a MODULE (plain .js, not .pb.js)
const OPEN_METEO = 'https://api.open-meteo.com/v1/forecast'; // literal → recorded as egress

function refreshAll(app) {
  const res = $http.send({
    url: OPEN_METEO + '?latitude=53.48&longitude=-2.24&current=temperature_2m,wind_speed_10m',
    method: 'GET',
    timeout: 20,                                  // ALWAYS set a timeout
  });
  if (res.statusCode !== 200) {
    throw new Error('weather source returned HTTP ' + res.statusCode);
  }
  const data = res.json;   // ONLY correct way to read the body — pre-parsed.
  // res.body is a Go BYTE SLICE: JSON.parse(String(res.body)) throws
  // "SyntaxError: Unexpected token at the end" on every response. If you
  // remember fetch-style res.body/JSON.parse, that is the WRONG API here.
  // …store readings via app.save(...) and return them
}
module.exports = { refreshAll: refreshAll };
// pb/pb_hooks/ops.pb.js — the route + the scheduled job use the SAME code path
routerAdd('POST', '/api/ops/weather-refresh', (e) => {
  const weather = require(`${__hooks}/weather.js`);   // require INSIDE the handler
  try {
    return e.json(200, { updated: weather.refreshAll(e.app).length });
  } catch (err) {
    console.error('weather-refresh failed:', err);    // → logs/pocketbase.log — ALWAYS
    return e.json(502, { error: String(err) });       //   log the CAUSE before the 502;
  }                                                   //   the browser only sees the status
});

cronAdd('weatherSync', '*/15 * * * *', () => {
  const weather = require(`${__hooks}/weather.js`);
  try { weather.refreshAll($app); }
  catch (err) { console.error('weatherSync failed:', err); }
});
  • Current PB API only: app.findRecordsByFilter(...), app.save(...), app.delete(...). $app.dao() does NOT exist in this PocketBase — it throws Object has no member 'dao'. If you remember .dao() from tutorials, your memory is a major version out of date; copy the working items.clear-done example instead.
  • PB find helpers THROW on no rows — they never return null. findFirstRecordByFilter/findRecordById on zero matches throws NotFound, which surfaces as a bare 404 response. if (!rec) after them is dead code. Wrap in try/catch (catch = "not found") or use findRecordsByFilter(collection, filter, sort, LIMIT, OFFSET) and check .length. Corollary when debugging: a 404 from a route you declared means your HANDLER threw, not that the route is missing — check logs/pocketbase.log for the [handler-error] line with the real cause.
  • Keep base URLs as string literals in the module (the tooling records the app's external hosts in the manifest from them).
  • Unreachable source / non-200 → console.error the cause, return a clean error; the UI shows its offline/empty state. NEVER substitute generated or random data for real data — a mock that renders is a lie that passes review. If the source cannot be reached, the app says so and so do you.
  • CraftBot's own connected services (Gmail, Slack, Notion, …) are NOT called this way — see references/INTEGRATIONS.md (the _craftbot_bridge.js helper). Third-party public APIs: direct $http.send as above.

UI — build in frontend/src/app/, importing ONLY from ../kit/index.ts:

  • Read data with useCollection('name', { sort: '-created' }) — it is realtime; never poll, never reload.
  • Write with await getPbClient().call((pb) => pb.collection('name').create(...)) — failures toast automatically.
  • Components: Button, Input, Card/CardHeader/CardBody, Dialog, Table, LoginGate, plus toast for feedback and useAuth() in multi-user apps.
  • Style with Tailwind utilities + kit tokens (var(--lui-*)). Never hardcode colors — theming is host-owned (style packs + dark mode must keep working).
  • Required UX: empty states with an action, loading states, confirmation dialogs for destructive actions, toasts on CRUD, responsive layout.

Update LIVING_UI.md after each feature (entities table, ops list, checklist).

App→agent triggers — when a feature needs the AGENT to react to something happening in the app (a button that asks the agent to act, backend logic that crossed a threshold), declare it in triggers.json and fire it via the kit's fireAgentTrigger (frontend) or _triggers_lib.js's fire() (hooks) — see references/TRIGGERS.md for the manifest format, the trust rules, and the design rules (idempotent instructions, generous cooldowns). Declare a trigger only where agent judgment adds value — plain code handles plain events.

Finish: launch, then verify

  1. living_ui_notify_ready(project_id="<PROJECT_ID>") — runs the gate (types → build → migrations-on-fresh-db → ops → ownership), then starts your code in the DEV environment (a copy on a hidden port with a fresh post-migration DB) and health-checks it. Its message gives you the dev URL and dev dir — test and read logs THERE; keep editing in the real project dir (each notify_ready syncs your edits in). On errors: read ALL of them, fix ALL of them, call it again. Success = app RUNNING (in dev) but NOT yet verified. Never start servers manually.
  2. REALITY CHECK — look at what actually exists, not at what you wrote. Success messages lie by omission; stored state does not. While the app runs:
    • GET /api/_a2app/describe → does every collection show the FIELDS you migrated? A collection showing only id means your migration silently did nothing (wrong key, wrong API — the cause doesn't matter, the emptiness is the proof).
    • Trigger one real data flow (call your refresh/main op), then read a record back (GET /api/collections/<name>/records?perPage=1) and LOOK at the values. Missing fields, empty strings, all-zero numbers = the write silently failed, whatever the op's status code said.
    • Any path you CANNOT trigger for real (scheduled email, posts to the user's accounts): dry-run itcallAction(name, sameParams, { confirmIrreversible: true, dryRun: true }) validates grant, params, placeholders and confirmation without executing. A path that was never run NOR dry-run is not done, whatever the code looks like. Reason about ANY mismatch between what you intended and what is stored — fix it before verifying. This catches the failure classes no error message reports.
  3. living_ui_walk_verify(project_id="<PROJECT_ID>") — an independent sub-agent walks the running app in a real (headless) browser against reference/requirements.md. Success announces the app to the user and completes the build. Failing features come back as a report: fix them, then repeat step 1 and step 3.

Test data is fine during the build: you are working in the DEV environment, whose database is disposable — at delivery the platform boots the LIVE app with a fresh database built purely from your migrations, so records you or the verifier created never reach the user. Data your migrations SEED survives (they run on the fresh live DB) — put anything the user must see on first open in a migration, never insert it by hand. Externally-fetched data does not carry over either: an app that syncs from an API must self-populate on an empty DB (fetch at boot or when the collection is empty — never rely on a sync that happened during the build).

HONESTY RULE: the app is ready ONLY when living_ui_walk_verify returns status: success. If you cannot make it pass, tell the user the build failed and exactly what's blocking. Never claim a broken app is ready, and never present generated data as live data — "live" in your message means the app fetched it from the real source.

Debugging

  • Full platform reference (bridge, jobs, kit API): living-ui/docs/agent-guide.md (repo-level, read on demand).
  • The RUNNING instance is the dev copy — its logs live in the dev dir that living_ui_notify_ready reported, not in the project dir: {dev_dir}/logs/frontend_console.log (console.error/warn + uncaught errors are auto-relayed) and {dev_dir}/logs/pocketbase.log.
  • Data inspection: the PB REST API on the dev port notify_ready returned (GET /api/collections/<name>/records). GET /api/_a2app answers env: "dev" if you need to confirm which instance a port is.

FORBIDDEN

  • Editing system-managed files (see ownership rule) — the gate will fail
  • Editing an already-applied migration — add a new one
  • Custom fetch layers, polling, or page reloads — use the kit's realtime hooks
  • Hardcoded colors or raw <button>/<input> — kit components + tokens only
  • Declaring ops without routes (or routes without ops)
  • Mock/random data standing in for external data (Math.random() weather, hardcoded "sample" rows) — unreachable source means an honest empty state plus a report, not a simulation
  • Printing or copying .superuser credentials
  • Starting pocketbase, vite, or npm run servers by hand
  • Ending the run mid-build — pause ONLY for a user question (final send_message), finish ONLY via living_ui_walk_verify

Signals

GitHub stars
382
Forks
45
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
living-ui-creator
Source
github.com/craftos-dev/craftbot