Living UI Creator
SkillDev toolsCreate 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.
No other account needed.
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)
- Task instruction contains
Project ID+Project Path→ the project is already scaffolded. Use those values. Skip scaffolding. - 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:
| Path | Purpose |
|---|---|
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 modules | custom verbs + their helpers |
operations.json | declarations for those verbs (non-system entries) |
LIVING_UI.md | your 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
- If
reference/requirements.mdstarts withMARKETPLACE DECISION: install <app-id>— do NOT build. Callliving_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_ready→living_ui_walk_verify). If the list says "none specified", ask the user what to change (a FINALsend_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.
- Read
agent_file_system/GLOBAL_LIVING_UI.md— colors, fonts, enforced rules. - Read
{project_path}/LIVING_UI.mdandreference/requirements.md. The creation wizard interviewed the user and synthesizedrequirements.md— it is the binding spec: implement it exactly and mirror its checklist intoLIVING_UI.md. If it is absent, build from the project description; only ask the user (a FINALsend_message,continue_work=false) when something is genuinely blocking and you cannot reasonably decide it yourself. - 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), usebridge.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.
- 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
- 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.mdis the binding spec verification checks against; mirror the feature checklist inLIVING_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-generate ↔ fetch('/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¤t=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 throwsObject has no member 'dao'. If you remember.dao()from tutorials, your memory is a major version out of date; copy the workingitems.clear-doneexample instead. - PB find helpers THROW on no rows — they never return null.
findFirstRecordByFilter/findRecordByIdon 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 usefindRecordsByFilter(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.errorthe 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.jshelper). Third-party public APIs: direct$http.sendas 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, plustoastfor feedback anduseAuth()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
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.- 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 onlyidmeans 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 it —
callAction(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.
living_ui_walk_verify(project_id="<PROJECT_ID>")— an independent sub-agent walks the running app in a real (headless) browser againstreference/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_readyreported, 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/_a2appanswersenv: "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
.superusercredentials - Starting
pocketbase,vite, ornpm runservers 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