App prompt skills
This page is the canonical “what every Turbobase app prompt contains” reference. It exists so a template prompt can be knobs + app-specific intent + a link here, instead of 100 lines of duplicated playbook boilerplate.
If you are writing a prompt from scratch (your own scaffold, a custom variation, an instance-specific app), open this page and the build-apps overview. Together they describe the entire skeleton — what to put in the prompt and what the agent is expected to do once it runs.
The three pages a generated app’s agent reads at run time are:
- This page — the standard prompt skeleton.
- App auth wiring — session + cookie + supabase-js helpers to paste verbatim.
- App design system — visual language to apply verbatim.
The build-apps overview is the deeper reference — auth wire shape, PostgREST reads, Realtime, the write boundary, the catalog API, the smoke-test ritual. The sections below tell the agent which parts of that reference to consult and when.
The prompt skeleton
Section titled “The prompt skeleton”Every Turbobase app prompt has the same shape. The order matters — the agent reads top to bottom.
1. One-paragraph description of what the app does.2. "Do not ask clarifying questions" framing (see Decisions are locked)3. Knobs block (see Knobs)4. Step 0 — Read the shared playbook (see Fetch the playbook)5. Step 1 — Discover the live catalog (see Discover the catalog)6. Goal — the screens / flows7. App-specific composition + write body (see Write path)8. Realtime layer (see Realtime client island)9. Decisions already made — do NOT ask10. Deliverables and stop conditions (see Smoke-test ritual)11. Non-goals (see Standard non-goals)Sections 1–5 and 8–11 are the same across templates and are described here. Sections 6–7 are the app-specific bits and live in the per-template prompt.
The standard knobs every template surfaces. Copy the values from your Turbobase app: the anon key and URLs live under Settings › Integrations & AI access, and the catalog token is minted on the Access tokens page — your account menu, or /settings/tokens. Sign in with your Turbobase account to reach that page: a token belongs to a Turbobase account, so it is not offered to administrators who opened the app from inside NetSuite.
TURBOBASE_API_URL = http://localhost:4100 # Vite dev-proxy target for /v1, /auth (writes, catalog); same-origin in prodSUPABASE_URL = http://localhost:54331 # Vite dev-proxy target for /rest, Realtime WS; same-origin in prodAPP_URL = http://localhost:3000 # the app's own Vite dev serverCATALOG_TOKEN = <mint at /settings/tokens> # build-time catalog discovery (agent runs curl; never shipped in the bundle)TENANT_ANON_KEY = <copy from /admin/settings> # optional dev-fallback apikey for supabase-js (prod reads tb_jwt)APP_DIR = ./turbobase-<app>ACCENT = violet | emerald | blue | roseURL reachability — local vs deployed. In local dev the app runs on its own Vite server at APP_URL and proxies /v1, /rest, /auth, and the Realtime WS to the instance origin (TURBOBASE_API_URL / SUPABASE_URL) — so localhost:3000 behaves same-origin and the app can fetch relative paths. When the app is deployed it’s served under https://<slug>.turbobase.app/a/<app> at the instance origin, so those same paths are already same-origin: the app fetches /v1/... and /rest/v1/... relative, no proxy and no port-forward.
Anon key safety. TENANT_ANON_KEY is the instance’s public Supabase anon key — RLS gates the data, so it’s safe to ship to the browser (Vite exposes it as import.meta.env.VITE_TENANT_ANON_KEY). It’s only an optional dev fallback: in production supabase-js reads the same-origin tb_jwt cookie for the apikey position, so you usually don’t need to ship an anon key at all.
Template-specific knobs. Append per-template knobs below the standard ones. A static SPA has no server, so anything that must stay secret (a raw ANTHROPIC_API_KEY) cannot live in the bundle — route that feature through an instance-side endpoint instead. Browser-safe knobs (e.g. HOT_LICENSE_KEY, the Handsontable license for the customer-grid template) ship via Vite’s import.meta.env.VITE_*. Name the knob, say whether it’s browser-safe or must never leave the instance, and whether it gates a feature or the whole app.
Pick one accent per template: violet, emerald, blue, or rose. The visual language is otherwise identical so the family looks like one product.
Fetch the playbook
Section titled “Fetch the playbook”The three docs URLs every prompt opens with. docs.turbobase.io is public — no token, no header, no sign-in — so WebFetch and curl both read these pages as-is.
# WebFetch each URL, or via curl:curl -s https://docs.turbobase.io/build-apps/design-system/curl -s https://docs.turbobase.io/build-apps/auth/curl -s https://docs.turbobase.io/build-apps/What to do with each page:
- Design system — visual language. Apply verbatim. Do not invent your own color palette, density rules, or component conventions. Light mode only.
- Auth wiring — the supabase-js session helpers. Your app builds NO sign-in page, no callback route, no auth guard. It’s served same-origin with the instance, so it inherits the instance’s session cookies automatically; if the visitor isn’t signed in, the serving route 302s them to
/login(the instance’s own hosted sign-in page, Zitadel) before your SPA ever loads, then bounces them back once they’re signed in. Paste thelib/turbobase/*.tshelpers (read the non-HttpOnlytb_jwtcookie, build the supabase-js client, arm Realtime) verbatim. There is no/loginroute, no callback route, no code exchange, and no credentials in your app — by the time your code runs, the request already carries a valid session. - Build-apps overview — RLS, Realtime, write paths, run-and-smoke ritual. The deeper reference.
If anything in your prompt disagrees with these pages, the docs win.
Discover the catalog
Section titled “Discover the catalog”Before writing any PostgREST select=, fetch the catalog. It is the live schema reference and stays in sync as records are added.
curl -s "${TURBOBASE_API_URL}/v1/catalog/records?token=${CATALOG_TOKEN}" | jq .curl -s "${TURBOBASE_API_URL}/v1/catalog/records/<recordType>?token=${CATALOG_TOKEN}" | jq .Rules:
- Every
select=you write MUST be a subset of the columns the catalog reports for that record. An assumed-but-absent column 400s the entire PostgREST query (PostgREST rejects an unknown column for the wholeselect=), so the page renders nothing. - If
nullable: false, treat the column as required in form validation. - If a record type you depend on isn’t in the catalog yet on this instance, stop and tell the user — the instance is running an older version. Don’t branch on uncertainty.
- On PostgREST 400 “column does not exist”, re-check the catalog.
Row identity — read it off the catalog, don’t assume pg_row_id:
- Entity sub-types (
customer,vendor,contact,employee,partner) key onexternal_id. They are views over the polymorphicentitytable, and they do not expose apg_row_idcolumn — includingpg_row_idin theselect=400s the read. (The underlying table does carry one, but it is not part of the view contract you read and write through, so don’t reach for it.) - Transaction records (
salesOrder,invoice,customerPayment, …) key onpg_row_id(nullableexternal_iduntil the worker syncs).
Reliable rule: if the catalog’s column list contains pg_row_id, that’s the row key; otherwise it keys on external_id. Use the same column as the React row key, the Realtime match key, and the write identifier (see Write path below).
Write path
Section titled “Write path”All record creates + updates go through the Turbobase write boundary — POST {TURBOBASE_API_URL}/v1/data/:recordType for single-row and parent-with-lines alike. The full reference (request shape, transform writes, idempotency) lives at build-apps#write; the sections here cover what every prompt needs to spell out explicitly.
Authentication. Writes authenticate via the turbobase_session HttpOnly cookie — NOT a Bearer token. Because the app is served same-origin with the instance, a plain fetch('/v1/data/<recordType>', { method: 'POST', credentials: 'same-origin', ... }) carries that cookie automatically — no route handler, no proxy, no cross-origin request. Reads stay on PostgREST; only writes go through the boundary.
Body shape.
POST /v1/data/<recordType> # same-origin, relative pathContent-Type: application/json# Cookie: turbobase_session=<...> # rides automatically with credentials: 'same-origin'
{ "idempotencyKey": "<crypto.randomUUID()>", "parent": { ... }, // header columns "lines": [ { ... } ], // omit for single-row records "pgRowId": "<for transaction updates>", "externalId": "<for entity sub-type updates>", "expectedSyncVersion": <last read> // optimistic-concurrency lock; required on updates}Idempotency: always send crypto.randomUUID() as the top-level idempotencyKey. A retry with the same key dedupes and returns the original row — safe to retry.
Response shapes — handle all of these.
| Status | Body | Meaning | UI action |
|---|---|---|---|
200 |
{ ok: true, row: { pg_row_id, external_id } } |
Saved. external_id is null until the worker stamps it; row starts write_status='pending'. |
Toast success; route to the detail view. Optional: subscribe to write_status to flip the toast to “Synced — #<external_id>” when the worker lands. |
422 |
{ error: 'validation_failed', rejection: { code, field, message } } |
A required field is missing/invalid. | Map rejection.field to the form field and show rejection.message inline — exactly like ordinary form validation. |
409 |
{ error: 'conflict' } |
Stale expectedSyncVersion — a teammate or the sync engine changed the row first. |
Revert the cell/form, toast “Row updated by another user”, refetch that one row. |
503 |
{ error: 'hook_unavailable', rejection: { code: 'HOOK_UNAVAILABLE', message } } |
One of the instance’s safety checks could not finish in time. Nothing was wrong with the record and nothing was saved. | Send the same body again, same idempotencyKey; the Retry-After header says how many seconds to wait. Do not show it as a validation error and do not ask the user to change anything — show rejection.message if you show anything. |
403 |
— | Read-only mode or not permitted. | Error toast. |
other 5xx / network |
— | Write failed. | Error toast with a “Retry”. |
If the worker later rejects the write upstream, the row’s write_status becomes needs_input (something on this row the user must correct) or blocked (usually nothing for the user to do). Surface a small “needs attention” affordance on any row whose write_status is not synced.
On needs_input, turbobase.record_write_state.fields names each column when the connected system named one, and for those, its outcome says whether the value you sent was applied, diverged on the way, or never sent and still empty. It is empty on a refusal the connected system raised over the record’s content without naming a column, which is still the user’s to correct — so gate your fix prompt on the status and render message, never on fields having an entry.
Render turbobase.record_write_state.message rather than glossing the status. blocked is mostly “we have this in hand”, but not always: a write refused because the record was changed in the connected system first is blocked — no field on the row fixes it — and the only thing that clears it is the user opening the record again and re-applying their change. That row carries reason_code = 'source_changed_since_read' and a message saying so. A write that never reached the connected system at all is the other one: reason_code = 'source_unreachable', and again only the user’s next save re-sends it. The message is authoritative for every status, and it is written to be shown to the person looking at the record.
pending is not a state a row stays in. Every write ends — synced, needs_input, blocked, or awaiting_approval — so a screen that waits for pending to clear will always get an answer. A write that could not be sent at all ends blocked with reason_code = 'source_unreachable'; one addressed to a kind of record the connected system does not have ends blocked with reason_code = 'source_record_type_unknown', and that one needs work in the connected system before any save can land; one that failed in a way nothing recognised ends blocked with reason_code = 'write_failed_unexpectedly' and the generic held text. None of them re-arms on its own: the user saving the record again is what sends it, and for the middle one only after the missing kind of record exists.
awaiting_approval is a wait, not a refusal. The instance’s guardrails can require a person to approve a write before it is sent — typically a delete, or an update to something the tenant marked destructive. Such a write is never attempted, so there is no refusal and nothing on the row to correct: it sits at awaiting_approval until somebody approves it, at which point it is sent and the row goes on to synced, or until the approval window runs out, at which point it ends blocked with reason_code = 'guardrail_hold_expired' and the user’s next save re-sends it. Show the message and leave the record alone; do not offer a correct-and-save prompt, because fields is empty by construction and there is nothing for the person in front of you to fix. reason_code names the guardrail rule that held it.
write_settle_conflict means “do NOT save this again”. It is the only ending where the write demonstrably LANDED — the connected system created the record and handed back its id — and what failed afterwards is linking that id onto the row. So the row reads as unsynced over a record that already exists, and the user’s instinct to re-save is the one action that creates a duplicate in their account. The message names the id, and no retry is queued. If your screen offers a “try again” affordance on a blocked row, suppress it for this code and show the message instead. Read the id from the row’s record_write_state.external_id rather than parsing it out of the message. Your app cannot link the row by writing external_id — it is a protected column the write boundary refuses to set — so link it with the settle verb: POST {TURBOBASE_API_URL}/v1/data/<recordType>/settle with { "pgRowId": "…" } (or turbobase data settle <recordType> <pgRowId> from a terminal). The id comes from the row’s write state, never from the body; the row ends synced and holding that id, and the failure is resolved. A row that is not in this state answers 409 NOT_SETTLE_CONFLICT.
source_response_lost means “we don’t know — go and look”. It is the ending for a brand-new record the connected system was sent but never answered for: the request was refused in a way that explains nothing, and that system’s own scripts can create a record and then fail afterwards, so it may be sitting there right now. The row ends blocked with reason_code = 'source_response_lost', and nothing is retried. message is safe to render as-is: it is plain language written for the person looking at the record — that the record may already exist in the connected system, where to look for it, and that saving again could create a second copy — never the raw refusal the connected system answered with, and never longer than 500 characters. Treat it like write_settle_conflict above, minus the id: send the person to check the connected system before they save again, because a re-save over a record that did land is a duplicate — and on a financial document that is the expensive kind. There is no settle verb to offer here; unlike write_settle_conflict there is no id to link, which is the whole of the difference. Suppress any “try again” affordance, show the message, and do not offer a correct-and-save prompt: fields is empty by construction and there is nothing on the record for the user to fix.
Sync state in the UI — surface or hide? This is a per-template product decision. Operations-facing apps (order management, customer grid) show the sync state. End-user apps (expenses, where the user only cares about workflow status) hide it entirely. Lock the choice in your “Decisions already made” table; don’t ask the user.
Transforms (estimate → SO, SO → invoice, payment → refund) go through POST {TURBOBASE_API_URL}/v1/sync/events/from-chat, not the write boundary — the source-side lifecycle verb runs there. See build-apps#write for the body.
Never POST directly to a canonical table (/rest/v1/order, /rest/v1/entity, any *_line table). Since the writer-role cutover, direct canonical writes return 42501 at the grant layer.
Never put a PostgREST embed on a must-succeed query. An embed (?select=...,currency:currency_id(symbol)) silently requires a declared FK that may not exist on an instance. When the FK is absent PostgREST does NOT null the embed — it 400s the entire request. Default to scalar selects on required-path reads and resolve decorative joins in a separate, best-effort lookup. See the design-system “Money & currency” section for the canonical statement.
Realtime client island
Section titled “Realtime client island”Live dashboards are a client island — a plain React component that seeds once (in a useEffect), then subscribes. The single most common bug in a generated dashboard is that it seeds the initial read and never opens the channel, so the data only changes on a full page reload.
The pattern:
// browser-side, after browserSupabase() from lib/turbobase/client.tsconst sb = browserSupabase();sb.channel(`<scope>:<table>`) .on( 'postgres_changes', { event: '*', schema: 'turbobase', table: '<table>', filter: '<column>=eq.<value>' }, (payload) => applyDelta(payload), ) .subscribe();applyDelta rules — match every row by its identity column (external_id for entity sub-types, pg_row_id for transactions — read the rule off the catalog, see Discover the catalog):
INSERT→ prepend (trim if the list has a cap). Flashbg-emerald-500/10for ~1.5s.UPDATE→ merge in place. Flashbg-amber-400/10for ~1.5s. If the row is currently being edited by this user (saving: true), QUEUE the delta and apply once the save resolves — otherwise you race-condition over the user’s typing.DELETE→ do not write this handler. It cannot fire for a deleted record. A delete is anupdate … set deleted_at = now(), and a restrictive RLS policy hides the tombstoned row from the Realtime authorisation probe, so the subscriber receives no frame of any kind — the row just stops appearing. See Deletes never arrive. Removals come from a reconciliation sweep: re-run the island’s seed read on an interval (30-60s) and on tab focus, and replace the list with the result — the tombstoned row is absent from it. Realtime carries arrivals and edits; the sweep carries removals.
Then derive every visible surface from the in-memory list — KPI tiles, charts, recent-rows tables, anything that depends on the data. Do NOT issue a separate aggregate query per tile. The reconciliation sweep above is the only repeated read: one re-run of the island’s seed query on a 30-60s timer (and on tab focus), batched, replacing the list wholesale. Everything else redraws from state.
Live indicator. Reflect channel state in a small “Live” pill in the dashboard header: emerald “Live” once SUBSCRIBED, muted “Connecting…” before, amber “Reconnecting…” on CHANNEL_ERROR / TIMED_OUT (supabase-js auto-reconnects). Tear the channel down on unmount.
RLS still applies. A user only receives events for rows they could read via REST. The Authorization: Bearer <jwt> + setAuth(jwt) step from the auth doc arms the WS for RLS — no extra wiring.
See build-apps#subscribe-to-realtime for the full reference.
Smoke-test ritual
Section titled “Smoke-test ritual”Build + lint green is not the stop condition. The bugs that survive both are runtime-only — a guard that throws during a render, an embed that 400s because an FK is missing on this instance, an env var read at boot that’s still a placeholder. The agent must run the dev server itself and probe the live routes before declaring done.
The canonical ritual lives at build-apps#run-the-generated-app. The short version every prompt repeats:
- After
npm run buildandnpm run lintpass, startnpm run devfrom${APP_DIR}in the background / non-blocking. Do NOT run it in the foreground — a blocking dev server hangs the session and burns context. - Report the local URL (
http://localhost:3000for local dev; the URL the deployed dev-server prints otherwise). - Probe the live routes:
- There is no
/loginroute — the app has no sign-in page. Don’t probe for one. - Unauthenticated
/renders the SPA shell, doesn’t crash. In local dev the Vite server servesindex.htmlfor every route (SPA fallback), socurl -si http://localhost:3000/returns 200 with the app HTML — NOT a 500 and NOT a self-built sign-in form. (In production the serving route 302s an unauthenticated visitor to/loginbefore the SPA loads; the app itself never renders a sign-in form.) A 500 here means a component threw during render — fix it, don’t ship it. - With a valid session (in local dev, sign in on the instance origin first so the browser holds the
tb_jwtcookie the Vite proxy forwards), load/→ the dashboard resolves its identity row without a “no record” empty state. That empty state on a valid user usually means a required-path query 400’d — most often an embed coupling the read to a missing FK. De-embed (scalar select + separate best-effort lookup) and re-test.
- There is no
- Verify the dashboard is actually live, not refresh-on-reload. The
Livepill turns emerald (SUBSCRIBED), and a row inserted/updated in a second tab (or via the app’s own write surface) appears with no reload. A dashboard that only updates on full reload is a bug — it almost always means the component seeds the initial read but never opens the Realtime channel. - Treat a 500 or a spurious empty state as a failure to fix, not a finished app. Report what was built and what wasn’t verified.
Restarting the dev server is cheap and expected when the app’s environment changes — Vite reads its config and .env at boot and does NOT hot-reload changes to the dev-proxy target or an env var, so those need a restart.
Decisions are locked
Section titled “Decisions are locked”Every prompt opens with:
Do not ask clarifying questions. Every decision is locked below. If you genuinely cannot proceed, stop and report what’s blocking; do not branch on uncertainty by asking the user to confirm.
This works because:
- Every knob the agent could reasonably want is in the Knobs block, filled from your Turbobase app’s settings pages (see Knobs).
- Every defaulted choice is in a “Decisions already made — do NOT ask” table at the bottom of the prompt. Auth endpoint, anon key source, idempotency, category source, status palette, write path, write-state-in-UI policy — whatever the template uses, name the decision and the source of the value.
- Speculative
if catalog has X / else Ypaths are the #1 source of asking — Claude surfaces them as “OK?” decisions. Don’t write them. State the one canonical path. If a record type or column might not exist on an older instance, the failure mode is “stop and tell the user”, not a fallback path.
Separate product decisions (locked) from integration assumptions (verify live). “Every decision is locked” must NOT discourage the agent from verifying environment-dependent integration details:
- Locked product decisions (state concretely, “do NOT ask”): accent color, status-pill palette, which fields a form shows, page layout, the write path, idempotency, category/tax-code source filters, whether to surface sync state. These don’t depend on the live instance’s schema relationships.
- Integration assumptions (state as intent + a robust default, never as a pasted schema-coupled query): FK existence / whether a relationship can be embedded, exact column names on a view, whether a lookup table is populated. Write “resolve the employee’s ISO 4217 currency code” + the robust default (scalar select + a separate best-effort lookup), not a pasted
currency:currency_id(symbol)embed the agent can’t verify without running the app.
Standard non-goals
Section titled “Standard non-goals”Every template inherits these. Append per-template non-goals after them.
- No backend / no rewriting Turbobase. The instance is the backend.
- No shipping
CATALOG_TOKENin the bundle.CATALOG_TOKENis a build-time discovery step the agent runs (acurlagainst/v1/catalog/records) to learn the schema — it never appears in the shipped static bundle. At runtime the app talks to the boundary same-origin with the session cookie; there is no server-side secret to expose because a static SPA has no server. (A genuinely secret template key — a rawANTHROPIC_API_KEY— likewise can’t live in a static bundle; route that feature through an instance-side endpoint.) - No design choices that contradict the design-system doc.
- No analytics, marketing pages, sign-up flow. This is an authenticated in-instance app, not a landing page.
- No hard-coded record ids. Read from the catalog or query at runtime.
- No direct canonical writes through PostgREST. The writer-role cutover denies them — use the write boundary.
- No update without
expectedSyncVersionon optimistic-concurrency surfaces (grids, inline editors). Silent overwrites are how real data gets clobbered.
Writing a prompt from scratch
Section titled “Writing a prompt from scratch”If you are scaffolding a new template or a one-off custom app (instance-specific, an internal tool), the minimum viable prompt is roughly:
Build a Vite + React + TypeScript (static SPA) + shadcn/ui <app description> on top of Turbobase.
[Standard "do not ask" opener — see Decisions are locked.]
Knobs: TURBOBASE_API_URL = ... SUPABASE_URL = ... APP_URL = http://localhost:3000 CATALOG_TOKEN = ... TENANT_ANON_KEY = ... APP_DIR = ./<app-dir> ACCENT = <one of: violet | emerald | blue | rose> <plus any template-specific knobs>
Step 0 — Read the shared playbook FIRST: https://docs.turbobase.io/build-apps/skills/ — this page https://docs.turbobase.io/build-apps/design-system/ https://docs.turbobase.io/build-apps/auth/ https://docs.turbobase.io/build-apps/
Step 1 — Discover the live catalog for: <list the recordTypes this app uses>.
Goal: <screens, in 3–6 lines>.
Write path: POST {TURBOBASE_API_URL}/v1/data/<recordType> per the Write pathsection of the skills doc. <Brief notes on which fields go in parent / lines.>
[Realtime needs, if the app is "live".]
Decisions already made — do NOT ask: <table of locked product decisions specific to this app>
Deliverables: per the Smoke-test ritual section of the skills doc.
Non-goals: <append per-app non-goals after the standard ones from the skills doc>.The agent fetches this page along with the design-system and auth docs, applies the standard sections it describes, and only needs your app-specific intent on top. The whole prompt fits in roughly 80–120 lines instead of 200–400.
Adding to the standard skeleton
Section titled “Adding to the standard skeleton”When you discover a pattern that’s repeated across templates and isn’t yet here, add it. The rule is the same as for the design-system doc: one canonical place, and every template inherits the change without prompt edits.
Before adding, check whether the pattern already lives in build-apps, auth wiring, or design system. If yes, link to it from here. If no, add the section here and link to it from the per-template prompts.