This is the full developer documentation for Turbobase
# Turbobase Docs
> An ERP operations and development platform for Oracle NetSuite.
Connect your instance and Turbobase mirrors it into a Postgres you
own, with bidirectional sync, row-level security, realtime, and a
named-agent cast on top (Pilot for chat-driven ops, Mechanic for
sync healing). One operator gets the reach of a whole team of
specialists.
Turbobase is an ERP operations and development platform for Oracle NetSuite. It gives a single NetSuite operator the power to build, automate, diagnose, and manage an instance like an entire team of specialists.
Turbobase is a managed service. Sign up at [console.turbobase.io](https://console.turbobase.io/). Sign-up is invite-gated today, so if you don’t have an invite yet, [request early access](https://console.turbobase.io/early-access). The platform provisions your instance, connects NetSuite, and runs the stack for you. These docs cover the platform and the canonical schema; the console drives provisioning.
## Where to start
[Section titled “Where to start”](#where-to-start)
* **Start here.** [Getting started](/getting-started/what-is-turbobase/) is the front door: what Turbobase is, then the three steps to a live instance — provision your instance, connect your first source, and open your app.
* **Connecting NetSuite?** [NetSuite OAuth 2.0 setup](/netsuite-oauth-setup/) is the click-by-click reference. The console runs these steps for you when you connect from the onboarding flow.
* **Want the vocabulary?** The [Glossary](/getting-started/glossary/) defines every word you’ll meet, in plain language, and it is the one place each is defined. [Key concepts](/concepts/) is the longer read: how the named pieces of the platform fit together (connectors, the canonical mirror, sync events, hooks, guardrails, the audit log, scheduled tasks, channels, Atlas, proposals and solutions, Studio, the Analyzer, apps, the knowledge base, and permission tiers) in one page.
* **Driving the platform.** [The console](/console/overview/) covers sign-up, orgs, instances, billing, and adding sources; [your Turbobase app](/tenant/data-model/) covers the app every instance ships with: data model, [Pilot](/tenant/pilot/), [knowledge base](/tenant/knowledge-base/), [Development](/tenant/development/) (proposals, solutions, releases), the [Analyzer](/tenant/analyzer/), analytics, permissions, access tokens, and more.
* **Extending Turbobase.** [Writing hooks](/extending/writing-hooks/) and [conduits](/extending/writing-conduits/) cover the authoring surface.
* **On the command line.** The [CLI](/cli/overview/) drives a live instance: hosted apps, extensions, promoting a configuration between environments, and the files an AI coding agent reads. Every command is in the [CLI reference](/cli/reference/).
* **Connecting another source?** [Add sources](/console/add-sources/) brings Stripe, QuickBooks, Salesforce, Shopify, HubSpot, and the rest of the registered set into the same canonical layer.
* **Building an app on your instance?** [Build apps on Turbobase](/build-apps/) shows you how to point a SvelteKit, Next.js, React, or mobile app at your instance’s PostgREST and Realtime endpoints.
## Machine-readable bundles
[Section titled “Machine-readable bundles”](#machine-readable-bundles)
Every page on this site is published as Markdown. The [`llms.txt`](/llms.txt) index and the single-file [`llms-full.txt`](/llms-full.txt) bundle are generated from the same source, so an automated reader gets the canonical text.
## Need help?
[Section titled “Need help?”](#need-help)
Email for onboarding, billing, and anything the docs don’t answer.
# Connect your first source
> Connect Oracle NetSuite so Turbobase can mirror it into your Postgres, and see what else you can connect.
Shipped
Once your instance reaches **Ready** (see [Provision your instance](/getting-started/provision-your-instance/)), the console drops you into the **setup wizard** at `/instances//setup`. This is where you choose your first source, connect it, create your app’s first admin, and finalize, after which your live Turbobase app opens.
Most instances start with **Oracle NetSuite**, and the rest of this page assumes that path. Everything else you can connect is covered under [Connecting additional sources](#connecting-additional-sources).
## The setup wizard
[Section titled “The setup wizard”](#the-setup-wizard)
The wizard has four steps. It is connector-driven: the **Connect** step is named after the source you choose (and is skipped entirely for Standalone). There is no AI setup step, because your instance comes preconfigured with the platform’s default AI model.
1. **Data source**: pick the system of record to mirror. Most instances start with Oracle NetSuite. The grid also shows the other business systems Turbobase can mirror — Stripe, HubSpot, Salesforce, Zoho CRM, QuickBooks, Xero, Bill.com, Ramp, Shopify, Zendesk, Intercom, Jira, Asana, Notion, GitHub, Workday, Dynamics 365 Business Central, GetSales.io, DocuSeal, PrintNode and WhatsApp Business — the knowledge sources it can read — Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage — and **Standalone** (no connector), which is always selectable. Every tile is listed on [Add sources](/console/add-sources/). Which connector tiles you can select depends on what’s enabled for your instance; a tile that isn’t enabled for you yet shows an early-access hint with a sales contact.
2. **Connect {Source}**: named for your pick, for example **Connect NetSuite**. Authorize the source so Turbobase can read from it (details below). Standalone skips this step.
3. **Initial admin**: set the initial admin user / password for your Turbobase app. These are the credentials your team will use at `.turbobase.app`, not your console sign-in.
4. **Finalize**: Turbobase runs the initial sync, restarts your instance’s services, and marks setup complete. The **Set-up** button disappears from the instance’s row in the instances list, and its slug becomes the link that opens your app.
The wizard is resumable. Each step is idempotent and the server re-reads the next step on every load, so if you navigate away and come back you pick up where you left off.
## Connecting NetSuite
[Section titled “Connecting NetSuite”](#connecting-netsuite)
NetSuite runs inline in the wizard’s Connect step. It is two things at once: an OAuth 2.0 consent, and a one-time deploy of the Turbobase managed bundle into your NetSuite account. The bundle is what gives you real-time sync out of NetSuite rather than polling alone; see [NetSuite real-time sync](/extending/netsuite-realtime-sync/) for what it installs.
Your NetSuite account does not need every optional feature turned on. If a feature the bundle’s integration role can use is off — Multiple Subsidiaries on a single-company account is the usual one — the deploy leaves out only the access that needs it and finishes, and the deploy log says what was left out. If you turn the feature on later, the next time your bundle is updated it includes that access again.
[NetSuite OAuth 2.0 setup](/netsuite-oauth-setup/) is the click-by-click reference for issuing the credentials, if you want to see the steps the console is running on your behalf or need to do them by hand.
## Advanced settings: customizations only
[Section titled “Advanced settings: customizations only”](#advanced-settings-customizations-only)
The NetSuite connect card carries an **Advanced settings** panel, collapsed by default, holding one choice: **Data sync**. It is on the NetSuite card only, because it is the only source where turning record mirroring off leaves something behind to run on.
Leave it on (the default) and Turbobase mirrors your records so apps, search and write-back can use them. That is what the product does, and it is what almost every instance wants.
Turn it off and your instance runs on **customizations only**: Turbobase still discovers your fields, scripts and workflows, still keeps your schema in step, and still runs the [Analyzer](/tenant/analyzer/), but it never copies your record data. This is for the customer who specifically does not want their records held in Turbobase.
On every other source, mirroring records is the whole connector, so there is no such choice to make — connecting one starts the mirror.
The choice is **not symmetrical**. You can turn data sync on later from inside your instance; once on, it cannot be turned off again. The wizard warns you before you leave it off for that reason.
## Connecting additional sources
[Section titled “Connecting additional sources”](#connecting-additional-sources)
Turbobase runs on NetSuite and can bring other systems into the same canonical layer, either as your first source instead of NetSuite or added later alongside it. They all use the same wizard step and the same connector contract.
Most connectors authorize over OAuth. You never paste long-lived secrets that Turbobase stores and reads back: the connect step either redirects you to the provider’s own consent screen or, for a few connectors, collects connect-time credentials that are written one-way into your instance’s secret store.
The flow at a high level:
1. You click **Connect {Source}** in the wizard.
2. For shared-app OAuth connectors (Asana, Notion, Jira, QuickBooks, and others), Turbobase mints the provider’s authorize URL and redirects your browser to the provider. You pick the workspace / account / company on the provider’s own consent screen, then approve.
3. The provider redirects back to the console, which completes the OAuth exchange and stores the resulting access on your instance, write-only, never read back into the console.
4. The console polls the connection to success and advances the wizard.
Connector specifics:
* **NetSuite**: OAuth 2.0 consent plus a one-time managed bundle deploy into your NetSuite account. Runs inline in the wizard’s Connect step.
* **Stripe / Bill.com / Fireflies.ai / FeedHive / Granola / GetSales.io / DocuSeal / PrintNode**: collect credentials in the wizard. Bill.com also prompts for an MFA code; FeedHive takes the API key from **Settings → Account → API Key** and reads by default, and Turbobase posts on your behalf only once publishing is switched on for your instance; Granola takes a workspace API key and registers its own webhook endpoint for you; GetSales.io needs your own `.getsales.io` host alongside the key; DocuSeal and PrintNode take an API token, and write back only once that is switched on for your instance.
* **WhatsApp Business**: you sign in to Meta in a popup and pick the WhatsApp Business account to connect — there is nothing to paste. Your customers’ conversations are then mirrored onto your instance beside everything else it knows about those people, and replies and approved templates can be sent back out through your instance’s API. **Meta charges the per-message fees to your own WhatsApp Business account, on the payment method you hold with them — those charges do not come through Turbobase.** Sending is switched on per instance and starts off. See [Add sources](/console/add-sources/) for the rules on what may be sent and when.
* **Azure Blob Storage**: collects a Microsoft Entra directory id, application id and client secret, plus the storage account and the one container to mirror. Two more choices sit on the same form, and neither is defaulted for you: **how the files are kept** — copied into your instance, or left in Azure and fetched each time one is opened — and whether Turbobase may **add files** to that container, which starts off and needs us to switch it on. Turning writing on requires the account to have blob versioning or blob soft delete turned on, checked when you connect. See [Add sources](/console/add-sources/) for what each choice means for you.
* **HubSpot / Shopify / Zendesk / Salesforce**: OAuth with your own provider app. You supply the connection details on the connect form, then approve on the provider’s consent screen.
* **Zoho CRM**: OAuth with Turbobase’s own app — there is nothing to register. Choose **production** or **sandbox** on the connect form, then approve on Zoho’s consent screen. Your organisation’s data centre is detected from that redirect, so a Zoho organisation in any region connects the same way. A CRM Plus subscription connects through this same tile: the sales data in that bundle lives behind the Zoho CRM API.
* **Asana / Notion / Jira / GitHub / Google Workspace / QuickBooks / Xero / Intercom / Ramp / Workday / Dynamics 365 Business Central**: a provider consent redirect that runs on the dedicated connect-source route (`/instances//connect-source/`); the wizard links you there and back. Most need no input at all — you approve on the provider’s own screen and pick the organisation, repository or drive there; a few collect a little config first (Business Central asks for your Entra tenant id and the BC environment).
* **Standalone**: no source. Your instance starts as a blank, native Turbobase database you populate yourself.
Records from every connected source land on the same canonical schema, so a `customer` from Stripe sits in the same table as a `customer` from NetSuite, and [Atlas](/tenant/atlas/) consolidates the two when they are the same real-world entity.
## After it connects
[Section titled “After it connects”](#after-it-connects)
When Finalize completes, your instance is set up and its slug in the instances list links straight to it. Move on to [Open your app](/getting-started/open-your-tenant/) to sign in to the live app.
Need to connect *another* source later, after setup is done? See [Add sources](/console/add-sources/).
# Glossary
> Plain-language definitions of the Turbobase words you'll meet as a customer — what each thing is and when you'd reach for it.
A single reference for the words you’ll meet using Turbobase, in plain language: what each thing is and when you’d reach for it. For how the named pieces fit together, see [Key concepts](/concepts/).
| Term | Definition |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **instance** | Your own provisioned Turbobase — your own database plus your own running app, created just for your organization. Nobody else shares it. |
| **Turbobase app** | The application that runs in your instance — the one your team opens at `.turbobase.app`: your data, Pilot chat, knowledge base, and the Apps surface. “Your Turbobase app” on first mention; “the app” after that. |
| **console** | The customer site at [`console.turbobase.io`](https://console.turbobase.io) where you sign in, create and manage instances, connect sources, and see your billing. |
| **organization** | The billing and membership entity. Your organization owns your instances and is what invoices bill to. “Org” is fine as a short form. |
| **account** | Your personal sign-in identity and profile — who *you* are when you sign in. Distinct from your organization, which is the shared billing entity your account belongs to. |
| **source** | A connectable upstream system you sync with Turbobase, for example Oracle NetSuite, QuickBooks, or Shopify. The default word for the system itself. |
| **connector** | The pluggable link mechanism between your instance and one source. Say “source” when you mean the upstream system; say “connector” when you mean the link itself — its contract, its auth, its sync behavior. |
| **canonicalizer** | The part of a connector that turns a record shaped the way its source shapes it into the columns you actually query. It is why a customer from NetSuite and a customer from QuickBooks land in the same table under the same column names. |
| **sink** | Your canonical mirror — the one database schema every connected source flows into, and the thing your apps, your queries and Pilot all read. |
| **sync** | Keeping your canonical mirror current with a connected source. Changes in the source flow to your instance; changes you make can flow back too, once write-back is enabled for that source. |
| **initial sync** | The first historical load from a newly connected source into your instance. It runs in the background after you connect; from then on, realtime sync keeps things current. |
| **write-back** | The other half of sync: a change you make in Turbobase pushed back to the source it came from. It ships off and is enabled per source, so an instance starts read-only upstream. |
| **sync event** | One record’s trip across, in either direction — the source into your instance, or your instance back to the source. |
| **delta cursor** | The bookmark sync keeps for each kind of record: how far through a source’s changes your instance has got. It is what makes a re-sync take seconds instead of re-reading the whole history. |
| **originating system** | Which side made the most recent change to a row — the source, or Turbobase. It is what stops a change syncing in and then bouncing straight back out again. |
| **source health** | The view that tells you whether each connected source is syncing, and what failed if it isn’t. |
| **sync failure** | A record that didn’t make it across. The Mechanic works these; you see them on source health. |
| **custom field** | A field from your source (or one you add) promoted into a real, queryable column rather than something buried in the raw payload. |
| **custom table** | A table you create in your instance for data that doesn’t come from any connected source. |
| **OAuth connect** | The one-time authorization step that securely links a source to your instance. You grant access once; Turbobase keeps the connection live from there. |
| **Pilot** | Pilot, your AI assistant — the agent you talk to in the app. Answers questions over your data, builds apps from plain-English prompts, and runs scheduled tasks. Also reachable over connected channels. |
| **Mechanic** | The sync-healing agent. Watches sync failures, fixes the patterns it recognizes within the autonomy you’ve granted it, and escalates the rest with a recommendation. |
| **Conductor** | The behind-the-scenes coordinator: routes events to the right agent, fires notifications, and keeps scheduled work moving. You rarely address it directly. |
| **Concierge** | The onboarding agent — the click-path for connecting your particular source, and the check that your credentials actually work. |
| **learning mode** | The opening phase of a new instance, while Turbobase learns your rules from real sync rejections. A “Learning” indicator shows in the app until the instance settles. |
| **skill** | A capability bundle that teaches an agent one thing well — how to read a particular kind of record, or how a house rule of yours applies. Some ship with Turbobase; you can write and publish your own. |
| **playbook** | A long-form operating manual your agents consult: how your business actually handles a situation, written out. Rebuilt nightly from what your instance has learned, so it stays true as you change. |
| **permission tier** | How far an agent may act on its own — apply the fix, ask you for one tap first, or escalate it to a person. You set one for each kind of sync failure the Mechanic meets, across your whole instance; every kind starts on one-tap approval, and guardrails then tighten individual operations further. |
| **Analyzer** | The customization-analysis surface for a connected source. Inventories source-side customizations — scripts, custom fields, workflows, roles, forms — and surfaces risk and improvement proposals. |
| **risk family** | How the Analyzer groups what it finds so you can triage by the kind of exposure: inventory, GL posting, fulfillment, data integrity, and security. |
| **Studio** | The chat-first canvas where you and Pilot shape a **proposal** into the **solutions** that realize it. You talk in the middle, your inputs sit on the left, and the draft takes shape on the right. |
| **Development** | The admin area where changes to your instance are proposed, designed, shipped, and watched — Proposals, Solutions, Releases, App Management, and Processes behind one tab strip. |
| **proposal** | A drafted change to your instance, with the evidence behind it. You shape it in Studio, then **mark it complete** to freeze it — after that, what was approved stays approved. |
| **solution** | A concrete deliverable minted from a completed proposal — the thing that actually realizes the change. One proposal can mint several. |
| **solution kind** | What flavour a solution is: a hook, a customization, a conduit, config, documentation, an app, a data manipulation, a skill, or native (something your instance already does out of the box). |
| **data manipulation** | The solution kind for fixing data: a bounded, previewed, repeatable data change you apply as a one-off job rather than a deploy. |
| **release** | A tracked deploy. Every release is gated, audited, watched against a pre-release health baseline, and reversible. |
| **Inputs** | The drawer where raw material for proposals collects — notes you drop in, uploaded documents, query results, and signals the platform raises itself. Triage it, then start a proposal from what matters. |
| **signal** | Something the platform noticed and thinks is worth reacting to — a risk finding, or a regression spotted after a release — raised into Inputs. |
| **Standards** | Your house rules: notes and documents recording how your business wants things built. They ground every proposal, so drafts respect them automatically. |
| **template** | A starting point for a proposal — an app, a piece of documentation, or a source change — that seeds the brief so you refine rather than start from blank. |
| **epic / label** | Grouping for proposals. An epic collects related work; labels tag it. Both are just ways to find things again later. |
| **task** | A job your instance runs to a timetable rather than when you ask — a question put to Pilot, a saved view delivered somewhere, or a system job. Ask Pilot to schedule, pause or stop one, or manage them at `/admin/development/scheduled-tasks`. |
| **system job** | Scheduled work Turbobase runs for you rather than something you set up: the regular re-sync, the check for fields added in your source, and the like. It appears alongside your own tasks. |
| **knowledge base** | Your instance’s library of documents and facts. Your team can search it directly, and it grounds Pilot’s answers. |
| **app** | An internal app built on your data from a plain-English brief and served inside your instance. Your team opens the apps shared with them from the **Apps launcher** at `/apps`. |
| **channel** | A notification surface (Slack, …) where your instance delivers alerts, confirmation requests, and scheduled-task results — and where you can talk to Pilot. |
| **hook** | A rule that runs on a write *before* it’s pushed back to a connected source — to validate the change (and reject it if it’s invalid) or to rewrite it into the shape the source expects. Reach for a hook to enforce your own rules on outbound data. |
| **provenance** | Where a hook came from: you wrote it, the Mechanic learned it from real sync rejections, or it arrived with a rule book promoted from another of your instances. Shown on every hook, so you always know what you are looking at. |
| **conduit** | A bridge that automatically propagates a record from one connected system to another — for example, a customer created in QuickBooks shows up in NetSuite without anyone re-keying it. |
| **extension** | What you author with the `@turbobase/extensions` SDK — your own hooks, conduits and processes — and deploy with the Turbobase CLI. |
| **process** | A multi-record sequence an app asks the platform to run — raise a purchase order, wait for the source to post it, then raise the bill — performed on the server in order, with the source’s verdict awaited between steps and a rollback when a step fails. |
| **guardrail** | A policy that decides whether a privileged operation is allowed to run at all: allow it, ask for confirmation first, or deny it. Guardrails are the complement to hooks — a hook shapes a write’s *content*, a guardrail gates whether the *operation* happens. |
| **write boundary** | The one validated path every change into your canonical tables goes through, so hooks, guardrails and the audit log cannot be sidestepped. A write that tries to go around it is refused. |
| **Atlas** | Cross-source identity resolution. When two sources both reference the same real-world customer, vendor, or contact, Atlas links them so you get one canonical record instead of duplicates. |
| **identity field** | The normalized key Atlas matches on when it decides two records from two sources are the same real-world thing — an email address, a phone number, a tax ID. |
| **audit log** | The append-only record of privileged operations, guardrail decisions, and sign-in events in your instance — a tamper-evident history of what happened and who did it. |
| **access token** | A personal access token (PAT) you mint in the app to call your instance’s API from scripts, integrations, or the CLI. “PAT” for short. |
| **MCP server** | The endpoint that lets an AI client (Claude Code, Cursor, …) read your instance — your catalog, knowledge base, and data — under exactly the permissions you already have. |
| **plan** | Your pricing plan — the commitment level your organization is on. |
| **billing** | Your organization’s usage and invoices: what you owe and why. |
| **usage** | The metered activity — AI tokens, synced records — that rolls up into your billing. |
| **sign in** | Authenticating to the console or to your Turbobase app. (“Sign-in,” hyphenated, is the noun for the event.) |
| **identity provider (SSO)** | Your own sign-in system — Google Workspace, or any OIDC provider — wired to your instance so your team signs in with accounts they already have. |
| **standalone** | An instance where Turbobase is your system of record, with no upstream source connected — you create and own the data directly. |
| **marketplace** | Install a published hook or conduit into your instance with one command — or, if you’re an approved publisher, share your own for others to install. |
# Open your app
> Open your provisioned Turbobase app and sign in to start working with your data.
Shipped
Once the [setup wizard](/getting-started/connect-your-first-source/) finalizes, the instance’s row in the console’s instances list turns its slug into a link. Clicking that slug takes you to your Turbobase app at `.turbobase.app`, in a new tab.
## A separate sign-in
[Section titled “A separate sign-in”](#a-separate-sign-in)
Your Turbobase app is a different realm from the console, with its own credentials. The console sign-in (`console.turbobase.io`) is for managing your subscription — orgs, instances, billing. The app (`.turbobase.app`) is where your team does the actual work, and it has its own user store.
You set the **first app admin** during the Admin step of the setup wizard. Those are the credentials you use here — *not* your console email/password. From inside the app, that admin invites the rest of your team and manages their permissions; see [Users and permissions](/tenant/users-and-permissions/) and [Auth levels](/tenant/auth-levels/).
Note
The console and the app live on different domains by design, so their sessions never cross. There’s no single sign-on between them in the current release — sign into each separately.
## What you see first
[Section titled “What you see first”](#what-you-see-first)
When you sign in, the app is already populated from the source you connected during setup, Oracle NetSuite for most instances. From the app you can:
* Browse your mirrored data under **Data**, across the [data model](/tenant/data-model/) — the canonical tables your source maps into.
* Chat with **[Pilot](/tenant/pilot/), your AI assistant** — grounded in your own data, once an administrator has enabled Pilot chat for your instance. That setting covers the app; asking Pilot from a connected [channel](/tenant/channels/) works either way.
* Check [source health](/tenant/source-health/) to confirm your source is syncing.
* Add [custom fields](/tenant/custom-fields/) and [custom tables](/tenant/custom-tables/) on top of the mirrored data.
* Open the [knowledge base](/tenant/knowledge-base/) — your team’s living library of how the business runs.
* Review the [audit log](/tenant/audit-log/) of changes.
Administrators additionally get the [Development](/tenant/development/) pipeline — proposals, solutions, and tracked releases — and can build [apps](/build-apps/) on your data.
Note
If an administrator has shared an app with you but not the admin surface, your home is the **Apps launcher** at `/apps` — just the apps you’ve been given.
## Where to go from here
[Section titled “Where to go from here”](#where-to-go-from-here)
* Bring additional systems into the same canonical layer from the console: [Add sources](/console/add-sources/).
* Keep an eye on consumption and your bill: [Billing and usage](/console/billing-and-usage/).
* Go beyond the built-ins with [hooks](/extending/writing-hooks/) and [conduits](/extending/writing-conduits/).
# Provision your instance
> Sign up at the console and provision a dedicated Turbobase instance for your org.
Shipped
Provisioning is a console-only flow: you sign up, create an organization, create an instance, and watch it provision to **Ready**. Everything happens in the [console](/console/overview/) at `console.turbobase.io` — there is nothing to install.
Signup is invite-gated. If you don’t have an invite yet, request one at `console.turbobase.io/early-access`; we’ll send you an invite link when we’re ready for you.
## 1. Sign up
[Section titled “1. Sign up”](#1-sign-up)
1. Open your invite link, or go to `console.turbobase.io/signup`.
2. Enter your first and last name, email, and a password (at least 12 characters). The invite code travels with the link, so you usually don’t type it.
3. Verify your email when prompted, then you’re signed in to the console.
The console is the **customer realm** of Turbobase — a sign-in distinct from your live Turbobase app. See [Open your app](/getting-started/open-your-tenant/) for why they’re separate.
## 2. Create an organization
[Section titled “2. Create an organization”](#2-create-an-organization)
An organization (org) is the billing and ownership boundary that owns your instances. On first sign-in the console walks you into creating one:
1. Go to **Orgs → New** (`/orgs/new`).
2. Enter the org name. The console auto-suggests a URL-safe **slug** from the name; you can edit it.
3. Provide a contact email, and optionally a website and a phone number. Your role is shown as **Admin** and can’t be changed — the first member of a new org is always its admin.
4. Submit. You’re now an admin of the org.
One human can belong to multiple orgs and switch between them; see [Orgs and instances](/console/orgs-and-instances/).
## 3. Create an instance
[Section titled “3. Create an instance”](#3-create-an-instance)
An instance is your own Turbobase — its own database and app at `.turbobase.app`.
1. Go to **Instances → New** (`/instances/new`). The form is scoped to your **active org** (set by the org switcher in the nav).
2. Enter the instance name; the console auto-derives an instance **slug** you can edit. This slug becomes your subdomain, so choose it carefully.
3. The region is shown read-only: every instance runs in our **US East (Ashburn)** region today. The plan picker stays visible so you can see the tiers, but it’s locked while pricing is finalized: every new instance is created on the entry plan, **Ignition**.
4. Submit. The console redirects you to `/instances//provisioning`, where you watch the status live.
Note
Creating an instance requires **Manage instances** on the org. If you can’t see the form, ask an org admin to tick it for you. A newly self-signed-up org also needs staff approval before it can provision — if your org isn’t enabled yet, the page adds a **You’re on the waitlist** banner above the form with a sales address to write to. The form still works: your instance keeps its slug and plan and waits with a **New (no access)** badge until the org is enabled.
## 4. Watch it provision to Ready
[Section titled “4. Watch it provision to Ready”](#4-watch-it-provision-to-ready)
Provisioning runs through a small state machine. The console shows each stage as a friendly status label; you don’t have to do anything but wait (it takes a couple of minutes):
| What you see | What’s happening |
| ----------------------------------------- | ------------------------------------------------- |
| Provisioning… | The instance record is created and work begins |
| Provisioning… (database) | Your dedicated Postgres schema is built |
| Provisioning… (services) | The instance’s app and sync services are deployed |
| Provisioning… (waiting for health checks) | We wait for everything to report healthy |
| **Ready** | Your instance is live |
If something goes wrong the status flips to **Failed** — reach out to support. (You may also see **New (no access)** or **Pending** while the org waits on approval, as above.)
The full status vocabulary, including the upgrade and pause states, is documented in [Orgs and instances](/console/orgs-and-instances/).
## Next step
[Section titled “Next step”](#next-step)
A Ready instance has no data yet. Continue to [Connect your first source](/getting-started/connect-your-first-source/) to start the setup wizard and connect Oracle NetSuite.
# What is Turbobase?
> What Turbobase is, who it is for, and what you get when you provision an instance.
Shipped
Turbobase is an ERP operations and development platform for Oracle NetSuite. Connect your instance and Turbobase mirrors it into a live dedicated Postgres database. This queryable copy of that data allows an operator to diagnose, automate, and build without the traditional performance overhead of NetSuite.
You never install or run anything. Turbobase is a managed service: you sign up at the console, create an instance, and we provision the database, the sync engine, and your Turbobase app for you on dedicated infrastructure.
## Who it is for
[Section titled “Who it is for”](#who-it-is-for)
This guide is written for the **operator**, the person at your organization who signs up, provisions an instance, connects NetSuite, and hands the live app to their team. One operator gets the reach of a whole team of specialists: the platform builds, automates, diagnoses, and manages the instance alongside you. You work entirely through the [console](/console/overview/) at `console.turbobase.io`.
## What you get
[Section titled “What you get”](#what-you-get)
When you provision an instance you get:
* **A dedicated instance** at `.turbobase.app`, with its own Postgres database, never shared with another customer’s data.
* **Live NetSuite sync.** Turbobase mirrors your NetSuite records into your instance’s database and keeps it current. Write-back to the source is gated per source and turned on when you’re ready for it.
* **[Pilot](/tenant/pilot/), your AI assistant.** Chat grounded in your own data.
* **Development in the app.** A proposal-and-solution pipeline where you and Pilot shape changes in [Studio](/tenant/development/), then ship them as tracked releases.
* **[Apps](/build-apps/).** Internal apps built on your data from a plain-English brief and served inside your instance.
* **The [Analyzer](/tenant/analyzer/).** An inventory of your NetSuite customizations (scripts, custom fields, workflows, roles, forms) with the risk and improvement proposals that come out of it.
* **A [knowledge base](/tenant/knowledge-base/).** A living library of how your business runs that your team searches and that grounds Pilot’s answers.
* **Self-service management.** Orgs, instances, sources, billing, and usage, all from the console.
* **Extensibility.** Hooks, conduits, and a marketplace for going beyond the built-in behaviour (see [Extending Turbobase](/extending/writing-hooks/)).
## How it is managed
[Section titled “How it is managed”](#how-it-is-managed)
Two surfaces matter to you as a customer:
| Surface | Where | What you do there |
| ---------------------- | --------------------------- | --------------------------------------------------------------------------------------------- |
| **The console** | `console.turbobase.io` | Sign up, create orgs, provision and manage instances, connect sources, view billing and usage |
| **Your Turbobase app** | `.turbobase.app` | Your team’s day-to-day work with the live data |
The console and the app are separate sign-ins with separate credentials. See [Open your app](/getting-started/open-your-tenant/).
## Connecting additional sources
[Section titled “Connecting additional sources”](#connecting-additional-sources)
Turbobase runs on NetSuite and can bring other systems into the same canonical layer. A second source lands in the same Postgres, on the same canonical schema, under the same row-level security, so one query spans NetSuite and everything else instead of a round-trip through each vendor’s API.
* **Stripe is live** alongside NetSuite, on the same connector contract: webhook-led ingest, canonical projection, and write-back.
* **A broader set is registered** against that contract and offered in the console: QuickBooks, Xero, Salesforce, Zoho CRM, Shopify, HubSpot, Workday, Dynamics 365 Business Central, Jira, Asana, Notion, GitHub, Zendesk, Intercom, Ramp, Bill.com, GetSales.io, DocuSeal, PrintNode, WhatsApp Business, and the knowledge sources Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage. Each is enabled per instance, and [Add sources](/console/add-sources/) says what each one brings in.
* **[Atlas](/tenant/atlas/) consolidates identity across sources**, so the same real-world customer seen in NetSuite and in Stripe resolves to one canonical entity rather than two rows.
* **[Conduits](/extending/writing-conduits/)** pull from a system that has no connector at all and land the result on the same canonical tables.
* **More than one connection of the same source** is supported: two NetSuite accounts, two QuickBooks companies, Stripe sub-accounts. Each is its own connection, with its mirrored rows kept apart so records sharing an internal id are never merged.
**Standalone instances** (no upstream source at all) follow the same contract, with Turbobase itself as the system of record. The schema, RLS, hooks, guardrails, audit log, channels, and app surface are unchanged.
[Add sources](/console/add-sources/) covers the flow for connecting a source to an instance that is already running.
## Next step
[Section titled “Next step”](#next-step)
Ready to get an instance running? Start with [Provision your instance](/getting-started/provision-your-instance/).
# Architecture overview
Turbobase is an ERP operations and development platform for Oracle NetSuite. Your NetSuite records land in one Postgres your instance owns, so an AI agent (or any app you build) reads them from a single place instead of paying for a round-trip through the source’s API on every question. That is what lets one operator build, automate, diagnose, and manage an instance like an entire team of specialists.
The connector contract underneath is deliberately generic, so the same engine carries every other system you connect. NetSuite and Stripe are the live source connectors today. A broader set is registered against that same contract and offered in the console (QuickBooks, Xero, Salesforce, Zoho CRM, Shopify, HubSpot, Workday, Dynamics 365 Business Central, Jira, Asana, Notion, GitHub, Zendesk, Intercom, Ramp, Bill.com, GetSales.io, DocuSeal, PrintNode, and the knowledge sources Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage), each enabled per instance. Standalone instances (no upstream source) follow the same contract. See [Connecting additional sources](#connecting-additional-sources) below.
> New to the vocabulary? The [Glossary](/getting-started/glossary/) defines every word, and it is the one place each is defined. [Key concepts](/concepts/) then explains how the named pieces of the platform fit together (connectors, the canonical mirror, sync events, hooks, guardrails, the audit log, scheduled tasks, channels, Atlas, and the named agents) in one page. If you haven’t provisioned yet, [Getting started](/getting-started/what-is-turbobase/) is the front door.
## How the pieces fit together
[Section titled “How the pieces fit together”](#how-the-pieces-fit-together)
```plaintext
Source systems
──────────────
NetSuite Stripe QuickBooks / Salesforce / … (per instance)
Standalone (no upstream)
│ Webhooks (source → your Postgres)
│ REST / SDK (your Postgres → source)
▼
┌──────────────────────────────────────────────────────┐
│ Your Turbobase instance │
│ │
│ Bidirectional sync │
│ delta cursors · line fan-out · conflict resolver │
│ every row stamped with who wrote it last │
│ │
│ Named agents (managed AI model) │
│ Conductor · Mechanic · Pilot · Concierge │
│ per-instance guardrails for what AI may do │
│ │
│ Your Postgres (turbobase schema) │
│ Postgres reads · Realtime subscriptions · RLS │
└──────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ Consumers you build │
│ Web apps (SvelteKit, Next.js, React, …) │
│ Mobile apps via PostgREST + JWT │
│ Realtime subscribers, dashboards, AI agents │
└──────────────────────────────────────────────────────┘
```
## What happens when a record changes
[Section titled “What happens when a record changes”](#what-happens-when-a-record-changes)
**From the source into your Postgres.** When a record changes upstream — a sales order saved in NetSuite, a charge captured in Stripe — the source emits a signed webhook. Turbobase verifies the signature, normalises the record onto the canonical schema, and upserts it into your `turbobase` schema. Realtime publishes the row change to anything subscribed. For sources without webhooks, the sync engine pulls deltas on a cursor and produces the same result.
**From your Postgres back to the source.** A write to a canonical table marked `originating_system = 'hybrid'` queues a write-back. Hooks run first (validate, optionally rewrite the patch), guardrails decide whether the operation is allowed for the caller, and the connector pushes the patch to the source’s API. On success, the sync event commits. On failure, a `sync_failure` row is written and the Mechanic agent picks it up.
Write-back is gated per source and ships **off**: until it’s enabled for a source on your instance, the canonical tables still accept your writes but nothing is pushed upstream.
**Deleting a record.** You delete through a dedicated boundary — `del(recordType, { pgRowId })` (or `{ externalId }`) in the client, which calls `DELETE /v1/data/:recordType`. The instance soft-deletes (tombstones) the canonical row and, for a delete-capable bidirectional source, queues the delete to the source on the same audited, gated path as a write. A hard `DELETE` on a canonical table is rejected — an app has no delete grant there, and stamping `deleted_at` yourself is not the delete path — so the boundary is the only way to remove a record. A `pgRowId` that is not a Turbobase row id is refused with a typed `422` naming the field, before the instance attempts the delete — the same answer the write boundary gives for the same mistake, rather than a generic server error. (`externalId` is free-form text and is never screened.) It returns `{ deleted, enqueued }`; `enqueued` is false for a row that never synced upstream (a purely local tombstone). Delete-to-source is live for **NetSuite**; other sources are parked and their deletes are rejected (never silently upserted) until finished.
**Guardrails and rate caps.** Write-back is governed by guardrails your admin manages (Compliance → Guardrails). The instance has a global *posture* — **deny-by-default** (nothing writes back unless a rule allows it — an allow-list) or **allow-by-default** (everything writes back unless a rule denies it — a deny-list). Deletes are treated as a distinct, destructive operation: opening a record for *writes* does not open it for *deletes* (a `.delete` rule is separate from `.write`), and a delete is denied by default under deny-by-default. Circuit-breakers cap throughput so a runaway app can’t mass-mutate a live source: **max updates** and a tighter **max deletes**, each enforced **per minute, per hour, and per day**. Past any window’s cap, the excess is denied and recorded on the audit trail, and current usage against each cap is visible on the Guardrails page (with a 24-hour graph and an operator reset).
**When the source refuses a delete.** Some sources require more before they’ll delete — NetSuite, for example, can require a *deletion reason* on transactions, and refuses to delete a record other records depend on. Those come back as `sync_failure` rows (`deletion_reason_required`, `has_dependencies`), not silent failures, so you can supply the reason or clear the dependents.
**The record comes back when the delete does not happen.** The tombstone is committed before the delete is attempted, so a refusal used to leave the record hidden from your app forever while the source still held it. It no longer does: when the delete ends for good without the source performing it — refused, denied by a guardrail, or held for a confirmation nobody gave — the tombstone is reverted and the record is readable again. On every ending that records a write state it comes back carrying `write_status = 'blocked'` and a `record_write_state` row that says why; the one that does not is a delete stranded because we could not sign in to the source at all, which comes back with no write state of its own — reconnecting the source is the signal there, and the record is visible in the meantime rather than hidden. For a refusal about linked paperwork the reason code is `delete_blocked_by_dependents` and the message names the documents holding it (up to five, then a count), read out of your own mirror rather than from the source. A delete the source **performs** still removes the record exactly as before.
## Auth at a glance
[Section titled “Auth at a glance”](#auth-at-a-glance)
| Path | Mechanism |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Instance operators | Email + password → RS256 JWT in an HttpOnly session cookie |
| NetSuite portlet → Turbobase | A NetSuite portlet hands the browser a one-time code (or, on an account not yet moved over, a signed bridge JWT); Turbobase redeems it with NetSuite and exchanges it for a session cookie |
| Sync engine ↔ NetSuite | OAuth 2.0 with a rotated refresh token (PKCE supported) |
Row-level security scopes every Postgres read to the calling user via `has_capability()`, `scopes_match()`, and per-table policies. The anon key never bypasses RLS — a user without the right capability gets zero rows back, never someone else’s.
### How permission edits propagate
[Section titled “How permission edits propagate”](#how-permission-edits-propagate)
A Turbobase session is a sliding RS256 cookie, and how long it lasts is yours to set: a whole number of days from 1 to 30, instance-wide under **Settings → Sign-in → Sessions** and overridable for one role on the role editor. It is **one day** unless you change it. The capabilities + scopes minted into that JWT come from the connected source’s authoritative role (read at sign-in) and stay frozen for the cookie’s lifetime — there is no continual reconcile job that polls the source for role-permission edits. The refresh point is the next sign-in: when the cookie expires (or the user signs out and back in), the next mint reads the current source role and the user picks up any role-permission delta automatically.
The deliberate choice not to reconcile in the background is governance-driven. NetSuite, the lead source, does not let us deploy a User Event script on the `role` record — there is no cheap realtime signal for a role edit. A periodic poll over every linked user would chew connector governance for marginal benefit: signing out and back in applies a permission change immediately, which is what to do when you cannot wait for the session to run out — and the shorter you set the session, the smaller that wait is. For sources that *do* expose a role-change webhook in the future, the connector is free to wire one in; nothing in the schema or RLS layer assumes the JWT is the only mutator of `source_user_permissions`.
A `NULL` row value for a scope dimension is treated as “this row doesn’t carry this dimension” and is visible regardless of the user’s restriction. Non-NetSuite connectors (Stripe, Shopify, and so on) that don’t populate `location_id` / `department_id` / `class_id` need this so their rows stay readable under NetSuite-flavoured class / location / department scopes.
## Connecting additional sources
[Section titled “Connecting additional sources”](#connecting-additional-sources)
Turbobase runs on NetSuite and can bring other systems into the same canonical layer. This is an extension of the architecture above, not a separate one: a second source writes the same canonical tables, in the same Postgres, under the same RLS, so one query spans NetSuite and everything else.
**What is live and what is registered.** NetSuite and Stripe are the live source connectors. A broader set is registered against the same contract and offered in the console (QuickBooks, Xero, Salesforce, Zoho CRM, Shopify, HubSpot, Workday, Dynamics 365 Business Central, Jira, Asana, Notion, GitHub, Zendesk, Intercom, Ramp, Bill.com, GetSales.io, DocuSeal, PrintNode, and the knowledge sources Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage), each enabled per instance. Slack is not in that list: it is a [notification channel](/tenant/channels/), not a source.
**Cross-source identity.** When the same real-world entity appears in two sources, [Atlas](/tenant/atlas/) consolidates them onto one canonical entity. The matcher is deliberately connection-agnostic (it resolves on email, phone, tax id, domain, sku), which is what lets the same customer seen in two different accounts resolve to a single record rather than two.
**More than one connection of the same source.** An instance can hold several connections of one source: two NetSuite accounts, two QuickBooks companies, Stripe sub-accounts. Each is its own connection carrying its own opaque connection id, which is part of the natural key on every projection table, so a customer with internal id 123 in one account and a different customer 123 in another stay two rows and are never falsely merged.
**Systems with no connector.** [Conduits](/extending/writing-conduits/) pull from a system that has no connector at all and land the result on the same canonical tables, through the same adapter projection path.
**Scope dimensions across sources.** Rows from a source that doesn’t carry NetSuite’s class / location / department dimensions stay readable under those scopes. See [How permission edits propagate](#how-permission-edits-propagate) above for the `NULL` rule that makes this work.
### The connector contract
[Section titled “The connector contract”](#the-connector-contract)
A connector implements a small, fixed contract: verify webhook, parse envelope, list changes, write back, introspect the source’s schema. The sync engine, conflict resolver, agents, hooks, guardrails, and your apps are unchanged. NetSuite is the reference for stateful sources (deployed bundle, OAuth, governance limits); Stripe is the reference for stateless webhook-led sources. New connectors join via a managed onboarding flow, so talk to if you need a source that isn’t live yet.
## Cross-cutting subsystems
[Section titled “Cross-cutting subsystems”](#cross-cutting-subsystems)
Every connector sits inside the same set of generic subsystems. Each has a one-pager in [Key concepts](/concepts/); the diagram below shows where they sit on the write path.
```plaintext
Postgres row write (originating_system='hybrid')
│
▼
Realtime listener ──► queue a write-back sync_event
│
▼
Write-back worker
│
├─► Hooks (validate / rewrite)
├─► Guardrails (allow / audit / confirm / deny per op)
│ │
│ └─► Channels (Slack / log — for confirmations)
│
├─► Audit log (every privileged op)
│
▼
Connector pushes to the source ──► success → event committed
failure → sync_failure ──► Mechanic
```
| Subsystem | What it does |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Hooks** | Validate or rewrite outbound writes before they leave for the source. |
| **Guardrails** | Policy engine over privileged operations (allow / audit / confirm / deny), layered per source × per op-pattern × per actor. |
| **Audit log** | Append-only record of every privileged op, guardrail decision, and auth event. Visible in the operator dashboard. The system of record — kept for at least a year. |
| **Event log** | One queryable, tailable stream (`turbobase logs`, `GET /v1/events`) across guardrails, hooks, tasks, conduits and sync, threaded by one correlation id. Redacted at write, sampled, and expires — a diagnostic, not a system of record. |
| **Channels** | Pluggable notification fan-out (Slack, log) for confirmations and alerts. |
| **Tasks** | Cron-driven scheduled jobs — chat prompts, saved views, system maintenance jobs. |
| **Apps** | Chat-built apps that render over the canonical mirror. Embedded with a chat sidecar, or chromeless for dashboards and single-purpose links. |
| **Atlas** | Cross-source identity resolution — one canonical customer / vendor / contact when two sources both reference the same real-world entity. |
| **Mechanic** | Subscribes to `sync_failure`; applies pattern-specific resolutions inside your per-instance permission tiers. |
Adding a new connector means writing the connector — never extending the engine.
# Build apps on Turbobase
> Build a static React SPA served same-origin at your Turbobase instance. Auth, reads, realtime, and writes through one canonical contract.
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://.turbobase.app/a/`, 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”](#what-you-have-to-work-with)
* **Postgres reads** through PostgREST at `${SUPABASE_URL}/rest/v1/
`, 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](#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://.turbobase.app/a/`, 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](#typed-clients) below.
## Authenticate
[Section titled “Authenticate”](#authenticate)
Your app never handles a password and ships no sign-in page. It’s served **same-origin** with the instance at `https://.turbobase.app/a/`, 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 ` 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](/build-apps/auth/).
**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”](#read-with-postgrest)
```ts
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 .
### Batch the cold-load fan-out
[Section titled “Batch the cold-load fan-out”](#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](/build-apps/reading-data/#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()`:
```ts
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](#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()`](/build-apps/reading-data/#multiplex-the-cold-load-fan-out-with-batch).
## Subscribe to Realtime
[Section titled “Subscribe to Realtime”](#subscribe-to-realtime)
```ts
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”](#deletes-never-arrive-so-reconcile-instead)
Danger
**Do not write an `on DELETE → remove from the list` handler.** It can never fire for a deleted record. Nothing in Turbobase hard-deletes a mirrored row: a delete — whether your app asked for it via `DELETE /v1/data/…` or the source system reported one — is an `update … set deleted_at = now()`. Tombstones are soft on purpose, because a hard `DELETE` would cascade the line rows away and make a source-side un-delete unrecoverable.
The tombstone is then hidden by a **restrictive** `SELECT` policy (`deleted_at is null`, on `authenticated` and `anon`) that every mirror table carries. Realtime authorises each frame by re-running RLS against the row it is about to send, so the post-update row fails that probe and the frame is dropped. Your subscriber gets **no `INSERT`, no `UPDATE`, and no `DELETE`** — the row simply stops existing, with no event to tell you.
**On an instance with more than one system connected, a delete is routed by the record’s own system** — read off the record, not off the system the signed-in session belongs to. Two things follow for your code. A delete of a record belonging to a system whose upstream delete is not implemented yet comes back refused, naming that system, so handle the refusal rather than assuming a `DELETE` that returns 2xx is the only outcome. And `DELETE /v1/data/{recordType}?externalId=…` is refused `422 SOURCE_AMBIGUOUS` when that id exists in more than one connected system, or in two accounts of one system — a source’s own id is only unique within one connection. Pass `pgRowId` instead, which is on every row your reads return. (The flip side: that lookup is no longer narrowed to the connection your session signed in against, so an id that *is* unique across your connections now resolves the record it names rather than coming back `404`.) Two more refusals are worth handling: a record whose system has since been disconnected is refused rather than queued to another system, and one whose own system cannot be read at that moment is refused as retryable — which is not a statement that the record is gone.
**A record type two of your systems spell the same way needs `sourceSystem`.** `account` is the one that does today: it means a GL account in NetSuite and a customer in Salesforce and in Zoho CRM, so with two of those connected the record type alone does not say which table a delete addresses, and the delete is refused `422 SOURCE_AMBIGUOUS` rather than applied to one of them. Pass `?sourceSystem=netsuite` to say which you mean. It is a qualifier, not a destination — it says which table to look in, the record’s own system still decides where the delete is sent, and a key that matches no row in the table you qualified comes back `404` rather than removing another system’s copy. If only one system is connected you will never see this refusal and need not send the parameter.
**One published table is the exception: `entity_canonical`.** The combined identity list behind the `turbobase:identity_` record types is a consolidation rather than a mirror — it carries no `deleted_at`, and an Atlas merge or repair removes the row outright — so it *does* emit real `DELETE` frames. If you subscribe to it, write the handler. See below for what such a frame carries.
**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`](/build-apps/reading-data/#multiplex-the-cold-load-fan-out-with-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”](#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.
## Write
[Section titled “Write”](#write)
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.
```ts
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”](#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.
```ts
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.` 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:
```ts
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:
` 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:
` 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”](#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.
```bash
curl --cookie 'turbobase_session=...' \
https://.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.
```bash
# query param (browser-friendly; catalog-kind token only)
curl 'https://.turbobase.app/v1/catalog/records?token='
# bearer header (curl / Claude / CI)
curl -H 'Authorization: Bearer ' \
https://.turbobase.app/v1/catalog/records
```
Either mode returns:
```json
{
"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 `_id` ↔ `_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”](#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”](#money-is-in-the-documents-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:
```json
{ "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.
### The exchange rate on a given date
[Section titled “The exchange rate on a given date”](#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:
```plaintext
/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=1
```
Three things about that query are not optional:
* **`effective_date=lte.`.** 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.`.** 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”](#ground-an-agent-in-the-instance)
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:
```bash
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 `_id` ↔ `_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”](#typed-clients)
Generate a `Database` type for your client app with the Supabase CLI:
```bash
supabase gen types typescript --project-id --schema turbobase > src/lib/turbobase.d.ts
```
Then point supabase-js at it:
```ts
import type { Database } from './lib/turbobase';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(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 per customer
[Section titled “One instance per customer”](#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](#the-exchange-rate-on-a-given-date) 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”](#deploy-your-app)
Your app is a static bundle — no Node server, no SSR. Build it, then hand the `dist/` to your instance:
```bash
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 ./dist`.
Either way the app is served at `https://.turbobase.app/a/` — 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-before-you-go-live)
`--preview` builds and uploads exactly as a deploy does, but serves the result at its **own URL** and leaves `/a/` untouched:
```sh
turbobase apps deploy . --preview --label "new pick screen"
# preview v7 → https://.turbobase.app/_p/pv_… (expires in 7 days; /a/ 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 ` makes it live (the reviewed bytes ship as-is; nothing is rebuilt), or `turbobase apps preview discard` closes it. `turbobase apps preview list ` shows what is open, and `--json` makes that scriptable.
`--replace-label ""` 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”](#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”](#preflight-what-a-deploy-would-do)
```sh
turbobase apps plan . --app
```
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](#roles-you-declare-an-admin-assigns)). `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”](#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.
```sh
turbobase apps pull ./ # write the current source to disk
# ... edit ...
turbobase apps push ./ # record your edits as a new revision
turbobase apps deploy ./ # build + go live
```
**A deploy from a source directory records source too.** Run `turbobase apps deploy ./` 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 ./` 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](/build-apps/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](/build-apps/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”](#declare-the-source-writes-your-app-needs)
If your app writes back to a source system, say so in `public/turbobase.json`:
```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](/tenant/guardrails/) and **refuses, naming the rule**, if one denies it:
```plaintext
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.
## App manifest — name, description, icon
[Section titled “App manifest — name, description, icon”](#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:
```json
{
"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”](#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 ``, 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””](#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:
```json
{
"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.
## Roles: you declare, an admin assigns
[Section titled “Roles: you declare, an admin assigns”](#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:
```json
{
"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 `:*` 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 ` 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”](#scopes-restricting-a-roles-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:
```json
{
"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.}}` 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:`. 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.
### Capabilities, and gating your UI on them
[Section titled “Capabilities, and gating your UI on them”](#capabilities-and-gating-your-ui-on-them)
A capability is `:` — `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`:
```tsx
import { hasCapability, write, TurbobasePermissionError } from './lib/turbobase';
{hasCapability('read:invoice') && Invoices}
if (!hasCapability('read:invoice')) {
return You don't have access to invoices. Ask an admin for the Auditor role.;
}
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”](#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:
```json
{
"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:
```plaintext
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.
## Share and launch
[Section titled “Share and launch”](#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/` 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”](#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](https://docs.anthropic.com/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”](#one-shot-prompt)
Open an empty directory, run `claude`, then paste:
```text
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/
(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 ./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.
```
### Why this works
[Section titled “Why this works”](#why-this-works)
* **Auth.** The app is served same-origin at `https://.turbobase.app/a/`, 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 ` + `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](#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: , ... }, idempotencyKey }` instead, so NetSuite’s lifecycle verbs (line sourcing, GL inheritance) run.
### Variations
[Section titled “Variations”](#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/`. 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.
### Run the generated app
[Section titled “Run the generated app”](#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:
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”](#smoke-test-the-running-app--buildlint-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 (`:
(...)`) 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”](#where-to-go-from-here)
* Read the [prompt skills page](skills/) 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/.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`](https://console.turbobase.io/).
# App auth wiring
> How a hosted Turbobase app authenticates — served same-origin under your instance, it inherits the session cookie and reads tb_jwt for supabase-js. Copy the one small browser client helper verbatim.
This page is the single source of truth for how a hosted Turbobase app authenticates a user, and wires supabase-js so that PostgREST reads and Realtime channels carry the user’s role into RLS. Every app template links here instead of inlining the auth boilerplate per prompt.
If you are writing a new template prompt, say *“Wire auth per `https://docs.turbobase.io/build-apps/auth/`”* and skip the inline copy.
## Your app is served same-origin — there is no sign-in page to build
[Section titled “Your app is served same-origin — there is no sign-in page to build”](#your-app-is-served-same-origin--there-is-no-sign-in-page-to-build)
A hosted app is a **static SPA** that Turbobase serves from your instance at `https://.turbobase.app/a/` — the **same origin** as the instance’s API, PostgREST, and Realtime. That single fact removes the entire auth surface a cross-origin app would have to carry (a sign-in page, an origin allowlist, a single-use sign-in code, a server-side redemption route, CORS). None of it exists here.
Because the app is same-origin:
* **It inherits the instance session.** When the browser loads `/a/`, it already carries the instance’s `turbobase_session` (HttpOnly) cookie. If the visitor is **not** signed in, the serving route **302s them to `/login`** on the instance *before your SPA ever loads*. So your app renders only for authenticated users — **it has no sign-in page, no sign-in form, no `/login` route, no guard/redirect logic of its own.**
* **Reads and Realtime go straight to PostgREST**, same-origin, over supabase-js.
* **Writes go straight to the write boundary** (`POST /v1/data/:recordType`), same-origin — the browser attaches the `turbobase_session` cookie automatically. No Bearer token, no proxy route handler.
```plaintext
Browser → GET /a//… (served by the instance)
↓ no session cookie?
302 → /login (the instance's own sign-in page, same origin)
user signs in on the instance
↓ session cookie is now set, first-party
GET /a//… your SPA loads, already authenticated
↓
reads → GET /rest/v1/… (supabase-js, same-origin)
writes → POST /v1/data/… (fetch, credentials:'same-origin')
realtime → wss same-origin
```
The only cookie your code touches is **`tb_jwt`** — a non-HttpOnly mirror of the session JWT that the instance sets alongside `turbobase_session` at sign-in. supabase-js runs in the browser and can’t read HttpOnly cookies, so it reads `tb_jwt` for the `apikey`/`Authorization` value. Same JWT, readable cookie name.
## What an app can reach — read this before you install one
[Section titled “What an app can reach — read this before you install one”](#what-an-app-can-reach--read-this-before-you-install-one)
An app runs with the **full authority of the signed-in user**. It is served same-origin and reads that user’s own session JWT, so to PostgREST, Realtime and the write boundary it *is* that user.
Concretely, an app you install can:
* read **every table the user’s RLS allows** — not only the tables its manifest declares;
* subscribe to **every table in the realtime publication**, on the same terms;
* act with the user’s role. If the user is an admin, the app is an admin — an “admin-only” RLS policy is not a barrier between your data and app code.
**The manifest is not an authorization boundary.** It describes what an app intends to use; it does not constrain what the app *can* reach at runtime. Treat installing an app as granting it that user’s access, and install only apps you would trust with the account.
This holds for the manifest’s `roles` block too, and it is worth being explicit because the block *looks* like it grants something. Deploying a NEW role creates a role row — nothing more. **The role has no holders until an admin assigns it**, so an app cannot widen its own access, or anyone else’s, by declaring a role it needs. `appRole` is refused rather than accepted-and-ignored, because admin is assigned, never authored.
Once an admin HAS assigned a declared role, a redeploy does change what its holders can reach, within limits an admin can see. It can add capabilities and never removes one — and never re-adds one an admin removed, nor a wildcard that would grant it again, until an admin grants it back. It can set the role’s `scopes` — narrow its rows with a restriction, or grant it a Slack channel’s history with `content` — and it can take back only what the same app put there: a channel an admin added stays, even if the app later declares the same channel (finding it already on the role, the deploy adopts it and does not own it); a channel an admin removed is never re-added by a deploy — not while the app keeps declaring it, and not after the app stopped declaring it, dropped the role, or was deleted and reinstalled — until an admin puts it back; a restriction the app wrote and an admin then changed keeps the admin’s value (and if that value is wider, any capabilities the deploy would add to the role are refused); and a restriction that would overwrite an admin’s or another app’s is refused, failing the deploy. The deploy reports every one of those changes, and so does `turbobase apps plan` when it is told which app it is planning (`--app `). The result is on **Compliance › Users › Roles** (`/admin/roles`) for an admin to read and undo.
One of those grants reaches further than Slack itself would: a holder of a role whose `content` lists a channel reads that channel’s history even if they are not a member of it, or not a Slack user at all — private channels included. That is the point of it, and it is bounded tightly: exact channels only, never a wildcard and never a direct message; only together with `read:memory`; only while the user is acting as that role; and gone on their very next request once the channel is removed from the role. It reads history — and, when the role also holds `write:activity:message`, posts a new message into the channel, by its id; it never edits or deletes a message there, and never reads the channel’s `channel_message` rows. Pilot is unchanged. `write:activity:message` is deliberately not `write:activity`: the narrow capability posts only into channels the role is granted and does nothing else, where `write:activity` is every write on the shared `activity` table — other connectors’ tasks and posts, edits of existing rows, and a post into every public channel.
The client-side `hasCapability()` helper is on the same footing as the manifest. It reads the session’s own claims to decide what to render, so it is exactly as trustworthy as the browser it runs in. Use it to explain a denial — a screen that says “ask your admin for the Auditor role” beats an empty table — never to protect data. Row-level security is what actually refuses the request, and it refuses it whether or not the app bothered to ask.
This is why secret-bearing columns are revoked at the grant level rather than merely gated by an admin RLS policy: a policy that says “admins only” is satisfied by any script running in an admin’s browser tab. Columns such as `source_install.session_private_key` and `channel_config.bot_token` are therefore unreachable through `/rest/v1/*` by any session role, admin included.
Narrowing this — an audience-scoped token with a manifest-derived table allowlist — is a deliberate future change, not a shipped guarantee. Until then, the rule above is the contract.
## The one file you copy — `lib/turbobase/client.ts`
[Section titled “The one file you copy — lib/turbobase/client.ts”](#the-one-file-you-copy--libturbobaseclientts)
This is the entire auth layer. There are **no route handlers** to paste — a same-origin SPA has no server of its own.
```ts
import { createClient } from '@supabase/supabase-js';
// Same-origin in production: the instance's API + PostgREST + Realtime are all on
// this origin. VITE_SUPABASE_URL is only for local dev (see "Local dev" below).
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || window.location.origin;
function readCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[1]) : null;
}
/** supabase-js client for reads + Realtime, authed by the instance session. */
export function browserSupabase() {
// Prod: the readable tb_jwt cookie. Dev: an optional pasted token fallback.
const jwt = readCookie('tb_jwt') ?? import.meta.env.VITE_TB_JWT ?? '';
const client = createClient(SUPABASE_URL, jwt, {
db: { schema: 'turbobase' },
global: {
headers: {
Authorization: `Bearer ${jwt}`,
'Accept-Profile': 'turbobase',
'Content-Profile': 'turbobase',
},
},
auth: { persistSession: false, autoRefreshToken: false },
});
if (jwt) client.realtime.setAuth(jwt);
return client;
}
/**
* Writes go through the canonical write boundary, same-origin. The HttpOnly
* turbobase_session cookie rides automatically with `credentials:'same-origin'`
* — no Bearer token, no proxy. Reads stay on PostgREST (above); only mutations
* go through the boundary.
*/
export async function writeRecord(recordType: string, body: unknown): Promise {
return fetch(`/v1/data/${recordType}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
```
That’s it — no server-side route handlers and no route guard: a same-origin SPA has none of the auth scaffolding a cross-origin app needs. The serving route handles the not-signed-in case for you.
## Why the JWT goes in the `apikey` position
[Section titled “Why the JWT goes in the apikey position”](#why-the-jwt-goes-in-the-apikey-position)
`createClient(SUPABASE_URL, jwt, …)` — the second argument is normally the public anon key. We pass the session JWT instead. Why:
* **The `Authorization: Bearer ` header is the real auth signal.** RLS evaluates the JWT in that header — never the `apikey` argument. Passing the JWT in both keeps the two consistent.
* **The session JWT is signed by your instance**, so Realtime’s JWKS trusts it. (In local dev, the bundled anon key is signed with a `kid` the instance’s Realtime no longer trusts, which triggers WebSocket 403s — another reason to use the session JWT.)
If you have a reason to pass the anon key instead, get it from `/admin/settings` and set `VITE_TENANT_ANON_KEY`, then swap the first `createClient` argument — the `Authorization` header still does the work.
## Local dev — a Vite proxy makes localhost same-origin
[Section titled “Local dev — a Vite proxy makes localhost same-origin”](#local-dev--a-vite-proxy-makes-localhost-same-origin)
While you build the app, it runs on its own dev server (`http://localhost:3000`), which is a **different origin** from the instance — so the same-origin cookie trick doesn’t apply directly. The fix is a Vite dev **proxy**: forward the instance paths through your dev server so the browser sees one origin.
vite.config.ts
```ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const TENANT = process.env.SUPABASE_URL || 'http://localhost:54331';
const API = process.env.TURBOBASE_API_URL || 'http://localhost:4100';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/rest': { target: TENANT, changeOrigin: true },
'/realtime': { target: TENANT, changeOrigin: true, ws: true },
'/v1': { target: API, changeOrigin: true },
'/auth': { target: API, changeOrigin: true },
'/login': { target: API, changeOrigin: true },
},
},
});
```
Now open `http://localhost:3000`: an unauthenticated load proxies to the instance’s `/login`, you sign in, the cookies land on `localhost:3000`, and reads / writes / Realtime all work same-origin — exactly as they will in production. For a quick data smoke without signing in through the proxy, paste a `tb_jwt` from a signed-in browser session on the instance into `VITE_TB_JWT` (dev only) and the client helper above uses it as a fallback.
## Env vars
[Section titled “Env vars”](#env-vars)
| Var | Where | Value |
| ---------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SUPABASE_URL` | dev only (`vite.config.ts`) | Instance PostgREST/Realtime origin for the dev proxy. Prod: unused — the app uses `window.location.origin`. |
| `TURBOBASE_API_URL` | dev only (`vite.config.ts`) | Instance API origin (`/v1`, `/auth`) for the dev proxy. Prod: unused (same-origin). |
| `VITE_TB_JWT` | dev only, optional | A pasted session JWT the client falls back to when the `tb_jwt` cookie is absent (local iteration without signing in through the proxy). Never set in production. |
| `VITE_TENANT_ANON_KEY` | optional | Public anon key from `/admin/settings`, if you prefer it over the session JWT in the `apikey` slot. |
There are **no server-only env vars** — a static SPA has no server. A secret an app would have needed (e.g. an AI key for receipt vision) is not held by the app at all; it calls a first-party instance endpoint instead (see the expenses template’s `POST /v1/apps/receipt-extract`).
## Realtime
[Section titled “Realtime”](#realtime)
Reads paint first, then Realtime streams deltas into the same state. `browserSupabase()` already calls `client.realtime.setAuth(jwt)`, so channels carry the user’s role into RLS:
```ts
const sb = browserSupabase();
sb.channel('dash:invoice')
.on('postgres_changes', { event: '*', schema: 'turbobase', table: 'invoice' }, (payload) => {
// INSERT → prepend; UPDATE → merge by external_id / pg_row_id.
// No DELETE handler: deletes are soft tombstones hidden by RLS, so no frame
// is emitted at all — removals come from a reconciliation sweep instead.
})
.subscribe();
```
Deletes are the one thing this stream does **not** carry — see [Deletes never arrive](/build-apps/#deletes-never-arrive-so-reconcile-instead) for the mechanism and the sweep that replaces them.
## Refresh, expiry, and logout
[Section titled “Refresh, expiry, and logout”](#refresh-expiry-and-logout)
* **Expiry.** The session JWT follows the instance’s own session length — **1 day** by default, and an administrator may set it per instance or per role — sliding, so every authenticated request resets it. Your app does not refresh manually. If a read or write returns 401 (the session lapsed mid-visit), send the user back to the instance’s sign-in page: `window.location.href = '/login'`. The serving route takes it from there.
* **A session that ENDED answers 403, not 401, and it is not the same thing.** A session can stop being valid long before its token expires: the person signed out in another tab, an administrator suspended them, or their role was changed. Turbobase refuses those at the database, so a read or write of your instance’s tables that used to work comes back **403** — through `/rest/v1`, through `POST /v1/data` and through the live data stream alike. Handle it the same way you handle a 401, and treat the pair as one case:
```ts
if (res.status === 401 || res.status === 403) {
// The session lapsed, ended or changed. Nothing to retry — re-authenticate.
window.location.href = '/login';
return;
}
```
Two things follow for your app. **Do not retry a 403** — it is a statement about the session, not a transient failure, and retrying it loops. **Do not treat it as “no data”** — the refusal is deliberately an error rather than an empty list, precisely so you can tell the two apart.
* **The 403 says which kind it is, in `details`.** A 403 from `/rest/v1` carries a JSON body, not an empty one, and `details` is the field to read: `"turbobase:session_dead"` means the session itself has ended and signing in again fixes it. Any other 403 means this session may not touch that table or those rows, which signing in again will not change — so an app that tells the two apart can send one user to `/login` and show the other a permissions message instead of bouncing them in a loop.
```json
{
"code": "42501",
"details": "turbobase:session_dead",
"message": "this sign-in session has ended",
"hint": "Sign in again to continue."
}
```
`message` says which of the three causes it was — ended, suspended, or access changed — and is written to be shown to a person. Read `details` to branch, never `message`.
* **A dead session can still answer 200, so never read a 200 as proof it is live.** The clearest case is a read with nothing to return: the check runs once per statement and only when the statement actually reaches rows, so a query over a table that is empty for you can come back `200 []` on a session that has ended. That one is not a way in — there is nothing there to read. Treat it as the general rule rather than the only exception: handle the 403 wherever it appears, and do not infer that a session is still good from a request that succeeded.
* **On `POST /v1/batch` the 403 is on the SUB-RESULT, not on the response.** The batch is partial-success by design: the envelope answers `200` and each sub-query carries its own `{ ok, status, body }`, so a session that ends between one read and the next comes back as `200` with `results[i].ok === false` and `results[i].status === 403` — the same body, with the same `details`, one level down. Check each sub-result’s own status before you read its `body` as rows; an app that branches on the envelope alone will hand an error object to a screen as data, which is the “no data” mistake above wearing a 200.
```ts
const { results } = await batch([...]);
const dead = results.find((r) => r.status === 403 && r.body?.details === 'turbobase:session_dead');
if (dead) {
window.location.href = '/login';
return;
}
```
* **A live subscription stops delivering changes too, and silence is not a signal.** Realtime reads under the same session, so changes stop arriving once it ends. Whether your client is told, or simply goes quiet, is not something to build on — so a long-lived screen should re-read its data on reconnect rather than assume no events means nothing changed.
* **A changed role recovers in one request to the instance — which is why the redirect above is the right move.** When an administrator edits the role a signed-in person holds, the instance re-mints their session cookie the next time the browser asks the *instance* for something: a page load, or a write through `POST /v1/data`. A `/rest/v1` read goes straight to PostgREST and refreshes nothing, so an app that only reads can sit on 403 indefinitely. Sending the user to `/login` is what breaks that: the navigation reaches the instance, the cookie is replaced with one carrying their new access, and somebody whose session is otherwise fine is not asked to sign in again.
* **Sign-out.** Signing out is the instance’s concern — the app is embedded in it, and there is no app-owned session to clear. `/auth/logout` takes a **POST**, not a link: it clears `turbobase_session` and the `tb_jwt` twin and answers `{ ok: true, redirect: '/login' }`, so send the browser where it says.
```ts
async function signOut() {
const res = await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
const { redirect } = await res.json();
window.location.href = redirect ?? '/login';
}
```
## What this page deliberately does NOT cover
[Section titled “What this page deliberately does NOT cover”](#what-this-page-deliberately-does-not-cover)
* Sign-up. Turbobase instances don’t self-serve user sign-ups — an admin invites a user (email + role) via `/admin/users`, and the row links to the person’s identity on their first sign-in (matched by verified email).
* Password reset. Credentials live in the instance’s identity provider, which owns reset and email verification.
* Hosting / deploy. Build your SPA to `dist/`, then deploy it from **Development › App Management → Deploy a version** (or `turbobase apps deploy ./dist`). See [`Build apps on Turbobase`](/build-apps/).
# Connect Claude Code to your instance
> Point Claude Code (or Cursor, or any MCP client) on your own machine at your Turbobase instance, so it knows your tables, your knowledge base, and your business processes — then build and ship an app with it.
You can point Claude Code on your laptop at your Turbobase instance. Once connected it can read your record catalog, your published knowledge base, and the record reference docs for your connected sources — and it can create, refine, and ship a hosted app.
This is the do-it-yourself path. If you’d rather describe the app in a chat and have Turbobase build it, use the template gallery in your app instead — **Development › Proposals**, the Templates icon in the header; see [Development](/tenant/development/). The two paths meet in the middle — an app started from a proposal can be pulled down and refined here, and an app authored here shows up in the same place.
## The two endpoints
[Section titled “The two endpoints”](#the-two-endpoints)
Your instance exposes **two** MCP servers. They do different jobs and authenticate differently, and you’ll want both.
| | Data | Authoring |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Endpoint | `https://.turbobase.app/mcp` | `https://.turbobase.app/mcp/authoring` |
| Sign-in | OAuth — a browser window, your normal sign-in | A personal access token |
| Gives you | Your catalog, your knowledge base, record anatomy, live rows, Turbobase’s own documentation — plus customization search for an administrator | Create / pull / push apps, scaffold + deploy extensions, Turbobase’s own documentation, customization search |
| Scoped to | **Your** permissions, row by row | Instance admin |
The data endpoint is what makes the difference between an agent that guesses your schema and one that knows it. The authoring endpoint is what lets it ship.
**Both endpoints can read these pages, so your session need not go out to the web for them.** `search_turbobase_docs` takes a phrase and returns ranked hits; `read_turbobase_doc` takes a hit’s slug and returns the whole page. The pages are the ones your instance’s own release shipped with, so what your assistant reads describes the version you are building against, and every answer names that version. They are deliberately separate from `search_documentation` / `read_document`, which read **your** knowledge base — your processes and policies. Platform questions (“what does a hook receive”) go to the first pair; business questions (“how do we handle returns”) to the second. Either pair can be used inside a `batch_read` on the data endpoint, so a search and two page reads are one call. If you prefer plain HTTP, the same corpus is at `GET /v1/catalog/docs/search?q=…` and `GET /v1/catalog/docs/document/:slug` on the `catalog:read` scope your token already carries.
**Customization search is on both, and on the data endpoint it is administrator-only.** `search_catalog` and `explain_object` — “which script handles rebates?”, then that script in depth — answer over your whole connected account, so there is nothing to narrow person by person. On the data endpoint an administrator gets them with no token at all; anyone else is not offered them, and their assistant will say it has no tool for the job rather than refusing halfway through. On the authoring endpoint they come with the token, which is instance-admin by definition.
### Shipping over the authoring endpoint needs your explicit approval
[Section titled “Shipping over the authoring endpoint needs your explicit approval”](#shipping-over-the-authoring-endpoint-needs-your-explicit-approval)
Over the network, every tool that ships something asks for `approve: true` before it acts, and answers `approval_required` without it: `deploy`, `apps_push`, `tables_apply`, and — when your token carries `analyzer:run` / `releases:write` — every Analyzer tool that deploys a bundle, ships or rolls back a release, or toggles managed-script instrumentation. Claude Code shows you that answer and asks; say yes and it re-invokes the tool with the approval. Read-only tools never ask. The endpoint also takes one request per call: a JSON-RPC batch (an array body) is refused, which no MCP client sends in normal use.
## Setup
[Section titled “Setup”](#setup)
### 1. Turn the MCP server on
[Section titled “1. Turn the MCP server on”](#1-turn-the-mcp-server-on)
An administrator enables it at **Settings › Integrations & AI access**. Until it’s on, both endpoints refuse every caller. That page also shows both URLs, filled in for your instance.
### 2. Mint a token
[Section titled “2. Mint a token”](#2-mint-a-token)
Sign in with your Turbobase account first: a token belongs to a Turbobase account, so this page is not offered to administrators who opened the app from inside NetSuite.
On the **Access tokens** page — your account menu, or `/settings/tokens` — mint a personal access token with:
* `extensions:deploy` — the whole hosted-app surface (create, push, pull, list)
* `mcp:authoring` — the authoring endpoint itself
* `catalog:read` — read the record catalog
Copy it when shown; it’s stored hashed. Add `analyzer:run` and `releases:write` only if you also want the Analyzer and release tools. **`analyzer:run` authorizes source-system work**, including account capture and deploying a managed bundle into your connected Oracle NetSuite account. App work needs none of that.
### 3. Install the CLI and sign in
[Section titled “3. Install the CLI and sign in”](#3-install-the-cli-and-sign-in)
```sh
curl -fsSL https://console.turbobase.io/install-cli.sh | bash
turbobase auth login --base-url https://.turbobase.app --token tb_pat_your_token_here
```
On Windows: `powershell -c "irm https://console.turbobase.io/install-cli.ps1 | iex"`. Both commands — and a `turbobase auth login` line with your instance URL already filled in — are in your instance under **Settings → Integrations & AI access**. Prefer npm? `npm install -g @turbobase/cli` works too.
One instance, one login. Every command below uses it — you never pass a URL again.
### 4. Wire up Claude Code
[Section titled “4. Wire up Claude Code”](#4-wire-up-claude-code)
If you’re building an **app**, skip this step — `turbobase apps init` (below) lays the wiring down as part of the project.
For a hook-authoring workspace, run it in an empty directory:
```sh
turbobase project init
```
Either way you get a `.mcp.json` already filled in with your instance URL and token (both MCP servers), a `CLAUDE.md`, and a `.claude/` directory of skills. Start Claude Code in that directory. The first time it uses the data server it opens your instance’s sign-in page — there’s no client ID to paste.
`.mcp.json` holds a live token. App projects gitignore it for you; in a hooks workspace, keep it out of version control yourself.
## Building an app
[Section titled “Building an app”](#building-an-app)
### Start from scratch
[Section titled “Start from scratch”](#start-from-scratch)
```sh
turbobase apps init collections # scaffold the project + the Claude wiring
cd collections
turbobase apps create collections # register it on your instance
claude
```
`apps init` lays down the same scaffold a proposal-generated app gets, and it is more than the SPA:
* **The app** — Vite + React + TypeScript, Tailwind v4 and shadcn/ui (New York, neutral) per the [design system](/build-apps/design-system/), the auth-wired client, and a starter component that already does a row-level-security-scoped read, a write and a delete.
* **The rest of the deployed state** — `hooks/` with a worked `before_save` example, `conduits/`, and `turbobase/sync.json` + `turbobase/schedules.json`. See [deploy, plan, promote](/extending/deploy-plan-promote/): your repo is the desired state, and merging applies it.
* **The CI workflow** — `.github/workflows/turbobase-app-deploy.yml`: a preview URL per pull request (updated in place on every push), production on merge to your default branch.
* **The Claude Code wiring** — `CLAUDE.md`, `.mcp.json` for both MCP servers, `.claude/` skills, and a session hook that refreshes your generated types, an instance grounding brief, and `ADOPT.md` (platform changes this project should act on).
It also switches on a **Turbobase consultant** output style, written for ERP consultants, NetSuite administrators and business analysts: business outcome first, source-system vocabulary, and short answers unless you ask for detail. Pick a different style in `/config`, or delete the `outputStyle` line in `.claude/settings.json`, if you would rather have stock Claude Code.
Nothing to hand-write, and nothing to remember to run second. `apps init` never overwrites an existing file, so re-running it in an older project is how you pick up additions.
Its dev server proxies `/rest`, `/realtime`, `/v1`, `/auth` and `/login` to your instance, so `npm run dev` behaves same-origin: sign in on first load and reads, writes and Realtime all work exactly as they will in production. The app is served at `http://localhost:3000/a/collections/` — not `/`, which redirects there.
Then ask Claude Code to build the thing. A useful prompt names the records, so it goes and looks them up:
> Look at the customer and invoice record types in the catalog, read our knowledge base on the collections process, then rewrite src/App.tsx as a list of overdue invoices grouped by customer. Follow the Turbobase design system.
When you like it:
```sh
turbobase apps push collections # store the source on your instance
turbobase apps deploy collections # build + go live
```
Your app is live at `https://.turbobase.app/a/collections`.
### Start from a proposal
[Section titled “Start from a proposal”](#start-from-a-proposal)
If the app was generated in Studio, pull it down and keep going:
```sh
turbobase apps pull collections ./collections
```
Refine it with Claude Code, then push your changes back and deploy:
```sh
turbobase apps push collections ./collections
turbobase apps deploy collections
```
Pushing is what keeps the instance holding the source of record. Skip it and your changes exist only on that laptop — the next `apps pull`, from any machine, returns the version before your edits.
## Push and deploy are different things
[Section titled “Push and deploy are different things”](#push-and-deploy-are-different-things)
This trips people up, so it’s worth being explicit:
* **`apps push`** records source. Nothing is built, nothing changes for your users.
* **`apps deploy`** builds the app on your machine and deploys the result. This is what goes live.
You can push a dozen times while iterating and deploy once.
There is one place the difference bites beyond “where does my source live”, and it is easy to miss: **deploy triggers the knowledge ingest below, but push is what supplies the files it reads.** A deploy uploads your built bundle and no markdown at all. Skip the push and the ingest runs against your previous revision, finds nothing new, and reports nothing wrong.
`apps push` sends text files only. It skips `node_modules/`, `dist/`, `build/`, `coverage/`, `.git/`, `.claude/`, `.turbobase/`, `.next/`, `.svelte-kit/`, `.vercel/`, `.cache/`, and anything that isn’t text — so images and fonts belong in your build pipeline or object storage, not the source tree. `.claude/` is skipped because a coding agent writes nested worktrees under it, each a second full copy of the project; `INSTANCE-LEARNINGS.md` and `TURBOBASE-FEEDBACK.md` live at the project root and are still pushed.
It also never sends credential files: `.mcp.json` and `.env*` (except `.env.example`) are withheld, and it says so on stderr when it withholds one. Your token stays on your machine.
## Feed what you learned back into your knowledge base
[Section titled “Feed what you learned back into your knowledge base”](#feed-what-you-learned-back-into-your-knowledge-base)
Building against a live mirror teaches you things no page documents yet: the twin table nobody mentioned, the subsidiary number that turned out to be wrong, the rule that doesn’t actually hold. Write those down as you go, in a `## Suggested KB edits` section (something is missing) or a `## Knowledge-base corrections` section (something published is wrong) in any markdown file at your app root — `INSTANCE-LEARNINGS.md` is the conventional home.
Your instance ingests those sections on the next `turbobase apps deploy` and then stops. A deploy never writes your knowledge base; it only queues what it found. Turning a finding into a change is a separate, explicit step.
**Push before you deploy, or there is nothing new to ingest.** The deploy triggers the ingest; the ingest reads the source tree your last `turbobase apps push` recorded — not the files on your disk, and not the built bundle the deploy just uploaded. So the order is:
```sh
turbobase apps push . # records INSTANCE-LEARNINGS.md and the rest
turbobase apps deploy # goes live, and queues the ingest over that push
```
Get it backwards and nothing fails: the job runs, succeeds against the older revision, and writes nothing — after which `kb push` and `skills push` report an empty queue, which reads exactly like “it hasn’t finished yet”. `apps deploy` prints the source revision it queued the ingest against, and warns when there is none or when your local markdown is newer than it. Believe that line over the queue.
Then, to turn findings into changes:
```sh
turbobase kb push --dry-run # read the plan, write nothing
turbobase kb push # same plan, then asks before writing
```
**The unit is a page, not a finding.** Everything you wrote about one page is one entry in the plan and one write: twelve findings about `REC-01` append once, and four findings asking for the same page nobody has written yet create that page once. So the plan reads “19 pages”, not “53 things”, and you can see which findings were merged before you confirm.
The plan is printed before anything happens, and for each page it names the findings landing on it, the exact markdown it appends, and — loudest — whether confirming **withdraws that page from readers**. A correction whose verdict says a published claim is contradicted by live data pulls the page off the reader surface; the page’s text is not touched by that withdrawal, so publishing it again restores it exactly. Additive findings never withdraw anything.
Confirming writes the pages:
* **An existing page is appended to**, under a `## From app developers` heading. Nothing already on the page is rewritten — the only edit is an insertion, so a push cannot destroy a sentence an editor wrote.
* **A new page lands unpublished**, filed under a “From app developers” page for an editor to fold into the tree.
Either way nothing reaches readers until someone publishes a knowledge-base release, so a push is never a publication. `--dry-run` reviews without writing, `--diff` prints the exact markdown on the confirm path too, `--yes` skips the question for a scripted run, and `--app` / `--section` / `--limit` narrow what you act on.
Three things worth knowing:
* **It’s admin only.** Your token needs the same scope `apps push` uses, and your account has to be an administrator of the instance — writing the shared knowledge base is not a per-developer act.
* **The plan is binding.** If someone else works through the queue while you’re reading, confirming fails with “the pending queue changed” and writes nothing. Re-run to see the current plan. You never confirm one plan and get another.
* **A page someone is editing is skipped, not overwritten.** If a colleague has unpublished work on a page your push targets, that page is left alone and its findings stay queued — the plan tells you who, before you confirm. Re-run once they’ve published.
## What Claude Code can and can’t do
[Section titled “What Claude Code can and can’t do”](#what-claude-code-can-and-cant-do)
**It can:** read your catalog, search and read your published knowledge base, read the record anatomy docs for your connected sources, read live rows through your own permissions, and create, pull, push, and list apps. It can also scaffold, test, and deploy hooks, conduits, and processes.
**It can’t:**
* **Create a proposal or run Studio’s AI generation.** Those are in the app, by design — the metered generation step has no external API. Claude Code joins the loop at `apps pull`.
* **Ship an app on its own.** Shipping builds a local directory and uploads the result, which a server-side MCP endpoint can’t reach. Claude Code runs `turbobase apps deploy` as a shell command instead.
* **See anything your account can’t.** Data reads run through the same row-level security as the app. A colleague connecting their own client sees their own slice.
* **Read unpublished knowledge base drafts.** Only the published release is served.
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
**Every call returns 503.** The MCP server is off. An administrator turns it on at Settings › Integrations & AI access.
**`insufficient_scope` on an authoring call.** The token is missing a scope — the error names which one. Mint a new token with the full set; scopes are fixed at mint time.
**401 on the authoring endpoint with a token that works elsewhere.** The two endpoints are separate sign-ins. A data-endpoint OAuth token is rejected at `/mcp/authoring`, and a personal access token is rejected at `/mcp`. Check which URL the header is attached to.
**429 on an authoring call, and your own token has barely been used.** Two budgets guard `/mcp/authoring`: 60 requests a minute from one address, which applies before the token is read, and 60 a minute per token after it. A refusal carrying `{"statusCode":429,"error":"Too Many Requests"}` is the per-address one — something else is calling the same instance, typically another agent, a CI runner or a second container. It need not share your outbound address: until an instance’s network is configured to pass the caller’s address through, that budget is shared by every client of the instance. One carrying `{"error":"rate_limited"}` is your token’s own. Wait out the minute; nothing is revoked.
**`no source for this app`.** Nothing has been pushed or generated yet. `turbobase apps list` shows a dash in the SOURCE column for exactly these — push once and it fills in.
**Claude Code doesn’t seem to know about a table.** Ask it to call `list_record_types` or read the `catalog:///` resource first. It reads the catalog on demand, not automatically.
**Claude Code can’t find a *field*, and concludes the data isn’t there.** `list_record_types` describes record TYPES and never their columns, so a field not named after its record is invisible to it — a customer’s credit limit lives on `entity.credit_limit`, and nothing in the record list says so. Ask it to call `search_fields` with the words you’d use (“exchange rate”, “credit limit”); it returns the record type, table and column that hold it.
## Related
[Section titled “Related”](#related)
* [Building apps](/build-apps/) — the full app contract: reads, writes, realtime
* [App auth wiring](/build-apps/auth/) — the one file you copy
* [Design system](/build-apps/design-system/) — what apps here look like
* [Knowledge base](/tenant/knowledge-base/) — where `turbobase kb push` pages land
* [MCP server](/tenant/mcp-server/) — the data endpoint in detail
* [Access tokens](/tenant/access-tokens/) — token kinds, scopes, revocation
* [CLI reference](/cli/reference/) — every command
# Code standards
> The lint, format, dead-code and duplicate checks a Turbobase app starts with — what npm run check catches, how files are formatted for you, and how to add the standard to an app you already have.
Every app `turbobase apps init` creates comes with **`@turbobase/code-standards`**, an open-source package (MIT, on npm) that checks your code the same way on every machine and formats it so nobody spends time on style. It is the same standard Turbobase’s own code is held to.
## What it catches
[Section titled “What it catches”](#what-it-catches)
The checks look for the mistakes that compile, build and still break a screen:
* a React hook called after an early `return` — the screen works until the record it was waiting for arrives, then crashes;
* a promise nobody waited for, so an error is lost and a write can finish after the screen moved on;
* an object with the same key written twice, where the first value is silently replaced;
* code nothing uses any more, and the same block copied in two places inside your app.
Duplicates are counted inside your app only. Code that looks like another app’s is never a finding.
## The two commands
[Section titled “The two commands”](#the-two-commands)
| Command | What it does |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `npm run check` | Reports every lint finding and every file not in the house format, and exits non-zero if there is one. Run it before you push. |
| `npm run fix` | Applies every automatic fix and formats every file. |
The house format is tabs, semicolons, single quotes and lines of up to 120 characters.
**When Claude Code edits a file in your app, it is formatted straight away** by a hook in `.claude/settings.json`. The hook uses the copy of the package installed in your project, never a download, and it never blocks an edit: before your first `npm install` it simply does nothing.
## The files in your project
[Section titled “The files in your project”](#the-files-in-your-project)
| File | What it holds |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.oxlintrc.json` | The lint rules, extended from the package’s React profile. |
| `oxfmt.config.ts` | The house format, re-exported from the package. |
| `code-standards.json` | What counts as one unit for duplicates (your whole app) and which files are skipped. |
| `code-standards.baseline.json` | The findings your app already had when it adopted the standard. A new app starts with an empty one. `npm run check` only fails on findings beyond it, and will not run without it. |
| `knip.json` | Where the unused-code check starts looking: your app, plus your hooks, conduits and processes. |
`src/types.ts` is skipped on purpose. `turbobase project types` writes it from your instance’s catalog and rewrites it every session, so a finding in it is not one you could fix. `src/lib/turbobase.ts`, the client your app talks to your instance through, is skipped for the same reason: it is ours, and an update replaces it.
After the first `npm install`, run `npm run fix` once to put every file in the house format; from then on `npm run check` passes until something new needs fixing.
## Adding it to an app you already have
[Section titled “Adding it to an app you already have”](#adding-it-to-an-app-you-already-have)
The steps are in the [release log](/releases/), under **For app developers — adopt these**, and `turbobase project adopt` writes them into your project’s `ADOPT.md`, where Claude Code picks them up at the start of a session. In short:
1. `npm install -D @turbobase/code-standards`.
2. Add `.oxlintrc.json`, `oxfmt.config.ts`, `code-standards.json` and `knip.json` at the app’s root, as a new app has them, and remove `baseUrl` from your `tsconfig.json`.
3. Add `"check": "code-standards check"` and `"fix": "code-standards fix"` to `scripts` in `package.json`. If you already have a `check` script, keep it and give these two other names.
4. Run `npm run fix` once and commit the result on its own, so the formatting change is one commit you can skip past when reading history.
5. Run `npx code-standards baseline` and commit `code-standards.baseline.json`. It records what your app already has, so the check passes today and fails only on something new.
6. Run `npm run check` before every push. The baseline can only shrink: fix a finding, run `baseline` again, and commit the smaller file.
# App design system
> The shared visual language every Turbobase app template ships with. Enterprise-SaaS feel — tight, neutral, dense without being cramped.
This page is the single source of truth for what apps built on Turbobase look like. Every app template links here instead of inlining the rules — so the order-management demo, the expense-management demo, and anything new you ship stay visually coherent without copy-pasting 80 lines of style guidance into each prompt.
**`turbobase apps init` ships most of this stack.** A scaffolded project already has React 19, Tailwind v4 (as a Vite plugin — there is no `tailwind.config.js`; the theme lives in `src/styles.css`), shadcn/ui in New York style on the neutral base with `components.json` set up so `npx shadcn@latest add ` works immediately, six of its primitives already written out (button, card, badge, table, input, label), lucide-react, and self-hosted Inter.
The four libraries named below that a starter app does not need are **not** installed: `react-hook-form`, `zod`, `sonner` and `date-fns`. Add them when you reach for them — the point of naming them here is that when you do need a form, a toast or a date, these are the ones to pick, so every app on the platform reaches for the same four.
If you are writing a new template prompt, the prompt should read: *“Before writing any UI code, read `https://docs.turbobase.io/build-apps/design-system/` and apply it.”* Then the prompt only adds the **template-specific** rules (status pill palette for that domain, KPI tiles, etc.).
This page covers how an app **looks**. It does not cover the rules that stop a screen showing the **wrong data** — rendering an id where a name exists, an empty list that is really a failed query, a sync timestamp presented as a business date. Those live in the `app-design` skill in your project (`.claude/skills/app-design/SKILL.md`) and in [Build apps](/build-apps/).
## North star
[Section titled “North star”](#north-star)
Looks like a real SaaS product on first paint, not a generated demo. The reference points are Linear, Notion, Stripe Dashboard — products where density and restraint do the heavy lifting and color is rationed. Avoid the AI-generated tells: gradient backgrounds, glassmorphism, oversized rounded corners, candy palettes, animated mesh blobs.
## Stack defaults
[Section titled “Stack defaults”](#stack-defaults)
* **Framework**: Vite + React + TypeScript (static SPA), React 19 stable (do not pin RC).
* **Styling**: Tailwind v4.
* **Components**: shadcn/ui, **New York** style, **neutral** base color.
* **Forms**: react-hook-form + zod.
* **Icons**: lucide-react.
* **Toasts**: sonner.
* **Dates**: date-fns.
* **Font**: Inter, self-hosted via `@fontsource/inter` (or a `` to Google Fonts). Headings get `tracking-tight`.
Do NOT add: Redux, tRPC, Prisma, NextAuth, a UI kit other than shadcn, chart libraries unless the template explicitly needs more than hand-rolled SVG.
### The one carve-out: Konsta, on a phone surface
[Section titled “The one carve-out: Konsta, on a phone surface”](#the-one-carve-out-konsta-on-a-phone-surface)
That list stands. The single exception is **Konsta**, and only when the app has a genuine phone-first surface: a handheld or scanner workflow, a field or warehouse task, or a prompt that explicitly asks for mobile. No trigger, no Konsta — an app that would merely *also* look fine on a phone does not qualify, and reaches the phone through the responsive floor below instead. Everything else is shadcn, and shadcn only.
**The phone surface is a different product, not the desktop reflowed.** That single instruction separates a good implementation from a bad one: different navigation (a tab bar, not a sidebar), different density (one task per screen, not a table of twelve columns), different targets. A desktop table squeezed into 390px is not a mobile surface.
Four conditions. Each one ships silently broken on its own — a green build and a wrong screen:
1. **React 19 is required.** Konsta 5 declares no `react` peer dependency, so npm installs it against React 18 with no warning at all. Its components take `ref` as a plain prop — the React 19 API; there is not one `forwardRef` in the package — and React 18 strips `ref` out of props before the component ever sees it. Anything that MEASURES (a segmented control positioning its highlight, a popover finding its anchor) gets nothing back and degrades silently: `tsc --noEmit` is clean, `vite build` is green, the component is inert.
2. **Tailwind must scan the whole package** — `@source "../node_modules/konsta/**/*.js"` in `src/styles.css`. The **whole package**, not the entry. `konsta/react` resolves to `react/konsta-react.js`, which only re-exports; the class strings are built in `shared/classes/*.js`. Point Tailwind at the entry alone and it compiles enough to look nearly right while every inset, rounding and width class is silently missing.
3. **Override Konsta’s Material colour scale**, or it tints the rest of the app — it ships its own palette and left alone that palette wins. Map it onto the accent this template already picked. Pick ONE Konsta theme (`ios` or `material`) at build time; it is not a user-facing toggle and never a `prefers-color-scheme` branch.
4. **On that surface Konsta REPLACES shadcn.** The two do not sit side by side — one screen, one kit, or you get two type scales in one view and two components fighting over the same focus. `components.json` stays exactly as it is: the desktop and tablet surfaces are still shadcn.
## Color and contrast
[Section titled “Color and contrast”](#color-and-contrast)
* **Background**: white. Light mode only — no dark default, no gradients on the body.
* **Surface**: cards are `bg-card` (which maps to `white`) with `border border-border`.
* **Accent**: pick ONE per template — `violet`, `emerald`, `blue`, or `rose`. Use it for the primary CTA, focused inputs, active nav, and one or two key numbers per screen. Everything else stays neutral.
* **Danger / overdue**: `rose`. **Success / paid**: `emerald`. **Pending / draft**: `amber`. **Closed / inactive**: `zinc`. **In progress / approved**: `blue`.
* **Text hierarchy**: `text-foreground` for headings + primary numbers, `text-muted-foreground` for everything else. No more than two text colors per card.
## Density and rhythm
[Section titled “Density and rhythm”](#density-and-rhythm)
* **Spacing scale**: multiples of 4. Card padding `p-5` (sm) / `p-6` (md+). Gap between cards `gap-6`. Sections separated by `space-y-8`.
* **Border radius**: `rounded-lg` for cards, `rounded-md` for buttons + inputs, `rounded-full` only for status pills and avatar circles. No `rounded-2xl` or `rounded-3xl`.
* **Borders**: `border border-border` (subtle). Never `border-2`. Tables get `border-b border-border` between rows, no vertical borders.
* **Shadows**: `shadow-sm` on cards. No `shadow-xl`, no glow.
## Typography
[Section titled “Typography”](#typography)
* **H1 (page title)**: `text-2xl font-semibold tracking-tight` (sm:text-3xl). One per page.
* **H2 (section)**: `text-lg font-semibold`.
* **Body**: `text-sm` for content, `text-xs` for metadata.
* **KPI numbers**: `text-2xl font-semibold tabular-nums`. Currency lines use `tabular-nums` everywhere they appear in tables.
* **Eyebrows**: tiny uppercase pills — `text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground`. Used sparingly above section titles.
## Component patterns
[Section titled “Component patterns”](#component-patterns)
### Top bar
[Section titled “Top bar”](#top-bar)
`h-14`, `border-b border-border`, `bg-background`, brand on the left (the Turbobase wordmark + a tiny accent dot — `
Turbobase
` with ``), user menu on the right. No theme toggle (the app is light-mode only). No drop-shadow. No backdrop blur.
### Page header
[Section titled “Page header”](#page-header)
Page title + a one-line description below it (`text-sm text-muted-foreground`), primary CTA right-aligned. If the page has tabs, they sit immediately under the header in a `border-b border-border` rail, not a pill group.
### How much prose a screen carries
[Section titled “How much prose a screen carries”](#how-much-prose-a-screen-carries)
A short label or one clause. That is the budget, and it applies to every surface on the screen — the line under the page title, a section intro, a note beside a control.
Anything longer goes behind an **info icon opening a modal**: a `variant="ghost"` icon button with lucide `Info` and an `aria-label`, opening a shadcn `Dialog`. The reader who wants the explanation gets all of it; the reader who does not gets their data. A multi-sentence paragraph under every heading, or a full-width card of explanation sitting below the table, is the tell of a screen that has not decided what it is for — it pushes the actual content below the fold and reads as filler even when it is accurate.
`Dialog` is already sanctioned, and the modal rule in [Accessibility floor](#accessibility-floor) already applies to it: labeled close button, focus trapped.
### KPI strip
[Section titled “KPI strip”](#kpi-strip)
Four tiles across at `lg`, stacks below. Each tile: tiny eyebrow → big number (`tabular-nums`) → a delta line showing `▲ 12% vs last 30d` in `text-xs`. Up = `emerald-500`, down = `rose-500`, flat = `muted-foreground`. No sparklines inside the KPI — those go in a dedicated card.
### Tables
[Section titled “Tables”](#tables)
shadcn `
`. Header row `text-xs uppercase tracking-wider text-muted-foreground`. Body rows `hover:bg-muted/40`. No zebra striping. Money columns right-aligned, `tabular-nums`. First column is the human-readable identifier (tran\_id, expense report number, customer name), last column is the action (status pill or kebab menu). Designed empty state: lucide icon + one line of copy + (where applicable) a primary CTA.
### Money & currency
[Section titled “Money & currency”](#money--currency)
Always show the **ISO 4217 currency code** (`AUD`, `USD`, `INR`), never a source-system display name or symbol-name. This matters because the canonical `*_currency_name` denorm on a row carries whatever the source labels the currency (Oracle NetSuite, for instance, stores `name` as a free-text label, so you’ll see `"AUS"`, `"US Dollar"`, `"Canadian Dollar"` in the same column). **Do not render `currency_name`**: it is inconsistent and not a code.
The reliable code lives on the `turbobase.currency` lookup, whose `symbol` column is the ISO 4217 code:
```plaintext
currency.external_id → matches the row's `currency_id`
currency.symbol → ISO 4217 code (AUD, USD, INR …) ← display THIS
currency.name → source label (may be "AUS") ← do NOT display
```
So you resolve the code by mapping the row’s `currency_id` to a `currency` row’s `symbol`. **Default to a scalar select + a separate lookup — do NOT embed `currency` into a query that has to succeed.**
The tempting one-liner is a PostgREST embed (`?select=...,currency_id,currency:currency_id(symbol)`). It looks clean, but it silently couples the query to a *declared FK relationship* `
.currency_id → currency` that **may not exist on a given instance**. When the FK is absent, PostgREST doesn’t return a null embed — it **400s the entire request**, so a decorative currency symbol takes down the whole read (the employee fetch returns nothing, the dashboard shows “no record”). An embed on a must-succeed query is a latent 400.
The robust pattern, which never blocks the primary read:
```plaintext
# 1. Primary read — scalar columns ONLY, including currency_id:
?select=...,total,currency_id
# 2. Resolve the symbol once, best-effort, in a SEPARATE lookup.
# Either fetch the whole small list (~12 rows) and map client-side …
GET /rest/v1/currency?select=external_id,symbol&is_inactive=eq.false
# … or look up the one code you need:
GET /rest/v1/currency?external_id=eq.&select=symbol&limit=1
```
Build a `currency_id → symbol` map from step 2 and render `map[row.currency_id] ?? row.currency_id` (fall back to the raw id, never to `currency_name`). If step 2 fails or returns nothing, the amount still renders — you’ve just lost the prettier code, not the row.
Format the amount with `Intl.NumberFormat(undefined, { style: 'currency', currency: symbol })` — passing the ISO code makes `Intl` place the correct symbol and decimals automatically.
> **Embed only if you’ve confirmed the FK exists on the live instance.** `currency:currency_id(symbol)` is fine in a query whose failure is acceptable (a purely decorative read you’ve verified against the live catalog/relationships), but it must never sit on a required-path query — the employee/identity fetch, the dashboard’s hero read, anything whose `null` shows the user a broken screen. See the integration-assumptions rule in [`Build apps on Turbobase`](/build-apps/).
>
> This is about *guessing* at an FK on a lookup column. It does **not** apply to a header’s line table (`item_receipt_line(…)`), which is a real foreign key present on every instance, or to a relationship your app declared — both are safe on a required-path query. See [Reading the mirror](/build-apps/reading-data/).
### Charts (hand-rolled SVG)
[Section titled “Charts (hand-rolled SVG)”](#charts-hand-rolled-svg)
No chart library — hand-roll small dashboard charts (a 6-month bar trend, a 14-day sparkline) as a single inline `