Date: 2026-10-14. Status: accepted.
Decision
Use TanStack AI for the authenticated web assistant: chat() streams from the Worker over SSE, useChat consumes it, and @tanstack/ai-persistence stores messages through a Drizzle message store. Keep one Worker, Postgres through Hyperdrive, and R2. No Workers AI binding, Durable Object, Queue or KV is added.
TanStack AI provides the required provider adapters, typed tools, approval/input interrupts, streaming client and persistence middleware in one protocol. Effect AI would introduce another model/tool protocol and additional unstable Effect modules beyond ADR-0005. Vercel AI SDK also supports this class of application, but using it alongside TanStack AI would duplicate the chosen tool and message contracts. Neither alternative supplies a needed capability missing from this implementation.
Keep the existing @modelcontextprotocol/server transport and Better Auth MCP OAuth. Adapting the catalog preserves the in-process JWKS lookup, token revocation guards, scope challenges and dual MCP Apps/OpenAI widget resources. A transport migration offers no benefit that justifies changing those contracts. TanStack managed/manual MCP clients, remote MCP tool loading, server tasks and server sessions are not needed for this local catalog.
One catalog and authorization
packages/api/src/ai/catalog.schema.ts defines every toolDefinition with Zod input/output contracts. catalog.ts binds the server implementations. The assistant passes these tools to chat(); mcp/server.ts adapts the same entries to MCP. Tool implementations call the existing oRPC/domain operations instead of copying todo, file or import SQL. Client code imports leaf-safe contracts and types, never this server catalog. ADR-0002 still applies.
The catalog has 15 tools, including the read-only document_read. It takes a confirmed fileId, an optional chunk cursor and a page limit. The default page contains two chunks and the maximum contains four, each at most 8 KiB. search includes ranked matches from converted document chunks; fetch reads the matching chunk offset and returns nextCursor for continued reading. files_list exposes each confirmed file's ID and conversion status alongside its metadata. Original file bytes never enter tool results. The assistant and MCP use the same Organization-scoped document operations described in ADR-0008.
template://organization/documents/{fileId} returns the first bounded JSON page, with nextCursor for document_read. Resource reads pass through the same membership and MCP read guards as tools. The plan-from-document prompt asks the agent to read relevant pages, identify assumptions and call the existing draft_task_plan. Tool approval creates an inert proposal; the User must separately Apply it to create Todos. Source text is untrusted data. The assistant instructions forbid following instructions inside documents and require it to disclose when a response uses only part of a document.
Every call checks current membership. The assistant uses the Session's Active Organization and rejects a change of Organization during a turn. An MCP token carries no Active Organization, so its context stays pinned to the User's personal Organization. OAuth read/write scopes and token revocation remain additional MCP checks.
Every write has needsApproval: true. Approval is a persisted record bound to the User, Organization, tool name and parsed arguments. Assistant approvals also bind the Thread and tool-call ID. Client-supplied approval booleans cannot independently authorize a write. The execution transaction locks current membership and the approval, checks its expiry and exact arguments, applies the domain operation, records the audit event, and stores the result in the approval ledger. Replaying an executed approval returns that result without repeating the mutation.
MCP writes return a pending approval and a link to Integrations. The User approves or denies there. Legacy draft tools remain available; approving creation of a proposal still leaves its separate Apply step. MCP approval records expire after 15 minutes and admission allows at most 20 unexpired pending records per User. Tool audit metadata identifies the tool and Organization without copying prompts, messages or credentials.
Promise and streaming boundary
AI calls are outbound transport, but this ADR makes a narrow exception to ADR-0005: TanStack AI owns its native Promise/async-iterator lifecycle behind packages/api/src/ai/http.ts. Wrapping chat() in Effect would introduce a second cancellation and finalization owner around SSE, interrupts and persistence. Existing scheduled jobs and other outbound transports retain their Effect boundaries.
The HTTP boundary owns the abort controller, request deadline, bounded JSON input, rate limit, authentication and pool lifetime. It creates one bounded pool with createJobDb and creates Better Auth with the current request and that database. The SSE iterator's finally releases the Thread lease and closes the pool; requests that fail before streaming close it in the outer finally. Ordinary thread-management oRPC procedures retain the existing request scope. Nothing opens a pool or runtime with I/O at module scope.
withPersistence writes streaming snapshots at most once per second and saves the completed transcript on finish. Stop, timeout and disconnect abort the current model run; they recover the last successful snapshot rather than flushing every partial token. Reloading reads stored messages and pending approval/input interrupts. There is no background execution, persisted stream log or resumable transport after disconnection; a later request starts or continues a turn from the saved state. A lost connection can therefore lose text emitted after the last successful snapshot. The approval ledger protects completed writes independently of message persistence.
Configuration and cost
Follow ADR-0004: capability comes from server configuration, visibility from a flag. AI_PROVIDER and its matching nonempty secret must be configured, and ai.assistant must evaluate true. Otherwise the assistant route/API is unavailable and navigation is hidden. This gate does not disable the existing MCP server or its approval flow.
AI_MODEL overrides the provider default. Defaults are Anthropic claude-sonnet-5-5, OpenAI gpt-6-astra, and Gemini gemini-4-argon. The permitted package family predates these model IDs, so the documented createModel/extendAdapter API accepts the selected text model without unsafe casts or bypassing release quarantine. Keys never become client configuration.
Defaults are five model/tool steps, 2,048 output tokens per model call, a 60-second abort deadline, and ten requests per minute per User. The rate limit reuses the Postgres-backed limiter. Input is capped at 16 KiB and one user message of 8,000 characters; message history and stored snapshots are bounded separately. Model turns and write tools have no application-level automatic retry. These limits bound individual requests, not monthly spend; deployments still need provider budget controls.
The deterministic adapter requires both an ai-test build and AI_TEST_PROVIDER=true. A normal production build cannot select it, even if that environment variable is set. Tests use isolated local Postgres and this adapter, never a live model by default.
Persistence and public discovery
Threads belong to one User and one Organization. They retain titles, messages, tool results and pending interrupts until the User deletes the Thread or the User/Organization is deleted through foreign-key cascades. There is no automatic age-based deletion. Thread-linked approval records cascade with the Thread; standalone MCP approvals retain their records until User/Organization deletion. Expiry prevents execution but does not delete the ledger. Provider-side retention is separate from this application's database retention.
Reading a document in an assistant conversation sends the selected text pages to the configured model provider and stores the tool results in the Thread. MCP clients receive only the pages they request. Conversion runs in a browser Web Worker and does not call a hosted model or Firecrawl API. awaitingBrowser tells agents when Files must complete conversion before text is available. Organization membership governs document access; a document cannot grant access to another Organization by naming it in its text.
The additive schema uses the journaled db:apply-pending flow. A Thread lease permits one active request; stale leases expire. Disabling the provider or flag preserves history. Deleting a Thread does not undo domain changes already made by approved tools.
Public /llms.txt, /llms-full.txt and Markdown pages derive from the landing, legal content, demo posts/profiles and connection guide. They expose no private Thread or Organization content. Sitemap, robots and alternate links distinguish public documents from authenticated routes. Existing OAuth discovery endpoints remain authoritative. No server-card endpoint is advertised: the official ext-server-card work is experimental rather than a stable MCP discovery requirement.
Version selection, quarantine exclusions rejected, bundle measurements and verification evidence are recorded in the measurement report. Setup and client connection instructions are in Connect your agent.
The Worker enables enable_request_signal so client disconnects reach the model abort controller. Its outer fetch passes Cloudflare waitUntil into the request scope before delegating to TanStack Start, whose two-argument wrapper otherwise drops the Worker context. A completion promise keeps lease release and pool closure alive after cancellation, within Cloudflare's cleanup window. See Request.signal and waitUntil.