Skip to content

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.

  • Postgres reads through PostgREST at ${SUPABASE_URL}/rest/v1/<table>, scoped to the turbobase schema. 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 via POST /v1/batch (the client’s batch() 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 through POST /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 turbobase schema — see Typed clients below.

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 from document.cookie. supabase-js reads it for the apikey position, the Authorization: Bearer <tb_jwt> header, and client.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.

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:

  • order is the table name, not the sort call — sales orders live in turbobase."order". Table names are the catalog’s table value, not the record type: salesOrder reads from order, and customer / vendor / employee / contact all read from entity. Take the name from GET /v1/catalog/records rather than from the record type.

  • Accept-Profile: turbobase is set automatically by the db: { schema: 'turbobase' } client option. If you hand-roll fetch, you must set it yourself — PostgREST’s default schema is public, 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_VENDOR gets zero rows from /rest/v1/vendor even though the request itself returns 200 OK with 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.

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 set
if (!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().

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:

  • sources names what was accepted, not what landed. The upstream writes are asynchronous, so every external_id in it is null when you get the response — there is nothing yet to report. row.external_id is the record’s own id in the system it belongs to, and it fills in the same way a single-system create’s does.
  • sources appears 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 and sources is 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 missing sources as “they are on their way”.
  • Send an idempotencyKey on 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 with sources — 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 is 422 SOURCE_NOT_WRITABLE, and one that means a different record by that name is 422 SOURCE_AMBIGUOUS — each naming the system you need to fix. A list carrying an entry that is not a system name at all is 422 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 — so txn_source_link?master_table=eq.entity&master_row_id=eq.<pg_row_id> over PostgREST tells you each system’s write_status (ok, pending, blocked, divergent, muted) and, for a refused copy, the last_error the 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.

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.

Terminal window
curl --cookie 'turbobase_session=...' \
https://<slug>.turbobase.app/v1/catalog/records

2. 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.

Terminal window
# 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/records

Either 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, timestamp or status. Lead tables with display columns; use identifier columns for filters, joins and links.
  • displayFor / idFor — the canonical <x>_id ↔ <x>_name pairing. entity_name is the human-readable twin of entity_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 formatted currency.native, the sibling column holding the ISO 4217 code (normally currency_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.

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_id is 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_name is the source’s free-text name for the currency record, which an administrator can rename. Read currency_code.
  • Do not sum money across mixed currencies without converting each figure first. Group by currency_code and 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.

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&quote_currency_code=eq.GBP
&effective_date=lte.2026-03-09&order=effective_date.desc&limit=1

Three 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=1 takes 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.

Everything the instance knows about itself is reachable in the same token realm:

  • GET /v1/kb/search?q=… and GET /v1/kb/document/:slug — the published knowledge base: how this business runs its processes. From Claude Code, the search_documentation / read_document MCP 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=… and GET /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 the catalog:read scope your token already carries, and every answer names the release it came from.
  • GET /v1/catalog/objects?q=… and GET /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:
Terminal window
turbobase project grounding --records purchaseOrder,itemReceipt \
--query "inbound receiving" -o INSTANCE.md

Rule 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.

Generate a Database type for your client app with the Supabase CLI:

Terminal window
supabase gen types typescript --project-id <ref> --schema turbobase > src/lib/turbobase.d.ts

Then 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 row

Installing 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 = 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.

Your app is a static bundle — no Node server, no SSR. Build it, then hand the dist/ to your instance:

Terminal window
npm run build # Vite → static dist/ with index.html

Deploy 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 of dist/.
  • 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 builds and uploads exactly as a deploy does, but serves the result at its own URL and leaves /a/<app-slug> untouched:

Terminal window
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.

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.

Terminal window
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.

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.

Terminal window
turbobase apps pull <app> ./<app> # write the current source to disk
# ... edit ...
turbobase apps push <app> ./<app> # record your edits as a new revision
turbobase apps deploy <app> ./<app> # build + go live

A 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.

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 can
review 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.

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.

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.

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.
  • true or false, 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.

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.
  • content grants Slack history. It lists the exact channels the role may read, as slack:<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 scopes out 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 from roles, 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.

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.

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, so Warehouse and warehouse are one label.
  • color — one of slate, 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 kept

Ask 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.

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.

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.

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-proxy
targets in local dev; in production the app is served same-origin and fetches
relative 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 it
inherits 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 by
the serving route before the SPA loads. Build the app assuming a valid session is
already present.
Local dev — make localhost:3000 behave same-origin. Configure Vite server.proxy
to 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 the
browser 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 Realtime
below), 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 an
RC 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 demo
unless you need mutation orchestration; the Realtime layer already handles
freshness. Build with `npm run build` → static dist/; deploy via
Development › App Management → Deploy a version, or `turbobase apps deploy <slug> ./dist`.
Polish bar: live-demo ready. If a reviewer sees a blank state, a stuck
spinner, or a card without a Live pill within 3s of load, ship's not in.
  • 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 /login by the serving route before the SPA loads. The instance sets two cookies: the HttpOnly turbobase_session (server-side auth + writes) and a non-HttpOnly tb_jwt mirror that supabase-js reads from document.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 /rest and the Realtime WS to SUPABASE_URL). Pass the session JWT (tb_jwt) as the apikey position to createClient — Realtime trusts the instance’s signing key, so the JWT doubles as the apikey (TENANT_ANON_KEY is an optional dev fallback). Always call sb.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-design skill 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/batch call (the client’s batch() 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_changes channels — on the base tables order, invoice, and entity filtered to entity_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/:recordType is 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 (returns 422 with the offending field), commits parent + lines in one transaction under your RLS context, marks the row write_status='pending', and the worker pushes to the source. Header-only fields like entity_type, originating_system, and raw default — you only send the user-facing fields. source_system defaults 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 refused 422 SOURCE_NOT_CONNECTED rather 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 — account is a customer in Zoho CRM and Salesforce and a ledger account everywhere else — and for those, parent.source_system is 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 get 422 SOURCE_AMBIGUOUS listing the connected systems to pick from, and naming a connected system that has no record of that kind is 400 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_system sent on an update is verified against the record and never written onto it — restating what you read back is fine, a different value is 422 SOURCE_MISMATCH. On one of the record types above, where source_system is also what chose the table the row was looked for in, naming the wrong system can instead be 404 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 your public/turbobase.json or created with turbobase tables create — is addressed either by its bare slug (task) or by the customrecord_-prefixed spelling of it (customrecord_task), and both name the same rows. Either way the create takes source_system from the table’s own definition, so it is standalone rather than whichever system your session signed in against, the row lands write_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) is 422 SOURCE_MISMATCH as 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 NetSuite customrecord_*) 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 is 400 record type "…" is not writable rather 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 is 422 SOURCE_NOT_CONNECTED on 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-chat with { recordType, payload: { created_from_id: <parent external_id>, ... }, idempotencyKey } instead, so NetSuite’s lifecycle verbs (line sourcing, GL inheritance) run.
  • 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 salesOrder in 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). Same POST /v1/data/:recordType shape for all.

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:

  1. Start the dev server in the background / non-blocking from ${APP_DIR} — e.g. npm run dev detached so it keeps running across turns and does not hold the agent’s session open. Do NOT run it in the foreground; a blocking npm run dev hangs the agent and burns context.
  2. Report the local URL — the templates serve at http://localhost:3000.
  3. Smoke-test the live routes (next section) before declaring done.
  4. 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”:

  1. There is no /login route. The app has no sign-in page — curl -si http://localhost:3000/login 404s, and that’s correct. Don’t add one.
  2. Unauthenticated / renders the SPA shell, doesn’t crash. In local dev the Vite server serves index.html for every route (SPA fallback), so curl -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 /login before 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.
  3. 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_jwt cookie 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.

  • 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 deploy builds and deploys it.
  • Call GET /v1/catalog/records to 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.