# AI assistant and one tool catalog

The repository's architectural decision, rationale and consequences.

# AI assistant and one tool catalog

Date: 2026-10-14. Status: accepted.

## Decision [#decision]

Use [TanStack AI](https://tanstack.com/ai/latest/docs) for the authenticated web assistant: `chat()` streams from the Worker over SSE, `useChat` consumes it, and [`@tanstack/ai-persistence`](https://tanstack.com/ai/latest/docs/persistence/chat-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](0005-effect-boundary.md). 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 [#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](0002-no-barrels-on-server-capable-modules.md) 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](0008-documents-and-large-uploads.md).

`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 [#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 [#configuration-and-cost]

Follow [ADR-0004](0004-payments-providers.md): 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](https://tanstack.com/ai/latest/docs/advanced/runtime-adapter-switching) 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 [#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`](https://github.com/modelcontextprotocol/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](../measurements/2026-10-14-ai-assistant.md). Setup and client connection instructions are in [Connect your agent](../connect-your-agent.md).

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](https://developers.cloudflare.com/workers/runtime-apis/request/) and [waitUntil](https://developers.cloudflare.com/workers/runtime-apis/context/).

