{"service":"whisk","version":"0.1.0","state":"building-m1","about":["Whisk hosts apps that coding agents build for their humans.","You (the agent) do everything over plain HTTPS and git — no SDK, no client config.","Apps are client-side SPAs backed by a shared InstantDB database per organisation: auth, permissions, realtime, and storage are already provisioned by the time you deploy.","The platform is deterministic. It runs no AI. All cognition is yours."],"before_you_start":{"start_here":"Always begin with POST /v1/access {email} — one call for both first-timers and returning users; it tells you which. Do NOT decide up front whether to \"sign up\" or \"log in\". If your human already has a team, /v1/access emails them a code and you finish with {email, code} — you must never create a second team for a company that already has one.","you_are_not_accepting_terms":"Creating a team does NOT bind your human to anything. It prepares a provisional workspace that auto-deletes if never confirmed. Your HUMAN accepts Whisk's terms (https://whisk.run/terms) and becomes the owner when THEY claim the team — the email Whisk sends has a \"Continue to Whisk\" button that takes them to a claim page (confirm company name + accept terms in one step). They can also just give you the 6-digit code from that email (you POST /v1/access {email, code}). So go ahead and build — you are not the one accepting terms, and nothing is binding until your human claims.","who_your_human_is":"Collect your human as a PERSON, not just an email. When you create a team pass name (their full name) and org_name (their company's display name — e.g. \"Acme Ltd\"). name is used for their profile, the People list, greetings, and their git commit attribution; org_name shows on the workspace, git, invoices and receipts. Both are confirmable/editable by the human on the claim page. Update later with PATCH /v1/me {name} (the person) and PATCH /v1/org {name} (the company). When you add teammates, pass their names too: POST /v1/org/members {email, name}. The team handle (URL label) is derived from the email domain and is fixed.","human_home":"Your human signs in and manages everything at their team home page, https://<team>.whisk.page — apps, members, git access, and a \"Connect a coding agent\" button that hands them a token to paste into a new chat. Sign-in itself happens at id.whisk.run (they get bounced there and back automatically). The /v1/access response gives you the exact <team>.whisk.page URL.","cross_org":"One email can belong to MANY teams (a contractor, a family business, a collaborator) — each team is fully separate: its own database, sign-in, apps, and seats (a person on two teams is a full seat in each). Your token works on exactly ONE team. Discovery: authed responses (/v1/whoami, code sign-in, verify) include also_member_of listing the human's other teams; to work on one of those, the human gets you a token from THAT team's org home (\"Connect a coding agent\"). Being a member of someone else's team never blocks creating your own domain's team later — POST /v1/access with the work email just works.","one_team_per_domain":"A team is identified by the email domain. Everyone at acme.co.nz shares the one \"acme\" team — the first to confirm owns it, everyone after joins it. There is no team name to choose and no second team for the same company. A personal mailbox (gmail etc.) is its own one-person team. Ownership can be reclaimed by proving domain control via DNS: POST /v1/org/ownership/challenge."},"how_it_works":["1. POST /v1/access {email} with your human's work email. This ONE call works out what to do — you do not decide between \"sign up\" and \"log in\".","   • If it's a NEW team (their domain has none): you get a preview (nothing created). Show your human, ask for their name and company display name, then POST /v1/access {email, confirm:true, name:\"Ada Lovelace\", org_name:\"Acme Ltd\"} to create it — you get a bearer token immediately and a claim email goes to them.","   • If the team ALREADY EXISTS (a previous chat, a teammate, a lost token): it emails a 6-digit code to your human. Ask them for it and POST /v1/access {email, code} — you get a working token back. NEVER create a second team.","2. Create an app — POST /v1/apps {slug} — you get a git remote (your human's identity), an InstantDB config, and a reserved URL.","3. Push code — main deploys to production, any other branch deploys to a preview URL.","4. Your human claims the team from the email Whisk sent them (\"Continue to Whisk\" → confirm company name + accept the terms), or by giving you the emailed code (POST {email, code}). Then they just open the app and sign in with their work email."],"plans":{"tiers":"Free $0 (10 apps, 3 people) | Starter $19/mo (25 apps, 10 people) | Team $59/mo (100 apps, 50 people) | Business $249/mo (500 apps, 250 people, invoice billing on request). ACTIVE apps count; archived apps don't. Upgrades are self-serve at https://<org>.whisk.page → Billing.","gated_features":"Starter+: file storage ($files uploads), Whisk Connect (credentials + external calls), public form endpoints, counters, guest links. Team+: automations, schedules, rollups, raw webhook inbox, teams. Everything else — apps, deploys, previews, the database, permissions/roles/policies, sign-in, logs, exports, the verify tools — is on EVERY plan including Free. A gated endpoint returns 403 naming the plan; relay that to your human, it is their decision.","downgrades":"No grandfathering, but nothing is deleted. When a plan drops and the org is over the new cap (apps or people), higher-tier features stop (runtime-checked) and the org enters a grace window with a warning on the team home. If still over after the grace period the team is PAUSED: the team home stays open but no app opens, no automations/schedules run, and deploys are blocked — until the owner archives apps or removes people to get back within plan, which reactivates everything instantly. Archived apps: PATCH /v1/apps/:slug {\"archived\": true|false} — parked page, repo+data kept, not counted against the cap."},"endpoints":{"GET /v1/agent":"this manifest — the single source of truth; also at /docs, /v1/docs, /llms.txt, and docs.whisk.run","GET /healthz":"liveness","POST /v1/access":"THE entry point — give it an email and it works out the rest. {email}: new domain -> a preview (creates nothing); existing team -> emails a 6-digit code. {email, confirm:true, org_name?}: creates a NEW team (immediate bearer token; a claim email goes to your human; org_name = company display name, shown on workspace + invoices). {email, code}: signs you in to an EXISTING team (returning agent, teammate, or confirming a team you just made) and returns a working token. Aliased as /v1/signup and /v1/login — all identical. email is the HUMAN's work address; the team IS the email domain.","POST /v1/signup/preview":"body {email} — a preview with no side effects at all (no code emailed): shows whether this email would create or join, and the team name.","POST /v1/org/ownership/challenge":"returns a DNS TXT record to add to your domain. Auth required (a verified member on the team's own domain).","POST /v1/org/ownership/verify":"checks the TXT record and transfers ownership of the team to you. Auth required.","POST /v1/verify":"body {code} — the 6-digit code your human received. Auth required.","POST /v1/verify/resend":"fresh code to the same email. Auth required.","GET /v1/whoami":"your account + org + verification state. Auth required.","PATCH /v1/org":"body {name} — set the team's company display name (workspace, git, invoices, receipts). Owner only; the URL handle never changes. Auth required.","PATCH /v1/me":"body {name} — set your human's own display name (profile, People list, git commit attribution). Auth required.","free_plan_badge":"Apps on the Free plan show a slim \"Made with Whisk\" bar across the bottom. It disappears automatically (within minutes, no redeploy) when the team moves to any paid plan. Do not try to hide or remove it — upgrading is the way.","code_access":"An app's CODE is restricted separately from opening it: only the app's creator, holders of its \"admin\"/\"developer\" role, and the team owner can fetch or push the repo — enforced by git itself, so a non-developer's agent gets 403 from the remote. Grant/revoke: POST /v1/apps/:slug/members {email, role: \"developer\"} (\"editor\" is the legacy name and still accepted) / DELETE /v1/apps/:slug/members/:email. git.whisk.run serves ONLY git-over-https (no web UI, no browsing).","DELETE /v1/org":"delete the whole team and everything in it (apps, repos, URLs, data). Team owner only, irreversible. Auth required.","POST /v1/apps":"body {slug, team?, visibility?, name?, description?, currency?} — creates the app: private git repo, deploy credentials embedded in the returned remote URL, production + preview URLs reserved, shared org InstantDB config. Every app is seeded with a WORKING, good-looking starter (Whisk-themed shadcn/ui shell + dark mode + org sign-in + the full chassis already wired) — never blank. visibility: \"org\" (default — every member can open it), \"team\" (only its team; pass {\"team\": \"<team-slug>\"}), \"invite\" (private: only people granted a role). The app's tables follow its visibility automatically. SET WHAT YOU KNOW NOW: name (display name), description, currency (3-letter ISO — drives formatMoney() in the chassis; a New Zealand org wants NZD, and the default is USD until someone says otherwise). Auth required.","POST /v1/apps/:slug/session":"DRIVE THE UI YOU BUILT, before handing it over. Returns an Instant refresh_token for YOUR HUMAN's identity (never a synthetic user) plus a ready URL — open https://<app>.<org>.whisk.page/#whisk_token=<token> in your browser tooling and the app signs in as them (the token is a URL fragment: never sent to a server, stripped on arrival). Same data, same rules, same audit trail as their own session; minting one is itself audited. For the app's /__whisk/* routes send the token as the X-Whisk-User header. Auth required.","DELETE /v1/apps/:slug/tables/:table":"Delete a table: its rows, declaration and policy. If rows exist you get 409 with the row count — re-send {\"confirm\": true} (the blast-radius pattern). The table disappears from GET /v1/org/schema; declaring the same name again resurrects it empty, so a typo'd table is no longer permanent. The audit table cannot be deleted. Team variant: DELETE /v1/org/teams/:slug/tables/:table. Auth required.","GET /v1/apps/:slug/tables/:table/rows":"VERIFY WHAT YOU BUILT (check GET .../logs first when something is \"broken\"). A real rules-enforced read of the app's data AS YOUR HUMAN — never as anyone else: you see exactly the rows and fields they see. Query params: where=<json> ({\"status\":\"open\"} or {\"dueAt\":{\"$lt\":1790000000000}} — comparisons need the field indexed+typed), order=<field>.asc|desc (indexed field; default newest first), limit<=200, offset. The response says if there are more pages. Auth required.","POST /v1/apps/:slug/tables/:table/rows":"Write TEST ROWS as your human, rules enforced — the write succeeds or fails exactly where their own would, so fixtures obey the same physics as real data. body {\"rows\": [{...}]} (<=50). Rows are engine-stamped: automations NEVER fire on them (no fixture cascades), and createdAt/updatedAt are platform-set. Every write lands in the app's audit table as \"agent for <you>\". Also PATCH /rows/:id {\"set\": {...}} and DELETE /rows/:id. This replaces the never-firing-cron + manual-run trick — use this, it's honest and audited. 60 calls/min per app. Auth required.","PATCH /v1/apps/:slug":"body {name?, description?, currency?, visibility?, team?, confirm?} — app settings from the agent API: the SAME semantic as the in-app Settings page (same validation, same audit trail), so use whichever door is closer. Widening visibility (invite->team->org) returns 409 confirm_required with the blast radius — re-send with {\"confirm\": true} to accept. POST to the same path is an alias. Auth required.","POST /v1/org/teams":"[Team+] body {slug, name?} — a named group INSIDE the org: it has members (with roles), owns apps ({\"team\": \"<slug>\"} at app creation), and owns its OWN tables (prefix \"<teamslug>_\" — visible to team members only, shared across all the team's apps). Manage people: POST/DELETE /v1/org/teams/:slug/members {email, role: \"member\"|\"admin\"}. Auth required.","POST /v1/apps/:slug/tables":"body {names: [\"drafts\", \"secrets\"]} — pre-declare a private or team app's tables so their permission rules are live from the FIRST row (a private table is never briefly org-visible). The \"<app>_\" prefix is added if you omit it. Optional for org-visible apps; recommended for invite/team apps. A deploy also auto-declares tables it finds in your source (appTable() calls), so pushing is enough in practice. Team variant: POST /v1/org/teams/:slug/tables. Auth required.","POST /v1/apps/:slug/roles":"body {name} — declare a custom role on an app (e.g. \"approver\"). Grant it: POST /v1/apps/:slug/members {email, role}. Use it in table policies; the app reads the signed-in user's roles via whiskRoles(db) to drive role-aware UI. Auth required.","PUT /v1/apps/:slug/tables/:table/policy":"per-table access control (also on team tables at /v1/org/teams/:slug/tables/:table/policy). Body {owner_field?, rules: {view?, create?, update?, delete?: [terms]}, fields?: {<field>: [terms]}}. Terms: \"member\" (the app's audience), \"admin\", \"owner\" (row where owner_field == their email), \"everyone\" (whole org), any declared custom role, or {\"owner\": {\"field_equals\": {\"status\": \"pending\"}}}. Missing verbs default to the collaborative audience rule; fields gates who can SEE individual fields (e.g. hide salary_band from non-approvers). Widening access returns a blast-radius preview and needs {\"confirm\": true}. Example — leave approvals: {\"owner_field\": \"employeeEmail\", \"rules\": {\"view\": [\"approver\", \"admin\", \"owner\"], \"update\": [\"approver\", \"admin\", {\"owner\": {\"field_equals\": {\"status\": \"pending\"}}}]}}. Auth required.","GET /v1/apps/:slug/tables/:table/preview":"?as=<member email> — what that member ACTUALLY sees: their query run with real permission rules (row counts + sample). Use it to verify a policy does what you meant. Auth required.","GET /v1/apps":"list your org's apps. Auth required.","DELETE /v1/apps/:slug":"delete an app: its repo, its live + preview URLs, and its deploy history; the name frees up. Team owner or the app's creator only. Auth required.","GET /v1/apps/:slug/deploys/latest":"deploy status; queued/building return Retry-After — poll until live or failed (log_tail says why). Auth required.","GET /v1/org/schema":"the shared org database: existing entities (reuse before creating), which apps share it, and the naming conventions. Auth required.","POST /v1/org/members":"body {email} — let someone in your org sign in to its apps. Auth required.","GET /v1/org/members":"who can sign in. Auth required.","DELETE /v1/org/members/:email":"revoke their access completely: app sign-in, agent tokens, and git account. Team owner only. Auth required.","POST /v1/apps/:slug/hooks":"[Starter+ for forms, Team+ for raw hooks] body {name, form?} — webhook inbox. Returns an unguessable URL; every POST to it lands as a whisk_events row in the shared org DB, which the app queries live. Add form {fields: {<name>: \"string\"|\"email\"|\"number\"|\"boolean\" | {type, required?, max_length?}}, rate_limit?, action?, counter?} to make it a PUBLIC FORM ENDPOINT: the platform validates each submission (JSON or a plain HTML form post), rejects bad ones with 400 {ok:false, errors:{field: why}} so the form can show them, rate-limits per IP, drops unknown fields, and only clean rows land (kind \"form\"). action {credential, method, path, body?} additionally fires a Connect call per valid submission (row gets actionStatus). counter \"<name>\" atomically claims one slot per valid submission — a public BOOKING form with a hard cap: full -> 409 and nothing lands, accepted rows carry claimNumber. Auth required.","POST /v1/apps/:slug/schedules":"[Team+] body {name, cron, tz?, payload?, action?} — cron firings land as whisk_ticks rows in the org DB. action {credential, method, path, body?} additionally runs a Connect call each firing and stores the result in the row. Auth required.","POST /v1/apps/:slug/automations":"[Team+] body {name, when, if?, do, on_error?} — SERVER-SIDE AUTOMATION: a declared when/if/do rule the PLATFORM executes with no browser open. This is how an app reacts at 3am: webhook arrives -> transform -> post to an external API; record goes overdue -> notify someone; approved -> call Xero. when: {event:\"record.created\"|\"record.updated\", table} (chassis createdAt/updatedAt convention) | {event:\"record.due\", table, field, offset_minutes} (negative = before; fires once per (record, due value), only for fire-moments after the automation existed) | {event:\"webhook\", hook?} (fires inline on delivery) | {event:\"schedule\", cron, tz?}. if: [{field, eq|ne|gt|gte|lt|lte|contains|exists}] ANDed against the trigger record/payload. do (max 10, in order): {action:\"record.update\", set, table?+id?} (default: the triggering record) | {action:\"record.create\", table, set} | {action:\"notify\", to, message, link?} (in-app bell) | {action:\"connect\", credential, method, path, query?, body?} (secret injected server-side, 3 retries) | {action:\"http\", url, method?, headers?, body?} (https to public hosts only, 3 retries). EVERY string value templates over the trigger with {{field}} ({{body.x}} for webhooks, {{result.body.x}} for the previous step's response, {{now}}); a value that is exactly one template keeps its type. Table names everywhere (when.table, record.create/update table) accept either form — \"reports\" or \"<app>_reports\" name the SAME table, like every other endpoint. on_error runs if a step fails ({{error}} available). Every run lands as a whisk_runs row in the org DB {automation, automationName, app, trigger, status, steps, error?, at} — query it from the app for a visible run history. Engine writes never trigger other automations (no cascades). Bursts pace at ~48 fires/min per automation (a bulk import drains across ticks, nothing dropped); sustained >60/min auto-disables as a loop. Poll problems (missing index, bad field type) surface on the automation's last_status and in whisk_logs. A run interrupted by a platform restart is reported to whisk_logs and does NOT auto-retry — re-fire it deliberately. Also GET (list), POST /:id/run {record: \"<id>\"} (fire against a REAL record — do this right after declaring; also the recovery path for an interrupted run) or {context: {...}} (mock), POST /:id/enabled {enabled}, DELETE /:id. Limit 20/app. Auth required.","POST /v1/org/rollups":"[Team+] body {name, entity, where?, group_by?, op, field?, cron?, tz?} — server-side aggregation so browsers never download 50k rows to sum them. op: count|sum|avg|min|max (field required except count); group_by: a field (\"customer\") or time bucket over a ms-timestamp field (hour(f)|day(f)|week(f)|month(f)); where: flat exact matches. Results land as tiny read-only rows in entity whisk_rollups {rollup, bucket, value, count} — query them live for dashboards/charts. Creating computes IMMEDIATELY and returns a sample; cron (min 5-minutely) keeps it fresh; POST /v1/org/rollups/:name/run recomputes on demand. Also GET (list) and DELETE /:name. Auth required.","POST /v1/apps/:slug/guest-links":"[Starter+] body {table?, record_id, label?, expires_days?, allow?, actions?} — GUEST ACCESS: a scoped, expiring link for someone OUTSIDE the org (customer checks their ticket, vendor confirms an order, approver approves from an email) — no account, no sign-in; the unguessable URL is the capability. The page shows the record read-only; allow: [\"comment\"] adds a reply thread (lands in the app's comments table, audited); actions: [{label: \"Approve\", set: {status: \"approved\"}}] adds buttons the guest can press — written with YOUR delegated authority and audited under the link's label. Send the URL via your Connect email credential or any channel. expires_days <= 90 (default 30). Also GET (list, with view counts) and DELETE /:token (revoke — the page dies instantly). Auth required.","email":"Whisk deliberately sends NO email itself — connect the org's own provider through Whisk Connect instead. Outbound: store the provider credential once (Resend: {host: \"api.resend.com\", headers: {Authorization: \"Bearer re_...\"}} | Postmark: {host: \"api.postmarkapp.com\", headers: {\"X-Postmark-Server-Token\": \"...\"}}), then send from app code via POST /__whisk/connect/:id, from an agent via POST /v1/connect/:id, or UNATTENDED from an automation action {action: \"connect\", credential, method: \"POST\", path: \"/emails\", body: {from, to: \"{{owner}}\", subject: \"...\", html: \"...\"}} — e.g. \"when status becomes approved, email the requester\". Inbound: point the provider's inbound-parse webhook (Postmark inbound, Mailgun routes, SendGrid parse) at a hook URL from POST /v1/apps/:slug/hooks — each email lands as a whisk_events row and automations react to it. The org owns its sending domain and reputation; Whisk never touches the mail.","GET /v1/apps/:slug/logs":"query ?level=error|warn|info&since=15m|2h|3d&limit= — the app's diagnostic stream. Apps have no server, so this is how you see what REAL USERS hit: uncaught browser errors (the template reports them automatically, with stacks), whiskLog(\"...\", meta) breadcrumbs from app code, and platform-side failures (a Connect call from app code that returned 500, a failed schedule/form action, a failed deploy). Check this FIRST when your human says \"it's broken\". Auth required.","POST /v1/feedback":"body {message, context?} — tell the Whisk team where the platform had friction: anything confusing, surprising, broken, or harder than it should be. A human reads every one and the platform is tuned from them. Blunt is good. This is a SOLICITED request — the arrangement is disclosed to your human in the first signup relay, so send it freely unless they opted out. Long report? Messages cap at 20k chars and the response tells you if truncation bit — split follow-ups rather than losing the tail. Auth required.","POST /v1/org/counters":"[Starter+] body {name, max?} — an ATOMIC counter the platform owns: the one way to enforce \"only N seats/tickets/slots\" from client-side apps (two browsers can both read 9/10 and both write; the platform serializes claims so an 11th grant is impossible). Re-POST with a new max to resize. App code claims via POST <app-origin>/__whisk/counters/<name>/claim (X-Whisk-User header), body {} | {count} | {key:\"<unique>\"} (key claims succeed exactly once — coupons, one-per-person); /release gives slots back. Denials are 200 {granted:false, reason:\"full\"|\"already_claimed\"} — show them, don't retry. Live \"3 left\" UI: query entity whisk_counters (read-only, realtime). Also GET (list), GET/DELETE /v1/org/counters/:name, and agent-side /claim + /release. Auth required.","POST /v1/org/credentials":"[Starter+] body {name, host, headers?, query?, sign?, oauth?} — store a secret ONCE (e.g. {\"Authorization\": \"Bearer …\"}). Encrypted at rest, never returned, locked to that one host. For APIs that sign each request (e.g. Unleashed HMACs the query string), add sign {algo:\"hmac-sha256\", key:\"<secret>\", input:\"query\", encoding:\"base64\", into:\"header:api-auth-signature\"} and the platform computes the signature per request. For OAuth2 APIs (FedEx, Xero, ...), add oauth {token_url, client_id, client_secret, grant?: \"client_credentials\"|\"refresh_token\", refresh_token?, scope?, auth_style?: \"body\"|\"basic\"} — the platform fetches the access token, caches it until expiry, injects \"Authorization: Bearer ...\" on every call, refetches on 401, and keeps rotated refresh tokens. You never handle tokens. Auth required.","POST /v1/connect/:credentialId":"body {method, path, query?, headers?, body?} — the platform calls https://<credential host><path> with the stored secret injected and returns {status, body}. Also callable from app code at POST <app-origin>/__whisk/connect/:credentialId with the signed-in user's Instant refresh token in the X-Whisk-User header — so apps integrate with external APIs without ever holding a key."},"capability_services":{"why":"apps are client-side; these services cover the server-shaped needs: receiving webhooks, public forms with validation, running on a schedule, calling APIs with secrets (static keys, per-request HMAC signing, or full OAuth2), atomic counters for trusted invariants (\"only 10 seats\", single-use coupons), server-side rollups for aggregation, and app logs for diagnosing what real users hit","pattern":"the platform does the privileged thing; the result lands as a row in your shared org database; your app reacts to it live","retention":"whisk_events and whisk_ticks rows are kept 30 days — copy anything you need longer into your own entities"},"new_apps":{"template":"Every app is created from a working React+Vite+InstantDB starter with org SSO pre-wired AND a real design system — the shadcn/ui component set (src/components/ui) on Tailwind, wrapped in a Whisk-themed app shell (sidebar + header + dark mode) that matches the platform. Clone the git remote, edit src/App.jsx, reuse the components; do NOT hand-roll CSS or invent layout. Never start blank.","chassis":"The starter is a CHASSIS, not a blank page: it already ships the surfaces every internal business tool needs, in src/chassis/. KEEP them and build your business views beside them (App.jsx spreads ...chassisSections() into the nav). (1) Users & access — admins invite people (a brand-new email is added to the workspace and can sign in), set roles, and see permissions in plain English; this is THE user system (platform org members) — never build your own users table or auth. (2) Activity — the audit trail; call whiskAudit(action, subject, detail) on every mutating action. (3) Settings — name/description/visibility/delete. (4) Notifications — a live in-app bell; whiskNotify(email, message, link). (5) Records — a full worked CRUD example (search/filter/sort, create/edit, delete, CSV export, attachments, comments) to copy for your own data. The human manages users FROM INSIDE THE APP (Users page) — you do not have to. Conventions the app follows: every list has empty/loading/error states, every destructive action confirms, every mutation audits + toasts, admin UI gates on is_admin (from whiskRoles/appContext). PERMISSIONS ARE ENFORCED BY THE DATABASE, not the UI: a new app table defaults to collaborative (everyone with access can read/write every row) — a client-side filter is NOT a security boundary. The chassis table names audit/notifications/comments/records get hardened defaults automatically (append-only attributed audit; per-recipient notifications; authored comments; owner-or-admin-edited records). For your OWN sensitive tables set a policy: PUT /v1/apps/:slug/tables/:table/policy. Use a LITERAL string in appTable('name') so the table is pre-declared private from row zero. DECLARATIVE FIELDS (src/chassis/fields.jsx): do NOT hand-roll forms — call defineTable('orders', { title:{type:'text',required:true}, amount:{type:'currency'}, status:{type:'select',options:[...]}, due:{type:'date'}, rep:{type:'person'}, company:{type:'relation',table:'companies',display:'name'} }) ONCE and the chassis derives the validated create/edit form (<RecordForm>), the list columns (columnsFor/cellFor), the CSV (csvFor), and the server indexes. Field types: text, textarea, number, currency, date, datetime, select, checkbox, email, url, person (org member -> stores email), relation (same-app row -> stores id), contact (EXTERNAL person from the org-wide shared contacts table — customers/leads/vendors; picker creates new ones inline), attachment (uploads, stores path), autonumber ({prefix:'INV-', pad:4} — atomic platform sequence), tags, richtext (markdown), lineitems ({columns:{...}} — child rows inside the record: invoice lines), computed ({formula:(v)=>v.qty*v.price} — derived live, denormalised at save), lookup ({table,display,value} — rate/tier tables feeding computed), and LIFECYCLE: status {type:'lifecycle', states:{submitted:{initial:true}, approved:{from:['submitted'], by:'admin'}}} — who may make each move is ENFORCED BY THE DATABASE (compiled into the table's update rule; a member cannot approve from the console). An approval tool is just a 2-state lifecycle with by:'admin' — never build approvals any other way. <LifecycleActions> renders the audited transition buttons; <Board> is the drag-kanban over the states; <CalendarView table field> the month lens; <Leaderboard> ranked scores; <Attest subject version user> immutable \"I read/accept X\" evidence + <AttestStatus> coverage. Soft delete: useRecords {softDelete:true} / {trash:true} + softDelete()/restoreRecord(). Bookings: datetime start/end + defineTable opts.validate + findOverlap() blocks clashing saves. Rotation: automations' record.create/update actions take rotate:{field, people:[...]} — platform round-robins across runs. rating field ({max:5} stars) + <VoteButton subject user/> (one person one vote, race-proof). <ImportDialog table user/> — CSV import with auto-mapped columns, per-type validation, line-by-line error report (every list should offer it; the spreadsheet is where adoption starts). printRecord({table, record, heading, org}) — the record as printable paper: letterhead, fields, lineitems with totals (quotes/invoices/slips; browser print -> PDF). Add useTableIndexes(TABLE, isAdmin) on the owning page so indexes, autonumber counters AND lifecycle rules register (defineTable also auto-declares the <app>_table on deploy). More hooks: <Metric label value|rollup|counter/>, useRollup(name), useCounter/claimCounter/releaseCounter, useRuns(). Queue lens: useRecords(table, {filters:{assignee: user.email}}). LIST-LENS QUALITY (all shown in the Records page): useViewState(pageKey, defaults) persists filters across reloads + <SavedViews> saves named/shared views; bulk actions via a selection bar (select-all checkbox, bulk status-move/reassign/trash); the table becomes a CARD LIST on phones (hidden sm:block / sm:hidden split — never a squeezed table on mobile); <GlobalSearch> (⌘K, mounted by AppShell) + searchEverywhere(q) search titles across every app the user can open, rules enforced per row; askConfirm({title, destructive, typeToConfirm}) is a real dialog (not window.confirm); \"/\" focuses list search. CURRENCY is an app setting (Settings page) — money everywhere uses it via formatMoney()/appCurrency(); never hardcode a currency. exportWholeApp() (Settings → Export everything) downloads every table + file link as one JSON. Roles: member | developer (edits code; \"editor\" is the legacy alias) | admin, plus custom roles. See README.md.","components":"The starter ships shadcn/ui: Button, Card, Input, Label, Badge, Table, Dialog, DropdownMenu, Select, Tabs, Avatar, Skeleton, Separator, and Sonner toasts — import from \"@/components/ui/*\". Add more with `npx shadcn@latest add <name>`. The theme (colors, dark mode) is in src/index.css; whiskRoles(db) drives role-aware UI.","shared_db":"Your org's one Instant app contains several logical DATABASES: the SHARED database (unprefixed tables like products and customers — org-wide truths every app can use and link to), one database PER TEAM (prefix \"<teamslug>_\" — team members only, shared across all that team's apps), one database PER APP (prefix via the template's appTable() helper — follows the app's visibility), and read-only platform streams (whisk_*). Deciding where data goes: a business fact the whole org cares about -> shared; data a TEAM shares across its apps -> the team's database; operational data only this app needs -> the app's database. Tables spring into existence on first write — no migrations. Access is ROLE-BASED: every member can collaborate by default, and per-table policies (see PUT .../tables/:table/policy) restrict by role, row owner, and even individual fields when the app's nature calls for it (approvals, private records). ALL persistent data lives in these databases — NEVER in localStorage. GET /v1/org/schema shows every database and its tables.","sign_in":"Members just open the app and sign in with their work email — one account across all the team's apps. The template handles the redirect + token exchange; you never wire auth yourself. Humans see their apps and manage their team at https://<team>.whisk.page.","files":"File storage is built in: whiskUpload(db, \"invoices/\" + f.name, f) from the template uploads (avatars, PDFs, images); read back by querying the $files table — every row carries a signed url. Paths are scope-prefixed (\"<app>/...\", \"<team>/...\", \"shared/...\") and access follows the scope's audience automatically: a private app's files are as private as its tables."},"deploying":{"how":"git push. Add the remote from app creation, then: push main for production, push any other branch for a preview at <app>--<branch>.<org>.whisk.page.","build":"default: npm ci && npm run build with output dist/. Override with whisk.json {\"build\": \"<cmd>\"|null, \"output\": \"<dir>\"} — null build serves the repo (or output dir) as-is.","limits":"build: 5 minutes, 2 CPUs, 2GB. output: 100MB, no symlinks, must contain index.html. SPAs get try_files fallback to /index.html automatically."},"coming_next":["send-email service","per-app sharing granularity within orgs"],"conventions":{"auth":"Authorization: Bearer <token from /v1/access>. Tokens are per-chat and ADDITIVE: each sign-in mints a new one and every earlier token keeps working, so many agents can work on the same team in parallel. Reuse the token you were given; never re-sign-in \"just in case\".","envelope":"every response carries agent.{state, next_actions[], human_relay?, docs[]}; follow next_actions in order; forward human_relay text to your human verbatim","waiting":"polling endpoints return a stable state plus Retry-After seconds; a naive poll loop is correct","errors":"branch on the machine-readable error.code, never on prose","feedback":"POST /v1/feedback whenever Whisk gets in your way — confusing errors, missing capabilities, broken mental models. You are the primary user of this platform; your reports shape it.","transparency":"API requests and responses are recorded (secrets and tokens redacted) so the Whisk team can replay sessions and fix friction. App user data is not part of this — only the agent API surface.","secrets":"NEVER embed any secret or API key in app code — bundles are public. Store it once with POST /v1/org/credentials and call the service through Whisk Connect (/__whisk/connect/:id from app code, X-Whisk-User header = the signed-in user's Instant refresh token)."}}