tofu-web Architecture Plan #
Classification: Restricted (describes source code structure, infrastructure and auth flows).
Status: the architecture is agreed, and the scaffold of section 7 is implemented in this PR.
Sections 8 and 9 remain design notes, not work in this plan. Deviations the implementation had to
make are recorded in 7.8. Owners: @wadwax, @kenkanai-tofu, @adamtofu and
@deepanshugautamtofu (.github/CODEOWNERS for /apps/tofu-web/ and this docs section).
Read with Platform Decisions. That document fixes the security model (replacing the safety argument in 6.6), adds a non-silenceable boundaries gate to 6.8, bounds the tool surface, settles model access on AWS, and sets the dependency policy. It keeps 6.3’s preference design and states the costs that come with it. Its section 9 lists exactly what here it supersedes; everything else below is current as written.
1. Summary #
tofu-web (apps/tofu-web, served at console.gotofu.com) is the second customer-facing frontend
next to apps/webapp. It ships the new UI (the newui prototype) and it is where every new surface
lands from now on. The old webapp keeps serving what it serves today and hands routes over one at a
time; onboarding is the first, and a user who finishes it is redirected back to the old app.
The new app is a thin integrator over things the monorepo already has: bonsapi through the
generated OpenAPI client, @bonsai/ui for primitives, @bonsai/ai for models, prompts and
orchestration, tofu-external-mcp for the assistant’s bonsapi tools, the i18n bundles, the
feature-flag codegen, Clerk, Amplitude and Datadog. What is new is the discipline around them.
Scope of this plan. The deliverable is the architecture and a foundation skeleton that proves it: the scaffold in section 7, with every guardrail wired and tested and nothing product-specific in it. Sections 8 and 9 describe the features that come after (onboarding, then the workspace) so the skeleton is shaped for them; they are design notes, not work in this plan.
Decisions already taken:
| Decision | Outcome |
|---|---|
| Name, directory, host | tofu-web, apps/tofu-web, console.gotofu.com. The hasami project id, service-registry key, mise task prefix, image name and docs section all use tofu-web. |
| Bun scope | Bun runs scripts, tests and the production server. pnpm stays the workspace installer. |
| Onboarding step order | The hackathon’s review-first order stands; the Integrations step stays out of the stepper and lives in the assistant. |
| Chat bridge | Kept, for cross-tab awareness. Single-tab effects still go through commands. |
| Persisted client state | Banned. The app writes nothing to the browser that outlives the tab: no localStorage, sessionStorage, IndexedDB, Cache API or app-set cookies. Per-user preferences live in Clerk user public metadata, read through data/preferences and written only by a server function. |
| Layout skeletons before onboarding | The scaffold ships the / workspace shell (the prototype’s layout) with the entity nav as its one real feature (7.7), and the /onboarding chat-plus-surface layout, so onboarding lands in a shell that already works and already talks to the assistant (7.6). |
The six rules from the brief, the seventh added since, and the mechanism that enforces each:
| Rule | Mechanism |
|---|---|
| TanStack Start, TanStack everywhere, no Legend State | Start + Router + Query + Store + Form + Table + Virtual + AI (with ai-mcp and ai-orchestration) + Pacer + Devtools. UI state lives in TanStack Store and typed router search params. TanStack DB is a gated later spike, not a day-one dependency. |
| Components are dumb UI | Presenter/container split enforced by eslint-plugin-boundaries and no-restricted-imports: a presenter cannot import data, state, commands, router hooks or Clerk. Every presenter has a story. |
| SOLID, best developer experience | Five layers with one-way dependencies (domain → data → commands → containers → routes), each a lint boundary. A generator scaffolds a feature so the right shape is the easy shape. |
| Strict lint and format | Type errors fail the build. no-explicit-any, exhaustive-deps, jsx-key, jsx-a11y, switch-exhaustiveness-check are errors. Zero oxlint-disable in src/. knip for dead code. Biome sorts Tailwind classes. |
| Bun as the base | Bun runs scripts, tests and the production server (Nitro bun preset). pnpm stays the workspace installer because the monorepo’s overrides, patches and catalog live there. |
| Everything is an AI tool | Two tool sources, one runtime. bonsapi reads and writes reach the assistant as MCP tools from tofu-external-mcp, which the hackathon proved with the user’s own session token. Everything the UI itself can do is a command, and every command is simultaneously a button, a command-palette entry and a client-executed TanStack AI tool. The UI store exposes nothing but commands, so “full UI access” holds by construction. |
| No persisted client state; preferences in Clerk | no-restricted-globals and no-restricted-imports reject the storage APIs and every persister package, a CI grep catches the property-access forms, and a fixture proves each. Nothing survives a reload except through the URL, the server, or Clerk user public metadata, which data/preferences reads and one server function writes. |
Open decisions are collected in section 11.
2. Where we are: the current webapp #
2.1 What works and must carry over #
- Feature-first directories and a real boundary lint.
apps/webapp/.oxlintrc.jsonalready runseslint-plugin-boundarieswithshared,feature,appandsyncelements anddefault: disallow. The idea is right; the elements are too coarse (see 2.2). - A genuinely dumb UI library.
libs/typescript/uihas 68 components and 77 stories, nouseQueryorfetchanywhere, and CI fails when a component has no story (mise run ui-stories-check). This is the model for every presenter in the new app. It already holds the chat primitives (conversation,message,response). - Codegen as the contract. Orval generates the bonsapi client from
tools/swagger/external.yaml(176 operations, split by tag). i18n bundles are generated fromconfig/i18n. Feature flags are generated fromconfig/features.yaml. Country presets and accounting targets are generated from YAML. None of this needs redoing. @bonsai/ai. Server-only, capability tiers with per-tier fallback, encrypted prompts baked in at build time, Datadog LLM telemetry, and a shared request frame (streamChatRoute) that handles Clerk auth, a feature gate, a byte-capped body read and SSE. The hackathon branch adds the MCP client and per-candidate model options. The chat surface of the new app is this library plus a UI.- Pieces of the onboarding feature. The tracking contract (
features/onboarding/tracking/contract.ts) is an interface declared apart from the hook that implements it; the funnel decision (shared/utils/onboarding-funnel.ts) is a pure function with tests; the variant resolution is exhaustively commented and models every outcome as a discriminated union (resolving | redirecting | error | blocked | ready). The open-redirect guard (shared/lib/auth/trusted-redirect.ts) pins the host to configuration, never to the request. The OAuth popup handshake (use-oauth-popup.tsplus/onboarding/oauth-complete) is the right design and moves over as-is. - Fast tooling. oxlint, Biome and
tsgoare already the toolchain;--deny-warningsis on. Lefthook runs format, lint and type checks per file type on commit. - A command palette exists.
shared/state/commands.tshas aCommandregistry with ids, labels, groups and usage analytics. It is the seed of the command layer in section 6.5.
2.2 What hurts #
Numbers from apps/webapp/src on main, excluding generated code and tests unless stated:
| Measure | Value |
|---|---|
Lines of TypeScript in src/ |
~350k (about 45k generated) |
Lines in src/shared/ vs src/features/ |
181k vs 144k |
| Files over 500 lines | 125 |
| Largest hand-written file | shared/state/review.tsx, 3,082 lines |
Files importing @legendapp/state |
316 |
Files importing shared/state/review |
139 |
.tsx files in features/ and shared/components/ importing shared/lib/api directly |
324 |
oxlint-disable comments |
908 |
as any / : any occurrences |
122 / 155 |
Files marked 'use client' |
391 |
| Next.js route handlers acting as a backend-for-frontend | 34 |
What the numbers mean:
- “Shared” is the dumping ground. Shared is bigger than all features combined, holds 332
component files, and the boundary lint lets anything in
sharedimport anything else inshared. A shared component can import a generated API hook, a Legend observable and a Clerk hook in one file, and many do. - Two state systems glued by hand. TanStack Query is the source of truth, then
store-dispatcher.tsmirrors cache events into Legend observables, thenshared/state/review.tsxderives 3k lines of computed state, then components read either.shared/utils/query.tsis a 2.6k-line custom bridge (syncedQuery) between the two. Legend State is pinned at3.0.0-beta.30. Every REVIEW.md finding about “invalidate every dependent key” and “no wrong-default flicker” is a symptom of state having two homes. - Components own logic.
invoice-form.tsx(2,170 lines),line-table.tsx(2,228),extraction-filters.tsx(1,470),accounting/v2/index.tsx(1,685) each mix fetching, derivation, mutation and rendering. The four extraction types each have their own form, table and schema, which is why reviewers keep finding a fix in one sibling that was missed in three. The hackathon’s chat panel (review/components/extraction/chat/chat-panel.tsx, 1,091 lines) is the freshest example: transcript rendering, tool-call scanning, bridge publishing, history persistence and greeting injection in one component. - The type system is switched off where it matters.
next.config.tssetstypescript.ignoreBuildErrors: trueandeslint.ignoreDuringBuilds: true; the lint config setstypescript/no-explicit-any: off,react-hooks/exhaustive-deps: off,react/jsx-key: off,no-unsafe-optional-chaining: off. The@/*alias resolves to bothsrc/*andsrc/shared/*, which Jest has to mirror by hand. Zod is pinned at 3, which is why every hackathon tool schema is hand-written JSON Schema: TanStack AI’s schema conversion needs Zod 4.2. - Duplicate dependencies. Two form libraries (
react-hook-form,@tanstack/react-form), two animation libraries (framer-motion,motion), two HTTP clients plus a fetch wrapper (axios,openapi-fetch,fetch-instance), two IndexedDB wrappers (dexie,idb), two spreadsheet stacks, three AI SDKs (@anthropic-ai/sdk,openai,@bonsai/ai).@bonsai/uiitself pullsthreeand@fullpage/react-fullpage. - Browser storage is a fourth state store. 23 files touch
localStorage,sessionStorageor IndexedDB through four mechanisms: Legend’s persist plugins (the 887-linesync/ui.tsfilters and columns, feature preferences, improve requests, per-entity previews, suggestion dismissals, self-prompt progress), a custom IndexedDB plugin overidb,dexie, and rawlocalStoragecalls (http-cache.ts,session.ts, the PDF viewer’s in-progress edits, the update manager). Keys are global or per entity, never per user. The cost is visible in the code: both error boundaries deleteui-stateon crash because corrupted persisted state takes the app down, and sign-out has toclear()storage while preserving that same key. The preferences that should follow a user (locale, tour completion, the sidebar per organization) already live in ClerkunsafeMetadata, written through/api/auth/user/metadata, so the right pattern exists; it is one of five. - Next.js is used as a client SPA with a Node sidecar. 391 client components; the App Router is a file-based router for a client app, and the 34 route handlers are a backend-for-frontend (Stripe, Intercom, exports at 1,475 lines, AI, Clerk org writes). Nothing here needs React Server Components; TanStack Start’s server functions cover the sidecar with typed calls.
- Onboarding is good code on the wrong foundation. The flow is a fullPage.js scroll-hijack
with an imperative navigation gate (
navAllowedRef), an 823-line organization slide with 21useStatecalls, and a 748-line integrations slide. Variant resolution runs in the browser and has to activate and de-activate Clerk organizations to read the entity list, with a probe cap and backoff. Below thelgbreakpoint the hijack is switched off by toggling a data attribute. - Docs drift.
docs/internal/content/docs/webapp/architecture.mdlists features and a state model that no longer exist;ui.mdsays 35 components. The root REVIEW.md treats stale docs as findings, so the new app needs docs that are generated or colocated.
3. Precedents already in the monorepo #
The new app is not the first TanStack Start app here, and not the first tool surface.
| Precedent | What to take from it |
|---|---|
apps/sales-portal |
TanStack Start 1.167, Router 1.169, Query, react-router-ssr-query, Vite 8, Nitro, React Compiler, Zod 4. Clerk via @clerk/tanstack-react-start with clerkMiddleware() in src/start.ts and auth() inside createServerFn. shared/queries/* as queryOptions factories over server functions. Dockerfile, k8s resources and overlays, service-registry.json entry, detect-changes.yml filter, .tasks.toml enumeration. Copy the scaffold, not the code. |
apps/nigari |
Same stack plus @bonsai/ai chat routes (routes/api/account-import.ts), useChat + fetchServerSentEvents on the client, bun test with a preload setup. Shows the structured-output chat pattern end to end. |
apps/tofu-external-mcp |
169 tool definitions in 19 groups (name, method, URL, read-only/destructive annotations, description) and 163 zod-typed registrations over the generated bonsapi client, with @bonsai/mcp-response-shaping for byte caps and projections. On the hackathon branch it also accepts the webapp’s Clerk session token as a first-party credential, which is what lets an in-app agent use these tools as the signed-in user. Its REVIEW.md rules (“the description is the prompt”, “names drive selection”, machine-actionable errors) apply verbatim to in-app tools. |
@bonsai/ai |
Model tiers, fallback, prompts, telemetry, and (on the branch) createMCPClient, codeExecutionTool for hosted Agent Skills, candidateModelOptions. Two copies of streamChatRoute exist (webapp and nigari) and have already drifted; a third consumer means it moves into the library. |
@bonsai/ui |
The presenter library. Grows by absorbing the prototype’s components once their logic is stripped. |
config/i18n + tools/local/scripts/i18n-gen |
Message source and codegen. The new app consumes the same bundles through use-intl (the framework-agnostic core of next-intl, same ICU syntax), so no message migration. |
config/features.yaml + sales-portal-codegen-feature-flags |
Flag definitions generated per app. |
tests/e2e/tests/onboarding |
Playwright suite that exists today and is retargeted at the new host when onboarding moves. |
3.1 What the hackathon branch proved (tofu/niseko-hackathon-2026)
#
The branch is 476 files across bonsapi, the webapp, tofu-external-mcp and @bonsai/ai. The
parts that matter for this plan:
- An orchestrated assistant on TanStack AI.
/api/ai/review-chatruns a@tanstack/ai-orchestrationworkflow per turn:triagepickssupportorsales, the specialist drafts with its tools, and anauthorityagent produces the only text the user sees. Specialist steps are filtered out of the SSE stream server-side (chat-stream.ts), so their research never reaches the client. Agents are built per request, pinned to Claude Sonnet with prompt-cache breakpoints, and the sales agent loads thetofie-sales-repskill as a hosted Agent Skill through the code-execution tool. - bonsapi tools come from the MCP server.
external-mcp-client.tsconnects totofu-external-mcpwith the caller’s Clerk session token, narrows the tool list to an explicit allowlist (knowledge, integrations, extractions, billing, pricing), and hands it tochat()as an MCP tool source; each agent sees a further-scoped view. The support prompt walks the model through a full “connect Xero” flow using only those tools (get_integration_oauth_authorize_url,list_integrations,get_accounting_organizations,setup_integration,update_entity_settings). A warm-up route pre-connects on panel mount. - Local tools whose call is the effect.
recommend_pricing_plan,show_pricing_plansandsearch_webaretoolDefinition(...).server(...)tools; for the first two the server executor is an acknowledgement and the client acts on the call itself. The panel scans assistant messages fortool-callparts by name, then publishes a typed event on the chat bridge (a Redis stream per user and entity,POST /api/chat-bridge/publishand anEventSourceon/api/chat-bridge/stream), and a bridge subscription applies the UI change by writing Legend observables (pricingPlansPanel$,pricingPagePanel$). The bridge also carries webapp-to-chat signals (EXTRACTION_FINISHED,EXTRACTION_OPEN) that the panel turns into fixed greetings and into typed grounding facts forwarded to the server every turn (forwardedProps.contextFacts). - UI narration outside the prompt.
tool-status-config.tsholds per-tool lifecycle copy (calling, result, triggering, ready) because the model cannot observe when its call lands. - A reworked onboarding. bonsapi gains
POST /internal/api/v1/onboarding/organizations, which creates the Clerk organization, the BonsAI organization, an entity, the membership and seeded demo documents (an AP bill and a bank statement) in one call, marks the organizationis_onboarding(also on the publicOrganizationresponse) and prioritises its extractions. The flow provisions on arrival, opens on a real review of the demo bill, then Documents, Organization and Entity, and returns to the review of the user’s own upload with the assistant rail open. Progress and the “in onboarding” flag persist inlocalStorage; the Integrations step is gone (connections happen through the assistant); a plan page, an unlock dialog and Stripe Checkout return legs were added.
What the branch did not do, and what the new architecture changes:
- No client-executed tool exists yet; every UI effect is “server acknowledges, client observes
the call, bridge applies it”. TanStack AI supports client tools and approval responses
(
useChat’saddToolResultandaddToolApprovalResponse, per the branch’s own README), which removes the bridge round trip for single-tab effects. The bridge stays for cross-tab awareness. - Tool schemas are hand-written JSON Schema because the webapp is on Zod 3; the new app is on Zod 4 and derives them.
is_onboardingis only ever set, and it lives in two places: Clerk private metadata, and the bonsapi organization row that the Clerk webhook mirrors it into. Nothing on the branch clears either copy when the flow ends, because the flow ends inside the same app. The mirror has no ordering guard either:upsert_organizationrewrites the row from whichever organization payload arrives, and Clerk deliveries are retried and unordered. With two apps the flag decides the redirect, so completion needs a one-way transition that no webhook can undo (section 8.3).- The chat panel, the bridge stores and the history persistence are built on Legend observables
inside components. They become commands, a TanStack Store slice and a server-side transcript
store behind
ChatClient’s persistor (6.6); nothing is kept in the browser. - The branch mixes backend and frontend changes. Section 10 splits out the backend half that must
land on
mainbefore onboarding is built.
4. What the prototype tells us #
newui.zip is a v0-generated Next 16 app: 8,238 lines across 19 components, a Zustand store,
faker data, shadcn primitives.
Keep as specification:
- The information architecture: an entity sidebar whose rows carry one actionable count each, with expandable per-document-type and per-status subsections inside them and an inbox; document-type tabs and saved views with group-by; table and gallery of extractions; three right-hand panels (document preview, review drawer, page management) sharing one width and one divider; a selection action bar; a publishing queue that is a toast, not a bar; mobile variants of the drawer.
lib/domain/status.ts: oneRecord<Status, Hue>drives every status colour, and gating predicates (usesPreviewPanel,canManagePages,isAdvancedStatus) are the single source for which panel opens and what is allowed. This is exactly the “one shared guard” the review rules ask for.lib/domain/actions.ts: status → primary/secondary/bulk actions as data, not as JSX branches.lib/domain/filter.ts:matchesFiltersas the one predicate every list surface uses.lib/data/extraction-repository.ts: a repository interface whose list operations are pure transforms. The new app’sdata/layer plays this role against bonsapi.- The idea in
hooks/use-side-panel.tsof layout state extracted from the page, but not the file: it andhooks/use-drawer-mobile-view.tsare dead code, imported by nothing. The behaviour that runs is an inlined copy inapp/page.tsxand inextraction-drawer.tsx, and the copies have drifted. Read the call sites, not the hooks: the hooks would have you build per-panel widths of 50vw and 90vw and a 92% clamp, none of which the prototype actually does (7.8). - The shell itself, described as it actually behaves, because 7.6 builds from this and an earlier
draft of this bullet described things the prototype does not do. A left-docked entity sidebar
with a search box, resizable by a 1px
ResizableDividerrendered as its sibling, which collapses it when dragged below 100px and is unmounted while collapsed; collapsing sets its width to 0 and renders nothing, so there is no icon rail, and the only way back is aPanelLeftOpenbutton in the top bar. Opening a right-hand panel auto-collapses the sidebar and closing it restores what the user last chose, remembered by asidebarCollapsedByPanelbit. One shared divider serves all three right-hand panels. The top bar (top-filter.tsx, despite the name) holds the entity switcher as a dropdown menu, entity settings as a popover of integration and automation controls, an integration status pill, a Help button, and an account blob that is a coloured circle with no menu attached; upload is not in the top bar at all, but a full-width button in the sidebar plus a per-document-type item in the list’s overflow menu. There is no docking, no command palette (cmdkis installed and unused), and nothing is persisted but saved views. Where 7.6 departs from any of this, it says so.
Rework before porting:
- 12 of 19 components import the store directly (
extraction-list.tsx27 times,view-bar.tsx17,selection-action-bar.tsx13). Under rule 2 they become presenters that take props; the store access moves to one container per surface. document-management-view.tsxis 1,521 lines of drag-and-drop, split/merge domain logic, dialogs and rendering. Split into apagesdomain module (pure split/merge/duplicate), a container, and four presenters.- Its status model (uploading → needs action → extracting → needs verification → verified →
published/exported → done → archived) is a product simplification of bonsapi’s
DocumentStatus×ExtractionStatus×InvoiceStatus× publish/import status. The mapping is a domain function with a table test, not a computed observable. - All copy is hard-coded English; ids come from
Math.random(); there areconsole.log("[v0]…")calls. Treat it as a design, not as code.
5. Review rules the architecture must make structural #
Distilled from REVIEW.md, apps/webapp/REVIEW.md and apps/tofu-internal-mcp/REVIEW.md. Each
row is a rule reviewers keep having to apply by hand, and the mechanism that makes the violation
impossible or loud in the new app. The scaffold (section 7) ships every mechanism in this table
that does not need a feature to exist.
| Rule (evidence) | Mechanism |
|---|---|
| Sweep the siblings: a fix to one extraction type must reach all four (#4328, #4338, #4339, #4370) | One extraction-type registry: Record<ExtractionType, TypeConfig> for fields, statuses, actions, export targets. Adding a variant without a config entry is a compile error. One form, one table, configured; never four copies. |
| One shared guard, all paths through it (#4370, #4339, #4271) | Gating predicates live in domain/. Presenters cannot import raw state, so they cannot read around a guard. Commands are the only mutation path. |
| Invalidate every dependent query key (#4409) | Invalidation is derived, not hand-listed. Every query in data/queries declares the resources it reads; every mutation declares the resources it writes; the invalidation set is every key whose query reads a written resource, computed by one function. The registry test asserts every mutation writes at least one known resource and every query reads at least one, so a new count or list query is covered the moment it declares its reads. Tool results from MCP tools go through the same graph (6.6). |
| No wrong-default flicker while context is unresolved (#4370) | Route loaders resolve context before render (loader + pendingComponent); derived state is a discriminated union with an explicit unresolved arm; presenters render the neutral state for it. |
Falsy-zero bugs: if (x) dropping a legitimate 0 (webapp rules) |
strict-boolean-expressions as an error; money and rates are branded types in domain/money.ts with their own predicates. |
Accessibility: role, tabIndex, onKeyDown on interactives (#4284) |
jsx-a11y recommended set as errors; interactives come from @bonsai/ui only, enforced by banning @radix-ui/*, radix-ui and cmdk imports outside the UI library. |
| i18n: no literal strings, no inline disables (#4308) | i18next/no-literal-string stays on with a short exclusion list; message keys are typed from the English bundle so a missing key is a type error; CI greps src/ for oxlint-disable and fails on any hit outside a reviewed allowlist file. The hackathon’s untranslated tool narration and greetings become message keys carried by the command definition. |
| Env-var config defeated by hard-coded fallbacks (#4271) | One env.ts parses import.meta.env and process.env through a zod schema at boot; no-restricted-properties bans reading them anywhere else. |
No deep ternary chains with as any (#4271) |
no-explicit-any: error, consistent-type-assertions set to forbid as outside _generated, no-nested-ternary: error, keyed lookup maps in domain/. |
Perf hygiene: prebuilt Map lookups (#4370, #4409) |
Normalisation happens once in data/ selectors (select on queryOptions); presenters receive indexed view models. |
| Reuse shared components before writing new ones (#4222) | Primitives exist only in @bonsai/ui; app components/ may compose but not re-implement (same import ban as above); Storybook is the catalogue. |
| Docs must match code (#4370, #4284) | Route tree, tool catalogue, feature flags and i18n are generated. Architecture rules live in apps/tofu-web/AGENTS.md and apps/tofu-web/REVIEW.md, next to the lint config that enforces them. |
| The tool description is the prompt; names drive selection (#4394, #4407, #4352) | Tool names and descriptions live once: in @bonsai/mcp-tool for bonsapi operations (shared by tofu-external-mcp and any command that wraps one), and in the command definition for UI operations. Nothing re-describes a tool in a prompt, and nothing copies a description by hand. |
| No dead lifecycle code (#4372) | switch-exhaustiveness-check as an error; knip fails CI on unused exports, files and dependencies. |
| Fail closed (#4394, #4393, #4239, #4267) | Server functions return typed Result unions; no-empty catch as an error; permission checks live inside commands so a new UI entry point cannot skip them, and again inside every server function, which is the boundary that counts. |
| Cross-tenant checks on every caller-supplied id before any write (#4394) | Server functions take the organization from auth(), never from the body, and verify that a caller-supplied entity belongs to it through bonsapi’s internal API before acting (the hackathon’s assertEntityAccess). bonsapi enforces its own permissions on the session token for direct calls and MCP tools alike. Each check has the positive mirror test. |
| Test the transition, not the seeded end state (#4372) | Domain state machines are pure reducers in domain/, tested by transition under bun test. UI state in TanStack Store is testable without React. |
| PII out of telemetry (#4346, #4347) | sanitize-props and forbidden-property-patterns move from the webapp into @bonsai/analytics; the tracking client refuses props that fail them. |
| New packages must be wired into the mise enumeration or they never run in CI (#4320) | Part of the scaffold checklist; a CI step asserts every apps/* with a package.json appears in ts-check and ts-test. |
| Hasami note per affected project (#4380, #4308) | New [[project]] entry in .hasami/config.toml in the scaffold PR. |
6. Target architecture #
6.1 Layers and the dependency rule #
Dependencies point inward only. Each layer is an element type in the boundaries lint with
default: disallow.
routes/ file routes: loaders, search validation, guards, layout
│
containers/ the only React code allowed to read data, subscribe to state and run commands
│
commands/ use-cases: the single mutation path; each one is also an AI tool
│ │
data/ │ state/ TanStack Query (server state) TanStack Store (UI state)
│ │
domain/ pure TypeScript: types, view models, status machines, predicates, money
components/ presenters (props in, callbacks out); may import only domain types and @bonsai/ui
server/ TanStack Start server functions and API routes; may import domain and data
ai/ chat runtime: turns the command registry into client tools, applies tool effects
domain/has no React, no fetch, no Clerk. It is tested withbun testand is where the extraction-type registry, the status mapping from bonsapi enums to product statuses, the onboarding state machine and the permission predicates live.data/owns the generated client (data/api/_generated, Orval with thefetchHTTP client, no axios), onequeryOptionsand key factory per resource, andmutationOptionsthat declare the resources they write, from which invalidations derive (6.4). It exposes typed functions and hooks; it never renders. Its two halves are separate element types in the boundaries lint:data/queriesmay be imported by containers, routes, commands and the server;data/mutationsonly bycommands/. A container that imports a mutation fails lint, so the command registry is the only write path by construction, not by review.commands/is the use-case layer. A command has a name, a description written for a model, a zod input schema, a permission requirement, safety annotations, narration copy keys and anexecute. Commands composedata/mutations andstate/updates. Nothing else may call a mutation or write to a store.state/is TanStack Store: a small number of stores (ui,selection,onboarding,chat), each exposing state plusDerivedvalues, and mutated only through commands. Anything that must be shareable or survive reload lives in typed router search params instead.containers/sit in a feature. They calluseQueryoverdata/queries,useStore,useChat,useCommand, router hooks and Clerk hooks, then render presenters. They never importdata/mutationsoruseMutation; a write is a command. Naming:*.container.tsx, which is what the lint keys on.components/are presenters. They receive view models and callbacks, and nothing else.routes/compose containers, declareloader(prefetch throughqueryClient.ensureQueryData),validateSearch(zod),beforeLoad(auth and onboarding guards) andpendingComponent.server/is the backend-for-frontend: Clerk backend calls, bonsapi internal-API calls that need the service credential, Stripe, the chat SSE route and its orchestrator. Every server function authenticates withauth(), authorizes the caller against the organization and entity it is about to touch, validates input with zod and returns aResultunion. The service credential acts only on ids the authenticated session is entitled to; it never trusts an id from the body.
6.2 Directory layout #
apps/tofu-web/
AGENTS.md the rules below, for humans and agents
REVIEW.md review rules, read by PR-Agent
README.md
package.json bun scripts
vite.config.ts tanstackStart + nitro({ preset: 'bun' }) + tailwind + react compiler
.oxlintrc.json boundaries + restricted imports (see 6.7)
biome.json extends the shared preset; useSortedClasses on
tsconfig.json extends @bonsai/type/tanstack-start without overrides
knip.json
bunfig.toml bun test preload (DOM shim)
orval.config.ts fetch client into src/data/api/_generated
Dockerfile node builder, oven/bun runtime
.tasks.toml tofu-web-* mise tasks
src/
start.ts clerkMiddleware()
router.tsx QueryClient + ssr-query integration
env.ts the only reader of import.meta.env / process.env
routes/
__root.tsx
_app.tsx workspace shell layout (7.6): nav, top bar, panel host, outlet
_app/index.tsx home; renders the reference feature in the scaffold
onboarding.tsx assisted layout (7.6): chat on the left, a surface on the right
onboarding/index.tsx the surface slot; the reference feature now, the steps later
sign-in.tsx
api/health.ts
api/chat.ts SSE route over @bonsai/ai
api/chat-bridge/ publish.ts, stream.ts (cross-tab bridge)
domain/
permissions/
money.ts
entity-nav/ folder layout: tree building, moves, reorders, all pure (7.7)
extraction-type/ registry: Record<ExtractionType, TypeConfig> (workspace phase)
status/ bonsapi enums → product status, hues, gates (workspace phase)
onboarding/ state machine (onboarding phase)
data/
api/_generated/ orval output (fetch client)
api/client.ts token provider injection (browser: Clerk session; server: auth())
define.ts defineQuery (reads), defineMutation (writes, optimistic, publish)
queries/<resource>.ts queryOptions + keys + select + reads
mutations/<resource>.ts mutationOptions + writes (invalidations derived)
commands/
define.ts defineCommand, fromCatalog, can/always, registry, toClientTool,
toPaletteItem
surface.ts defineSurface, useSurface: scoped commands + typed context (7.6)
<feature>/*.ts
state/
layout.store.ts, ui.store.ts, chat.store.ts (+ selection, onboarding later)
features/<feature>/
containers/*.container.tsx
components/*.tsx feature-local presenters, each with a story
commands/, queries/ feature-scoped when not shared
components/ app-level presenters shared by features
app-shell/ nav, top bar, resizable divider, panel host, palette host (7.6)
ai/
chat.container.tsx useChat wiring; renders @bonsai/ui conversation primitives
tools.ts command registry → client tools
tool-effects.ts tool name → resources written + follow-up command
context.ts route + store + active surface → grounding facts sent each turn
transcript.ts pure helpers over UIMessage parts (tested)
bridge/ typed event catalogue, EventSource hook, publish helper
server/
ai/ orchestrator, agents, MCP client, prompts glue
auth.ts, chat-bridge.ts, …
tests/ bun test preload and helpers
A bun run new:feature <name> generator produces the feature skeleton (container, presenter
with story, command, query, test) so the correct shape is also the fastest one.
6.3 Homes for state #
| Kind of state | Home | Why |
|---|---|---|
| Server data (entities, extractions, integrations) | TanStack Query via data/queries |
Caching, deduping, invalidation, SSR hydration. |
| Shareable UI state (selected extraction, filters, panel, group-by, saved view, onboarding step) | Router search params or path, validated by zod | Deep links, back button, reload, and the assistant can navigate to any UI state. |
| Ephemeral app state (selection set, panel widths, upload staging, onboarding drafts, chat transcript) | TanStack Store, one store per concern, mutated by commands only | Testable without React, subscribable outside components, gone on reload by design. |
| Form drafts | TanStack Form inside a container, seeded from and written back to the store draft | Validation and submission stay in the form; the assistant and the keyboard both write the same draft. |
| Per-user preferences (theme, density, locale, dismissed hints and tours, default saved view, sidebar per organization) | Clerk user public metadata, read through data/preferences against a zod schema in domain/preferences, written only by the updatePreferences server function through Clerk’s backend client |
Follows the user across devices and across both apps, readable on the server for the first paint, visible to support and to the assistant, and impossible to write from the browser console. |
Local useState is allowed only for state the user cannot name (hover, focus, an uncommitted
keystroke). If a user could ask the assistant to change it, it is store or search state.
Nothing else survives a reload. The app never touches localStorage, sessionStorage, IndexedDB,
the Cache API or document.cookie, and never installs a persister (TanStack Query’s
persistQueryClient, idb, dexie, localforage, any store persist plugin): oxlint’s
no-restricted-globals and no-restricted-imports reject the bare globals and the packages, the
CI grep that hunts oxlint-disable also fails on window.localStorage, window.sessionStorage
and document.cookie, and fixtures in 7.4 prove all three. A library’s own bookkeeping that the
app never reads as state (Clerk’s session cookie, the router’s scroll restoration in
sessionStorage, devtools) is not app state and is out of scope. What must outlive a reload is in
the URL, on the server, or in Clerk metadata; what must not is in a store. The webapp’s ui-state
crash-wipes and its sign-out scrub (section 2) are the failure mode this removes, and a shared
machine holds nothing of the previous user.
Preferences are small and few. Clerk documents an 8 KB cap on a user’s metadata, and the session
token that carries any claim rides in a cookie, so the schema holds flags, ids and enums, never
rows: a saved view’s definition is bonsapi data, its id as the default is a preference.
Organization-scoped preferences nest under the organization id, as the webapp’s
entitySidebarByOrg already does. Writes go through one server function that authenticates with
auth(), validates the patch against the schema, merges it into the current publicMetadata
through clerkClient.users.updateUserMetadata, and returns the new object; the command applies
the result to the in-memory preferences at once, so the UI changes before any round trip, then
reloads the Clerk user so every data/preferences reader agrees. The two fields the first paint
needs, theme and locale, are also exposed as session claims so the server renders them without a
fetch. Claims are a cache: Clerk mints the session token with the claims of that moment and
refreshes it on its own schedule, so a reload straight after a change could render the old
value. The command therefore awaits getToken({ skipCache: true }) before it resolves, which
mints a token carrying the new claims and installs it in the cookie, and reports done only then.
The 7.5 check, change the theme and reload at once, must pass on the dev instance before the
claim path is kept; if it does not, the root loader reads the user record through the backend
client (one memoised call per request) and the claim is dropped.
publicMetadata rather than unsafeMetadata because the browser cannot write it: a preference
changes only through the server function’s schema, the way every other mutation goes through a
command. Product flags are not preferences: is_onboarding is organization metadata (8.3) and the
onboarding rollout gate is whatever 6.10 settles on.
6.4 Server state details #
- Orval config mirrors the webapp’s (
tags-split,react-query, v5) withhttpClient: 'fetch'and a mutator that takes a token provider, so the same generated functions run in the browser (Clerk session token) and in server functions (auth().getToken()). - The browser calls bonsapi directly for CRUD, as the webapp does today (lowest latency, no
second auth hop). Server functions are used only where secrets, the Clerk backend or bonsapi’s
internal API are needed (provisioning, prefill, Stripe, the chat route). bonsapi’s CORS
allowlist gains
console.gotofu.comand the dev hosts. - Every resource module exports
keys,queriesandmutations, built withdefineQueryanddefineMutationfromdata/define.ts(7.7 shows both). Queries declarereads(the resources they render:['entity'],['entity', 'extraction']), mutations declarewrites, andinvalidationsFor(mutation)derives the keys from that graph, so nobody lists keys by hand and a new list or count query is covered the moment it declares what it reads. The registry test asserts every mutation writes at least one known resource, every query reads at least one, and no resource is written without a reader; an unknown resource name fails it.ai/tool-effects.tsrows are the same declaration for server tools: an MCP tool name maps to the resources it writes. - TanStack DB (
@tanstack/react-dbwith the Query collection) is evaluated in the workspace phase for the extraction list, where optimistic status changes and live filtering are the whole feature. It is pre-1.0 and is not in the lockfile today, so it is a spike with a kill criterion, not a foundation.
6.5 Commands are tools #
The rule “everything can be controlled by chat, full CRUD, full UI access” is not a feature to add later; it is the shape of the mutation layer.
// commands/define.ts (sketch)
interface CommandDefinition<In, Out> {
name: string; // snake_case, stable, "names drive selection"
description: string; // written for the model; the description is the prompt
input: z.ZodType<In>; // Zod 4: also the tool's JSON Schema
output?: z.ZodType<Out>;
permission: PermissionCheck; // UX gate: keeps buttons and tools consistent; the server re-checks
annotations: { readOnly: boolean; destructive: boolean; confirm?: boolean; gesture?: boolean };
narration: { calling: MessageKey; done: MessageKey }; // what the hackathon's tool-status-config held
execute(ctx: CommandContext, input: In): Promise<Out>;
}
- The registry is the single list, scoped: the global commands plus those of the surface on
screen (7.6). From it we derive TanStack AI client tools (
ai/tools.ts), command-palette entries (the existingCommandshape from the webapp, minus Legend), and a generated markdown catalogue for docs. - Stores expose their actions only as commands. There is no
store.setStatereachable from a component, so “full UI access” is not a coverage target; it is what the store is. - Read commands wrap
queryClient.fetchQueryon the samequeryOptionsthe UI uses, so the model reads the cache the user is looking at. - bonsapi operations are not re-described as commands. They reach the assistant as MCP tools
from
tofu-external-mcp, exactly as the hackathon did, scoped by an allowlist per agent. A command exists for a bonsapi operation only when the UI needs a button for it, and then itsexecutecalls thedata/mutation, and its name, description and annotations are imported from@bonsai/mcp-tool, the library the scaffold lifts out ofapps/tofu-external-mcp/src/api/tool-groups(7.2), so the two cannot disagree. Nothing is copied by hand, not even in the interim.
6.6 Chat runtime and safety #
Client:
useChatfrom@tanstack/ai-reactwithfetchServerSentEvents('/api/chat'), the registry’s client tools, and grounding context (route, selected entity and extraction, onboarding step) built byai/context.tsand sent asforwardedPropsevery turn. This replaces the hackathon’scontextFacts, keeping its idea: typed facts, one builder, never ad-hoc fields. The surface on screen contributes its own facts (7.6), so the assistant knows what the user sees, what is selected and what is invalid, not only where they are.- A client tool call runs the command through the same path a click does, so cache invalidation,
optimistic updates, toasts and analytics are identical. The result goes back to the model
through
addToolResult; adestructivecommand opens the same confirmation the button would and reportscancelledif declined; agesturecommand (OAuth popup, file picker) reports what the user did. - Tool calls the server executed (MCP tools,
search_web) reach the client astool-callparts.ai/tool-effects.tsmaps tool names to the resources they write (the invalidations derive from the same graph as 6.4) and to an optional follow-up command; the hackathon’s “knowledge updated, retrigger?” nudge and itsEXTRACTION_FINISHEDgreeting become two rows in that table instead of effects inside a component.ai/transcript.tsholds the pure part-reading helpers the hackathon wrote defensively (extractPlanRecommendationand friends), with tests. - No client command takes a URL, host or redirect target as input. Navigation inputs are typed
ids (a step name, a provider, a plan), and the command computes the target from configuration
or fetches it from bonsapi through
data/, then applies the guard the webapp already applies to consent URLs (httpsonly, viasafeRedirect). A model can therefore ask for a popup or a redirect, but never choose where it goes. - Transcripts live on the server, never in the browser.
ChatClient’s persistor over thechatstore slice writes through a server function into the same Redis the bridge uses, keyed by the authenticated user and the entity (chat:<userId>:<entityId>), with the hackathon’s caps and a TTL; the key comes fromauth(), never from the request body. The branch keyed transcripts by entity alone, which lets a second user of the same entity on a shared browser read the first user’s conversation; the bridge stream is already keyed per user and entity, the transcript follows the same rule, and with nothing stored client-side (6.3) a shared machine holds no transcript at all. A user’s history follows them to any device. Sign-out clears the in-memory slice. - The chat bridge (Redis stream per user and entity, publish route,
EventSourcestream route, typed event catalogue with a zod schema per event) is kept for cross-tab awareness, as decided. Single-tab effects never go through it: a client tool applies its own command, and the bridge carries the fact that it happened to other tabs. Server-to-client domain signals (“extraction finished”) keep coming from bonsapi’s entity-updates SSE. The bridge’s IDOR guard (assertEntityAccess) moves with it.
Server:
routes/api/chat.tsuses the sharedstreamChatRouteframe moved into@bonsai/ai(Clerk auth, byte cap, optional feature gate, SSE, lazy session token), and runs the hackathon’s orchestrator (triage→ specialist →authority, withtoChatStreamfiltering silent steps) ported intoserver/ai/with its agents, allowlists and prompt-cache breakpoints intact.- Agents get two tool sources: the MCP tool source over
tofu-external-mcp(session token, request timeout, per-agent allowlist, warm-up on mount) and the app’s client tools forwarded byuseChat. Local server tools (search_web) stay server tools. - System prompts move into
@bonsai/ai’s encrypted registry underonboardingandworkspacedomains; the product facts and flow description the hackathon inlined are the first entries. The per-request context sentence is built from the typed grounding facts, as today. - Safety is in the command runtime and at the server boundary, not in the prompt. The permission
check inside a command is a UX gate: it keeps buttons, palette entries and tools consistent, but
a browser can skip it, so it is never the security boundary. The boundary is the server: every
server function authenticates and authorizes independently (6.1), operations that use the
service credential (provisioning, completion, Stripe) derive the organization from the session
and verify entity ownership before acting, and bonsapi enforces its own permissions on the
forwarded session token for direct calls and MCP tools alike. Every tool execution emits an
Amplitude event with sanitised props; the byte cap and per-user limits from
streamChatRouteapply. @tanstack/ai-orchestrationis consumed from a published release, not thepkg.pr.newbuild the branch pins.
6.7 The dumb-UI contract and how it is enforced #
A presenter:
- receives a view model and callbacks; never a store, a query result object or an entity id it must go and resolve;
- may import
react,@bonsai/ui,domain/types,lucide-react,motion,use-intl’suseTranslations, and the router’sLink; - may not import
@tanstack/react-query,@tanstack/react-store,@tanstack/ai-react, router hooks (useNavigate,useSearch,useParams),@clerk/*,data/,commands/,state/,server/, orai/; - has a colocated story that renders it from props alone (the existing
ui-stories-checkpattern, extended to the app), and passes the Storybook a11y addon; - is responsive by itself: container queries and one layout tree, not two trees behind
hidden lg:block. Panels are rendered by onePanelHostpresenter that is inline on wide screens and a sheet on narrow ones, driven by the same search-param state.
Enforcement is boundaries/element-types (presenter, container, route, command, data-query,
data-mutation, state, domain, server, ai; only command may import data-mutation) plus
no-restricted-imports with importNames for the router hooks and for useMutation outside
commands/, plus a ban on @radix-ui/*, radix-ui and cmdk outside libs/typescript/ui. The
sales-portal config
already bans relative sibling imports (no-restricted-imports: ["./*"]); keep that.
6.8 Strictness stack #
- TypeScript: the
@bonsai/type/tanstack-startpreset, unmodified (verbatimModuleSyntaxstays on; both TanStack apps currently switch it off), plusnoUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride,noFallthroughCasesInSwitch,noPropertyAccessFromIndexSignature.vite buildrunstsgo --noEmitfirst; a type error is a failed build. - oxlint:
correctness,suspiciousandperfas errors;typescript/no-explicit-any,no-non-null-assertion,switch-exhaustiveness-check,strict-boolean-expressions,react-hooks/exhaustive-deps,react/jsx-key,jsx-a11yrecommended,import/no-cycle,import/no-namespace,no-nested-ternary,no-consoleas errors; boundaries and restricted imports as above;i18next/no-literal-stringwith a short exclusion list;tofu-security/no-unsafe-location-assign;no-restricted-globalsforlocalStorage,sessionStorage,indexedDBandcaches, andno-restricted-importsfor every persister package (6.3).--deny-warningsstays. - Zero
oxlint-disableinsrc/. Exceptions go in onelint-exceptions.mdwith a reason and an owner, and a CI grep fails on any other occurrence. The same grep fails onwindow.localStorage,window.sessionStorageanddocument.cookie, the property-access forms the globals rule cannot see. - Biome: the shared formatter preset plus
useSortedClassesfor Tailwind, so class order is not a review topic. knip: unused files, exports, types and dependencies fail CI.- Tests:
bun testfor domain, commands, data registries, tool effects and stores;@testing-library/reactunderbun testwith a DOM preload for containers; Storybook for presenters; Playwright intests/e2efor flows. - Generated code lives under
_generated/directories, is ignored by lint, and is regenerated bymise run codegenlike every other generated file in the repo. - Bun is pinned in
.mise.toml(today it islatest), and the Dockerfile uses the same version.
6.9 Toolchain: what bun does and what pnpm keeps doing #
- Bun runs every script (
bun run dev,bun run build,bun test, generators), is the test runner, and is the production runtime: Nitro’sbunpreset emits a Bun server, and the runtime image isoven/bunrunning the emitted server. Vite itself runs under Node in the scaffold, as nigari and sales-portal do; flipping the dev server tobun --bun viteis a later experiment with its own kill criterion. - pnpm remains the workspace installer. The monorepo’s security overrides,
patchedDependencies,onlyBuiltDependenciesand the versioncataloglive inpnpm-workspace.yamland the rootpackage.json; the Dockerfiles install withpnpm install --filter "tofu-web...". Moving the workspace to bun workspaces is a separate decision with blast radius across five TypeScript apps and nine libraries, and is not required for any of the six rules. - CI wiring for the new app, all in the scaffold PR:
.tasks.tomltasks joined intots-check,ts-test,ts-codegen,ts-format-*,ts-lint-*,ts-type-check;service-registry.jsonentry;detect-changes.ymlfilter;deployment/resources/tofu-weband overlays fordev,dev-mng,prod;.hasami/config.tomlproject; this docs section.
6.10 Cross-cutting services #
| Concern | Choice |
|---|---|
| Auth | Clerk via @clerk/tanstack-react-start; clerkMiddleware() in start.ts; auth() in server functions; beforeLoad guards on routes. Same Clerk instance as the webapp, so a session at app.gotofu.com is already valid at console.gotofu.com through Clerk’s root-domain client cookie (verify on the dev instance in the scaffold). |
| Preferences | Clerk user public metadata behind data/preferences and the updatePreferences server function (6.3). No browser storage, no cookies; theme and locale also as session claims for the first paint. The webapp reads the same user’s unsafeMetadata today (locale, tours, sidebar per organization) at a handful of call sites; those switch to the public copy when the keys move. |
| Permissions | Roles and ORG_PERMISSIONS move from apps/webapp/src/shared/hooks/use-permission.ts into domain/permissions as pure predicates; commands evaluate them. |
| Schemas | Zod 4 throughout (as nigari and sales-portal already are), so command inputs double as tool schemas and server-function validators. |
| i18n | use-intl over the generated bundles in config/i18n, with a tofu-web namespace file per locale and typed keys generated from the English source. Chosen as the default because the message format is unchanged; swapping to i18next later touches one provider file. Locale resolution: the user’s preference from Clerk public metadata, else Accept-Language, else English; no locale cookie. |
| Feature flags | The existing codegen, run for this app like sales-portal-codegen-feature-flags. Note: flags are org-level, and a fresh sign-up has no organization until provisioning runs, so the rollout gate for onboarding is user metadata or an environment toggle, not an org flag. |
| Analytics | Amplitude through a new @bonsai/analytics that carries sanitize-props, forbidden-property-patterns and the onboarding event contract out of the webapp. The scaffold initialises the client; the library extraction comes with the first feature. |
| Observability | Datadog RUM and browser logs as the webapp does; dd-trace on the server through @bonsai/ai’s telemetry; the X-TOFU-TRACE-ID header on every bonsapi call. Sentry is dropped unless someone names a use it covers that Datadog does not. |
| Styling | Tailwind v4 through the Vite plugin, @bonsai/ui/styles/theme.css as the token source. The prototype’s palette becomes token changes in the theme, not a second theme. |
7. The scaffold: apps/tofu-web
#
This is the deliverable. The skeleton contains every guardrail, every integration point and one reference feature that exercises the whole vertical, and nothing product-specific. Onboarding and the workspace are not part of it.
7.1 What the skeleton is and is not #
It is:
- a deployable TanStack Start app at
console.gotofu.comthat signs a user in with Clerk and renders one page reading real data from bonsapi; - every layer of 6.1 present with at least one real module, so the boundaries lint has something to guard and the generator has a template to copy;
- the two layouts of 7.6, the workspace shell at
/and the assisted two-pane layout at/onboarding, with the reference feature as their only content; - the chat runtime end to end: a
/api/chatroute over@bonsai/aistreaming a reply, the command registry exposing the entity nav’s commands as client tools, the bridge routes and an event catalogue with its first event; - the full CI, deployment and release wiring the repo expects of an app;
- the rules written down where humans and agents read them (
AGENTS.md,REVIEW.md).
It is not:
- any onboarding step, workspace surface (list, gallery, panels, review) or hackathon backend change; of the prototype it builds only the shell (7.6) and the entity nav (7.7);
- a home for shared code the old webapp also needs (
@bonsai/analytics,@bonsai/auth); those extractions come with the features that need them.@bonsai/mcp-toolis the exception, because the command registry’s contract depends on it (7.2).
7.2 Files and wiring #
Inside apps/tofu-web:
| File | Content |
|---|---|
package.json |
Name tofu-web, type: module, imports alias #/*, engines matching the repo, and bun scripts: dev, dev:bare, build (tsgo --noEmit && vite build), preview, start (bun .output/server/index.mjs), test (bun test), check, format, format:fix, lint, lint:fix, typecheck, knip, codegen, new:feature. |
vite.config.ts |
tanstackStart({ srcDirectory: 'src' }), nitro({ preset: 'bun' }), tailwindcss(), viteReact() with the React Compiler preset, devtools(), resolve.tsconfigPaths, port and allowedHosts for Coder. |
tsconfig.json |
Extends @bonsai/type/tanstack-start unmodified, adds the six strict options from 6.8, paths for @/* and the @bonsai/ui source aliases the other apps use. |
.oxlintrc.json |
Element types presenter, container, route, command, data-query, data-mutation, state, domain, server, ai, root; the allow matrix from 6.7; no-restricted-imports with importNames; the @radix-ui/radix-ui/cmdk ban; jsx-a11y; the rule set from 6.8; jsPlugins for boundaries, i18next and tofu-security; ignorePatterns for routeTree.gen.ts and _generated/**. |
biome.json |
Extends the root preset; useSortedClasses on; excludes routeTree.gen.ts. |
knip.json |
Entry points for routes, start.ts, router.tsx, tests and stories; ignores _generated/**. |
bunfig.toml |
Test preload registering a DOM (happy-dom) and the FileReader shim nigari uses. |
orval.config.ts |
tags-split, react-query v5, httpClient: 'fetch', mutator src/data/api/client.ts, output src/data/api/_generated, Biome formatting hook. |
Dockerfile |
Builder on the repo’s pinned node:22 image with pnpm and Doppler (pnpm install --filter "tofu-web...", pnpm --filter tofu-web build); runtime oven/bun at the pinned version, non-root user, PORT, bun .output/server/index.mjs. |
.tasks.toml |
tofu-web-init, -dev, -build, -preview, -lint-check, -lint-fix, -format-check, -format-fix, -type-check, -knip-check, -test, -check, -fix, -codegen (openapi, i18n, feature flags), -new-feature. |
AGENTS.md |
The layer rules, the import matrix, “commands are the only mutation path”, “no URLs from the model”, “server is the auth boundary”, how to add a feature (the generator), how to add a command, what never to do. |
REVIEW.md |
Initial review rules: the boundary matrix, zero oxlint-disable, every command has a description, narration keys and a permission, a command wrapping a bonsapi operation takes its metadata from @bonsai/mcp-tool, every mutation declares what it writes and every query what it reads, every presenter has a story, every server function authorizes, nothing touches browser storage or cookies, and per-user preferences go through data/preferences into Clerk user public metadata. |
README.md |
How to run, test and deploy; a pointer to this document. |
At the repo root:
| Place | Change |
|---|---|
.mise.toml |
Include apps/tofu-web/.tasks.toml; pin bun to the version the Dockerfile uses. |
libs/typescript/.tasks.toml |
Add the tofu-web-* tasks to ts-codegen, ts-lint-check, ts-format-check, ts-format-fix, ts-type-check, ts-check, ts-fix, ts-test. Add a ts-apps-enumerated-check that fails when an apps/*/package.json is missing from these lists. |
.github/service-registry.json |
tofu-web entry with ecr_repo, paths and the shared TypeScript paths, like sales-portal. |
.github/workflows/detect-changes.yml |
apps/tofu-web/** in the typescript filter. |
deployment/resources/tofu-web/ and deployment/overlays/{dev,dev-mng,prod}/tofu-web/ |
Deployment (non-root, read-only root filesystem, PORT, readiness and liveness on /api/health, env from bonsai-secret), service, ingress and kustomization with the image placeholders eks-deploy rewrites, mirroring deployment/resources/sales-portal. Hosts, certificates and DNS per environment are in the table below. |
.hasami/config.toml |
[[project]] id = "tofu-web". |
.pr_agent.toml |
apps/tofu-web/REVIEW.md in repo_context_files. |
.github/CODEOWNERS |
/apps/tofu-web/ and /docs/internal/content/docs/tofu-web/, owned by @wadwax @kenkanai-tofu @adamtofu @deepanshugautamtofu. Both entries land with this plan so the scaffold PR is routed to its owners from its first commit. |
docker-compose.yml, tools/local/scripts/start_dev_up.sh |
tofu_web_node_modules volumes on the webapp, tofu-internal-mcp and tofu-external-mcp containers and in the ownership-heal list, because the scoped installs mount every workspace package’s node_modules path. |
config/i18n/<locale>/tofu-web.json and tools/local/scripts/i18n-gen |
A tofu-web namespace with the handful of keys the skeleton uses; codegen emits apps/tofu-web/src/i18n/messages/*.json and the typed key union. |
| Feature-flag codegen | A tofu-web-codegen-feature-flags target emitting src/flags/definitions.ts, like the sales portal’s. |
libs/typescript/mcp-tool |
New @bonsai/mcp-tool: the tool-groups/* definitions, createTool and the annotations helper lifted out of apps/tofu-external-mcp/src/api, which then imports them from the library (a mechanical change covered by its existing tests). tofu-web commands that wrap a bonsapi operation import their name, description and annotations from here. |
| bonsapi | CORS allowlist gains console.gotofu.com and the dev hosts (a backend PR alongside the scaffold). |
Environments, infrastructure and developer tooling #
Everything sales-portal needed outside its own directory (found by grepping the repo for it), plus what a public host needs that an internal one did not:
| Place | Change |
|---|---|
tools/app-infra/modules/ecr/main.tf |
aws_ecr_repository "ecr_tofu_web" named tofu-web-${var.env} with scan on push, applied to dev and prod before the first image push; ecr-build-push and the service registry call it tofu-web. |
| Ingress, certificate, DNS | Prod: host console.gotofu.com on its own ALB (bonsai-eks-tofu-web-alb-prod) behind bonsai-webapp-alb-sg-prod, whose allowlist is Cloudflare and the VPC, so the Cloudflare CNAME to the ALB must be proxied; a new ACM certificate for console.gotofu.com in the prod account (or a SAN on the webapp’s). Dev: dev-console.internal.gotofu.com under the existing *.internal.gotofu.com wildcard certificate and Cloudflare Access, like dev-app. dev-mng: joins the dev-mng-webapp ALB group with the external-dns annotations sales-portal uses there, because bonsapi’s CSP on the shared ALB is listener-scoped. |
deployment/overlays/dev-mng/tofu-web/env-unset.yaml |
Deletes WEBAPP_HOST and TOFU_WEB_HOST from the shared bonsai-secret and re-adds them pointing at the dev-mng instances, so the redirect handshake (8.3) never leaves the environment. |
Doppler bonsai project (dev_local, dev, prd) |
Reused: VITE_CLERK_PUBLISHABLE_KEY (inlined at build through the Dockerfile’s Doppler args), CLERK_SECRET_KEY, WEBAPP_HOST, BONSAPI_HOST, BONSAPI_INTERNAL_HOST, REDIS_HOST, REDIS_PORT, TOFU_MCP_URL, DATADOG_API_KEY, ANTHROPIC_API_KEY. New: TOFU_WEB_HOST (the app’s own origin, which the webapp also reads as the onboarding redirect target), VITE_DATADOG_APP_ID and VITE_DATADOG_CLIENT_TOKEN for a new RUM application named tofu-web. .github/actions/sync-secrets carries them into bonsai-secret unchanged. |
.github/workflows/deploy.yaml |
Bespoke build-tofu-web (ecr-build-push, dockerfile: ./apps/tofu-web/Dockerfile, ecr-repo-name: tofu-web, Doppler build args) and deploy-tofu-web (eks-deploy, type: deployment): the sales-portal pair, copied. |
.github/workflows/dev-mng-pr-build-deploy.yaml |
tofu-web in the bespoke list, plus build-tofu-web and deploy-tofu-web jobs (env: dev-mng, image-env: dev) gated on the registry’s changed-services output. |
.github/workflows/preview.yaml, tools/local/scripts/start_preview.sh, tools/coder/templates/preview-webapp/ |
The PR preview workspace starts tofu-web beside the webapp and exposes it, so a preview exercises both origins of the redirect handshake. |
tools/coder/templates/dev/main.tf |
coder_app "tofu_web": slug = "console", url = "http://localhost:3050", subdomain = true, share = "public", health check on /api/health, modelled on nigari’s; published with template-push.sh. Its own subdomain rather than a path on the :9000 nginx proxy, because Clerk redirects and the webapp-to-console handshake need a stable origin. |
tools/coder/scripts/override-coder-env.sh |
Rewrites TOFU_WEB_HOST to the workspace’s console app URL the way it rewrites WEBAPP_HOST today, so the webapp’s redirect lands in the same workspace. |
apps/tofu-web/.env.example (committed) and .env.local (ignored by the root .env.* rule) |
Dev-only overrides. vite.config.ts reads .env.local with loadEnv only when command === 'serve' and copies the non-VITE_ keys into process.env for the dev server, on top of what doppler run injected; bun run build, the Dockerfile (.dockerignore excludes .env*) and the container never see it. Precedence in dev: .env.local, then Doppler dev_local, then the defaults in env.ts. tofu-web-dev runs doppler run --project bonsai --config dev_local -- bun run dev, as sales-portal-dev does. |
.claude/launch.json |
A tofu-web configuration (doppler run --project bonsai --config dev_local -- pnpm --filter tofu-web dev, port 3050) beside sales-portal’s. |
tests/e2e/playwright.config.ts |
A console project with baseURL from TOFU_WEB_HOST, so the scaffold’s smoke test runs where the webapp’s suites run. |
| Root docs | The app table in REVIEW.md, the review-file lists in CLAUDE.md and .cursor/rules/code-review.mdc, docs/internal/content/docs/development/feature-flag.md for the new codegen target, docs/internal/content/docs/infra/coder.md for the new app. |
| Clerk dashboard | console.gotofu.com and the dev hosts added to the instance’s allowed origins and redirect origins. bonsapi’s verifier does not check azp today (only a test fixture names it); if authorized parties are ever enforced in bonsai-clerk or tofu-external-mcp, the console origin joins that list. |
| Datadog | A tofu-web RUM application (its id and client token go into Doppler as above) and tofu-web as the dd-trace service name; log and trace tags follow the webapp’s. |
7.3 Source skeleton #
What exists in src/ on day one, and why:
start.ts,router.tsx,env.ts,routes/__root.tsx(shell withClerkProvider, the sign-in gate,QueryClientProvidervia ssr-query, devtools in dev),routes/sign-in.tsx,routes/api/health.ts.routes/_app.tsxandroutes/onboarding.tsx, the two layouts of 7.6, with_app/index.tsxandonboarding/index.tsxrendering the reference feature in each.domain/permissions/: the role constants and predicates from the webapp’suse-permission.ts, as pure functions with tests.domain/money.ts: the branded money type with its predicates.data/api/client.tswith the token provider,data/api/_generatedfrom Orval,data/queries/entities.ts(keys,queries,select) and the registry test from 6.4.domain/preferences.ts(the zod schema: theme, density, locale, a per-organization map),data/preferences.ts(typed read ofpublicMetadataand of the session claims),server/preferences.ts(updatePreferences:auth(), schema, merge,clerkClient.users.updateUserMetadata) andcommands/preferences.tswithset_themeas a client tool, with tests for the schema, the merge and the command.commands/define.ts, the registry,commands/surface.ts(defineSurface,useSurface, 7.6),commands/navigation.tswithgo_to_route(the first command and the first client tool),commands/layout.ts(toggle_nav,dock_nav,resize_nav,open_panel,close_panel) and the palette adapter.state/layout.store.ts(nav collapsed, panel open, drag in progress),state/ui.store.ts(palette open) andstate/chat.store.ts, exposing only commands. All in memory; the theme, the nav’s dock side and its width are preferences, not store state.ai/:tools.ts,tool-effects.ts(the table and its test; its rows come fromfeatures/entity-nav, 7.7),context.ts,transcript.tswith tests over recorded parts,chat.container.tsxover the@bonsai/uiconversation primitives,bridge/with the typed event catalogue (one event:preferences.changed, 7.7), theEventSourcehook and the publish helper.server/auth.ts(require-user, require-org, entity-ownership check),server/chat-bridge.ts,server/ai/with the orchestrator skeleton (one agent, no tools) behindroutes/api/chat.tson thestreamChatRouteframe from@bonsai/ai.features/entity-nav/: the reference feature, worked through every layer in 7.7. It is the sidebar of the/shell and the surface/onboardingmounts in the scaffold: entities read from bonsapi, entities created through the catalogue-backedcreate_entity, personal folders created, renamed, deleted and reordered by drag and drop, entities moved between them, the layout saved as a Clerk preference, every action a command and therefore a tool. The generator produced its container, presenter, story, command, query and test files; the domain module and the surface were written by hand, which is the shape every feature copies.components/app-shell/: the presenters of 7.6 (Sidebar,TopBar,ResizableDivider,PanelHost, theCommandPalettehost, theChatRailframe), each with a story per state.tests/setup.tsand the generator script undertools/local/scripts/tofu-web/new-feature.ts.
7.4 Guardrails proven by CI #
The scaffold PR includes fixtures that make each guardrail fail on purpose and assert the failure, so the rules are tested, not just configured:
- a presenter that imports
@tanstack/react-queryfailsbun run lint; - a container placed under
components/fails the boundaries lint; - a container that imports
data/mutationsoruseMutationfails the boundaries lint; - a file with an
oxlint-disablecomment fails the CI grep; - a type error in a route fails
bun run build; - a command without a description, a narration key or a permission fails the registry test;
- a mutation without
writes, or a query withoutreads, fails the data registry test; - a presenter without a story fails the stories check;
- an unused export fails
knip; - a literal string in JSX fails the i18n rule;
- a store that calls
localStorage.setItemfailsbun run lint, and so does importing@tanstack/react-query-persist-client; - a
document.cookieassignment fails the CI grep.
7.5 Definition of done #
consoledev host serves the signed-in shell; the entity nav lists the caller’s entities read from bonsapi through the generated client (proves Clerk, CORS and codegen)./api/chatstreams a reply from@bonsai/aiunder the Bun runtime in the dev environment (proves the SSE path and the prompt-decrypt chokepoint in CI; this is the Bun kill criterion).- A folder change in one tab reaches a second tab through the bridge’s
preferences.changedevent, and the second tab’s nav updates without a reload. mise run ciis green with the new tasks enumerated; every fixture in 7.4 fails when its guard is removed.bun run new:feature sampleproduces a feature that passes every check unchanged.tofu-external-mcpbuilds and its registry tests pass with the tool definitions imported from@bonsai/mcp-tool.- The header’s theme toggle runs
set_theme: a reload fired the instant the command resolves keeps the theme with no flash, and a second browser signed in as the same user shows it (proves the metadata write path and the refreshed session claim). The browser’s storage inspector shows nothing written by the app. /renders the shell: the nav collapses by drag and bytoggle_nav, docks on either side, and both survive a reload and appear in a second browser; the top bar’s dropdowns open; every shell presenter has a story per state and passes the a11y addon./onboardingrenders the chat beside the entity nav mounted as a surface. Typing “create a folder called Clients and move Acme into it” runscreate_folderthenmove_entity_to_folderwith their narration, and dragging Acme onto Clients produces the identical layout through the same commands; “which folder is Acme in?” is answered from surface context alone. The registry test proves the surface’s tools exist only while it is mounted.- The Docker image builds and deploys to
devthrough the existing service pipeline. AGENTS.md,REVIEW.md, this docs section and a hasami note (tofu-web, feature, minor) are in the PR.
7.6 Layout skeletons: / and /onboarding
#
Two layouts exist in the scaffold, empty of product surfaces, so onboarding and the workspace land in a shell that already works on every screen size and already talks to the assistant.
/, the workspace shell. The prototype’s information architecture (section 4) as presenters
in components/app-shell/, composed by the pathless layout route routes/_app.tsx. It is not a
port: the departures are marked [new] where tofu-web does something the prototype does not,
and [later] where the prototype has something the scaffold does not build.
Sidebar: the frame only — a header that doubles as the dock handle, a search box, a slot for the nav, a slot for the collapsed state and a footer. What fills the nav slot in the scaffold isfeatures/entity-nav(7.7), a folder tree over the caller’s real entities, sourced from the webapp’sentitySidebarByOrgrather than from the prototype. Its edge is aResizableDividerthat resizes it and collapses it below the threshold. Collapsing shows an icon rail [new]: the prototype sets the sidebar’s width to 0 and renders nothing, and a rail is the better behaviour, so this is a deliberate change rather than a port. Opening a right-hand panel makes the nav read as collapsed and closing it restores what the user chose, which the prototype does with a remembered flag inapp/page.tsxand this app derives instead (navRailOnly).- [later] The prototype’s sidebar content is not in the scaffold and is not in 7.7: the
per-entity actionable count, the expandable per-document-type and per-status subsections, and the
inbox. Those need the extraction data the workspace phase brings (section 9), so they arrive with
it. Nothing in
apps/tofu-webcounts, groups by status or notifies today; do not read this section as saying otherwise. - Docking: the nav is dragged by its header to the left or the right edge and stays there. The dock side and the width are per-user preferences (6.3); the collapsed flag is store state, gone on reload.
TopBar, the prototype’stop-filter.tsxslot for slot but not control for control: the entity switcher stays a dropdown, and entity settings, the account and upload become dropdowns [new], where the prototype has a settings popover of integration and automation controls, an account circle with no menu, and no upload in the top bar at all. Every item is a command, so the palette and the assistant have the same menu. [later] the integration status pill and the Help button, both of which need data the scaffold does not read.PanelHost: one right-hand slot, inline on wide screens and a sheet on narrow ones (6.7); theCommandPalettehost [new], which the prototype does not have; the<Outlet />. In the scaffold the sidebar isfeatures/entity-nav(7.7) on the caller’s real entities and the outlet renders an empty state; nothing else. The slot keeps a fixed width and no divider until it has something in it: the prototype’s three surfaces (document preview, review drawer, page management) share one width and one divider, and that is what this becomes when they arrive in the workspace phase. A divider that resizes an empty slot is not worth shipping (7.8).- State:
state/layout.store.tsholds collapsed, panel open and drag-in-progress, mutated only bytoggle_nav,dock_nav,resize_nav,open_panelandclose_panel; the same commands are the assistant’s tools, so “collapse the sidebar” is a sentence, not a feature. - One responsive tree [new], and the clearest departure of all: container queries, the same
Sidebarfloated over the outlet on a narrow shell, no second tree behindhidden lg:block. The prototype does the opposite — it mountsSidebartwice andExtractionDrawertwice, and the drawer then carries a mobile and a desktop subtree inside itself, at three inconsistent breakpoints (640, 768, 1024). Rule 2 (6.7) is what forbids copying that. Stories cover collapsed, docked right, panel open and narrow.
/onboarding, the assisted layout. routes/onboarding.tsx is a Clerk-gated layout with two
panes: the chat on the left (ai/chat.container.tsx: transcript, composer, tool narration) and
any UI on the right (<Outlet />: a step, a form, the review workspace). Below md the chat is a
bottom sheet over the surface. What makes the right pane fully controllable by the chat and fully
visible to it is one contract, the surface:
// commands/surface.ts (sketch)
interface SurfaceDefinition<S> {
id: string; // 'entity-nav', later 'onboarding.organization'
commands: CommandDefinition[]; // what the chat can do here; the buttons call the same
schema: z.ZodType<SurfaceContext>; // the facts the surface publishes, typed and documented
context(state: S): SurfaceContext; // what is on screen, selected, invalid, possible next
}
useSurface(definition, state)in the container registers the surface for the lifetime of the mount. The registry then exposes the global commands plus the surface’s;ai/tools.tsregenerates the client-tool list, andai/context.tsmerges the route facts withcontext()into the grounding sent with every turn (6.6). Unmount deregisters: a tool exists only while its UI is on screen, which is also what keeps the tool list short enough to select from.- Full interaction: a tool call runs the surface’s command through the path the button uses (6.5),
so a step’s Continue and the assistant’s
save_organizationare one function with the same schema, permission check and narration. Form state stays in TanStack Form; the surface exposes the draft as context andset_fieldandsubmitcommands over it. - Full context: the surface’s facts travel with every request, tool results come back through
addToolResult, server tool effects throughai/tool-effects.ts, and the bridge keeps other tabs aware. The assistant reads everything the user sees and changes it only through commands, never around a validation. - In the scaffold,
onboarding/index.tsxmounts the entity nav as the surface (7.7): its folder and entity commands as tools, its folders, unfiled entities and selection as context. That proves the mechanism before any step exists; the steps of 8.1 are surfaces added in the onboarding phase, and the tool table in 8.4 is the union of their commands. - Tests: the registry test that a surface’s commands are tools only while it is mounted; a table
test for
ai/context.tswith and without a surface; a Playwright test that types “refresh the list” and sees the narration and the refetch.
7.7 Worked example: the entity nav, end to end #
The reference feature is a real one. The webapp’s entity sidebar already has personal folders:
shared/utils/folder/ keeps a per-organization layout in Clerk unsafeMetadata
(entitySidebarByOrg: ordered folders with icons, an entity-to-folder assignment map, collapsed
folder ids) and nav/entities/nav-entities.tsx (685 lines) renders it with hand-rolled HTML5
drag. The same feature on the new architecture exercises every layer once: a read from bonsapi, a
write to bonsapi, a per-user preference, drag and drop, an optimistic update with rollback, a
permission, and a surface the assistant can drive. This is what features/entity-nav contains.
Domain: domain/entity-nav/. Pure functions over a small, validated layout. The webapp’s
derivations.ts helpers move here with their behaviour unchanged and their tests as table tests.
// domain/entity-nav/layout.ts
export const folderSchema = z.object({
id: z.string(),
name: z.string().min(1).max(40),
icon: z.enum(FOLDER_ICONS),
});
export const navLayoutSchema = z.object({
folders: z.array(folderSchema).max(30), // ordered: position is the index
assignments: z.record(z.string().uuid(), z.string()), // entityId → folderId
collapsed: z.array(z.string()),
});
export type NavLayout = z.infer<typeof navLayoutSchema>;
export function buildTree(entities: Entity[], layout: NavLayout): NavTree;
export function createFolder(layout: NavLayout, folder: Folder): NavLayout;
export function renameFolder(layout: NavLayout, folderId: string, name: string): NavLayout;
export function deleteFolder(layout: NavLayout, folderId: string): NavLayout; // entities go to root
export function moveEntity(layout: NavLayout, entityId: string, folderId: string | null): NavLayout;
export function reorderFolders(layout: NavLayout, folderId: string, toIndex: number): NavLayout;
export function toggleFolder(layout: NavLayout, folderId: string): NavLayout;
export function normalize(layout: NavLayout, entities: Entity[]): NavLayout; // prunes unknown ids
Invariants the tests pin down: an entity is in at most one folder; deleting a folder never loses
an entity; reordering is a permutation; every function is total (an unknown id is a no-op, not a
throw); normalize is idempotent and drops assignments to folders or entities that no longer
exist. buildTree puts unassigned entities at the root and keeps bonsapi’s order inside a folder.
Data: data/queries/entities.ts, data/mutations/entities.ts, data/nav-layout.ts.
// data/queries/entities.ts
export const entityKeys = {
all: ['entity'] as const,
list: (orgId: string) => ['entity', 'list', orgId] as const,
};
export const entityQueries = {
list: (orgId: string) =>
defineQuery({
reads: ['entity'],
queryKey: entityKeys.list(orgId),
queryFn: ({ signal }) => listEntities({ n_per_page: 100 }, { signal }), // Orval, fetch client
select: (page) => page.items,
}),
};
// data/mutations/entities.ts
export const createEntityMutation = defineMutation({
writes: ['entity'],
mutationFn: (body: CreateEntityRequest) => createEntity(body),
});
// data/nav-layout.ts — the only module that knows where the layout lives
export const navLayoutQuery = (orgId: string) =>
defineQuery({
reads: ['preferences'],
queryKey: ['preferences', 'nav-layout', orgId] as const,
queryFn: async () => readPreferences().navLayoutByOrg[orgId] ?? EMPTY_LAYOUT,
});
export const saveNavLayoutMutation = defineMutation({
writes: ['preferences'],
mutationFn: ({ orgId, layout }: SaveNavLayout) =>
updatePreferences({ navLayoutByOrg: { [orgId]: layout } }), // the 6.3 server function
optimistic: ({ orgId, layout }) => [['preferences', 'nav-layout', orgId], layout],
publish: ({ orgId }) => ({ type: 'preferences.changed', orgId }), // bridge event, other tabs
});
readPreferences is the typed reader over the Clerk user’s publicMetadata (6.3). optimistic
is onMutate with rollback in onError, written once in defineMutation; publish is the
bridge’s first real event, so a second tab refetches the layout instead of waiting for focus.
Invalidation is derived from reads and writes (6.4): saveNavLayoutMutation invalidates every
query that reads preferences, and the MCP create_entity tool invalidates every query that
reads entity. Storage: an assignment is a UUID and a folder id, about fifty bytes, and a folder
about sixty, so the schema caps folders at thirty and updatePreferences rejects a
navLayoutByOrg above 4 KB with a typed error the UI renders as “too many placed entities”.
When that limit is reached in practice the layout moves to a bonsapi resource, and only
data/nav-layout.ts changes; the container, the commands and the presenter never knew where it
was stored.
Commands: commands/entity-nav.ts. Every action, whether from a click, a drag, the palette
or the assistant, is one of these.
export const createEntityCommand = defineCommand({
...fromCatalog(entityTools.CreateEntity), // name 'create_entity', description, annotations
input: z.object({
name: z.string().min(1),
slug: slugSchema,
countryId: z.string(),
shareKnowledgeOptIn: z.boolean().default(false),
}),
permission: can('org:entity:create'),
narration: { calling: 'entityNav.creating', done: 'entityNav.created' },
async execute(ctx, input) {
const entity = await ctx.mutate(createEntityMutation, toCreateEntityRequest(input));
await ctx.run(selectEntityCommand, { entityId: entity.id });
return { entityId: entity.id, slug: entity.slug };
},
});
export const moveEntityToFolder = defineCommand({
name: 'move_entity_to_folder',
description:
"Move an entity into one of the current user's navigation folders, or back to the top level " +
'with folderId null. Folders are personal: this changes only this user's sidebar.',
input: z.object({ entityId: z.string().uuid(), folderId: z.string().nullable() }),
permission: always, // the layout is the caller's own preference
annotations: { readOnly: false, destructive: false },
narration: { calling: 'entityNav.moving', done: 'entityNav.moved' },
async execute(ctx, { entityId, folderId }) {
const orgId = ctx.session.orgId;
const layout = await ctx.read(navLayoutQuery(orgId));
await ctx.mutate(saveNavLayoutMutation, { orgId, layout: moveEntity(layout, entityId, folderId) });
},
});
// create_folder, rename_folder, toggle_folder, reorder_folders({ folderId, toIndex }) follow the
// same shape; delete_folder is { destructive: true, confirm: true }; select_entity takes an
// entity id and sets the `entity` search param; filter_entities writes the store.
ctx.read is queryClient.ensureQueryData, ctx.mutate runs the mutationOptions, so the
optimistic update, the rollback, the derived invalidation, the bridge event and the analytics
event are identical for a button and for the model. create_entity takes its identity from
@bonsai/mcp-tool because bonsapi has that operation; the folder commands have their own
because bonsapi has no folders, and their descriptions tell the model the folders are personal.
select_entity takes an id, never a URL (6.6). Command tests run against a fake data layer: a
member without org:entity:create gets denied and the button is disabled by the same check; a
declined delete_folder reports cancelled; a failed save rolls the optimistic layout back and
reports the error.
State: state/entity-nav.store.ts. Only what is neither server data, URL, nor preference:
the sidebar filter text and the folder being renamed. Drag-in-progress is an uncommitted gesture
and stays inside the presenter. The selected entity is the entity search param, so it is a deep
link and the assistant’s select_entity and the browser’s back button agree. Collapsed folders
stay in the preference, as the webapp keeps them today, because a user expects them to follow.
Presenter: features/entity-nav/components/. EntityNav, FolderRow, EntityRow,
FolderDialog: props in, callbacks out.
interface EntityNavProps {
tree: NavTree;
selectedEntityId: string | null;
filter: string;
canCreateEntity: boolean;
onSelectEntity(entityId: string): void;
onToggleFolder(folderId: string): void;
onCreateFolder(name: string, icon: FolderIcon): void;
onRenameFolder(folderId: string, name: string): void;
onDeleteFolder(folderId: string): void;
onMoveEntity(entityId: string, folderId: string | null): void;
onReorderFolders(folderId: string, toIndex: number): void;
onCreateEntity(): void;
onFilter(value: string): void;
}
Drag and drop is @dnd-kit/core with @dnd-kit/sortable: a SortableContext over the folder
rows for reordering, droppable folder rows and a droppable root for moving entities, the
KeyboardSensor so both work without a mouse, and a DragOverlay for the ghost. The presenter
translates the single onDragEnd({ active, over }) into onReorderFolders or onMoveEntity, and
nothing else about dragging leaves it. It imports @bonsai/ui, domain/entity-nav types and
@dnd-kit; the boundaries lint rejects a store, a query or a router hook here (7.4). Stories:
empty organization, flat list, folders with one collapsed, a drag in progress, a filter with no
match, the narrow sheet; each passes the a11y addon.
Container: features/entity-nav/containers/entity-nav.container.tsx.
export function EntityNavContainer() {
const { orgId } = useAuth();
const entities = useQuery(entityQueries.list(orgId));
const layout = useQuery(navLayoutQuery(orgId));
const { filter, renamingFolderId } = useStore(entityNavStore);
const { entity: selectedEntityId = null } = useSearch({ from: '/_app' });
const run = useCommands();
const tree = useMemo(
() => buildTree(filterEntities(entities.data ?? [], filter), layout.data ?? EMPTY_LAYOUT),
[entities.data, layout.data, filter],
);
useSurface(entityNavSurface, { tree, selectedEntityId, filter });
if (entities.isPending || layout.isPending) return <EntityNavSkeleton />;
return (
<EntityNav
tree={tree}
selectedEntityId={selectedEntityId}
filter={filter}
canCreateEntity={run.can(createEntityCommand)}
onSelectEntity={(entityId) => run(selectEntityCommand, { entityId })}
onMoveEntity={(entityId, folderId) => run(moveEntityToFolder, { entityId, folderId })}
onReorderFolders={(folderId, toIndex) => run(reorderFoldersCommand, { folderId, toIndex })}
onDeleteFolder={(folderId) => run(deleteFolderCommand, { folderId })}
onCreateEntity={() => run(openCreateEntityDialog)}
onFilter={(value) => run(filterEntities, { value })}
/* toggle, create, rename: same pattern */
/>
);
}
The container is the only file that touches queries, the store, the router and the registry, and
it computes the view model with a domain function rather than in JSX. run.can drives the
disabled state from the same permission the tool checks, so the button and the model agree.
Its test renders it with a fake query client and a fake runner and asserts that a drag end on
folder b over position 0 dispatches reorder_folders with toIndex: 0, and that a member
without the permission sees the create action disabled.
Surface: features/entity-nav/surface.ts.
export const entityNavSurface = defineSurface({
id: 'entity-nav',
commands: [
createEntityCommand, createFolderCommand, renameFolderCommand, deleteFolderCommand,
moveEntityToFolder, reorderFoldersCommand, toggleFolderCommand, selectEntityCommand,
filterEntities,
],
schema: z.object({
folders: z.array(z.object({ id: z.string(), name: z.string(), entityCount: z.number(), collapsed: z.boolean(), entities: z.array(entityRef) })),
unfiled: z.array(entityRef),
selectedEntity: entityRef.nullable(),
filter: z.string(),
}),
context: ({ tree, selectedEntityId, filter }) => summarise(tree, selectedEntityId, filter),
});
With this on screen the assistant knows every folder and entity by name and id, so “move Acme into Clients” resolves to ids from context rather than from a lookup tool, and “which folder is Acme in?” needs no tool at all. On a route without the sidebar none of these tools exist.
Routes. routes/_app.tsx validates entity as an optional UUID search param, prefetches
entityQueries.list(orgId) in its loader, and renders EntityNavContainer in the shell’s
sidebar slot. select_entity is navigate({ search: (s) => ({ ...s, entity: entityId }) }).
Server. Nothing new for entities: the browser calls bonsapi with the session token (6.4) and
bonsapi enforces Organization:Admin on create regardless of what the client believed. The only
server function in the slice is updatePreferences (6.3), which authenticates, validates the
patch against the preferences schema, applies the 4 KB guard, merges without touching other
organizations’ layouts, writes through the Clerk backend client, and returns the result. Its test
uses a fake Clerk client: unauthenticated returns a 401 Result, an oversize layout returns the
typed error, and a patch for one organization leaves the others intact.
AI plumbing. ai/tool-effects.ts has two rows: the MCP tools create_entity and
delete_entity write entity, and create_entity has select_entity as its follow-up command,
the hackathon’s nudge pattern with a table row instead of a component. Narration keys live in
config/i18n/<locale>/tofu-web.json. The chat path for “create a folder called Clients and move
Acme into it”: the orchestrator has the surface context, calls create_folder({ name: 'Clients' }),
the client runs the command (optimistic layout, server function, bridge event), the model reads the
new folder id from the tool result and calls move_entity_to_folder, and the narration shows
“Creating folder Clients”, then “Moving Acme”. Dragging Acme onto Clients runs the second command
through the same function. Two entry points, one path, and nothing in browser storage.
What runs where.
| Layer | Test | Runner |
|---|---|---|
domain/entity-nav |
Table tests for every function and the invariants above | bun test |
data/ |
Registry test: reads and writes declared and known, invalidations derived | bun test |
commands/entity-nav.ts |
Fake data layer: permission denied, cancelled confirm, rollback | bun test |
features/entity-nav/components |
Stories per state, a11y addon, presenter import fixture | Storybook, bun run lint |
entity-nav.container.tsx |
Fake query client and runner; drag end dispatches the right command | bun test with DOM |
surface.ts |
Tools exist only while mounted; context matches the schema | bun test |
server/preferences.ts |
Fake Clerk client: auth, size guard, merge | bun test |
| End to end | Click path and chat path produce the same layout; second tab updates through the bridge; reload keeps the layout; storage inspector empty | Playwright |
7.8 Implementation notes #
Where the scaffold as built differs from 7.1–7.7, and why. Sections 1–6 describe the intent; this subsection is the record of what the implementation actually had to change, so a reader who trusts the plan is not surprised by the code. Anything not listed here landed as described above.
- 7.5’s first definition-of-done item is not met: the entity nav lists nothing. The Orval config
omits
includeHttpResponseReturnType: false, which bothtofu-external-mcpandtofu-internal-mcpset, sogetEntitiesis typed as the fetch envelope ({ data: GetEntities200; status; headers }) while thebonsapiFetchmutator returnsawait response.json()— the bonsapi body,GetEntities200itself.data/queries/entities.tsthen readsresponse.data.data ?? [], which type-checks against the envelope but at runtime indexes one level too deep:response.datais alreadyEntity[], so.dataisundefinedand the list is always[]. It therefore proves neither Clerk, CORS nor codegen, and it type-checks and passes its tests while doing so, because the fakes return the declared shape. One line inorval.config.tsplus regeneration fixes it. Thedetailquery hides the same mismatch: itsresponse.datahappens to yield the right value at runtime for the wrong reason. - The panel slot keeps a fixed width and no divider. 7.6 originally gave it a draggable edge.
The prototype’s three right-hand surfaces share one width and one divider, and until those
surfaces exist there is nothing to resize, so the divider is deferred to the workspace phase
rather than shipped against an empty slot.
ResizableDivideris built, exported and covered by stories, so wiring it is a prop, not a component. - Preference writes are a plain read, merge and write. An earlier revision of 6.3 and 7.7
specified a
versionon each versioned preference, abaseVersionon the patch, a typedconflict, a short per-user Redis lock and a single replay of the pure domain operation. That was reverted here and the code matches what 6.3 now says. The consequence is real and worth stating plainly: two tabs that write the same preference key lose one of the two changes silently, because the wholepreferencesobject is rewritten from a stale read. The per-key merge protects different keys from each other, not the same key from itself. - The size guard measures
navLayoutByOrgonly, at 4 KB. An earlier revision guarded the wholepublicMetadataobject at 4 KB and budgeted the nav layout at 2 KB inside it. As reverted, the nav layout may take half of Clerk’s 8 KB ceiling on its own, anddismissedHintsanddefaultSavedViewByOrgare unbounded and unmeasured. If the record does exceed Clerk’s ceiling, every later write fails, including the ones that would shrink it. Both of these are accepted for now, not overlooked; the workspace phase should revisit them together with the move of the nav layout to a bonsapi resource. - The sidebar’s nav is a folder tree, not the prototype’s list. This is 7.6 and 7.7 as written, not a drift from them. What the docs did not say until now is that the prototype’s counts, document-type and status subsections and inbox therefore exist nowhere in the app; 7.6 now marks them [later] so the workspace phase does not have to rediscover the gap.
- Do not treat the prototype’s
hooks/as its behaviour.use-side-panel.tsanduse-drawer-mobile-view.tsare dead code, imported by nothing, and the live copies inlined inapp/page.tsxandextraction-drawer.tsxhave drifted from them. The hooks specify per-panel widths of 50vw and 90vw, a 92% max-width clamp, and no awareness of page management; the running prototype preserves one shared user-set width, clamps to the viewport less a 260px floor for the table, and routesNEEDS_ACTIONstraight into page management. Section 4 now says this too. - Section 4’s shell bullet and 7.6’s control lists were wrong about the prototype in an earlier
revision, and the scaffold followed the description rather than the prototype in three places: it
built an icon rail (the prototype collapses to zero width), four top-bar dropdowns (the
prototype has one dropdown, one popover, and an account circle with no menu), and it attributed
the panel auto-collapse to
use-side-panel.ts(it lives inapp/page.tsx). The rail and the dropdowns are kept as improvements and marked [new]; the descriptions are corrected.
8. Design notes: onboarding (first feature after the scaffold) #
Not in scope for this plan. Kept here so the skeleton is shaped for it and so the backend work it depends on (section 10) is visible early.
8.1 Flow #
The flow the hackathon tested, on the new architecture:
- Arrival. A server function
resolveOnboarding()decides the variant (8.2). For a fresh sign-up it provisions through bonsapi’s onboarding endpoint: Clerk organization, BonsAI organization and entity, membership, seeded demo AP bill and bank statement,is_onboardingset. Retries must be idempotent across all of those systems. The branch compensates a partial failure by deleting the Clerk organization, but its retry check only scans the user’s memberships, so a failed compensation leaves an organization no retry can find and the next attempt provisions another. The endpoint therefore keeps a durable provisioning record keyed by the Clerk user, created by an atomic insert under a unique constraint on the Clerk user id before anything else exists (two concurrent retries cannot both create one; the loser reads the winner’s record), then updated after each step; a retry resumes from that record and reconciles the same Clerk organization, BonsAI organization, entity, membership and demo documents, and housekeeping sweeps records left half-done. - Review (
/onboarding/review). The real review workspace on the seeded demo bill: document pane with bounding boxes, the production review form, the decision footer. Continue raises the unlock dialog (subscribe, or keep going). - Documents (
/onboarding/documents). Staged uploads typed per file, sent to the provisioned entity, read by the pipeline with onboarding priority; the progress bar on the following steps is driven by that work. - Organization (
/onboarding/organization). Updates the provisioned organization: name, slug, country, logo, profile questions. Prefilled for referred users. - Entity (
/onboarding/entity). Updates the provisioned entity in place: name, slug, country, accounting software preset. Never creates a second entity. - Return to Review with the user’s own extraction and the assistant rail open, greeted with the extraction-finished message and its follow-ups (connect an integration, customise extraction). Integrations are connected from the chat through MCP tools, as the branch does, except that the consent step is a client command that takes only the provider and fetches the consent URL from bonsapi itself, in place of the markdown link the branch had the model write.
- Plans and checkout (
/onboarding/plans,/onboarding/payment-success). The plan page over the live Stripe catalogue, the assistant’s recommendation marked in the panel, Stripe Checkout with its return legs.
Steps are routes under the /onboarding layout (7.6), each a surface in the right pane with the
chat on the left, so a step is a URL the assistant can navigate to and the browser back button
works; there is no scroll-hijack. Progress lives in the step machine (domain/onboarding, a pure
reducer) and is persisted server-side, not in localStorage: the old webapp on another origin
cannot read the new app’s storage, and the redirect decision (8.3) has to be made there.
Organization.is_onboarding from bonsapi is that flag.
8.2 Variant resolution moves to the server #
Today the browser resolves the variant by activating candidate organizations on the Clerk session
and reading GET /api/v1/entities under each, with a probe cap and backoff. In the new app the
server function reads memberships, public and private metadata through the Clerk backend and the
organization’s is_onboarding and entity count through bonsapi’s internal API, provisions when
there is nothing yet, and returns one discriminated union (full, prefilled, entities-only,
blocked, redirect). Every reader of is_onboarding, this function included, consults the
bonsapi copy (8.3), never the Clerk copy the branch’s provision route scans. The client calls
setActive once, for the organization the server named.
Check before this phase: whether bonsapi’s internal API exposes an organization-scoped entity read to a service caller. The onboarding provisioning endpoint on the branch already answers with the entity it created, which covers the fresh-sign-up path.
8.3 The redirect handshake with the old webapp #
- The webapp’s
middleware.tssends an authenticated session with no active organization to/onboarding, andprotected-page.tsxsends an active organization with no entities there too. Both targets becomehttps://console.gotofu.com/onboarding, read from configuration (TOFU_WEB_HOST, the Doppler key the app itself reads, 7.2), behind the rollout gate from 6.10. A third trigger is added: an active organization whoseis_onboardingis true. The webapp keeps a thin/onboardingroute that forwards deep links. - Completion is a one-way transition, recorded before anything redirects. The branch only ever
sets
is_onboarding, and it exists twice: provisioning writes it into Clerk private metadata, and bonsapi’s Clerk webhook mirrors Clerk organization data into the bonsapi organization row, which is what bonsapi’s ownis_onboarding()reads. Nothing clears either copy, because the branch’s flow ends inside the same app. Clearing both is not enough on its own: Clerk deliveries are retried and unordered, andupsert_organizationrewrites the row from whichever organization payload arrives, so anorganization.updatedevent emitted while the flag was stilltruecan be delivered after completion and resurrect the mirror. So completion gets a column of its own, and the mirror can never undo it. The exit is a server functioncompleteOnboarding()that calls a new bonsapi internal endpoint, which setsonboarding_completed_aton the bonsapi organization row (a column the webhook never writes), then clears the Clerk metadata through bonsapi’s Clerk client so Clerk agrees, then reads both back before reporting success. Two invariants make the transition monotonic. First, bonsapi’sis_onboarding()isfalsewheneveronboarding_completed_atis set, whatever the mirrored metadata says, so every reader (the old webapp’s redirect trigger, variant resolution, the provisioning reuse check) sees completion the moment the endpoint returns. Second,upsert_organizationskips a payload whoseupdated_atis older than the one last applied to that row, which stops a stale delivery from reverting anything else Clerk mirrors. A webhook test that replays anis_onboarding: truepayload after completion and asserts the reader still saysfalseis part of the endpoint’s definition of done. Only then does the new app redirect tohttps://<WEBAPP_HOST>/<org.slug>/entities/<entity.slug>/reviewthrough the sameresolveTrustedRedirectguard the webapp uses, moved into@bonsai/auth, with the host pinned to configuration and never taken from the request. If the endpoint fails the step shows a retry and stays put, and the old webapp’s trigger reads bonsapi fresh on each request, so a finished user is never bounced between the two apps. The endpoint is part of the backend work in section 10. - The OAuth popup return page (
/onboarding/oauth-complete) lives in the new app, so theredirect_urlthe consent command passes to bonsapi points atconsole.gotofu.com. Check that bonsapi’s redirect validation accepts it. - Stripe Checkout success and cancel URLs point at the new origin.
8.4 What the assistant can do during onboarding #
Two tool sources, one conversation.
From tofu-external-mcp, with the hackathon’s support allowlist minus one entry: get_billings,
list_pricing_plans, the knowledge tools, list_integrations, get_accounting_organizations,
setup_integration, update_entity_settings, get_extraction, list_extractions,
create_extraction, trigger_extraction. get_integration_oauth_authorize_url is left out on
purpose: the consent URL must never pass through the model, so the client command below fetches
it itself.
From the app’s command registry, executed in the browser (all of these are also buttons):
| Command | Executes | Annotations |
|---|---|---|
go_to_step |
Router navigation to a step | read-only |
set_organization_profile, set_entity_draft |
Writes fields of the draft in the onboarding store; the form shows the values as they land | read-write |
check_slug_available |
Slug availability server function (debounced through TanStack Pacer when typed) | read-only |
save_organization, save_entity |
The same server function and bonsapi update Continue runs, with the same zod schema | read-write |
stage_documents, set_document_type, start_extraction |
Opens the picker (gesture), types a staged file, sends the batch and polls until settled | read-write, gesture on the first |
open_integration_consent |
Takes a provider id, never a URL. Opens the popup on the gesture, requests the consent URL from bonsapi’s authorize endpoint through data/ with the pinned return URL, checks the result is https before loading it (as the webapp’s useOAuthAuthorization does today), and reports the popup’s result |
read-write, gesture |
show_pricing_plans, recommend_pricing_plan |
Opens the plan panel; marks one plan with the reason | read-write |
open_unlock_dialog, start_checkout |
The unlock prompt; Stripe Checkout redirect | read-write, confirm |
finish_onboarding |
Calls completeOnboarding(), which records completion through bonsapi (8.3) and confirms it by reading it back, then performs the exit redirect; on failure it reports the error and the step stays |
read-write, confirm |
The assistant never bypasses a validation the form applies: both write the same draft, and the save command runs the same schema the server function does.
8.5 Tracking, testing, done #
- Tracking: the webapp’s onboarding tracking interface moves to
@bonsai/analyticsunchanged; the new hook implements it over TanStack Store instead of refs. Every event the funnel dashboards read today keeps its name and payload, plusassistant_tool_called,assistant_tool_declinedandassistant_route_selected. - Tests: transition tests for the
domain/onboardingreducer includingblockedanderror; command tests with a fakedata/layer covering permission denial, destructive confirmation and gesture reporting; table tests forai/tool-effectsandai/transcript; the bonsapi webhook replay test from 8.3; container tests for the stepper, the chat panel and the review workspace shell; stories for every step state;tests/e2e/tests/onboardingretargeted toconsole.gotofu.com, plus one test that starts in the old webapp, is redirected, completes onboarding, and lands on the review page. - Done when a new sign-up on dev is redirected to
console, walks the four steps by clicking and lands on the review page in the old webapp; the same flow completes by chat alone apart from the gestures; funnel dashboards show the same event names; and the old webapp’sfeatures/onboarding, its fullPage and glass dependencies and its onboarding API routes are deleted in the same release train.
9. Design notes: the workspace (second feature) #
The prototype’s document list on real data: extraction list, saved views, group-by, table and
gallery, the three side panels, page management, selection bar, publishing queue, inbox, and the
review page’s Chat tab. It is where domain/extraction-type and domain/status get their content
and where the TanStack DB spike is decided. Not detailed further here; it gets its own document
when onboarding is done.
10. Roadmap #
| Phase | Deliverable | Exit criterion | What it removes from the old webapp |
|---|---|---|---|
| 0. Scaffold (now) | Section 7 in full | Section 7.5 | Nothing |
1. Land the hackathon’s backend on main |
bonsapi onboarding service and internal endpoint with a durable provisioning record for idempotent retries (8.1), is_onboarding, priority extraction, plus the completion endpoint with onboarding_completed_at and the version-guarded webhook mirror (8.3), which the branch lacks; tofu-external-mcp session-token auth and list_pricing_plans; @bonsai/ai MCP and code-execution exports; a published @tanstack/ai-orchestration |
Reviewed and merged as their own PRs, with hasami notes per project | Nothing |
| 2. Onboarding | Section 8 | Section 8.5 | features/onboarding, the fullPage and glass dependencies, the onboarding API routes, the review-chat routes and chat bridge once the review page’s Chat tab also moves |
| 3. Workspace | Section 9 | Users on the flag do their review-queue triage in the new app | features/review list surfaces, state/review.tsx computeds that fed them |
| 4. Review, settings, summary | Remaining routes, one per release | Old webapp serves only redirects | Everything; the old app is archived |
Each phase also lifts one shared concern into a library so the old webapp can adopt it without
waiting: @bonsai/mcp-tool and the streamChatRoute move into @bonsai/ai in phase 0;
@bonsai/analytics and @bonsai/auth in phase 2; a shared generated bonsapi client package in
phase 4 (the webapp has 324 direct imports of its own generated paths, so that one needs an alias
shim).
11. Decisions #
Taken:
- Name, directory, host.
tofu-web,apps/tofu-web,console.gotofu.com. - Bun scope. Runtime, test runner and production server; pnpm stays the workspace installer.
- Step order. The hackathon’s review-first order; Integrations lives in the assistant.
- Chat bridge. Kept for cross-tab awareness.
- No persisted client state. Nothing the app writes outlives the tab; lint, a CI grep and fixtures enforce it (6.3).
- Preferences in Clerk user public metadata. Read through
data/preferences, written by one server function;unsafeMetadatais not used.
Open, with the default the scaffold takes if nothing is said:
- Where UI tools execute. Default: in the browser through the same commands the UI uses. The
scaffold builds the registry-to-client-tool bridge on that basis; the hackathon’s
server-acknowledge-and-observe pattern remains possible per tool through
ai/tool-effects.ts. - i18n runtime. Default:
use-intlover the existing bundles; one provider file to swap. - TanStack DB. Default: not in the scaffold; a spike in the workspace phase.
- Hackathon backend. Default: land it on
mainas phase 1 rather than rebuild it. Not needed for the scaffold. - Clerk cross-subdomain session. Verified in the scaffold on the dev instance; if it does not hold there, the scaffold documents the satellite-domain setup instead.
- Nav docking. Default: the nav docks left or right by dragging its header across the screen; dock side and width are preferences, collapsed is store state (7.6).
12. Risks and open questions #
- Onboarding without an organization cannot be flag-gated per org. The rollout gate has to be user metadata, an environment toggle, or a percentage; whichever it is must be readable by the old webapp’s middleware, which decides the redirect.
- The hackathon branch is one 476-file change. Its backend half (bonsapi,
tofu-external-mcp,@bonsai/ai) is what onboarding depends on; it needs to be split into reviewable PRs with hasami notes before onboarding starts. Its frontend half is reference, not something to merge. - bonsapi surface. For the scaffold: CORS for
console.gotofu.comand the dev hosts. Before onboarding: redirect validation on/api/v1/integrations/oauth/authorize, a service-callable entity read for variant resolution, and the completion endpoint with itsonboarding_completed_atcolumn and the version-guarded webhook mirror (8.3). Without it a finished user bounces between the two apps; clearing the flag alone is not enough, because a stale organization webhook delivered after the clear sets the mirror back. - Two apps, one session. Clerk’s root-domain cookie should make this transparent in
production; the dev instance (
*.accounts.dev) behaves differently and needs a check in the Coder workspace and preview environments. Part of the scaffold’s definition of done. - TanStack AI is young. Tools, client execution, approval flows and orchestration exist but the
API moves between minors, and the orchestration package is not yet a tagged release; pin it and
wrap it behind
ai/tools.tsandserver/ai/so a version bump touches two files. - MCP hop latency. Every bonsapi tool call goes browser → chat route →
tofu-external-mcp→ bonsapi. The branch’s warm-up route and cached client keep the first turn fast; keep both, and measure before deciding whether hot paths need a direct server tool. - Bun on the server. Nitro’s bun preset is the least-travelled part of this plan, which is why the scaffold’s definition of done streams the chat route under Bun in the dev environment. If the SSE route or dd-trace misbehave there, the runtime image falls back to Node and bun keeps its other roles.
- Scope creep from the prototype. Nothing from the prototype enters the scaffold beyond the design tokens.
- Clerk metadata as the preference store. The 8 KB cap and backend-only writes are the point,
but they mean the schema stays small (ids, enums, flags), a burst of preference changes is a
burst of Clerk API calls (debounce through Pacer, one write per settled change), and a Clerk
outage makes preferences read-only while the app keeps working on defaults. Public means
visible to the user’s own browser, so nothing sensitive goes there. The webapp’s
unsafeMetadatakeys (locale, tours,entitySidebarByOrg) move to the public object in a one-off backfill, and the webapp reads the public copy from then on. - Reload loses in-flight work by design. Upload staging and an uncommitted form do not survive a reload. Where that matters the answer is a server-side draft (onboarding progress already is one), never a storage exception; watch the funnel for reload abandonment before adding any.
- The public host is cross-team plumbing. The ECR repository, the ACM certificate, the Cloudflare record, the Clerk origins and the Datadog RUM application are Terraform or console steps outside the app PR (7.2). Do them while the scaffold PR is in review, so its first deploy is not blocked on infrastructure.
13. Reuse map #
| Today | Where it goes | When |
|---|---|---|
apps/webapp/src/shared/state/commands.ts Command shape |
commands/define.ts palette adapter |
Scaffold |
apps/webapp/src/shared/hooks/use-permission.ts role constants and predicates |
domain/permissions |
Scaffold |
Branch shared/lib/chat-bridge/types.ts, use-chat-bridge.ts, shared/server/chat-bridge.ts and the two bridge routes |
ai/bridge/, server/chat-bridge.ts, routes/api/chat-bridge/ |
Scaffold (contract and routes, no events) |
stream-chat-route.ts (webapp and nigari copies, branch version with lazy session token) |
@bonsai/ai createStreamChatHandler, framework-agnostic over Request |
Scaffold |
Branch app/api/ai/review-chat/orchestrator/*, shared/server/external-mcp-client.ts |
server/ai/ in the new app; the generic parts (toChatStream, scoped tool source, MCP client with timeout) into @bonsai/ai |
Scaffold skeleton; agents and tools with onboarding |
Branch pricing-plans-tool.ts part-reading helpers |
ai/transcript.ts, tested |
Scaffold |
Branch tool-status-config.ts |
narration keys on each command definition |
Scaffold shape; content with onboarding |
apps/webapp/src/shared/lib/tracking/*, onboarding tracking contract and events |
@bonsai/analytics |
Onboarding |
apps/webapp/src/shared/lib/auth/trusted-redirect.ts |
@bonsai/auth (server-safe, no React) |
Onboarding |
Branch shared/prompts/review-chat.ts |
@bonsai/ai encrypted registry, onboarding and workspace domains |
Onboarding |
Branch onboarding-progress.ts, onboarding-session.ts |
domain/onboarding reducer plus server-side is_onboarding; localStorage dropped |
Onboarding |
apps/tofu-external-mcp/src/api/tool-groups/*, createTool, annotations |
@bonsai/mcp-tool, imported back by tofu-external-mcp |
Scaffold |
Prototype lib/domain/* |
domain/status, domain/extraction-type (rewritten against bonsapi enums) |
Workspace |
Prototype sidebar.tsx, top-filter.tsx, resizable-divider.tsx, hooks/use-side-panel.ts |
components/app-shell/ presenters and state/layout.store.ts with the layout commands (7.6) |
Scaffold |
| Prototype presenters (view bar, rows, panels, action bars) | @bonsai/ui where generic, app components/ where product-specific, each with a story |
Workspace |
apps/webapp/src/shared/utils/folder/*, nav/entities/nav-entities.tsx, use-entity-sidebar-folders.ts |
domain/entity-nav (the pure helpers, behaviour unchanged) and features/entity-nav (7.7); the entitySidebarByOrg keys move to public metadata |
Scaffold |
apps/webapp/src/shared/lib/api/_generated |
Shared generated client package | Phase 4 |
use-tour-status.tsx, i18n/provider.tsx, /api/auth/user/metadata (unsafeMetadata) |
domain/preferences schema, data/preferences, server/preferences.ts over publicMetadata |
Scaffold |
apps/webapp/src/shared/sync/ui.ts persistent filters, columns and keywords |
Router search params where shareable, data/preferences where per user; nothing in browser storage |
Workspace |
apps/webapp/src/shared/utils/http-cache.ts, sync/db.ts, utils/persist.ts |
Dropped; TanStack Query’s in-memory cache and SSR hydration cover it | Scaffold |