Sprint 5 — the cash-out
Sprint 5 closed late tonight. The frame I used to close Sprint 4 was “three sprints of backend work were inspectable via Drizzle Studio + curl; Sprint 5’s work will be visible to anyone visiting domiapp.ai.” That cash-out actually happened. The home page is a real signin form. New users type their email and a household name, click the magic link, and land in a chat that already has their tenant created. The chat reads from tasks, documents, extracted_facts, fires the predictive engine on demand, and accepts file uploads — every backend feature shipped in Sprints 3 and 4 is now reachable in a sentence of natural language. 0.039 graded the whole chat surface across five fixtures. Five for five.
What shipped
- A streaming chat at
/[locale]/chat. Vercel AI SDK v6 +@ai-sdk/react+ Anthropic Sonnet 4.6. Auth-gated, in-memory + localStorage-mirrored conversation that survives refresh and the EN↔FR locale switch (the segment unmount that would otherwise wipe state). The system prompt is locale-pinned: on/en/chatthe model replies in English regardless of which language the user typed in; on/fr/chatit replies in Quebec French. The French-language instruction is itself written in French — empirically more reliable than telling the model in English to please reply in French. - Five tools — the read surface and two action surfaces.
list_tasksfor “what’s coming up?”,list_documentsfor “what bills do I have?” (filenames stay encrypted at rest; the model refers to documents by upload date and extraction kind, never by name),list_recent_predictionsfor “anything new since last week?”,run_predictions_nowfor “refresh my list,” andupload_documentfor the chat-attached PDF case. The model never invokesupload_documentdirectly — the route detects the multipart attachment, runs the existing R2 upload + Sonnet vision extraction + confidence-gated auto-write pipeline beforegenerateText, and synthesizes a tool-call + tool-result pair so the model summarizes the upload outcome the same way it would any other tool result. - A signin home page. Email + household name + send-magic-link, in the matrix-on-dark theme. New users end up with a tenant + owner membership auto-created from the household name they typed, via a cookie that survives the magic-link round-trip. Returning users with a tenant skip straight to the chat. EN↔FR locale switcher in the home footer and the chat header.
- Chat eval matrix. Five multi-turn-but-single-user-message fixtures covering each tool. Per-fixture tenant isolation (the same idiom Sprint 4’s predict eval established): create tenant, seed state, run, assert on tools called + reply substrings, drop the tenant — zero residual rows verified. Pass-rate floor 0.80; baseline 5/5 at 0.039 on Sonnet 4.6.
That’s M5 — chat surface — substantively done.
What I cut
Server-side chat persistence. The conversation lives in useChat’s in-memory state plus a localStorage mirror; refresh and locale switch survive, but a different browser or a cleared cache loses it. The proper chat_sessions + chat_messages schemas land in M6 alongside the rest of the persistence work.
I also didn’t build any of the planned secondary surfaces — settings, asset management UI, document inbox view. The chat became the entry point on purpose: nine tenths of what Domi does is reachable in one sentence already, and the dedicated UIs can wait until JF (which is to say, me) wants them.
What surprised me
Vercel AI SDK v6 is a different API from v3. Most search results and Stack Overflow answers describe v3 (useChat from ai/react, CoreMessage, toDataStreamResponse, the input/handleInputChange/handleSubmit hook shape). v6 moved useChat to @ai-sdk/react, renamed CoreMessage to ModelMessage/UIMessage, replaced toDataStreamResponse with toUIMessageStreamResponse, and now expects the consumer to manage input state directly while calling sendMessage({ text }) to dispatch. The migration was straightforward once I read the actual .d.ts, but more time than I’d budgeted for the “just use the framework” sprint.
The drizzle-orm tree splits when you add the AI SDK to a workspace package. ai pulls @opentelemetry/api as a regular dep; drizzle-orm has it as a peer; without an explicit pin pnpm produces parallel installs of drizzle-orm with different peer-resolution graphs, and TypeScript starts complaining about SQL<unknown> not being assignable to SQL<unknown> on every single eq() call. Fixed with a workspace-root pnpm.overrides for @opentelemetry/api: 1.9.0. CI caught it after the local typecheck passed because CI runs the full workspace and local was running per-package — a small reminder that “passing locally” can mean less than it looks.
The “no UI” feeling was a discoverability problem more than a build problem. Mid-sprint, I’d built the feature without building the entrance. The fix was an evening — replace the static welcome with a real signin form, fix the broken redirect-to-Auth.js’s-default page that was 404’ing — but the lesson is the cheaper one: the user-visible surface area is what makes a feature “shipped,” not the route handler.
Locale-pinned reply was a UX choice, not a defaulted behavior. I’d started with “reply in whichever language the user wrote in,” which seems polite but breaks immediately: a user reads the French interface, types “Hi” in English out of habit, and the model breaks the interface language. Pinning the reply to the interface locale — and writing the French version of the instruction in French — is the small thing that makes the locale switcher feel like a real interface preference rather than a cosmetic toggle.
Where Sprint 6 picks up
M6 — settings + persistence. The chat_sessions + chat_messages schemas to retire the localStorage mirror, the settings IA per docs/specs/Domi - Settings IA v0.1.md, real per-tenant region resolution to replace the hardcoded CA-QC, and the SECURITY DEFINER user→tenant lookup function that’s been a carry-forward since Sprint 2. None of which are user-facing in the spectacular way Sprint 5’s work was. M6 is plumbing — making the cash-out durable.