tofu-web Architecture Plan

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.json already runs eslint-plugin-boundaries with shared, feature, app and sync elements and default: disallow. The idea is right; the elements are too coarse (see 2.2).
  • A genuinely dumb UI library. libs/typescript/ui has 68 components and 77 stories, no useQuery or fetch anywhere, 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 from config/i18n. Feature flags are generated from config/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.ts plus /onboarding/oauth-complete) is the right design and moves over as-is.
  • Fast tooling. oxlint, Biome and tsgo are already the toolchain; --deny-warnings is on. Lefthook runs format, lint and type checks per file type on commit.
  • A command palette exists. shared/state/commands.ts has a Command registry 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 shared import anything else in shared. 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.ts mirrors cache events into Legend observables, then shared/state/review.tsx derives 3k lines of computed state, then components read either. shared/utils/query.ts is a 2.6k-line custom bridge (syncedQuery) between the two. Legend State is pinned at 3.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.ts sets typescript.ignoreBuildErrors: true and eslint.ignoreDuringBuilds: true; the lint config sets typescript/no-explicit-any: off, react-hooks/exhaustive-deps: off, react/jsx-key: off, no-unsafe-optional-chaining: off. The @/* alias resolves to both src/* and src/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/ui itself pulls three and @fullpage/react-fullpage.
  • Browser storage is a fourth state store. 23 files touch localStorage, sessionStorage or IndexedDB through four mechanisms: Legend’s persist plugins (the 887-line sync/ui.ts filters and columns, feature preferences, improve requests, per-entity previews, suggestion dismissals, self-prompt progress), a custom IndexedDB plugin over idb, dexie, and raw localStorage calls (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 delete ui-state on crash because corrupted persisted state takes the app down, and sign-out has to clear() storage while preserving that same key. The preferences that should follow a user (locale, tour completion, the sidebar per organization) already live in Clerk unsafeMetadata, 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 21 useState calls, 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 the lg breakpoint the hijack is switched off by toggling a data attribute.
  • Docs drift. docs/internal/content/docs/webapp/architecture.md lists features and a state model that no longer exist; ui.md says 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-chat runs a @tanstack/ai-orchestration workflow per turn: triage picks support or sales, the specialist drafts with its tools, and an authority agent 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 the tofie-sales-rep skill as a hosted Agent Skill through the code-execution tool.
  • bonsapi tools come from the MCP server. external-mcp-client.ts connects to tofu-external-mcp with the caller’s Clerk session token, narrows the tool list to an explicit allowlist (knowledge, integrations, extractions, billing, pricing), and hands it to chat() 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_plans and search_web are toolDefinition(...).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 for tool-call parts by name, then publishes a typed event on the chat bridge (a Redis stream per user and entity, POST /api/chat-bridge/publish and an EventSource on /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.ts holds 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 organization is_onboarding (also on the public Organization response) 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 in localStorage; 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’s addToolResult and addToolApprovalResponse, 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_onboarding is 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_organization rewrites 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 main before 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: one Record<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: matchesFilters as the one predicate every list surface uses.
  • lib/data/extraction-repository.ts: a repository interface whose list operations are pure transforms. The new app’s data/ layer plays this role against bonsapi.
  • The idea in hooks/use-side-panel.ts of layout state extracted from the page, but not the file: it and hooks/use-drawer-mobile-view.ts are dead code, imported by nothing. The behaviour that runs is an inlined copy in app/page.tsx and in extraction-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 ResizableDivider rendered 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 a PanelLeftOpen button in the top bar. Opening a right-hand panel auto-collapses the sidebar and closing it restores what the user last chose, remembered by a sidebarCollapsedByPanel bit. 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 (cmdk is 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.tsx 27 times, view-bar.tsx 17, selection-action-bar.tsx 13). Under rule 2 they become presenters that take props; the store access moves to one container per surface.
  • document-management-view.tsx is 1,521 lines of drag-and-drop, split/merge domain logic, dialogs and rendering. Split into a pages domain 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 are console.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 with bun test and 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 the fetch HTTP client, no axios), one queryOptions and key factory per resource, and mutationOptions that 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/queries may be imported by containers, routes, commands and the server; data/mutations only by commands/. 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 an execute. Commands compose data/ mutations and state/ 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 plus Derived values, 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 call useQuery over data/queries, useStore, useChat, useCommand, router hooks and Clerk hooks, then render presenters. They never import data/mutations or useMutation; 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, declare loader (prefetch through queryClient.ensureQueryData), validateSearch (zod), beforeLoad (auth and onboarding guards) and pendingComponent.
  • 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 with auth(), authorizes the caller against the organization and entity it is about to touch, validates input with zod and returns a Result union. 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) with httpClient: '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.com and the dev hosts.
  • Every resource module exports keys, queries and mutations, built with defineQuery and defineMutation from data/define.ts (7.7 shows both). Queries declare reads (the resources they render: ['entity'], ['entity', 'extraction']), mutations declare writes, and invalidationsFor(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.ts rows are the same declaration for server tools: an MCP tool name maps to the resources it writes.
  • TanStack DB (@tanstack/react-db with 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 existing Command shape from the webapp, minus Legend), and a generated markdown catalogue for docs.
  • Stores expose their actions only as commands. There is no store.setState reachable from a component, so “full UI access” is not a coverage target; it is what the store is.
  • Read commands wrap queryClient.fetchQuery on the same queryOptions the 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 its execute calls the data/ mutation, and its name, description and annotations are imported from @bonsai/mcp-tool, the library the scaffold lifts out of apps/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:

  • useChat from @tanstack/ai-react with fetchServerSentEvents('/api/chat'), the registry’s client tools, and grounding context (route, selected entity and extraction, onboarding step) built by ai/context.ts and sent as forwardedProps every turn. This replaces the hackathon’s contextFacts, 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; a destructive command opens the same confirmation the button would and reports cancelled if declined; a gesture command (OAuth popup, file picker) reports what the user did.
  • Tool calls the server executed (MCP tools, search_web) reach the client as tool-call parts. ai/tool-effects.ts maps 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 its EXTRACTION_FINISHED greeting become two rows in that table instead of effects inside a component. ai/transcript.ts holds the pure part-reading helpers the hackathon wrote defensively (extractPlanRecommendation and 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 (https only, via safeRedirect). 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 the chat store 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 from auth(), 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, EventSource stream 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.ts uses the shared streamChatRoute frame moved into @bonsai/ai (Clerk auth, byte cap, optional feature gate, SSE, lazy session token), and runs the hackathon’s orchestrator (triage → specialist → authority, with toChatStream filtering silent steps) ported into server/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 by useChat. Local server tools (search_web) stay server tools.
  • System prompts move into @bonsai/ai’s encrypted registry under onboarding and workspace domains; 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 streamChatRoute apply.
  • @tanstack/ai-orchestration is consumed from a published release, not the pkg.pr.new build 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’s useTranslations, and the router’s Link;
  • may not import @tanstack/react-query, @tanstack/react-store, @tanstack/ai-react, router hooks (useNavigate, useSearch, useParams), @clerk/*, data/, commands/, state/, server/, or ai/;
  • has a colocated story that renders it from props alone (the existing ui-stories-check pattern, 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 one PanelHost presenter 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-start preset, unmodified (verbatimModuleSyntax stays on; both TanStack apps currently switch it off), plus noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch, noPropertyAccessFromIndexSignature. vite build runs tsgo --noEmit first; a type error is a failed build.
  • oxlint: correctness, suspicious and perf as errors; typescript/no-explicit-any, no-non-null-assertion, switch-exhaustiveness-check, strict-boolean-expressions, react-hooks/exhaustive-deps, react/jsx-key, jsx-a11y recommended, import/no-cycle, import/no-namespace, no-nested-ternary, no-console as errors; boundaries and restricted imports as above; i18next/no-literal-string with a short exclusion list; tofu-security/no-unsafe-location-assign; no-restricted-globals for localStorage, sessionStorage, indexedDB and caches, and no-restricted-imports for every persister package (6.3). --deny-warnings stays.
  • Zero oxlint-disable in src/. Exceptions go in one lint-exceptions.md with a reason and an owner, and a CI grep fails on any other occurrence. The same grep fails on window.localStorage, window.sessionStorage and document.cookie, the property-access forms the globals rule cannot see.
  • Biome: the shared formatter preset plus useSortedClasses for Tailwind, so class order is not a review topic.
  • knip: unused files, exports, types and dependencies fail CI.
  • Tests: bun test for domain, commands, data registries, tool effects and stores; @testing-library/react under bun test with a DOM preload for containers; Storybook for presenters; Playwright in tests/e2e for flows.
  • Generated code lives under _generated/ directories, is ignored by lint, and is regenerated by mise run codegen like every other generated file in the repo.
  • Bun is pinned in .mise.toml (today it is latest), 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’s bun preset emits a Bun server, and the runtime image is oven/bun running the emitted server. Vite itself runs under Node in the scaffold, as nigari and sales-portal do; flipping the dev server to bun --bun vite is a later experiment with its own kill criterion.
  • pnpm remains the workspace installer. The monorepo’s security overrides, patchedDependencies, onlyBuiltDependencies and the version catalog live in pnpm-workspace.yaml and the root package.json; the Dockerfiles install with pnpm 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.toml tasks joined into ts-check, ts-test, ts-codegen, ts-format-*, ts-lint-*, ts-type-check; service-registry.json entry; detect-changes.yml filter; deployment/resources/tofu-web and overlays for dev, dev-mng, prod; .hasami/config.toml project; 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.com that 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/chat route over @bonsai/ai streaming 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-tool is 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 with ClerkProvider, the sign-in gate, QueryClientProvider via ssr-query, devtools in dev), routes/sign-in.tsx, routes/api/health.ts.
  • routes/_app.tsx and routes/onboarding.tsx, the two layouts of 7.6, with _app/index.tsx and onboarding/index.tsx rendering the reference feature in each.
  • domain/permissions/: the role constants and predicates from the webapp’s use-permission.ts, as pure functions with tests. domain/money.ts: the branded money type with its predicates.
  • data/api/client.ts with the token provider, data/api/_generated from 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 of publicMetadata and of the session claims), server/preferences.ts (updatePreferences: auth(), schema, merge, clerkClient.users.updateUserMetadata) and commands/preferences.ts with set_theme as 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.ts with go_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) and state/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 from features/entity-nav, 7.7), context.ts, transcript.ts with tests over recorded parts, chat.container.tsx over the @bonsai/ui conversation primitives, bridge/ with the typed event catalogue (one event: preferences.changed, 7.7), the EventSource hook 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) behind routes/api/chat.ts on the streamChatRoute frame 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 /onboarding mounts in the scaffold: entities read from bonsapi, entities created through the catalogue-backed create_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, the CommandPalette host, the ChatRail frame), each with a story per state.
  • tests/setup.ts and the generator script under tools/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-query fails bun run lint;
  • a container placed under components/ fails the boundaries lint;
  • a container that imports data/mutations or useMutation fails the boundaries lint;
  • a file with an oxlint-disable comment 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 without reads, 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.setItem fails bun run lint, and so does importing @tanstack/react-query-persist-client;
  • a document.cookie assignment fails the CI grep.

7.5 Definition of done #

  • console dev 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/chat streams a reply from @bonsai/ai under 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.changed event, and the second tab’s nav updates without a reload.
  • mise run ci is green with the new tasks enumerated; every fixture in 7.4 fails when its guard is removed.
  • bun run new:feature sample produces a feature that passes every check unchanged.
  • tofu-external-mcp builds 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 by toggle_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.
  • /onboarding renders the chat beside the entity nav mounted as a surface. Typing “create a folder called Clients and move Acme into it” runs create_folder then move_entity_to_folder with 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 dev through 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 is features/entity-nav (7.7), a folder tree over the caller’s real entities, sourced from the webapp’s entitySidebarByOrg rather than from the prototype. Its edge is a ResizableDivider that 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 in app/page.tsx and 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-web counts, 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’s top-filter.tsx slot 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); the CommandPalette host [new], which the prototype does not have; the <Outlet />. In the scaffold the sidebar is features/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.ts holds collapsed, panel open and drag-in-progress, mutated only by toggle_nav, dock_nav, resize_nav, open_panel and close_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 Sidebar floated over the outlet on a narrow shell, no second tree behind hidden lg:block. The prototype does the opposite — it mounts Sidebar twice and ExtractionDrawer twice, 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.ts regenerates the client-tool list, and ai/context.ts merges the route facts with context() 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_organization are one function with the same schema, permission check and narration. Form state stays in TanStack Form; the surface exposes the draft as context and set_field and submit commands over it.
  • Full context: the surface’s facts travel with every request, tool results come back through addToolResult, server tool effects through ai/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.tsx mounts 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.ts with 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 both tofu-external-mcp and tofu-internal-mcp set, so getEntities is typed as the fetch envelope ({ data: GetEntities200; status; headers }) while the bonsapiFetch mutator returns await response.json() — the bonsapi body, GetEntities200 itself. data/queries/entities.ts then reads response.data.data ?? [], which type-checks against the envelope but at runtime indexes one level too deep: response.data is already Entity[], so .data is undefined and 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 in orval.config.ts plus regeneration fixes it. The detail query hides the same mismatch: its response.data happens 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. ResizableDivider is 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 version on each versioned preference, a baseVersion on the patch, a typed conflict, 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 whole preferences object 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 navLayoutByOrg only, at 4 KB. An earlier revision guarded the whole publicMetadata object 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, and dismissedHints and defaultSavedViewByOrg are 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.ts and use-drawer-mobile-view.ts are dead code, imported by nothing, and the live copies inlined in app/page.tsx and extraction-drawer.tsx have 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 routes NEEDS_ACTION straight 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 in app/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:

  1. 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_onboarding set. 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.
  2. 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).
  3. 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.
  4. Organization (/onboarding/organization). Updates the provisioned organization: name, slug, country, logo, profile questions. Prefilled for referred users.
  5. Entity (/onboarding/entity). Updates the provisioned entity in place: name, slug, country, accounting software preset. Never creates a second entity.
  6. 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.
  7. 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.ts sends an authenticated session with no active organization to /onboarding, and protected-page.tsx sends an active organization with no entities there too. Both targets become https://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 whose is_onboarding is true. The webapp keeps a thin /onboarding route 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 own is_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, and upsert_organization rewrites the row from whichever organization payload arrives, so an organization.updated event emitted while the flag was still true can 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 function completeOnboarding() that calls a new bonsapi internal endpoint, which sets onboarding_completed_at on 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’s is_onboarding() is false whenever onboarding_completed_at is 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_organization skips a payload whose updated_at is 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 an is_onboarding: true payload after completion and asserts the reader still says false is part of the endpoint’s definition of done. Only then does the new app redirect to https://<WEBAPP_HOST>/<org.slug>/entities/<entity.slug>/review through the same resolveTrustedRedirect guard 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 the redirect_url the consent command passes to bonsapi points at console.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/analytics unchanged; the new hook implements it over TanStack Store instead of refs. Every event the funnel dashboards read today keeps its name and payload, plus assistant_tool_called, assistant_tool_declined and assistant_route_selected.
  • Tests: transition tests for the domain/onboarding reducer including blocked and error; command tests with a fake data/ layer covering permission denial, destructive confirmation and gesture reporting; table tests for ai/tool-effects and ai/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/onboarding retargeted to console.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’s features/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:

  1. Name, directory, host. tofu-web, apps/tofu-web, console.gotofu.com.
  2. Bun scope. Runtime, test runner and production server; pnpm stays the workspace installer.
  3. Step order. The hackathon’s review-first order; Integrations lives in the assistant.
  4. Chat bridge. Kept for cross-tab awareness.
  5. No persisted client state. Nothing the app writes outlives the tab; lint, a CI grep and fixtures enforce it (6.3).
  6. Preferences in Clerk user public metadata. Read through data/preferences, written by one server function; unsafeMetadata is not used.

Open, with the default the scaffold takes if nothing is said:

  1. 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.
  2. i18n runtime. Default: use-intl over the existing bundles; one provider file to swap.
  3. TanStack DB. Default: not in the scaffold; a spike in the workspace phase.
  4. Hackathon backend. Default: land it on main as phase 1 rather than rebuild it. Not needed for the scaffold.
  5. 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.
  6. 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.com and 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 its onboarding_completed_at column 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.ts and server/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 unsafeMetadata keys (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