Build apps on Turbobase
Turbobase is your backend. Once your instance is connected to a source (Oracle NetSuite for most instances, plus Stripe and the other registered connectors), every synced record is available in Postgres with row-level security, a Realtime publication, and a write-back queue. This page is the full reference for building an app on an instance. You build a static React SPA, deploy it into your instance, and it’s served same-origin at https://<slug>.turbobase.app/a/<app>, so it inherits the instance’s auth and talks to every contract below over relative paths.
Prefer a guided start? In your Turbobase app, the template gallery (Development › Proposals, the Templates icon in the header) turns a template into a proposal: you refine the brief conversationally, the AI generates the app source in Studio, and turbobase apps deploy builds and deploys it. Development › App Management (/admin/development/apps) is where deployed apps are managed — create, share, deploy a version, roll back.
What you have to work with
Section titled “What you have to work with”- Postgres reads through PostgREST at
${SUPABASE_URL}/rest/v1/<table>, scoped to theturbobaseschema. The same RLS policies that gate your Turbobase app gate the apps you build. A first paint that fans out many reads can multiplex them into one request viaPOST /v1/batch(the client’sbatch()helper) so the request count never trips the rate limiter — see Batch the cold-load fan-out. - Realtime subscriptions through the Supabase Realtime publication. Every synced table is already in
supabase_realtime. - Auth is inherited from the instance. Your app is served same-origin at
https://<slug>.turbobase.app/a/<app>, so it rides the instance’s session cookies automatically. If the visitor isn’t signed in, the serving route 302s to/login(the instance’s own hosted sign-in page) before your SPA loads — the app has no sign-in logic. The RS256 session JWT carries the user’s role, subsidiary scope, and capability set, and RLS enforces all three on every read — your app never checks them itself. - Writes through the Turbobase write boundary (
POST /v1/data/:recordType), which validates the record synchronously and queues the sync; transform-shape writes (e.g. sales order → item fulfillment) go throughPOST /v1/sync/events/from-chat. - Discovery through
GET /v1/catalog/records— live introspection of which records this instance syncs, what tables back them, and what columns each has. - Typed clients via the Supabase CLI’s TypeScript codegen against the
turbobaseschema — see Typed clients below.
Authenticate
Section titled “Authenticate”Your app never handles a password and ships no sign-in page. It’s served same-origin with the instance at https://<slug>.turbobase.app/a/<app>, so it inherits the instance’s session cookies automatically. If a visitor hits your app without a session, 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. There is no callback route, no code exchange, and no cross-origin request — by the time your code runs, the request already carries a valid session.
Two cookies matter to your app:
turbobase_session— the HttpOnly, Secure, SameSite RS256 session JWT. The serving route and the write boundary read it; your browser code cannot (and doesn’t need to).tb_jwt— a non-HttpOnly mirror of the same JWT, readable fromdocument.cookie. supabase-js reads it for theapikeyposition, theAuthorization: Bearer <tb_jwt>header, andclient.realtime.setAuth(...), so PostgREST reads and Realtime run under the user’s RLS context.
The JWT lasts as long as the instance’s session length — one day unless the administrator changed it, and settable per role up to thirty days — and it slides: once a session is more than half spent, the next authenticated request re-mints it, so a continuously-active user never runs out. You don’t need to refresh manually. The supabase-js wiring (reading tb_jwt, arming Realtime) is in the auth wiring doc.
Local development. On localhost your app runs on its own Vite dev server (APP_URL, e.g. http://localhost:3000) with a server.proxy that forwards /v1, /rest, /auth, and the Realtime WebSocket to the instance origin — so localhost:3000 behaves same-origin. Sign in on the instance origin first so the browser holds the tb_jwt cookie the proxy forwards.
The JWT carries app_role ('admin' | 'standard'), permissions[], ns_subsidiary_id, and similar. You read it indirectly through RLS — there is no client-side JWT inspection step in the happy path.
Read with PostgREST
Section titled “Read with PostgREST”import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: 'turbobase' }});
const { data, error } = await supabase .from('order') .select('external_id, tran_id, total, status, entity_name') .gt('total', 1000) .order('tran_date', { ascending: false }) .limit(50);Three things this snippet relies on:
-
orderis the table name, not the sort call — sales orders live inturbobase."order". Table names are the catalog’stablevalue, not the record type:salesOrderreads fromorder, andcustomer/vendor/employee/contactall read fromentity. Take the name fromGET /v1/catalog/recordsrather than from the record type. -
Accept-Profile: turbobaseis set automatically by thedb: { schema: 'turbobase' }client option. If you hand-rollfetch, you must set it yourself — PostgREST’s default schema ispublic, which is empty on a Turbobase instance. -
RLS gates rows, not the anon key. The anon key only identifies which PostgREST instance to talk to. Permissions come from the session JWT in the cookie. A user without
LIST_VENDORgets zero rows from/rest/v1/vendoreven though the request itself returns200 OKwith an empty body.
Pagination: PostgREST honours Range: 0-49 headers and emits Content-Range: 0-49/1234 on responses. The supabase-js client wraps this via .range(from, to).
Filter syntax: PostgREST’s filter dialect (?status=eq.open&total=gt.1000). The full reference is at https://postgrest.org/en/stable/api.html.
Batch the cold-load fan-out
Section titled “Batch the cold-load fan-out”When the first screen needs several reads at once — a dashboard querying every record type, or a header table plus the lookup tables you join to it in memory — issue them as one request instead of many parallel PostgREST calls. Even paced client-side, the request count of a cold-load fan-out eats your rate-limit budget (1000 requests per 10 seconds per client IP, shared across everything your instance serves — see Keep concurrency low), and once you’re refused users see a “some data is missing” gap.
POST /v1/batch takes { queries: string[] }, where each string is exactly the path-and-query you’d read normally (order?select=external_id,total&limit=100), and returns one result per query in order, each with its own ok / status — a 429 or 5xx on one read never fails the others. It’s a single HTTP request rather than many, so it spends one request of that budget instead of fifty (up to 50 queries per call). The scaffolded client exposes it as batch():
const [orders, invoices] = await batch([ 'order?select=external_id,total&order=tran_date.desc&limit=100', 'invoice?select=external_id,total,status&limit=100',]);if (orders.ok) renderOrders(orders.rows); // rows = the parsed result setif (!invoices.ok) note(`invoices unavailable (${invoices.status})`);It reads, and it is RLS-scoped exactly like a normal read — here is why it can’t do anything else. Three things stop a write going through this door, and all three would have to be undone: every sub-query leaves as a GET; each one is a single resource name, so there’s no way to name a stored procedure (PostgREST exposes those under rpc/, which this endpoint refuses); and the session you send is the same one your user reads with, which holds no write grant of its own — the database would refuse a write even if the first two were removed. To change a record, use the write boundary (Write) — the only path that runs your instance’s hooks, checks the caller’s capabilities and records the change.
Reserve individual reads for on-demand / drill-in loads after first paint. Full reference: Reading the mirror → batch().
Subscribe to Realtime
Section titled “Subscribe to Realtime”supabase .channel('order') .on( 'postgres_changes', { event: '*', schema: 'turbobase', table: 'order' }, (payload) => { // payload.new on INSERT / UPDATE. (payload.old on DELETE — but see // "Deletes never arrive" below: a deleted record does not emit one.) console.log('change', payload.new); } ) .subscribe();You can filter further on a column (for example, filter: 'entity_id=eq.cust-123' to fire only for one customer’s orders). The same RLS policies apply — Realtime checks the session JWT and only fires for rows the user could have read.
Subscribe to base tables, never to a view. Postgres logical replication is driven off the write-ahead log, so it only ever fires for a physical table. A channel opened on a view subscribes without error and then streams nothing, forever — the failure is silent, which is why it usually ships. The entity sub-type read surfaces (customer, vendor, employee, contact) are views over the entity table: read them through PostgREST as usual, but subscribe to entity and narrow with filter: 'entity_type=eq.customer'. Match those rows on external_id (the sub-type views do not expose pg_row_id); match transaction rows on pg_row_id, which is their primary key — external_id is null on a row your app just created and stays null until the write-back worker commits it upstream.
Base-table names are the catalog’s table value, not the record type: salesOrder reads and streams from order. Confirm the name against GET /v1/catalog/records rather than guessing from the record type — a channel opened on a table that does not exist is the same silent no-op as a channel on a view.
Every synced base table is in the supabase_realtime publication out of the box. If you need realtime on a custom table that isn’t published yet, contact support and we’ll add it — you don’t run migrations against your instance yourself.
Deletes never arrive, so reconcile instead
Section titled “Deletes never arrive, so reconcile instead”The supported pattern is a reconciliation sweep. Any list that must drop
deleted rows re-reads its own query on an interval (or on tab focus / user
action) and replaces the list with what came back — the tombstoned row is absent
from that read, because the same restrictive policy hides it. Realtime stays
responsible for INSERT and UPDATE, which is what it actually delivers; the
sweep is what removes rows. Thirty to sixty seconds is usually right for a
dashboard: it is a cold-load-shaped read, so send it through
POST /v1/batch
rather than a fan-out of individual requests.
Three more things about the frames you do get
Section titled “Three more things about the frames you do get”A real DELETE frame carries only the primary key. Replica identity is
DEFAULT on these tables, so Postgres writes only the key columns into the WAL
for a delete, and payload.old has nothing else in it. A channel filtered on the
primary key still matches and still fires. A channel filtered on any other
column — filter: 'entity_id=eq.cust-123' — can never match a delete frame,
because the column it filters on is not in the payload. (This is about genuine
row deletions, which the mirror tables do not do. It bites on tables you have
added yourself, and on entity_canonical, whose payload.old carries id and
not entity_type — so drop the row by id rather than narrowing on the
bucket.)
Line tables churn hard. Every header sync deletes and re-inserts all of
that header’s line rows rather than diffing them — so a channel on order_line
sees a delete/insert storm on every sync of the parent, not a targeted update of
the one line that changed. Subscribe to the header table and re-read the lines,
rather than trying to reconstruct a line set from the stream.
A frame is the WHOLE row, raw included. raw (the untouched source
payload) is 93-99% of a mirror row’s bytes: an invoice row averages ~20 KB and
reaches ~325 KB in the worst case whole, versus ~1.3 KB without raw. (Measured
on the local development database on 2026-08-11, by summing pg_column_size per
column across the mirror tables. Treat the figures as the shape of the problem,
not as your instance’s numbers — run the same sum against your own data when the
exact bytes matter.) Realtime sends every column and the pinned server version
has no way to select a subset, so a wide channel on a busy table is a lot of
traffic to the browser. Narrow the channel with filter, subscribe only to the
tables a screen actually shows, and let the reconciliation sweep above — a normal
PostgREST read, where you can select= just the columns you render — carry the
bulk.
Two paths, depending on what the write does.
Records (create + update) go through the Turbobase write boundary: POST /v1/data/:recordType. The boundary validates the record synchronously against the instance’s hooks (a missing or invalid field comes back as a 422 naming the field), writes it under your RLS context, and marks the row write_status = 'pending'. The write-back worker then pushes it to the source asynchronously and flips write_status to synced — or to needs_input / blocked if the source rejects it. Those two are one question, asked of the person looking at the record: needs_input means a value on this row releases the refusal, so the record_write_state row’s message says what to change and you offer an edit and a re-save; blocked means the fix is somewhere else — another document, a permission, a connection — so you show the message and stop. Read the message either way: fields names the column only when the connected system named one, and is empty on a refusal that is still the reader’s to correct. A third ending is neither: awaiting_approval means the instance’s guardrails require a person to approve the write before it is sent, so it has not been attempted and there is nothing on the row to fix — show the message and wait.
await fetch('/v1/data/customerPayment', { // same-origin, relative path method: 'POST', credentials: 'same-origin', // the turbobase_session cookie rides automatically headers: { 'content-type': 'application/json' }, body: JSON.stringify({ idempotencyKey: crypto.randomUUID(), parent: { entity_id: 'cust-123', payment_amount: '500.00', tran_date: new Date().toISOString().slice(0, 10) } // lines: [...] // header+sublist records (salesOrder, invoice, …) // pgRowId, expectedSyncVersion // updates: address the row + optimistic concurrency })});The body carries six top-level keys and no others: parent (the record’s own fields), lines, pgRowId, externalId, idempotencyKey, expectedSyncVersion. Anything else is refused 422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_BODY_KEY', field, message } }, naming the key it did not recognise, and nothing is written. This catches the one mistake that used to be silent: a body that put the record’s fields under fields (the RESTlet spelling) or data was read as an empty parent, so the boundary created a row of NULLs and answered 200. An empty body is refused the same way (EMPTY_BODY). Custom fields go under parent.raw, not a top-level envelope.
Responses: 200 { ok, row: { pg_row_id, external_id } }; 422 { error: 'validation_failed', rejection: { code, field, message } } (show inline against the field); 409 { error: 'conflict' } (stale expectedSyncVersion — re-read and reapply); 503 { error: 'hook_unavailable', rejection: { code: 'HOOK_UNAVAILABLE', message } } (send the same body again — see below); 403 (read-only mode / not permitted). The boundary authenticates via the same-origin turbobase_session cookie, which rides the request automatically — no Bearer token, no route handler, no proxy. Reads stay on PostgREST; only writes go through the boundary.
503 means try again, not fix something. Every write passes through your tenant’s safety checks first, and occasionally one of them does not finish in time. Nothing was wrong with the record and nothing was saved, so the one correct response is to send the same body again — the Retry-After header says how long to wait, in seconds. Do not show the person a validation error and do not ask them to change anything: there is nothing for them to change, and the message on the rejection is already written for them to read if you want to show it. Treat it exactly as you would any other temporary failure, and keep the retry idempotent by sending the same idempotencyKey. DELETE /v1/data/:recordType answers the same way for the same reason.
Two delete refusals are the opposite of a 503 — do not retry them. DELETE /v1/data/:recordType answers 422 { error: 'validation_failed', rejection: { code, message } } when the connected system was always going to refuse the delete, and it answers before anything is hidden, so the record is still there when you get one. Show the message; the answer will not change until somebody changes something.
DELETE_BLOCKED_BY_DEPENDENTS— other documents still point at the record: a payment or credit memo applied to the invoice, bills against the vendor. The message names each one by type and document number, so render it as the next action — clear those, then delete this one again.SOURCE_BUNDLE_TOO_OLD— the connected account is running a version of the Turbobase integration package older than the one that allows this record type to be deleted. The message names both versions. Nobody using your app can fix this; an administrator updates the package in the connected account. Allow up to an hour after they do before the refusal clears, because the deployed version is re-read on a schedule rather than on each delete.
A 200 from the delete route still does not mean “and it will stay gone.” It never did, and these two refusals narrow that gap rather than closing it: the commonest reasons a connected system refuses a delete are now caught on the request, but a dependency your instance does not hold a copy of is still only discovered when the delete reaches the source — and then the record comes back carrying the reason. Keep whatever handling you have for a record that reappears.
Create one record in several connected systems
Section titled “Create one record in several connected systems”If your customer keeps the same company or person in more than one of the systems they have connected, you can create it in all of them with one write. Name them on parent.source_system, as a list. The first name is the system the record belongs to; the rest get a copy.
await fetch('/v1/data/customer', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ parent: { source_system: ['netsuite', 'quickbooks'], // the record lives in netsuite; quickbooks gets a copy display_name: 'Acme Holdings' } })});// 200 { ok, row: { pg_row_id, external_id, sources: [// { source_system: 'netsuite', external_id: null },// { source_system: 'quickbooks', external_id: null }// ] } }You get back one record, and Turbobase remembers which systems hold a copy — so a later edit through the same boundary is sent to every one of them on its own. You do not repeat the edit per system, and the copies cannot silently drift apart.
Five things to build against:
sourcesnames what was accepted, not what landed. The upstream writes are asynchronous, so everyexternal_idin it isnullwhen you get the response — there is nothing yet to report.row.external_idis the record’s own id in the system it belongs to, and it fills in the same way a single-system create’s does.sourcesappears only when you named more than one system. A create naming one, or none, gets back exactly the two keys it always did. A one-element list means one system, not a degenerate fan-out. If you named several andsourcesis absent anyway, the record was created in the first-named system and no copy was recorded — the copies are the one part of the write that can fall away after the record is committed, so never read a missingsourcesas “they are on their way”.- Send an
idempotencyKeyon a multi-system create, because it is the only thing that can recover the copies. POST the same create again with the same key and the retry records the copies and answers withsources— it converges on the record you already have rather than making a second one. Without a key there is no call that recovers them: a plain re-POST creates a second record, and a list on an update is refused (below), so the record stays single-system until you add the copies yourself in each one. - Every name has to be usable, or the whole write is refused. A system that is not connected is
422 SOURCE_NOT_CONNECTED, one with no write path for that record type is422 SOURCE_NOT_WRITABLE, and one that means a different record by that name is422 SOURCE_AMBIGUOUS— each naming the system you need to fix. A list carrying an entry that is not a system name at all is422 SOURCE_INVALID, rather than that entry being quietly dropped and the record created in fewer systems than you asked for. Nothing is written in any of those cases: a record that exists in some of the systems you asked for, with nothing saying which, would be worse than writing to each system yourself. - Reading what each copy did. The per-system standing lives on
turbobase.txn_source_link, which is now readable by anyone who can read the record itself — sotxn_source_link?master_table=eq.entity&master_row_id=eq.<pg_row_id>over PostgREST tells you each system’swrite_status(ok,pending,blocked,divergent,muted) and, for a refused copy, thelast_errorthe system gave. One copy being refused never costs you the others.
Name Turbobase itself — 'standalone' — when the master is to live here rather than in a vendor’s system: source_system: ['standalone', 'netsuite'] keeps the record yours and pushes a copy out. Turbobase is never itself a write target, so nothing is sent to it.
On an update, source_system still names one system and is verified against the record — a record cannot be moved between systems, or given a copy in another one, by editing it. A list there is 422 SOURCE_LIST_ON_UPDATE.
Transform-shape writes (item fulfillment from a sales order, invoice from an order, refund from a payment, …) go through POST /v1/sync/events/from-chat. These can’t round-trip through PostgREST because the source system rejects them as plain inserts — NetSuite specifically expects N/record.transform(salesOrder → itemFulfillment) to inherit lines and FKs. The endpoint accepts:
await fetch('/v1/sync/events/from-chat', { // same-origin, relative path method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ recordType: 'itemFulfillment', // see allowlist below payload: { created_from_id: 'so-123' }, // string, the source-side id of the parent idempotencyKey: 'my-app-fulfill-so-123' // optional, dedups retries })});The current allowlist is itemFulfillment, invoice, customerPayment, customerDeposit, cashSale, creditMemo, estimate, salesOrder, returnAuthorization and vendorReturnAuthorization — the last two raise a return against the document that caused it. Anything outside that set returns 403 — admins use POST /v1/sync/events to dispatch arbitrary records.
This endpoint is gated exactly like the write boundary: the caller needs the record’s write:<table> capability (403 otherwise), and every key in payload must be a real column of the record’s canonical table — the only exception being created_from_id itself. An unrecognised key returns 422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_COLUMN', field, message } } and nothing is queued. Custom fields go under raw, as they do on POST /v1/data/:recordType; there is no fields / sublists envelope and no line array on this route.
It carries one gate the write boundary has no equivalent of: the parent named in created_from_id must be a record the signed-in user can read. A transform builds the new record out of an existing one, so write:order_fulfillment alone is not enough — the same RLS that decides which sales orders that user sees on a grid decides which ones they may fulfil. A parent they cannot read returns 403 { error: 'forbidden' } and queues nothing, and that answer is byte-identical to the one for a created_from_id that matches no record at all: the endpoint will not tell an app whether a record it cannot see exists. Show it as “that record isn’t available to you”, not “not found”.
Idempotency. Both write paths accept an idempotencyKey. Retries that carry the same key dedupe and return the original row / sync_event.id, so you can retry without fear of double-writing. On POST /v1/data/:recordType the retry is answered with the row the same caller committed under that key — { ok: true, replayed: true } carrying its pg_row_id and external_id — whether or not that caller can read the row back. So a role holding write:<table> without the matching read capability retries safely, and so does one whose row has since moved outside the dimensions it may browse. You choose the key, so a key another user committed is not an answer to your retry: that one keeps the original failure and tells you nothing about the row holding it.
Discover the catalog
Section titled “Discover the catalog”Two access modes:
1. Session cookie (humans + first-party apps). Reads are RLS-scoped — a caller without LIST_VENDOR does not see vendor_bill in the response.
curl --cookie 'turbobase_session=...' \ https://<slug>.turbobase.app/v1/catalog/records2. Access token (Claude Code, codegen tooling). Mint a catalog token on the Access tokens page — your account menu, or /settings/tokens — choosing the AI / catalog type. Sign in with your Turbobase account to reach it: a token belongs to a Turbobase account, so the page is not offered to administrators who opened the app from inside NetSuite. The value is revealed once at mint and can be revoked there (it’s hashed at rest, like a password). A personal access token (PAT) with the catalog:read scope works too, but header-only — a PAT presented on the query string is rejected so it can’t leak into URL logs. Either token gets back the full catalog without permission filtering. Schema only — actual row data still flows through PostgREST RLS.
# query param (browser-friendly; catalog-kind token only)curl 'https://<slug>.turbobase.app/v1/catalog/records?token=<value>'
# bearer header (curl / Claude / CI)curl -H 'Authorization: Bearer <value>' \ https://<slug>.turbobase.app/v1/catalog/recordsEither mode returns:
{ "records": [ { "recordType": "salesOrder", "source": "netsuite", "table": "order", "listOnly": false, "permkey": "TRAN_SALESORD", "readCapability": "read:order", "writeCapability": "write:order", "columns": [ { "name": "external_id", "pgType": "text", "nullable": false, "role": "identifier" }, { "name": "tran_id", "pgType": "text", "nullable": true, "role": "display" }, { "name": "entity_id", "pgType": "text", "nullable": true, "role": "identifier", "idFor": "entity_name" }, { "name": "entity_name", "pgType": "text", "nullable": true, "role": "display", "displayFor": "entity_id" }, ... ], "anatomyPath": "sources/netsuite/skills/anatomy/sales-order.md", "recipes": [] }, ... ]}Columns carry a semantic layer beyond the bare Postgres facts, wherever your instance knows more:
role— what the column is for:identifier,display,measure,timestamporstatus. Lead tables withdisplaycolumns; useidentifiercolumns for filters, joins and links.displayFor/idFor— the canonical<x>_id↔<x>_namepairing.entity_nameis the human-readable twin ofentity_id; render the name, filter on the id.label/format— authored display names (“Vendor”, “Bill #”) and render formats (currency.native,date.relative) where your instance has a display contract for the record.currencyKey— on a money column formattedcurrency.native, the sibling column holding the ISO 4217 code (normallycurrency_code). See below.description— the plain-English meaning of a custom field, when your instance has one (admin token required).
Admin-added custom records also carry a record-level description — the
plain-English account of what the record is for.
Check writeCapability before you offer an edit
Section titled “Check writeCapability before you offer an edit”readCapability and writeCapability name the capability each direction asks
for on your instance. The read one is static; the write one is resolved live
per request and is null whenever a Postgres-side edit of that record would be
refused — either the record is inbound-only (nothing pushes it back upstream) or
write-back is switched off for the whole source.
Render your editing affordances off it rather than caching it: an instance
granted write-back starts reporting a capability string on the very next call,
with no re-deploy and no re-generated types. Older instances omit the field
entirely — treat undefined as “unknown”, not as read-only.
Money is in the document’s own currency
Section titled “Money is in the document’s own currency”total, subtotal, tax_total, amount_paid, amount_remaining and balance
are the record’s own face value. The mirror copies the figure across exactly
as the source system wrote it and never converts it to a reporting or base
currency — so on a business that trades in more than one currency, two rows of
the same table hold numbers in different currencies.
Which one is in currency_code, an ISO 4217 code (USD, GBP, EUR) sitting
on the row beside the money, so you read it without a join. Format a money column
as currency.native and point currencyKey at it:
{ "key": "total", "label": "Total", "format": "currency.native", "currencyKey": "currency_code" }currency.native renders the amount in whatever currency the row records. When
currency_code is NULL the currency is genuinely unknown for that row and the
cell shows a bare number — do not fall back to a symbol, because $1,234.50 on a
sterling order is a wrong answer that reads like a confident one. currency.usd
and currency.eur still exist and are unchanged, but they hard-code a symbol and
should not be used on a mirrored column.
Two traps worth knowing:
currency_idis not a currency code. It is the source’s own reference: a NetSuite internal id ('2') on a NetSuite row, but the ISO code on a QuickBooks, Shopify or Dynamics one.currency_nameis the source’s free-text name for the currency record, which an administrator can rename. Readcurrency_code.- Do not sum money across mixed currencies without converting each figure
first. Group by
currency_codeand label each figure with its currency. A single total across mixed currencies is only meaningful once every amount has been converted at the rate in force on its own document date — see below.
The exchange rate on a given date
Section titled “The exchange rate on a given date”turbobase.currency.exchange_rate is the current rate on the currency
lookup row. Do not convert a dated document with it: the answer changes after
every sync, so a report built on it quietly rewrites its own history.
Read the document’s own exchange_rate column first. Every transaction
table carries one — see the list at the end of this section — holding the rate
the source stamped on that document when it was posted. It is the rate the
document was actually written at, so it beats any table lookup, and it never
moves under the row afterwards. Fall through to the dated table below only when
it is NULL, which means the rate is genuinely unknown.
Dated rates are mirrored in currency_rate, one row per currency pair per
effective_date. Read the rate as of the document’s own date — the latest
one on or before it:
/rest/v1/currency_rate?base_currency_code=eq.USD"e_currency_code=eq.GBP &effective_date=lte.2026-03-09&order=effective_date.desc&limit=1Three things about that query are not optional:
effective_date=lte.<date>. Ordering and taking the first row without it answers with the newest rate held, for a document that may predate every one of them — a confident wrong answer.order=effective_date.desc&limit=1takes the rate that was in force, not the nearest one. A document dated the 9th uses the rate effective the 1st, even if there is another effective the 10th.- An empty result means no rate is held for that date. It does not mean the rate is 1. Render the amount in its own currency rather than converting it.
If your tenant holds more than one connection to the same source, add
&connection_id=eq.<your connection>. Each connection mirrors its own rate
table, so two of them can hold a row for the same pair on the same
effective_date — and limit=1 with no tie-break then returns one of them
arbitrarily. On a single-connection tenant (connection_id is default and
there is nothing else to pick) the query above is complete as written.
Which way it multiplies: an amount in quote_currency_code ×
exchange_rate = the amount in base_currency_code. One unit of the quote
currency is worth exchange_rate units of the base currency. The two sides are
named after NetSuite’s own fields — its base currency is the subsidiary’s and
its transaction currency is the foreign one — which is the reverse of the
FX-market sense of “base”, so read the direction rather than inferring it from
the names.
Either code is NULL when the source’s currency ref could not be resolved. Those rows never match a pair filter, so an as-of read for that pair returns nothing rather than a wrong factor.
Which records carry their own exchange_rate. Every transaction table
does: order, invoice, cash_sale, credit_memo, estimate, vendor_bill,
purchase_order and journal_entry, alongside expense_report,
vendor_credit, vendor_prepayment and credit_card_charge (which holds the
card refunds too, as record_kind = 'refund'). That is the rate the source
applied to that document,
and it beats a table lookup whenever it is present — it can legitimately differ
from the dated rate. Six of those tables also carry the portable ISO
currency_code beside it — order, invoice, credit_memo, estimate,
purchase_order and vendor_bill. The other six do not: on cash_sale,
journal_entry, expense_report, vendor_credit, vendor_prepayment and
credit_card_charge the currency is currency_id / currency_name (the
source’s own reference, not an ISO code) for rows from an accounting system, so
resolve the ISO key through turbobase.currency if you need one there; a card
feed’s swipe on credit_card_charge carries currency_code directly.
A table can be fed by more than one system, and not every one of them fills
every column: currency_id and currency_name are the source’s own reference,
written for rows mirrored from NetSuite, and a row from another source can leave
them NULL. Treat a NULL as
“this row does not say”, the same way you treat a NULL currency_code —
render the bare number rather than assuming a currency.
Per-record detail (GET /v1/catalog/records/:type) inlines the anatomy markdown and recipe JSONs so a single fetch is enough to render docs in a client. Same dual-auth modes apply. From a Claude Code session, the record_anatomy MCP tool wraps this endpoint — read it before building UI over a record.
Ground an agent in the instance
Section titled “Ground an agent in the instance”Everything the instance knows about itself is reachable in the same token realm:
GET /v1/kb/search?q=…andGET /v1/kb/document/:slug— the published knowledge base: how this business runs its processes. From Claude Code, thesearch_documentation/read_documentMCP tools wrap these. For a custom record the KB is often the only spec that exists — search it before building over one.GET /v1/catalog/docs/search?q=…andGET /v1/catalog/docs/document/:slug— Turbobase’s own documentation, the pages your instance’s release shipped with. Platform questions (“what does a hook receive”) go here rather than to the knowledge base above, which is what this business wrote about itself. MCP tools:search_turbobase_docs/read_turbobase_doc. Only thecatalog:readscope your token already carries, and every answer names the release it came from.GET /v1/catalog/objects?q=…andGET /v1/catalog/objects/:kind/:externalId— the customization catalog: scripts, custom records, fields, workflows, each with its captured definition and a generated plain-English explanation (admin token required). MCP tools:search_catalog/explain_object.GET /v1/catalog/grounding?records=a,b&query=…— the assembled grounding brief (source primer, per-record anatomy, native-vs-custom, feature enablement, KB retrieval) as one markdown block; admin token required. The CLI writes it to a file for agent sessions:
turbobase project grounding --records purchaseOrder,itemReceipt \ --query "inbound receiving" -o INSTANCE.mdRule of thumb for generated UI: render display columns, filter on identifiers. The catalog marks each column’s role, its <x>_id ↔ <x>_name twin, and the label humans expect — an app that shows entity_id where entity_name exists is using the catalog wrong.
GET /v1/catalog/openapi.json is a passthrough of PostgREST’s root OpenAPI document, scoped to the turbobase schema. Point openapi-typescript / openapi-fetch / similar codegen at it. Session-only — the token mode doesn’t apply because the OpenAPI proxy needs a JWT to scope the spec.
Typed clients
Section titled “Typed clients”Generate a Database type for your client app with the Supabase CLI:
supabase gen types typescript --project-id <ref> --schema turbobase > src/lib/turbobase.d.tsThen point supabase-js at it:
import type { Database } from './lib/turbobase';import { createClient } from '@supabase/supabase-js';
const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: 'turbobase' }});
const { data } = await supabase.from('order').select('*'); // typed rowInstalling the Supabase CLI is your app’s responsibility — there’s nothing Turbobase-specific to wrap. Re-run the command after a schema change to pick up new columns.
One instance per customer
Section titled “One instance per customer”One instance = one customer’s data. The same Turbobase instance hosts at most one customer’s data. A product that wants per-customer isolation does it at the edge — route customer-a.yourapp.com to the customer-a instance and customer-b.yourapp.com to a different one. Don’t put a customer id in your queries; there isn’t one.
A single instance can connect multiple sources (NetSuite + Stripe + QuickBooks side by side, each under its own source_system value), and more than one connection to the same source — two NetSuite accounts, two QuickBooks realms, two Salesforce orgs. Each connection is one row with its own connection_id, and a single-connection instance carries default.
What that means for your queries: connection_id is on the mirror tables and is part of the natural key, so the same customer internal id in two NetSuite accounts is two rows rather than one merged one. On a single-connection instance you can ignore it. On a multi-connection one, filter on it anywhere two connections could hold a row you would otherwise read as one — the dated exchange-rate read above is the worked example. The one place it is deliberately absent is entity_canonical: consolidated identity is matched across connections on purpose, so the same real company seen in both accounts resolves to one canonical row.
Deploy your app
Section titled “Deploy your app”Your app is a static bundle — no Node server, no SSR. Build it, then hand the dist/ to your instance:
npm run build # Vite → static dist/ with index.htmlDeploy the dist/ into your instance one of two ways (create the app first — New app under Development › App Management — if it doesn’t exist yet):
- In-product — open Development › App Management (
/admin/development/apps) in your Turbobase app, choose Deploy a version, and drag-drop a zip ofdist/. - CLI —
turbobase apps deploy <app-slug> ./dist.
Either way the app is served at https://<slug>.turbobase.app/a/<app-slug> — same origin as the instance. The serving route applies the SPA fallback (every client route resolves to index.html) and the 302-to-/login guard for unauthenticated visitors. Turbobase hosts it for you; there’s no separate host to configure and nothing to self-host.
Preview before you go live
Section titled “Preview before you go live”--preview builds and uploads exactly as a deploy does, but serves the result at its own URL and leaves /a/<app-slug> untouched:
turbobase apps deploy <app-slug> . --preview --label "new pick screen"# preview <app-slug> v7 → https://<slug>.turbobase.app/_p/pv_… (expires in 7 days; /a/<app-slug> unchanged).Send that URL to whoever asked for the change — it opens for anyone who can already open the app, so no new access is granted. Then turbobase apps promote <app-slug> <preview-id> makes it live (the reviewed bytes ship as-is; nothing is rebuilt), or turbobase apps preview discard closes it. turbobase apps preview list <app-slug> shows what is open, and --json makes that scriptable.
--replace-label "<text>" re-deploys onto the open preview carrying that label, opening one if there is none — so a pull request keeps a single preview URL for its whole life instead of minting a new one per commit.
Deploy from GitHub
Section titled “Deploy from GitHub”turbobase apps init scaffolds .github/workflows/turbobase-app-deploy.yml. Set the repo variables TURBOBASE_BASE_URL and TURBOBASE_APP_SLUG plus the secret TURBOBASE_PAT (scope extensions:deploy), and:
- a pull request gets a preview URL, posted as a single comment that is edited on every push, plus a plan of what merging would change in your hooks/conduits/sync config;
- a merge to your default branch applies that config and then puts the app live;
- closing the pull request discards its preview.
Until TURBOBASE_BASE_URL is set every job skips rather than fails, so the workflow is not red on a brand-new repo.
Preflight what a deploy would do
Section titled “Preflight what a deploy would do”turbobase apps plan . --app <slug>Writes nothing, and exits non-zero on what otherwise surfaces as a failed deploy after review: a table field another app already declares at a different type, a source write a guardrail refuses (see below), and a declared role that can’t be created or whose scopes conflict (see Roles). apps deploy re-runs every check and enforces it — the preflight only moves the failure earlier.
--app names the app the way the deploy does, and the role check needs it: it is how the plan tells your app’s earlier role declarations from another app’s. Without it the plan lists nothing a deploy would retire, and leaves out — with a notice — every scope an app has already declared on a role, which the deploy then decides and may refuse. The scaffolded workflow passes --app "${{ vars.TURBOBASE_APP_SLUG }}"; a workflow scaffolded before that runs the plan without it, so add it.
For an app generated through a proposal, turbobase apps deploy covers the whole tail: it pulls the generated source (from Studio → Generate app source) when the directory is empty, builds it locally, and uploads the dist/ through the same deploy path. Every deploy, rollback, enable, and disable lands as an entry under Development › Releases.
Keeping the source on the instance
Section titled “Keeping the source on the instance”Your instance can hold an app’s source, not just its built output. That’s what makes an app refinable later — from another machine, by a colleague, or by a coding agent.
turbobase apps pull <app> ./<app> # write the current source to disk# ... edit ...turbobase apps push <app> ./<app> # record your edits as a new revisionturbobase apps deploy <app> ./<app> # build + go liveA deploy from a source directory records source too. Run turbobase apps deploy <app> ./<app> against a directory holding a package.json and it cuts the version and then records that directory as a new revision, through the same collector push uses. So the two-command loop above is a choice rather than a requirement: push is for recording WITHOUT going live, and --no-source on a deploy is for going live without recording — a CI preview loop, say, or a build machine whose tree is not the source of record.
The version is cut first and the source recorded after it, so a collection that fails costs you the revision and never the deploy. The deploy prints recorded N source files on the instance (rev R), or a line beginning source not recorded: naming what to do about it.
An app with no source on the instance cannot be changed from a user’s change request. Whoever is using your app can file one from the floating Suggest a change button, and working it means editing the app’s source — which a built bundle cannot be turned back into. An app that was only ever deployed as a prebuilt bundle, or always with --no-source, therefore has no automatic path at all: Development › App Management says so on that app’s row and names the one command that fixes it, and the widget tells the person who filed the request that someone will make the change by hand. One turbobase apps push <app> ./<app> is the whole fix.
One consequence worth knowing: the knowledge ingest reads the newest recorded revision. A --no-source deploy uploads your built bundle and no markdown, so if you edited INSTANCE-LEARNINGS.md or TURBOBASE-FEEDBACK.md, let the deploy record them or push first — otherwise the ingest runs against your previous revision and finds nothing. See Connect Claude Code.
Every recorded revision is appended — nothing is overwritten, and apps pull always serves the newest. Either path sends text files only: node_modules/, dist/, build/, .git/ and .claude/ are skipped, as is any file that isn’t text, so binary assets belong in your build pipeline rather than the source tree. Credential files are withheld by name and listed on stderr: .env, .env.* and .mcp.json, which holds a live access token. .env.example is a template with no secrets in it and travels as source.
turbobase apps list shows where each app stands — whether it’s live, and which source revision the instance holds. A dash in the SOURCE column means the instance has the built app but not its source: it was uploaded as a prebuilt bundle, or every deploy used --no-source, so there is nothing to pull and nothing a change request can be applied to.
An app you author yourself never has to involve a proposal at all — turbobase apps create, then push and deploy. See Connect Claude Code for driving that loop from a coding agent on your own machine.
Declare the source writes your app needs
Section titled “Declare the source writes your app needs”If your app writes back to a source system, say so in public/turbobase.json:
{ "writes": [ { "source": "netsuite", "recordType": "salesOrder", "ops": ["write"] } ]}ops is write and/or delete, defaulting to ["write"]. They are checked separately, because permission to write a record never implies permission to delete one.
This is a request, not a grant. The deploy evaluates each declaration against your instance’s write guardrails and refuses, naming the rule, if one denies it:
turbobase.json: this app declares 1 write a guardrail refuses: netsuite salesOrder.write — blocked by guardrail "ns-task-only" (rule).An admin must allow these writes (Compliance → Guardrails, or `turbobase guardrails create`),or the app must stop declaring them. An app cannot grant itself a write.
Turbobase has staged 1 matching guardrail rule as PROPOSED — not enforced. An admin canreview the evidence and activate it on Compliance → Guardrails, or dismiss it.An app cannot add a guardrail — not from its manifest and not from its repo. Granting a write is an admin action, deliberately: a project that could author its own guardrail could author itself permission to write anything.
What the refused deploy leaves behind. So your admin isn’t left translating a rejection message into a rule by hand, the refusal stages the matching guardrail for them: one rule per denied declaration, scoped to that source and that op (salesOrder.write, never *.write), landing on Compliance → Guardrails under Proposed. Staged is not granted — a proposed rule is inert, the deploy still fails, and the write stays blocked until an admin clicks Activate. Each proposal shows the evidence behind it: which app asked, which declared write, which rule refused it, and the deploy attempt it came from. An admin who doesn’t want it clicks Dismiss, and the same request is never proposed again — so re-deploying to nag them does nothing. Re-deploying while it is still pending is safe: proposals converge on one row per app + write rather than piling up.
Declaring is worth doing. Without it, a blocked write is discovered one row at a time in production, by an end user, and reported in a sync_failure row the app author never reads.
App manifest — name, description, icon
Section titled “App manifest — name, description, icon”Declare how your app presents itself in a turbobase.json at the root of your build output. With Vite, put it in public/turbobase.json and it’s copied to the root for you. Every field is optional:
{ "name": "Warehouse Management", "description": "Pick, pack, and ship against live NetSuite inventory.", "icon": "favicon.svg"}name— the display name shown in the launcher. It’s separate from your slug, which is fixed at create time and is the URL (/a/wms); the manifest changes the label, never the address. Max 60 characters.description— one or two lines under the name in the launcher card. Max 200 characters.icon— a path, relative to your build root, to the icon file (e.g.favicon.svg). It wins over the sniffed favicon below.
The manifest is read at deploy time. name and description are applied to your app only when you declare them, so a deploy never wipes a value an admin set by hand. A malformed manifest (bad JSON, an over-length field, an icon that isn’t in the bundle) fails the deploy with a message telling you what to fix — it’s never silently ignored.
turbobase apps init and AI-generated apps ship a starter turbobase.json and a matching public/favicon.svg for you, so a fresh app already has a name and an icon.
Icon, without a manifest
Section titled “Icon, without a manifest”You don’t need a manifest just for an icon. Ship a favicon and the launcher uses it: at deploy time Turbobase reads your entry document’s <link rel="icon">, falling back to a conventional favicon.svg / favicon.png / favicon.ico at the root of your build output.
The icon is recorded per version, so rolling back restores that version’s icon with it. It’s served from your own bundle through the same access control as the app, so someone who can’t open your app can’t fetch its icon either.
No icon is fine — the launcher draws a lettered tile from your app’s name, in a colour derived from its slug, so it stays the same colour every time and adding apps never re-colours the others.
Turning off “Suggest a change”
Section titled “Turning off “Suggest a change””Every app Turbobase serves carries a floating Suggest a change button, so whoever is using it can file a change request with a screenshot of the screen they were on. Some apps should not have it — a kiosk display, a shop-floor terminal, an app whose users are not the people who commission changes to it. Say so in the manifest:
{ "suggestChanges": false}- Omit the key and the button stays. Every app that says nothing behaves exactly as it does today; you only ever write this key to turn the button off.
trueorfalse, nothing else."false"in quotes,0, or anything that isn’t a boolean fails the deploy naming the key, rather than quietly picking a side.- It also closes the endpoint. A page someone left open before you deployed keeps its button until they reload, but the request it sends is refused — so turning it off means your users stop filing, not that they stop until the next refresh.
Like the icon, the answer is recorded per version: a preview shows its own bundle’s answer while your live app is untouched, and rolling back restores the previous version’s answer with no redeploy and no manifest edit.
It does not change who may file a change request — that is the proposal:create capability, which an admin grants across every app at once. This key is about one app.
Roles: you declare, an admin assigns
Section titled “Roles: you declare, an admin assigns”An app usually needs more than one kind of user — a warehouse clerk who ships orders, an auditor who only reads. Declare those roles in the same manifest:
{ "roles": [ { "name": "Warehouse clerk", "description": "Picks and ships orders", "capabilities": ["read:order", "write:order_fulfillment", "read:cr_pick_list"] } ]}Deploying creates each one as an ordinary role — the same thing an admin manages on Compliance › Users › Roles (/admin/roles), with nothing marking it as app-made.
A declared role has no holders until an admin assigns it. Deploying grants nobody anything, including you. This is the same asymmetry as declared writes, one step further: an app may say what it needs and may not say who gets it. Tell your users the role exists and their admin assigns it on Compliance › Users (/admin/users).
One thing you cannot declare: appRole. A manifest role is always a standard role — admin bypasses capability checks entirely, so it is assigned, never authored. Declaring it is a deploy error rather than an ignored key.
Capabilities only ever add. A declared role that already exists gains the capabilities it is missing and loses none, so removing a capability from your manifest does not revoke it from anyone who holds that role — ask an admin to narrow it. An admin’s narrowing is permanent: a capability an admin removes from the role in Users › Roles is never added back by a deploy — not while your manifest keeps declaring it, and not after your app drops the role or is deleted and reinstalled — and neither is a <verb>:* wildcard that would grant it again. The deploy reports it as kept removed until an admin grants it back. A name that collides with a built-in (Admin, Data admin, Standard) fails the deploy: built-ins are immutable, and rename is the fix.
A preview deploy reports what it would do without creating anything; promoting the preview applies it. turbobase apps plan . --app <slug> shows the same verdicts and every warning, writes nothing, and exits non-zero if a role can’t be created or its scopes conflict — so a pull request catches it. Without --app it can’t tell your app’s earlier declarations from another app’s, so it shows no retirements and leaves any scope an app has already declared on a role undecided, with a notice — it never reports a conflict the deploy wouldn’t. With it, it also lists what "roles": [] retires.
Scopes: restricting a role’s rows, and granting it a Slack channel
Section titled “Scopes: restricting a role’s rows, and granting it a Slack channel”A declared role may carry scopes — the same field an admin edits on Users › Roles. It holds two kinds of dimension with opposite defaults:
{ "roles": [ { "name": "Receiver", "capabilities": ["read:item_receipt", "read:memory"], "scopes": { "location": ["{{user.location}}"], "content": ["slack:C0PUBLIC"] } } ]}- Restrictions take rows away.
subsidiary,location,department,class— or any lowercase name of letters, digits and_— narrows every table that carries that dimension to the listed values. Absent or empty means unrestricted. A whole-value{{user.<field>}}token narrows each holder to their own value. A dimension no table on your instance carries restricts nothing, and the plan warns you so you don’t ship a role you believe is narrower than it is. contentgrants Slack history. It lists the exact channels the role may read, asslack:<channel id>. Absent or empty means nothing. At most 16 values; no wildcards and no{{user.…}}tokens. Slack is the only source whose content can be granted today, and direct messages can never be.
content works only with read:memory. Someone who isn’t an admin reads a channel’s history — its memory_document rows, whose source_system is slack and whose scope_ref is the channel id — only when the role they are acting as holds read:memory and lists that channel in content. Either half alone reads nothing, and the deploy warns about a role with one and not the other. A granted channel is readable whether or not the person is a member of it — or has a Slack account at all — private channels included. Pilot is not affected. A person who holds several roles reads the channel only while acting as the role that grants it. A channel the role isn’t granted comes back as an empty list, like any other denied read — gate the screen on hasCapability('read:memory') and say which role to ask for.
content with write:activity:message also lets the role post into the channel. A POST /v1/data/channel_message with raw.channel set to a granted channel is accepted whether or not the person is a member of the channel or a Slack user — private channels included — and goes out as your workspace’s Slack app, like every post. Send the channel id: a grant names an id, so a channel name matches nothing and is refused. write:activity:message posts only into the channels the role is granted — not into any other channel, public ones included — and does nothing else: no other kind of activity row, no edits, no deletes. Don’t reach for write:activity instead: that is every write on the shared activity table (other connectors’ tasks and posts, edits of any existing row, a post into every public channel), it does not post through a grant, and a role holding it posts only where its user can see the channel, as before. The post’s author_user_id is set to the person who posted it, whatever your payload says. The grant covers creating the message and nothing else: in a private channel they are not in, the person can’t edit or delete a message, and can’t read the channel’s channel_message rows — not even their own post (read the channel’s history through read:memory, above, and keep your own copy of what the app sent if you show it). A retried create whose idempotencyKey already landed is still replayed for them — the answer is the message they committed, and nothing is posted twice — even though they cannot read the channel’s messages otherwise. Direct messages can never be granted, so posting into one still needs membership.
Keep a customer’s channel id out of an app other accounts install. A shared app ships its roles with read:memory and no content, and each account adds its own channel to roles[].scopes.content in its own deploy. Only an app built for exactly one account names that account’s channels directly.
What a redeploy does to scopes. Unlike capabilities, scopes your app set can move — and only those:
- Leaving
scopesout changes nothing, and neither does leaving one dimension out. An older manifest can’t retract anything. content: a redeploy adds the channels new since your last deploy and removes the ones you stopped declaring — only channels your app added, and not while another app on the same role still declares them."content": []removes every channel your app added. A channel an admin added — one your app didn’t put there — is never touched, even if your manifest later declares the same channel: the deploy adopts a channel the role already has, and adopting isn’t owning. A channel an admin removed is never re-added by a deploy — not while your app keeps declaring it, and not after"content": [], dropping the role, or deleting and reinstalling the app; the deploy reports it as kept removed until an admin puts it back in Users › Roles. Removing the role fromroles, or deleting the app, also removes the channels your app added to it.- Restrictions: a restriction is your app’s only when your deploy wrote it — on a role it created, or on one that was unrestricted on that dimension (which narrows what its current holders see). Your app can narrow, widen or clear (
[]) a restriction it wrote, until an admin changes it — after that the admin’s value stays and the deploy tells you, and if the admin’s value is wider than yours, a deploy that adds capabilities to that role is refused, because they would reach rows your declaration keeps them from. Declaring the value a role already has adopts it: nothing changes, and your app can’t later clear it ([]leaves it) or change it (a different value is a conflict). Declaring a restriction your app never wrote on a role that already has a different one, or one another app declares differently on the same role, is a conflict that fails the whole deploy, capabilities included, because the capabilities without their restriction would widen the role. Set the value on Users › Roles (the next deploy adopts it) or give your app its own role. A role your app stops declaring keeps its restrictions exactly as they are.
A channel removed from a role stops being readable on the holder’s very next request. Anything a role gains reaches its holders the next time they sign in.
Capabilities, and gating your UI on them
Section titled “Capabilities, and gating your UI on them”A capability is <verb>:<record> — read:order, write:invoice, delete:invoice (a write allow does not imply delete), read:cr_pick_list for a custom table your app declared. Gate your screens on the same strings you declared, using the helpers in your generated src/lib/turbobase.ts:
import { hasCapability, write, TurbobasePermissionError } from './lib/turbobase';
{hasCapability('read:invoice') && <NavLink to="/invoices">Invoices</NavLink>}
if (!hasCapability('read:invoice')) { return <Empty>You don't have access to invoices. Ask an admin for the Auditor role.</Empty>;}
try { await write('itemFulfillment', { parent, lines });} catch (e) { if (e instanceof TurbobasePermissionError) { setError("You don't have permission to ship orders — ask an admin for the Warehouse clerk role."); return; } throw e;}Check before you render — this is the part people miss. A read the user isn’t allowed doesn’t fail; RLS filters the rows and you get an empty array. Without the check, someone who may not see invoices reads “No invoices yet” and reports missing data. A write is different: it returns 403, which the client throws as TurbobasePermissionError specifically so you can tell it apart from a validation failure and name the role to ask for.
hasCapability is UX, not security — it reads the signed-in user’s own claims, so it’s exactly as trustworthy as their browser. RLS is the guarantee; hiding a screen is a courtesy to the user, not a boundary.
Labels: group your app in the launcher
Section titled “Labels: group your app in the launcher”An instance with fifteen apps gives everyone one flat grid at /apps. Labels narrow it: a coloured chip on your app’s card, and a filter pill above the grid. Declare them in the same manifest, and they arrive with the app instead of having to be re-typed on every instance you ship to:
{ "labels": [ { "name": "Warehouse", "color": "amber" }, { "name": "Finance" } ]}name— what the chip says. Max 40 characters, up to 12 labels per app. Names are matched case-insensitively, soWarehouseandwarehouseare one label.color— one ofslate,violet,amber,emerald,rose,sky,indigo,fuchsia. Omit it for a neutral slate chip. Anything else fails the deploy, naming the eight — a typo would otherwise ship green and render grey.
A label is navigation, never permission. It changes nothing about who can open your app — that’s the audience, below. Someone who can’t open your app can’t see its labels either.
Your manifest owns your app’s labels, and only your app’s. Declared labels are attached, and any label your app carries that the manifest no longer names is removed from your app — so deleting a line is how you take a chip off. An empty "labels": [] clears them all; leaving the key out entirely means “no opinion”, and whatever your app carries is left alone.
The colour is only used when the label is new. Label names are shared across the whole instance — one Finance row that your app, three other apps and the filter bar all read. So a name your instance doesn’t have yet is created at the colour you declared, and one it already has is used as it stands: your "color": "amber" will not repaint someone else’s chip, or overwrite a swatch an admin picked on Development › App Management. The deploy says so when they differ:
labels added Finance violet — declared amber, the instance's colour is keptAsk your admin if you want the instance’s colour changed — it’s one click on that page.
A preview deploy reports what it would do and attaches nothing; promoting the preview applies it. turbobase apps plan shows which labels already exist and which would be created.
Share and launch
Section titled “Share and launch”Every hosted app has an audience — who may open it: only you (private), everyone in the instance, a role (admin or standard), or specific people. Admins and the app’s owner can always open it. Set the audience from Development › App Management; the row’s RLS policy enforces it server-side, so an unshared app is invisible to everyone else.
End users open apps from the Apps launcher at /apps — a grid of every enabled app the signed-in user may open, each linking to its /a/<slug> URL. An app with no deployed version yet shows as a muted “Not deployed yet” card and isn’t openable. Standard (non-admin) users who have at least one app shared with them land on /apps as their home when they sign in — for them, your app is Turbobase.
Generate with Claude Code
Section titled “Generate with Claude Code”The in-product template gallery (Development › Proposals → Templates) is the guided version of this — the proposal system generates the app for you. If you’d rather drive your own Claude Code session, the shortest path from zero to a demoable frontend on your instance is to drop the prompt below into a fresh session. The agent scaffolds a Vite + React + TypeScript static SPA (Tailwind v4 + shadcn/ui) with a dashboard (top customers, recent orders, recent invoices) and a create-sales-order form — and no sign-in page: served same-origin at your instance, the serving route handles sign-in (302 to /login) before the SPA loads, so the app is wired only to PostgREST (reads) + Realtime + the same-origin write boundary (POST /v1/data/:recordType) contracts documented above.
One-shot prompt
Section titled “One-shot prompt”Open an empty directory, run claude, then paste:
Build a Vite + React + TypeScript static SPA + shadcn/ui demo app on top of Turbobase.
Knobs (replace with your own from /admin/settings — these are Vite dev-proxytargets in local dev; in production the app is served same-origin and fetchesrelative paths): TURBOBASE_API_URL = http://localhost:4100 # proxy target for /v1, /auth SUPABASE_URL = http://localhost:54331 # proxy target for /rest, Realtime WS APP_URL = http://localhost:3000 # this app's own Vite dev server
Same-origin, no auth code. The app is served same-origin with the instance, so itinherits the instance's session cookies. There is NO sign-in page, NO callback route,NO code exchange: an unauthenticated visitor is 302'd to the instance's /login bythe serving route before the SPA loads. Build the app assuming a valid session isalready present.
Local dev — make localhost:3000 behave same-origin. Configure Vite server.proxyto forward /v1, /rest, /auth, and the Realtime WebSocket to the instance origin(TURBOBASE_API_URL / SUPABASE_URL). Sign in on the instance origin first so thebrowser holds the tb_jwt cookie the proxy forwards.
Routing — react-router-dom (client-side; the serving route has an SPA fallback): / — dashboard: KPI strip + sparkline + 3 live cards (top 5 customers, last 10 orders, last 10 invoices) + inline "New order" Sheet that opens over the page. /orders/new — same form, standalone route (keep for deep-link / share); body: { idempotencyKey, parent: { entity_id, subsidiary_id, tran_date }, lines: [{ item_id, quantity, rate, amount }] } POSTed same-origin to /v1/data/salesOrder.
Auth + supabase-js wiring (same-origin) const jwt = readCookie('tb_jwt'); // non-HttpOnly mirror cookie const sb = createClient(window.location.origin, jwt, { // same-origin; jwt is the apikey db: { schema: 'turbobase' }, global: { headers: { Authorization: 'Bearer ' + jwt, 'Accept-Profile': 'turbobase', 'Content-Profile': 'turbobase' }}, auth: { persistSession: false, autoRefreshToken: false } }); sb.realtime.setAuth(jwt); // arms WS for RLS
supabase-js talks to PostgREST + Realtime SAME-ORIGIN (window.location.origin); in local dev the Vite proxy forwards /rest and the Realtime WS to SUPABASE_URL. Pass the session JWT (tb_jwt) as the apikey position — Realtime trusts the instance's signing key, so the JWT doubles as the apikey. (TENANT_ANON_KEY from /admin/settings is an optional dev fallback for the apikey slot.)
Reads: PostgREST /rest/v1/<table> (same-origin) via the sb client above.Tables (the catalog's `table` value, NOT the record type): order, invoice,entity (customer is a read view over it — read either, but see Realtimebelow), item (or inventoryItem if catalog lists it), subsidiary, location.
Realtime layer — this is the demo's wow moment. REST paints, stream updates.The dashboard MUST show both at once so a viewer sees data go live.
- On mount, each card fetches its slice via PostgREST (initial paint). - Then open three Supabase Realtime channels — on BASE TABLES only, because logical replication never fires on a view: order, invoice, and entity narrowed with filter 'entity_type=eq.customer' (the customer read surface is a view over entity, so a channel on it streams nothing, silently). - On INSERT → prepend the row (trim list to its limit, 5 / 10 / 10). - On UPDATE → merge in place, matched on the row's identity column: pg_row_id for order / invoice (their primary key — external_id is null until the write-back worker commits the row upstream), external_id for entity rows (the sub-type views do not expose pg_row_id). - Do NOT write a DELETE handler. A deleted record is soft-tombstoned (deleted_at) and hidden by a restrictive RLS policy, so Realtime emits NO frame at all — not an UPDATE and not a DELETE. Instead, re-run each card's seed read every 45s (and on tab focus) through POST /v1/batch and replace the list with the result: the tombstoned row is simply absent. Realtime handles arrivals and edits; the sweep handles removals. - UI cues (make it obvious data is streaming): • A green pulsing "Live" pill in each card header. • New / updated rows briefly flash bg-emerald-500/10 (~1.5s) then settle. • Card footer shows "last event Xs ago" updating every second.
import { createClient } from '@supabase/supabase-js' const sb = createClient(window.location.origin, jwt, { db: { schema: 'turbobase' } }) const CHANNELS = [ { table: 'order', filter: undefined }, { table: 'invoice', filter: undefined }, { table: 'entity', filter: 'entity_type=eq.customer' }, ] for (const { table, filter } of CHANNELS) { sb.channel(`dash:${table}`) .on('postgres_changes', { event: '*', schema: 'turbobase', table, ...(filter ? { filter } : {}) }, (p) => applyDelta(table, p)) .subscribe() }
Same RLS gates Realtime — a user only receives events for rows they could have read via REST. No extra auth wiring.
Design — make this look like a product, not a demo - FIRST, activate any high-quality design skill available in this Claude Code session (for example, `frontend-design`). If none is available, follow the directives below verbatim — they're the floor, not the ceiling. - Dashboard composition (top to bottom): 1. Header with title + a pulsing "REST + Realtime" eyebrow + primary CTA "+ New order" that opens an inline Sheet (NOT a route nav). 2. KPI strip: 4 tiles — Orders 30d, Revenue 30d, Open invoices, Overdue $. Each tile shows a tiny ▲/▼ % vs the prior 30d. 3. Sparkline: sales-order count by day for the last 14 days. Hand the SVG yourself (no chart dep needed). Size it per the design-system "Charts (hand-rolled SVG)" rule — responsive via a fixed viewBox + preserveAspectRatio, w-full, bounded height, coords in viewBox space (not scaled to pixel width). Hover → day + count. 4. The three live cards. Order rows show tran_id, entity_name, status pill (color-coded), money. Invoice rows show paid / remaining + a "N days overdue" rose badge when amount_remaining > 0 AND due_date < today. - Inline new-order Sheet: slides in from the right with the same fields as /orders/new. Submit → toast → close sheet → orders card flashes the new row in via Realtime. Never reload the page. - Visual rules: light mode only (no dark mode / theme toggle), white bg, single accent (violet/emerald), 4-pt spacing scale, generous whitespace, no boxy 1990s borders. Skeleton loaders. Designed empty states. - Status pill palette (orders): pending → zinc, approved → blue, pending_fulfillment → amber, billed → emerald, closed → zinc-muted, cancelled → rose. Invoices: open → amber, paid → emerald, overdue → rose.
Full reference (auth, RLS, realtime, write paths, column lists): https://docs.turbobase.io/build-apps/
Stack: Vite + React + TypeScript static SPA (React 19 STABLE — do NOT pin anRC build, peer mismatches will block install), react-router-dom, Tailwind v4,shadcn/ui (New York, accent=violet, light mode only — no dark mode),react-hook-form + zod, sonner. TanStack Query optional — skip for this demounless you need mutation orchestration; the Realtime layer already handlesfreshness. Build with `npm run build` → static dist/; deploy viaDevelopment › App Management → Deploy a version, or `turbobase apps deploy <slug> ./dist`.
Polish bar: live-demo ready. If a reviewer sees a blank state, a stuckspinner, or a card without a Live pill within 3s of load, ship's not in.Why this works
Section titled “Why this works”- Auth. The app is served same-origin at
https://<slug>.turbobase.app/a/<app>, so it inherits the instance’s session cookies — no sign-in page, no callback route, no code exchange. An unauthenticated visitor is 302’d to the instance’s/loginby the serving route before the SPA loads. The instance sets two cookies: the HttpOnlyturbobase_session(server-side auth + writes) and a non-HttpOnlytb_jwtmirror that supabase-js reads fromdocument.cookie. Without the readable mirror, supabase-js in the browser would have no JWT and every PostgREST / Realtime call would go unauthenticated. - Realtime apikey. supabase-js runs same-origin (
window.location.origin; in local dev the Vite proxy forwards/restand the Realtime WS toSUPABASE_URL). Pass the session JWT (tb_jwt) as theapikeyposition tocreateClient— Realtime trusts the instance’s signing key, so the JWT doubles as the apikey (TENANT_ANON_KEYis an optional dev fallback). Always callsb.realtime.setAuth(jwt)after init so the channels carry the user’s role into RLS. - Design. The prompt asks Claude Code to activate a
frontend-designskill if one is installed in the session; the explicit directives in the prompt (KPI strip, sparkline, inline Sheet for new orders, status pill palette, density rules) are the floor. Either path produces something demoable, not template-y. - Reads. Every PostgREST request needs
Authorization: Bearer <jwt>+Accept-Profile: turbobase. The same RLS that gates the in-product app gates the demo — a logged-in user only sees what their role + subsidiary scope allow. - Cold-load fan-out. A dashboard that opens by querying every record type fires many reads at once; even paced, the request count can trip the per-request limiter. Collapse the first-paint reads into one
POST /v1/batchcall (the client’sbatch()helper) — same query strings, results in order, partial-success per read. See Batch the cold-load fan-out. - REST + Realtime, side by side. The dashboard’s job is to make the architecture visible: REST paints the first frame (top customers, last 10 orders, last 10 invoices), and three
postgres_changeschannels — on the base tablesorder,invoice, andentityfiltered toentity_type=eq.customer— stream deltas into the same lists. A viewer doing a live demo sees a new sales order created in NetSuite (or via/orders/new) flash into the dashboard within ~1 s without a refresh — that’s the moment the product sells itself. Realtime checks the same session JWT, so RLS gates the stream identically to the REST read; no extra auth code. - Writes.
POST /v1/data/:recordTypeis the canonical write path for every writable record — single-row (customer,item,customerPayment,customerDeposit,timeBill) and header+sublist (salesOrder,invoice,itemFulfillment,cashSale,opportunity,estimate,creditMemo,expenseReport). Send{ parent, lines?, pgRowId?, idempotencyKey?, expectedSyncVersion? }. The boundary runs the instance’s validation hooks synchronously (returns422with the offending field), commits parent + lines in one transaction under your RLS context, marks the rowwrite_status='pending', and the worker pushes to the source. Header-only fields likeentity_type,originating_system, andrawdefault — you only send the user-facing fields.source_systemdefaults too on an instance with ONE system connected; with several, a create should name which one the record belongs to (parent.source_system: 'zoho_crm'), and naming a system that is not connected is refused422 SOURCE_NOT_CONNECTEDrather than accepted and left with nowhere to sync. Naming it is what decides the table. A few record types mean a different record in different systems —accountis a customer in Zoho CRM and Salesforce and a ledger account everywhere else — and for those,parent.source_systemis what tells the boundary which one you mean, on a create and on an edit alike. Two refusals remain, and both are a question you can answer: with nothing named on such a record type you get422 SOURCE_AMBIGUOUSlisting the connected systems to pick from, and naming a connected system that has no record of that kind is400 record type "…" is not writable. An UPDATE takes its system from the record itself, so you can edit records from every connected system in one session;source_systemsent on an update is verified against the record and never written onto it — restating what you read back is fine, a different value is422 SOURCE_MISMATCH. On one of the record types above, wheresource_systemis also what chose the table the row was looked for in, naming the wrong system can instead be404 row not found or not permitted— the row was looked for in that system’s table. Restate what you read back and it resolves. A table your app authored belongs to the instance, and its rows stay here. A custom table — one declared in yourpublic/turbobase.jsonor created withturbobase tables create— is addressed either by its bare slug (task) or by thecustomrecord_-prefixed spelling of it (customrecord_task), and both name the same rows. Either way the create takessource_systemfrom the table’s own definition, so it isstandalonerather than whichever system your session signed in against, the row landswrite_status='synced', and it is never queued to a connected system — there is nothing upstream to send it to. Naming one anyway (parent.source_system: 'netsuite'on a table NetSuite does not have) is422 SOURCE_MISMATCHas the write is made, instead of an accepted write that fails against that system hours later. The same rule decides the other direction: a custom record a connected system owns (a NetSuitecustomrecord_*) is stamped with the system that has it whichever system your session is signed in against, and a prefixed name that no active table definition on this instance resolves is400 record type "…" is not writablerather than a write attempted against a table that is not there — “active” because a definition whose last reconcile failed keeps its table and is not advertised, which is the rule the bare slug has always had. A table whose owning system has since been disconnected is422 SOURCE_NOT_CONNECTEDon the same door, rather than a row saved with nowhere to send it. - Transforms. For “convert estimate to sales order” / “create invoice from this SO” / “apply payment to this invoice” flows, the write goes through
POST /v1/sync/events/from-chatwith{ recordType, payload: { created_from_id: <parent external_id>, ... }, idempotencyKey }instead, so NetSuite’s lifecycle verbs (line sourcing, GL inheritance) run.
Variations
Section titled “Variations”- Different framework. The contracts are the same; swap the stack line for any static-SPA toolchain — Vite + Svelte, Vite + Vue, SolidStart in SPA mode, or Astro with a client island. Keep it a static build (no SSR server) so it serves same-origin under
/a/<app>. The auth, read, and write calls don’t change. - Different first record. Replace
salesOrderin the path with any record type the boundary accepts — header+sublist (invoice,itemFulfillment,cashSale,opportunity,estimate,creditMemo,expenseReport) or single-row (customer,item,customerPayment,customerDeposit,timeBill). SamePOST /v1/data/:recordTypeshape for all.
Run the generated app
Section titled “Run the generated app”This is the canonical “what the agent does once the build is green” rule — every app template in the template gallery references it instead of repeating the steps, so the behaviour stays identical across templates.
Once npm run build and npm run lint pass, the agent starts the dev server itself so the user lands on a running app, not a build log:
- Start the dev server in the background / non-blocking from
${APP_DIR}— e.g.npm run devdetached so it keeps running across turns and does not hold the agent’s session open. Do NOT run it in the foreground; a blockingnpm run devhangs the agent and burns context. - Report the local URL — the templates serve at
http://localhost:3000. - Smoke-test the live routes (next section) before declaring done.
- Report what was built and what wasn’t verified (which screens you scaffolded, what you couldn’t exercise without live data).
If the dev server can’t start (port in use, missing dependency), report the exact error and the command to start it manually (cd ${APP_DIR} && npm run dev) rather than silently stopping.
Stopping or restarting the dev server is cheap and reversible — restart it whenever the app’s environment changes (for example, editing the Vite server.proxy target or an env var; Vite reads its config and .env at boot and does NOT hot-reload those changes, so a restart is required to pick them up).
Smoke-test the running app — build+lint green is not enough
Section titled “Smoke-test the running app — build+lint green is not enough”A build that compiles and a lint that passes prove the code is well-formed, not that it runs. The bugs that survive both are runtime-only: a guard that throws during a render, a PostgREST embed that 400s because an FK doesn’t exist on this instance, an env var read at boot that’s still a placeholder. None of those move the build/lint signal. Now that the dev server is up, probe the real routes and treat a 500 or a broken happy-path as a failure to fix, not “done”:
- There is no
/loginroute. The app has no sign-in page —curl -si http://localhost:3000/login404s, and that’s correct. Don’t add 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 has no sign-in logic and never renders a sign-in form.) A 500 on/means a component threw during render — fix it; don’t ship the 500. - With a valid session, the dashboard resolves its identity row. In local dev, sign in on the instance origin first so the browser holds the
tb_jwtcookie the Vite proxy forwards, then load/→ the dashboard renders without a “no record” empty state (e.g. expenses must NOT show “Your sign-in email isn’t on any employee record” for a user who has one). That empty state on a valid user usually means a required-path query 400’d — most often a PostgREST embed (<rel>:<col>(...)) coupling the read to an FK that doesn’t exist on this instance. De-embed it (scalar select + a separate best-effort lookup) and re-test.
If any probe fails, fix the cause and re-run the probe — a green build with a 500 on / is not a finished app.
Where to go from here
Section titled “Where to go from here”- Read the prompt skills page when writing a Claude Code prompt against Turbobase — it captures the standard sections (knobs, playbook fetch, catalog discovery, write-path responses, realtime client-island, smoke-test ritual) that every app template uses, so a custom prompt can be a thin wrapper instead of 400 lines of boilerplate.
- Start from a template in your Turbobase app — Development › Proposals → Templates. The template becomes a proposal, Studio generates the app source, and
turbobase apps deploybuilds and deploys it. - Call
GET /v1/catalog/recordsto introspect every record this instance syncs. - Per-record reference for the NetSuite model lives at
sources/netsuite/skills/anatomy/<record>.md. The catalog exposes the path for each record so a client can render it inline. - To provision a new instance or connect another source, head to
console.turbobase.io.