+```
+
+**Context hooks replaced with unified state API:**
+
+All individual context hooks replaced by `useAuiState` / `useAui`:
+
+```diff
+- const { messages } = useThread();
++ const messages = useAuiState(s => s.thread.messages);
+
+- const runtime = useThreadRuntime();
++ const thread = useAui().thread();
+
+- const { isEditing } = useComposer();
++ const isEditing = useAuiState(s => s.composer.isEditing);
+
+- const runtime = useComposerRuntime();
++ const composer = useAui().composer();
+
+- const { status } = useMessage();
++ const status = useAuiState(s => s.message.status);
+
+- const runtime = useMessageRuntime();
++ const message = useAui().message();
+```
+
+Other deprecated hooks: `useAssistantRuntime`, `useEditComposer`, `useThreadListItem`, `useThreadListItemRuntime`, `useMessagePart`, `useMessagePartRuntime`, `useAttachment`, `useAttachmentRuntime`, `useThreadModelContext`, `useThreadComposer`, `useThreadList`.
+
+**Event names changed to camelCase:**
+
+| Old | New |
+|-----|-----|
+| `thread.run-start` | `thread.runStart` |
+| `thread.run-end` | `thread.runEnd` |
+| `thread.model-context-update` | `thread.modelContextUpdate` |
+| `composer.attachment-add` | `composer.attachmentAdd` |
+| `thread-list-item.switched-to` | `threadListItem.switchedTo` |
+| `thread-list-item.switched-away` | `threadListItem.switchedAway` |
+
+Unchanged: `thread.initialize`, `composer.send`.
+
+**`thread().composer()` invocation (0.12.11):**
+
+```diff
+- aui.thread().composer.send();
++ aui.thread().composer().send();
+```
+
+**`submitMode` prop (0.12.10) — deprecates `submitOnEnter`:**
+- `"enter"` (default) — submit on Enter
+- `"ctrlEnter"` — submit on Ctrl/Cmd+Enter, plain Enter for newlines
+- `"none"` — disable keyboard submission
+
+**Zod 4 required** — `@assistant-ui/react-ai-sdk` 1.3.x requires `zod@^4.3.6`
+
+**New primitives:**
+- `ChainOfThoughtPrimitive` (0.12.8)
+- `SelectionToolbarPrimitive` (0.12.10)
+- `SuggestionPrimitive` (0.12.3)
+
+**`@assistant-ui/core` extraction (0.12.11):**
+- Framework-agnostic core extracted to `@assistant-ui/core`
+- Shared React code in `@assistant-ui/core/react` (re-exported by `@assistant-ui/react` and `@assistant-ui/react-native`)
+
+**Search for deprecated patterns:**
+```bash
+grep -rn "useAssistantApi\|useAssistantState\|useAssistantEvent\|AssistantIf\|submitOnEnter\|useThread()\|useComposer()\|useMessage()\|useThreadRuntime\|useComposerRuntime\|useMessageRuntime" --include="*.tsx" --include="*.ts"
+```
+
+---
+
+## Migration: → 0.11.x (Runtime Rearchitecture)
+
+### From 0.10.x
+
+**New unified state API** (hooks renamed to `useAui`/`useAuiState`/`useAuiEvent` in 0.12.x):
+
+```typescript
+import {
+ useAssistantApi,
+ useAssistantState,
+ useAssistantEvent
+} from "@assistant-ui/react";
+
+// State access (replaces various useThread* hooks)
+const messages = useAssistantState(s => s.thread.messages);
+const isRunning = useAssistantState(s => s.thread.isRunning);
+
+// Actions
+const api = useAssistantApi();
+api.thread().append({ role: "user", content: [{ type: "text", text: "Hello" }] });
+api.thread().cancelRun();
+
+// Events
+useAssistantEvent("composer.send", (e) => {
+ console.log("Message sent:", e.messageId);
+});
+```
+
+**AI SDK v5/v6 support added:**
+- Use `useChatRuntime` for AI SDK v6
+- `useAISDKRuntime` still works for migration
+
+**Renames:**
+- `toolUIs` → `tools` (0.11.39)
+- `useLocalThreadRuntime` deprecated, use `useLocalRuntime`
+
+---
+
+## Migration: → 0.10.x (ESM Only)
+
+### From 0.9.x
+
+**BREAKING: CommonJS dropped**
+
+Update bundler if needed:
+```json
+// package.json
+{
+ "type": "module"
+}
+```
+
+Or configure bundler for ESM:
+```javascript
+// next.config.js
+export default {
+ experimental: {
+ esmExternals: true
+ }
+}
+```
+
+**New APIs:**
+- `ContentPart` renamed to `MessagePart` (0.10.25)
+- `MessageContent.ToolGroup` added
+- `runtime.thread.reset()` added
+
+---
+
+## Migration: → 0.9.x (Edge Split)
+
+### From 0.8.x
+
+**Edge package split:**
+- Edge runtime utilities moved to separate entry points
+- Check imports if using edge runtime
+
+---
+
+## Migration: → 0.8.x (UI Split)
+
+### From 0.7.x
+
+**BREAKING: Pre-styled UI moved out of `@assistant-ui/react`**
+
+0.7.x: `Thread` etc. were re-exported from `@assistant-ui/react` via `./ui` subpath
+0.8.0+: Use shadcn/ui registry (recommended) or `@assistant-ui/react-ui` (legacy, not maintained)
+
+**Option 1: shadcn/ui Registry (Recommended)**
+
+```bash
+# Using assistant-ui CLI
+npx assistant-ui add thread thread-list
+
+# Or using shadcn CLI
+npx shadcn@latest add "https://r.assistant-ui.com/thread"
+```
+
+Components are copied to your project (e.g., `components/assistant-ui/thread.tsx`).
+
+```diff
+// Styled components - now local files
+// Note: ThreadWelcome is now embedded inside Thread (shows when thread is empty)
+- import { Thread, ThreadWelcome } from "@assistant-ui/react";
++ import { Thread } from "@/components/assistant-ui/thread";
+
+// Primitives remain in @assistant-ui/react (no change)
+import { ThreadPrimitive } from "@assistant-ui/react";
+```
+
+**Option 2: Legacy Package (Not Recommended)**
+
+`@assistant-ui/react-ui` exists but is not actively maintained.
+
+**Search for imports to update:**
+```bash
+grep -r "from ['\"]@assistant-ui/react['\"]" --include="*.tsx" --include="*.ts" | grep -v "Primitive"
+```
+
+**setResult/setArtifact merged (0.8.18):**
+```diff
+- tool.setResult(result);
+- tool.setArtifact(artifact);
++ tool.setResponse({ result, artifact });
+```
+
+---
+
+## Migration: → 0.7.x (Thread API)
+
+### From 0.6.x or 0.5.x
+
+**BREAKING (0.7.44): Thread API moved**
+
+```diff
+- runtime.switchToThread(threadId);
++ runtime.threads.switchToThread(threadId);
+
+- runtime.switchToNewThread();
++ runtime.threads.switchToNewThread();
+
+- runtime.threadList
++ runtime.threads
+```
+
+**Search:**
+```bash
+grep -r "runtime\.switchToThread\|runtime\.switchToNewThread\|runtime\.threadList" --include="*.tsx" --include="*.ts"
+```
+
+**Deprecated features dropped (0.7.0):**
+- All previously deprecated APIs removed
+- `ThreadListItemPrimitive` introduced
+
+---
+
+## Migration: → 0.5.x (Runtime API)
+
+### From 0.4.x
+
+**maxToolRoundtrips → maxSteps (0.5.74):**
+```diff
+- maxToolRoundtrips: 5,
++ maxSteps: 5,
+```
+
+**New Runtime API introduced (0.5.61+):**
+- `ThreadRuntime.Composer`
+- Status/attachments/metadata on all messages
+
+---
+
+## Migration: → 0.4.x (Message Types)
+
+### From 0.3.x
+
+**BREAKING: Message type renames**
+
+```diff
+- import type { AssistantMessage, UserMessage } from "@assistant-ui/react";
++ import type { ThreadAssistantMessage, ThreadUserMessage } from "@assistant-ui/react";
+```
+
+**Search:**
+```bash
+grep -r "AssistantMessage\|UserMessage" --include="*.tsx" --include="*.ts" | grep -v "Thread"
+```
+
+**System message support added**
+
+---
+
+## Migration: → 0.3.x
+
+### From 0.2.x
+
+**BREAKING: Message.InProgress dropped**
+- Use message status instead of `Message.InProgress`
+
+---
+
+## Migration: → 0.2.x
+
+### From 0.1.x
+
+**BREAKING: MessagePartText renders as ``**
+- Text parts now wrapped in paragraph element
+- Adjust CSS if needed
+
+---
+
+## Automated Search Commands
+
+Find patterns that need updating:
+
+```bash
+# Old thread API
+grep -rn "runtime\.switchToThread\|runtime\.threadList" --include="*.tsx" --include="*.ts"
+
+# Old message types
+grep -rn "AssistantMessage\[^C\]\|UserMessage\[^C\]" --include="*.tsx" --include="*.ts"
+
+# Old tool API
+grep -rn "setResult\|setArtifact" --include="*.tsx" --include="*.ts"
+
+# Styled imports (need shadcn registry migration)
+grep -rn "from ['\"]@assistant-ui/react['\"]" --include="*.tsx" | grep -v "Primitive\|Runtime\|use"
+```
+
+## Verification
+
+After migration:
+
+```bash
+# Type check
+npx tsc --noEmit
+
+# Build
+pnpm build
+
+# Test
+pnpm test
+```
+
+Manual verification:
+- [ ] App starts
+- [ ] Chat renders
+- [ ] Messages send/receive
+- [ ] Tools work
+- [ ] Thread switching works
diff --git a/.agents/skills/update/references/breaking-changes.md b/.agents/skills/update/references/breaking-changes.md
new file mode 100644
index 0000000..b71a2e0
--- /dev/null
+++ b/.agents/skills/update/references/breaking-changes.md
@@ -0,0 +1,109 @@
+# Breaking Changes Quick Reference
+
+Fast lookup for breaking changes by version.
+
+## By Version
+
+| Version | Breaking Change | Migration |
+|---------|-----------------|-----------|
+| **0.11.0** | Runtime rearchitecture | Use `useAui`, `useAuiState`, `useAuiEvent` |
+| **0.10.0** | CommonJS dropped | Use ESM, set `"type": "module"` |
+| **0.8.18** | `setResult`/`setArtifact` merged | Use `setResponse({ result, artifact })` |
+| **0.8.0** | UI moved out of core | Use shadcn registry (recommended) or primitives |
+| **0.7.44** | `runtime.switchToThread()` moved | Use `runtime.threads.switchToThread()` |
+| **0.7.44** | `runtime.threadList` renamed | Use `runtime.threads` |
+| **0.7.0** | Deprecated features dropped | Update to non-deprecated APIs |
+| **0.5.74** | `maxToolRoundtrips` renamed | Use `maxSteps` |
+| **0.4.0** | `AssistantMessage` renamed | Use `ThreadAssistantMessage` |
+| **0.4.0** | `UserMessage` renamed | Use `ThreadUserMessage` |
+| **0.3.0** | `Message.InProgress` dropped | Use message status |
+| **0.2.0** | `MessagePartText` renders as `
` | Adjust CSS |
+
+## By Pattern
+
+### Import Changes
+
+```diff
+# Styled components (0.8.0+) - use shadcn registry (recommended)
+- import { Thread } from "@assistant-ui/react";
++ import { Thread } from "@/components/assistant-ui/thread";
+# Note: Run `npx assistant-ui add thread` to install
+
+# Message types (0.4.0+)
+- import type { AssistantMessage, UserMessage } from "@assistant-ui/react";
++ import type { ThreadAssistantMessage, ThreadUserMessage } from "@assistant-ui/react";
+
+# AI SDK v6 (react-ai-sdk 1.0+)
+- import { useChat } from "ai/react";
+- import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
++ import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk";
+```
+
+### API Changes
+
+```diff
+# Thread switching (0.7.44+)
+- runtime.switchToThread(id);
+- runtime.switchToNewThread();
+- runtime.threadList
++ runtime.threads.switchToThread(id);
++ runtime.threads.switchToNewThread();
++ runtime.threads
+
+# Tool response (0.8.18+)
+- tool.setResult(result);
+- tool.setArtifact(artifact);
++ tool.setResponse({ result, artifact });
+
+# State access (0.11.0+)
+- const { messages } = useThread();
++ const messages = useAuiState(s => s.thread.messages);
+
+# Actions (0.11.0+)
+- useThreadActions().append(...)
++ useAui().thread().append(...)
+```
+
+### Config Changes
+
+```diff
+# Tool steps (0.5.74+)
+- maxToolRoundtrips: 5,
++ maxSteps: 5,
+```
+
+## Search Commands
+
+Find code needing updates:
+
+```bash
+# All breaking patterns
+grep -rn "runtime\.switchToThread\|runtime\.threadList\|AssistantMessage[^C]\|UserMessage[^C]\|setResult\|setArtifact\|maxToolRoundtrips" --include="*.tsx" --include="*.ts"
+
+# Specific version checks
+grep -rn "from ['\"]@assistant-ui/react['\"]" --include="*.tsx" | grep -v Primitive # 0.8.0
+grep -rn "Message\.InProgress" --include="*.tsx" # 0.3.0
+```
+
+## AI SDK v6 Changes (Separate)
+
+See [./ai-sdk-v6.md](./ai-sdk-v6.md) for AI SDK specific migrations:
+
+| Old | New |
+|-----|-----|
+| `maxSteps` | `stopWhen: stepCountIs(n)` |
+| `parameters` | `inputSchema` (in `tool()`) |
+| `toDataStreamResponse()` | `toUIMessageStreamResponse()` |
+| `generateObject()` | `generateText() + Output.object()` |
+| `CoreMessage` | `ModelMessage` |
+| `Message` | `UIMessage` |
+
+## Version Compatibility
+
+| @assistant-ui/react | react-ai-sdk | AI SDK | Zod |
+|---------------------|--------------|--------|-----|
+| 0.12.x | 1.3.x | 6.x | 4.x |
+| 0.11.x | 1.2.x | 6.x | 3.25+ or 4.x |
+| 0.10.x | 0.x | 4.x-5.x | 3.x |
+| 0.8.x-0.9.x | 0.x | 4.x | 3.x |
+| < 0.8.0 | 0.x | 4.x | 3.x |
diff --git a/.claude/skills/assistant-ui b/.claude/skills/assistant-ui
new file mode 120000
index 0000000..c770cbe
--- /dev/null
+++ b/.claude/skills/assistant-ui
@@ -0,0 +1 @@
+../../.agents/skills/assistant-ui
\ No newline at end of file
diff --git a/.claude/skills/cloud b/.claude/skills/cloud
new file mode 120000
index 0000000..de1aae2
--- /dev/null
+++ b/.claude/skills/cloud
@@ -0,0 +1 @@
+../../.agents/skills/cloud
\ No newline at end of file
diff --git a/.claude/skills/primitives b/.claude/skills/primitives
new file mode 120000
index 0000000..9a7c317
--- /dev/null
+++ b/.claude/skills/primitives
@@ -0,0 +1 @@
+../../.agents/skills/primitives
\ No newline at end of file
diff --git a/.claude/skills/runtime b/.claude/skills/runtime
new file mode 120000
index 0000000..16c9849
--- /dev/null
+++ b/.claude/skills/runtime
@@ -0,0 +1 @@
+../../.agents/skills/runtime
\ No newline at end of file
diff --git a/.claude/skills/setup b/.claude/skills/setup
new file mode 120000
index 0000000..0e9a465
--- /dev/null
+++ b/.claude/skills/setup
@@ -0,0 +1 @@
+../../.agents/skills/setup
\ No newline at end of file
diff --git a/.claude/skills/streaming b/.claude/skills/streaming
new file mode 120000
index 0000000..3a00ac1
--- /dev/null
+++ b/.claude/skills/streaming
@@ -0,0 +1 @@
+../../.agents/skills/streaming
\ No newline at end of file
diff --git a/.claude/skills/thread-list b/.claude/skills/thread-list
new file mode 120000
index 0000000..d560d03
--- /dev/null
+++ b/.claude/skills/thread-list
@@ -0,0 +1 @@
+../../.agents/skills/thread-list
\ No newline at end of file
diff --git a/.claude/skills/tools b/.claude/skills/tools
new file mode 120000
index 0000000..18f2f10
--- /dev/null
+++ b/.claude/skills/tools
@@ -0,0 +1 @@
+../../.agents/skills/tools
\ No newline at end of file
diff --git a/.claude/skills/update b/.claude/skills/update
new file mode 120000
index 0000000..dd8efb8
--- /dev/null
+++ b/.claude/skills/update
@@ -0,0 +1 @@
+../../.agents/skills/update
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..1bcb451
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+node_modules
+ui/node_modules
+data
+data-trace
+local/demo_session.json
+local/demo_session.local.json
+.env
+.env.local
+.git
+ui/dist
+*.tsbuildinfo
+docker/certs/*
+!docker/certs/.gitkeep
diff --git a/.env.local.example b/.env.local.example
index c445f3d..95bff07 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -22,3 +22,19 @@
# helps long local sessions; the default keeps the JWT-refresh path
# exercised in dev.
# DEMO_TTL_SECONDS=1800
+
+# Symmetric HS256 secret. REQUIRED in prod, optional in demo (an ephemeral
+# random one is generated at boot when unset — every restart invalidates
+# open sessions, which boot logs warn about). Generate with:
+# openssl rand -hex 32
+# Length must be >= 32. Rotating the value invalidates every open session.
+# AUGCHATD_JWT_SECRET=
+
+# Trust the reverse proxy to terminate mTLS and forward
+# X-Client-Cert-Verify / X-Client-Cert-Subject. Only set to true in
+# deployments where augchatd is reachable ONLY through the proxy
+# (loopback / unix socket / firewalled internal network). See
+# docs/deployment/nginx.conf.example and adr-0012-out-of-process-tls.
+# When unset/false in prod, the mTLS-protected control-plane routes are
+# not mounted and POST /sessions returns 404.
+# TRUSTED_PROXY=false
diff --git a/.gitignore b/.gitignore
index 20604e5..4f7ef57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,8 @@ ui/dist/
# Committed counterpart: local/demo_session.json.example
local/demo_session.json
local/demo_session.local.json
+
+# Self-signed certs generated by `docker compose run --rm cert-init`.
+# Keep the directory in git via .gitkeep but never the contents.
+docker/certs/*
+!docker/certs/.gitkeep
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..77cfd6c
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "mcp-docs-server": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@assistant-ui/mcp-docs-server"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3b99364
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+# Multi-stage build for augchatd. TLS is terminated by nginx (adr-0012);
+# this image only listens on plain HTTP at AUGCHATD_PORT and must be kept
+# off the public network — see docker-compose.yml.
+
+FROM oven/bun:1 AS ui-builder
+WORKDIR /build/ui
+COPY ui/package.json ui/bun.lock ./
+RUN bun install --frozen-lockfile
+COPY ui/ ./
+RUN bun run build
+
+FROM oven/bun:1 AS deps
+WORKDIR /app
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile --production
+
+FROM oven/bun:1-slim AS runtime
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=ui-builder /build/ui/dist ./ui/dist
+COPY src/ ./src/
+COPY package.json tsconfig.json ./
+ENV AUGCHATD_PORT=8080
+EXPOSE 8080
+CMD ["bun", "run", "start"]
diff --git a/README.md b/README.md
index d5d7ddd..7cdd3c5 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
```bash
# Your backend, once per chat session:
-curl -X POST https://augchatd.your-infra/sessions \
+curl -X POST https://augchatd.your-infra:8443/sessions \
--cert prod-client.pem --key prod-client.key \
-H 'Content-Type: application/json' \
-d '{
@@ -101,6 +101,35 @@ Demo mode is for local testing and public demos only. It bypasses mTLS, runs sin
augchatd serves `GET /healthz` on the same origin in both modes, returning `{ "mode": "demo" | "prod", "status": "ok" }`. The `mode` field is the safety net for accidental demo deploys — fail your deploy if a production health check reports `"mode": "demo"`.
+### Production-ish boot (Docker Compose, self-signed mTLS)
+
+For a prod-mode boot on your machine — nginx terminating TLS in front of augchatd, mTLS on `:8443`, browser TLS on `:443`:
+
+```bash
+# 1. Generate the self-signed CA + server + client bundle into docker/certs/.
+# Idempotent; re-running with the same domain is a no-op.
+docker compose run --rm cert-init augchatd.local
+
+# 2. Per-deploy secrets.
+echo "AUGCHATD_JWT_SECRET=$(openssl rand -hex 32)" > .env
+echo "AUGCHATD_DOMAIN=augchatd.local" >> .env
+
+# 3. Boot. augchatd has no published ports — only nginx is reachable.
+docker compose up --build
+```
+
+The proxy refuses to start if `docker/certs/server.crt|server.key|clients-ca.crt` is missing; augchatd refuses to start until the proxy is healthy. See [ADR-0014](spec/src/architecture/adrs/0014-docker-compose-prod-deployment.md) for the wiring rationale and [ADR-0012](spec/src/architecture/adrs/0012-out-of-process-tls.md) for why TLS lives outside augchatd. The compose stack is prod-only; demo via Docker continues to use the `docker run` snippet above.
+
+### Deploying to a VPS (Ubuntu/Debian)
+
+Assuming the repo is already on the VPS (`git clone` or `scp`), one script does the rest:
+
+```bash
+sudo ./scripts/deploy.sh
+```
+
+It installs Docker + Compose, enables it on boot, configures UFW (22, 80, 443, 8443), generates `.env` (preserving `AUGCHATD_JWT_SECRET` across reruns so live JWTs survive a re-deploy), seeds self-signed certs via the `cert-init` service, and boots the stack. Idempotent — safe to re-run after a partial failure or to update the domain.
+
## How it works
```
diff --git a/bun.lock b/bun.lock
index 8746b57..eda6369 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,24 +5,25 @@
"": {
"name": "augchatd",
"dependencies": {
- "@ai-sdk/anthropic": "latest",
+ "@ai-sdk/anthropic": "^3.0.79",
"@ai-sdk/openai": "^3.0.65",
"@aws-sdk/client-s3": "^3.1054.0",
"@modelcontextprotocol/sdk": "^1.29.0",
- "ai": "latest",
- "hono": "^4.6.0",
+ "@streamdown/cjk": "^1.0.3",
+ "ai": "^6.0.191",
+ "hono": "^4.12.23",
"zod": "^4.4.3",
},
"devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.5.0",
+ "@types/bun": "^1.3.14",
+ "typescript": "^6.0.3",
},
},
},
"packages": {
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.79", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-saEX+h5JDOkT9P/+REKDyikbnJiToFuLipgNcsmu4Zr3GW5kW1m9HhvrPK+vj63itIOsoZU6tmVIjkrePOlIUA=="],
- "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.119", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VAhfRWC+JexZakkVfmjaJKaTj00x7/UHdE8kMWL3NhuQAlf8oXtg9r4dfvFZrByXxchGRBvYE3biEUyibkg0xg=="],
+ "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.120", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.65", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZlVoWH+zrdiYDiUt6n/xvfCsk33mzsB81TUQkBRVx79rxU1FKZqVH9J/QCtEpSLqx0cUzjvtIw9l9p7EbUv+dw=="],
@@ -120,20 +121,30 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@streamdown/cjk": ["@streamdown/cjk@1.0.3", "", { "dependencies": { "remark-cjk-friendly": "^2.0.1", "remark-cjk-friendly-gfm-strikethrough": "^2.0.1", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw=="],
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
- "ai": ["ai@6.0.190", "", { "dependencies": { "@ai-sdk/gateway": "3.0.119", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-T+ixHbWZ6jmHRREpVVJTkFyWJeCekCdzLPan7lp1F32jG5OUw4+odlVYjtMRXVzogU+pWzpMmXdRiHUmdL/q0w=="],
+ "ai": ["ai@6.0.191", "", { "dependencies": { "@ai-sdk/gateway": "3.0.120", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@@ -146,6 +157,8 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -160,8 +173,14 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -186,6 +205,8 @@
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
@@ -202,6 +223,8 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
+
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -212,7 +235,7 @@
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
- "hono": ["hono@4.12.22", "", {}, "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw=="],
+ "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -224,6 +247,8 @@
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -242,6 +267,52 @@
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+ "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="],
+
+ "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.4.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg=="],
+
+ "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark-util-types": "*" }, "optionalPeers": ["micromark-util-types"] }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
@@ -276,6 +347,12 @@
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
+ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
+
+ "remark-cjk-friendly": ["remark-cjk-friendly@2.0.1", "", { "dependencies": { "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA=="],
+
+ "remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@2.0.1", "", { "dependencies": { "micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-pWKj25O2eLXIL1aBupayl1fKhco+Brw8qWUWJPVB9EBzbQNd7nGLj0nLmJpggWsGLR5j5y40PIdjxby9IEYTuA=="],
+
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -306,18 +383,34 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
@@ -328,8 +421,6 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
- "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..8c5cbfd
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,84 @@
+# augchatd prod-mode stack. TLS termination is out-of-process by design
+# (spec/src/architecture/adrs/0012-out-of-process-tls.md); compose wires
+# the nginx proxy + the augchatd app + an on-demand cert-init service.
+#
+# Bring-up:
+# docker compose run --rm cert-init # one-off, idempotent
+# echo "AUGCHATD_JWT_SECRET=$(openssl rand -hex 32)" >> .env
+# echo "AUGCHATD_DOMAIN=" >> .env
+# docker compose up --build
+#
+# Guarantees encoded here:
+# - augchatd's port 8080 is NEVER published to the host.
+# - nginx refuses to boot without certs (entrypoint check).
+# - augchatd's depends_on waits for nginx's healthcheck to pass
+# (= nginx process running with a valid config). If certs are
+# missing nginx exits non-zero and augchatd never starts.
+
+services:
+ augchatd:
+ build: .
+ environment:
+ AUGCHATD_MODE: prod
+ AUGCHATD_PORT: 8080
+ TRUSTED_PROXY: "true"
+ AUGCHATD_JWT_SECRET: ${AUGCHATD_JWT_SECRET}
+ AUGCHATD_TRACE_DIR: /app/data-trace
+ volumes:
+ - ./data:/app/data
+ - ./data-trace:/app/data-trace
+ networks:
+ - augchatd_net
+ depends_on:
+ nginx:
+ condition: service_healthy
+ restart: unless-stopped
+
+ nginx:
+ build: ./docker/nginx
+ environment:
+ AUGCHATD_DOMAIN: ${AUGCHATD_DOMAIN:-augchatd.local}
+ ports:
+ - "443:443"
+ - "8443:8443"
+ volumes:
+ - ./docker/certs:/etc/ssl/augchatd:ro
+ networks:
+ - augchatd_net
+ healthcheck:
+ test: ["CMD-SHELL", "nginx -t && pidof nginx >/dev/null"]
+ interval: 5s
+ timeout: 3s
+ retries: 6
+ start_period: 2s
+ restart: unless-stopped
+
+ cert-init:
+ build: ./docker/cert-init
+ profiles: ["tools"]
+ volumes:
+ - ./docker/certs:/out
+ # Usage: docker compose run --rm cert-init [--force]
+
+ # Let's Encrypt cert via HTTP-01 standalone (adr-0015). Overwrites only
+ # server.{crt,key} in docker/certs/ — cert-init's CA + clients-CA stay.
+ # Port 80 is published only when invoked with --service-ports, so an
+ # accidental `docker compose up` never holds 80 on the host.
+ letsencrypt-init:
+ build: ./docker/letsencrypt-init
+ profiles: ["tools"]
+ ports:
+ - "80:80"
+ volumes:
+ - ./docker/certs:/out
+ - augchatd_letsencrypt:/etc/letsencrypt
+ # Usage:
+ # docker compose run --rm --service-ports letsencrypt-init issue
+ # docker compose run --rm --service-ports letsencrypt-init renew
+
+networks:
+ augchatd_net:
+ driver: bridge
+
+volumes:
+ augchatd_letsencrypt: {}
diff --git a/docker/cert-init/Dockerfile b/docker/cert-init/Dockerfile
new file mode 100644
index 0000000..db9858b
--- /dev/null
+++ b/docker/cert-init/Dockerfile
@@ -0,0 +1,6 @@
+FROM alpine:3.20
+RUN apk add --no-cache openssl
+COPY gen-certs.sh /usr/local/bin/gen-certs.sh
+RUN chmod +x /usr/local/bin/gen-certs.sh
+WORKDIR /out
+ENTRYPOINT ["/usr/local/bin/gen-certs.sh"]
diff --git a/docker/cert-init/gen-certs.sh b/docker/cert-init/gen-certs.sh
new file mode 100644
index 0000000..266f9a6
--- /dev/null
+++ b/docker/cert-init/gen-certs.sh
@@ -0,0 +1,162 @@
+#!/bin/sh
+# gen-certs.sh — idempotent self-signed cert bundle for augchatd's nginx.
+#
+# Usage: gen-certs.sh [--force]
+#
+# Writes the following into /out (bind-mounted from ./docker/certs/):
+# ca.crt, ca.key — local CA (10y). Reused as clients-CA.
+# server.crt, server.key — nginx server cert for .
+# SAN: DNS:, DNS:localhost, IP:127.0.0.1.
+# clients-ca.crt — copy of ca.crt; nginx uses it for
+# ssl_client_certificate on the mTLS leg.
+# client-sample.crt, .key — test client; Subject /O=demo/CN=tester
+# (parsed by src/mtls-trust.ts, mapped by
+# src/identity.ts → tenantId=demo, userId=tester).
+#
+# Idempotency: a per-artifact check skips regeneration unless the file
+# is missing, expires in < 30 days, or (for server.crt) its SAN does not
+# include the requested . `--force` regenerates everything.
+
+set -eu
+umask 077
+
+usage() {
+ echo "Usage: gen-certs.sh [--force]" >&2
+ exit 64
+}
+
+[ $# -ge 1 ] || usage
+DOMAIN="$1"
+shift
+FORCE=0
+if [ $# -gt 0 ]; then
+ case "$1" in
+ --force) FORCE=1; shift ;;
+ *) usage ;;
+ esac
+fi
+[ $# -eq 0 ] || usage
+
+OUT=/out
+cd "$OUT"
+
+note() { echo "cert-init: $*"; }
+
+valid_30d() { openssl x509 -in "$1" -noout -checkend 2592000 >/dev/null 2>&1; }
+
+server_san_ok() {
+ openssl x509 -in server.crt -noout -ext subjectAltName 2>/dev/null \
+ | grep -q "DNS:$DOMAIN"
+}
+
+# 1. CA — generate only when missing or --force.
+if [ "$FORCE" = "1" ] || [ ! -s ca.key ] || [ ! -s ca.crt ]; then
+ note "generating local CA"
+ openssl req -x509 -newkey rsa:4096 -nodes \
+ -keyout ca.key -out ca.crt \
+ -days 3650 \
+ -subj "/CN=augchatd-local-CA/O=augchatd-local" \
+ >/dev/null 2>&1
+else
+ note "ok: ca.crt + ca.key present"
+fi
+
+# 2. clients-ca.crt — straight copy of the CA.
+if [ "$FORCE" = "1" ] || [ ! -s clients-ca.crt ]; then
+ note "writing clients-ca.crt"
+ cp ca.crt clients-ca.crt
+else
+ note "ok: clients-ca.crt present"
+fi
+
+# 3. Server cert — regen on missing, near-expiry, SAN mismatch, or --force.
+need_server=0
+if [ "$FORCE" = "1" ]; then need_server=1; fi
+if [ ! -s server.crt ] || [ ! -s server.key ]; then need_server=1; fi
+if [ "$need_server" = "0" ] && ! valid_30d server.crt; then
+ note "server.crt expires within 30 days — regenerating"
+ need_server=1
+fi
+if [ "$need_server" = "0" ] && ! server_san_ok; then
+ note "server.crt SAN does not include DNS:$DOMAIN — regenerating"
+ need_server=1
+fi
+
+if [ "$need_server" = "1" ]; then
+ note "generating server cert for $DOMAIN"
+ openssl req -new -newkey rsa:2048 -nodes \
+ -keyout server.key -out server.csr \
+ -subj "/CN=$DOMAIN/O=augchatd-local" \
+ >/dev/null 2>&1
+
+ cat >server.ext </dev/null 2>&1
+
+ rm -f server.csr server.ext
+else
+ note "ok: server.crt valid for $DOMAIN"
+fi
+
+# 4. Sample client cert — for `curl --cert/--key` against the mTLS leg.
+# Subject /O=demo/CN=tester → RFC2253 form "CN=tester,O=demo"
+# (src/mtls-trust.ts), then src/identity.ts maps O→tenantId, CN→userId.
+need_client=0
+if [ "$FORCE" = "1" ]; then need_client=1; fi
+if [ ! -s client-sample.crt ] || [ ! -s client-sample.key ]; then need_client=1; fi
+if [ "$need_client" = "0" ] && ! valid_30d client-sample.crt; then
+ note "client-sample.crt expires within 30 days — regenerating"
+ need_client=1
+fi
+
+if [ "$need_client" = "1" ]; then
+ note "generating sample client cert (O=demo, CN=tester)"
+ openssl req -new -newkey rsa:2048 -nodes \
+ -keyout client-sample.key -out client-sample.csr \
+ -subj "/O=demo/CN=tester" \
+ >/dev/null 2>&1
+
+ cat >client.ext </dev/null 2>&1
+
+ rm -f client-sample.csr client.ext
+else
+ note "ok: client-sample.crt valid"
+fi
+
+# Lock down permissions: production-grade keys 600 (only read by nginx/CA
+# operations inside the container as root, so the bind mount's host owner
+# does not matter). client-sample.key is intentionally 644: it's a
+# zero-value test cert that the host operator needs to read via curl when
+# exercising the mTLS leg (see README "Production-ish boot"). Losing 600
+# on a real client cert is a leak; on this one it's a UX win.
+chmod 600 ca.key server.key 2>/dev/null || true
+chmod 644 ca.crt server.crt clients-ca.crt client-sample.crt client-sample.key 2>/dev/null || true
+
+echo
+note "artifacts:"
+ls -1 ca.crt ca.key clients-ca.crt server.crt server.key client-sample.crt client-sample.key
+echo
+note "server cert fingerprint:"
+openssl x509 -in server.crt -noout -fingerprint -sha256
diff --git a/docker/certs/.gitkeep b/docker/certs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docker/letsencrypt-init/Dockerfile b/docker/letsencrypt-init/Dockerfile
new file mode 100644
index 0000000..0eb8a78
--- /dev/null
+++ b/docker/letsencrypt-init/Dockerfile
@@ -0,0 +1,6 @@
+FROM alpine:3.20
+RUN apk add --no-cache certbot openssl
+COPY letsencrypt.sh /usr/local/bin/letsencrypt.sh
+RUN chmod +x /usr/local/bin/letsencrypt.sh
+WORKDIR /out
+ENTRYPOINT ["/usr/local/bin/letsencrypt.sh"]
diff --git a/docker/letsencrypt-init/letsencrypt.sh b/docker/letsencrypt-init/letsencrypt.sh
new file mode 100644
index 0000000..ac694d4
--- /dev/null
+++ b/docker/letsencrypt-init/letsencrypt.sh
@@ -0,0 +1,118 @@
+#!/bin/sh
+# letsencrypt.sh — issue / renew the augchatd nginx server cert from
+# Let's Encrypt via HTTP-01 standalone. Always targets the LE PRODUCTION
+# endpoint (no --staging). Writes the resulting fullchain.pem and
+# privkey.pem into /out/server.crt and /out/server.key — the same paths
+# nginx already serves. The CA + clients-CA + sample client cert in the
+# bundle stay where cert-init left them (LE does not issue those).
+#
+# Subcommands:
+# issue [--force]
+# renew [--dry-run]
+#
+# The cert-name `augchatd` is the on-disk identifier certbot stores under
+# /etc/letsencrypt/{live,renewal,archive}/augchatd/ — kept constant so
+# `renew` always picks the right one.
+
+set -eu
+umask 077
+
+CERT_NAME=augchatd
+LIVE_DIR="/etc/letsencrypt/live/$CERT_NAME"
+OUT=/out
+cd "$OUT"
+
+note() { echo "letsencrypt-init: $*"; }
+
+usage() {
+ echo "Usage:" >&2
+ echo " letsencrypt.sh issue [--force]" >&2
+ echo " letsencrypt.sh renew [--dry-run]" >&2
+ exit 64
+}
+
+# Copies the live fullchain + privkey into /out, fixing perms. Called
+# after both `issue` and `renew` (no-op on renew when nothing rotated).
+publish_cert() {
+ [ -s "$LIVE_DIR/fullchain.pem" ] || {
+ note "WARN: $LIVE_DIR/fullchain.pem missing; not publishing"
+ return 0
+ }
+ cp "$LIVE_DIR/fullchain.pem" "$OUT/server.crt"
+ cp "$LIVE_DIR/privkey.pem" "$OUT/server.key"
+ chmod 644 "$OUT/server.crt"
+ chmod 600 "$OUT/server.key"
+}
+
+# Idempotency check for `issue`: is the current server.crt already a
+# Let's Encrypt cert for with > 30 days left?
+already_valid_for() {
+ domain="$1"
+ [ -s "$OUT/server.crt" ] || return 1
+ openssl x509 -in "$OUT/server.crt" -noout -checkend 2592000 >/dev/null 2>&1 || return 1
+ openssl x509 -in "$OUT/server.crt" -noout -issuer 2>/dev/null \
+ | grep -q "Let's Encrypt" || return 1
+ openssl x509 -in "$OUT/server.crt" -noout -ext subjectAltName 2>/dev/null \
+ | grep -q "DNS:$domain" || return 1
+ return 0
+}
+
+cmd="${1-}"
+[ -n "$cmd" ] || usage
+shift
+
+case "$cmd" in
+ issue)
+ [ $# -ge 2 ] || usage
+ DOMAIN="$1"; shift
+ EMAIL="$1"; shift
+ FORCE=0
+ if [ $# -gt 0 ]; then
+ case "$1" in
+ --force) FORCE=1; shift ;;
+ *) usage ;;
+ esac
+ fi
+ [ $# -eq 0 ] || usage
+
+ if [ "$FORCE" = "0" ] && already_valid_for "$DOMAIN"; then
+ note "ok: server.crt is LE-issued for $DOMAIN; >30 days remaining"
+ publish_cert # ensures perms even if file already present
+ exit 0
+ fi
+
+ note "requesting cert from Let's Encrypt PROD for $DOMAIN"
+ certbot certonly --standalone \
+ --non-interactive --agree-tos -m "$EMAIL" \
+ --cert-name "$CERT_NAME" \
+ -d "$DOMAIN"
+
+ publish_cert
+ note "issued; fingerprint:"
+ openssl x509 -in "$OUT/server.crt" -noout -fingerprint -sha256
+ openssl x509 -in "$OUT/server.crt" -noout -issuer
+ ;;
+
+ renew)
+ DRY=""
+ if [ $# -gt 0 ]; then
+ case "$1" in
+ --dry-run) DRY="--dry-run"; shift ;;
+ *) usage ;;
+ esac
+ fi
+ [ $# -eq 0 ] || usage
+
+ note "running certbot renew --cert-name $CERT_NAME${DRY:+ $DRY}"
+ # shellcheck disable=SC2086
+ certbot renew --cert-name "$CERT_NAME" $DRY
+
+ if [ -z "$DRY" ]; then
+ publish_cert
+ fi
+ ;;
+
+ *)
+ usage
+ ;;
+esac
diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile
new file mode 100644
index 0000000..bd912e2
--- /dev/null
+++ b/docker/nginx/Dockerfile
@@ -0,0 +1,11 @@
+FROM nginx:1-alpine
+
+# Limit envsubst to the augchatd template var. Without this filter, the
+# image substitutes every env var present in the container, which would
+# clobber nginx's own $host/$proxy_add_x_forwarded_for/$ssl_client_verify
+# references if any of those names ever leaked into the environment.
+ENV NGINX_ENVSUBST_FILTER_VARS=AUGCHATD_DOMAIN
+
+COPY nginx.conf.template /etc/nginx/templates/augchatd.conf.template
+COPY entrypoint.sh /docker-entrypoint.d/00-require-certs.sh
+RUN chmod +x /docker-entrypoint.d/00-require-certs.sh
diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh
new file mode 100644
index 0000000..4e64bdf
--- /dev/null
+++ b/docker/nginx/entrypoint.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Refuse to boot nginx if any of the certs the augchatd config references
+# is missing. Hard-failing here is the gate that satisfies requirement #3
+# (proxy only starts if a cert is available) and — combined with the
+# compose healthcheck — requirement #4 (augchatd only starts if the proxy
+# starts successfully).
+
+set -e
+
+CERT_DIR=/etc/ssl/augchatd
+for f in server.crt server.key clients-ca.crt; do
+ if [ ! -s "$CERT_DIR/$f" ]; then
+ echo "nginx: required cert missing at $CERT_DIR/$f" >&2
+ echo "Run: docker compose run --rm cert-init " >&2
+ exit 1
+ fi
+done
diff --git a/docker/nginx/nginx.conf.template b/docker/nginx/nginx.conf.template
new file mode 100644
index 0000000..803f1ae
--- /dev/null
+++ b/docker/nginx/nginx.conf.template
@@ -0,0 +1,78 @@
+# augchatd's mTLS-protected control plane — containerized variant.
+#
+# Two server blocks (per adr-0012-out-of-process-tls):
+# - port 443: browser-facing TLS; mTLS headers stripped, proxied to /
+# - port 8443: mTLS-verified control plane; X-Client-Cert-Verify +
+# X-Client-Cert-Subject forwarded to /sessions only.
+#
+# Implementation-specific shape (per adr-0014-docker-compose-prod-deployment):
+# - server_name is parameterized by ${AUGCHATD_DOMAIN} (envsubst at boot)
+# - proxy_pass targets the docker service `augchatd:8080` via the
+# embedded docker resolver (127.0.0.11), with the upstream held in a
+# variable so the lookup happens at request time, not boot. This is
+# deliberate: compose blocks augchatd's start until nginx is healthy
+# (depends_on: service_healthy), so the upstream hostname does NOT
+# exist at nginx boot. A literal `proxy_pass http://augchatd:8080`
+# deadlocks. augchatd's port stays off the host; the docker network
+# is the back-channel adr-0012-out-of-process-tls requires.
+#
+# augchatd consumes X-Client-Cert-Verify / X-Client-Cert-Subject from this
+# proxy and trusts them only when TRUSTED_PROXY=true. The header trust
+# is declarative — nothing else verifies origin. Keep augchatd off the
+# public network.
+
+resolver 127.0.0.11 valid=10s ipv6=off;
+resolver_timeout 5s;
+
+# Browser-facing JWT API.
+server {
+ listen 443 ssl;
+ http2 on;
+ server_name ${AUGCHATD_DOMAIN};
+
+ ssl_certificate /etc/ssl/augchatd/server.crt;
+ ssl_certificate_key /etc/ssl/augchatd/server.key;
+
+ location / {
+ set $augchatd_upstream http://augchatd:8080;
+ proxy_pass $augchatd_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto https;
+ # Strip any inbound impersonation of the mTLS headers — the
+ # browser-facing leg never carries a verified client cert.
+ proxy_set_header X-Client-Cert-Verify "";
+ proxy_set_header X-Client-Cert-Subject "";
+ # SSE / streaming for chat tokens.
+ proxy_buffering off;
+ proxy_read_timeout 300s;
+ }
+}
+
+# mTLS control-plane (POST /sessions, DELETE /sessions/:id).
+server {
+ listen 8443 ssl;
+ http2 on;
+ server_name ${AUGCHATD_DOMAIN};
+
+ ssl_certificate /etc/ssl/augchatd/server.crt;
+ ssl_certificate_key /etc/ssl/augchatd/server.key;
+
+ ssl_client_certificate /etc/ssl/augchatd/clients-ca.crt;
+ ssl_verify_client on;
+ ssl_verify_depth 2;
+
+ location /sessions {
+ set $augchatd_upstream http://augchatd:8080;
+ proxy_pass $augchatd_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Client-Cert-Verify $ssl_client_verify;
+ proxy_set_header X-Client-Cert-Subject $ssl_client_s_dn;
+ }
+
+ location / { return 404; }
+}
diff --git a/package.json b/package.json
index ac710a4..68734a0 100644
--- a/package.json
+++ b/package.json
@@ -12,17 +12,18 @@
"dev:ui": "cd ui && bun run dev"
},
"dependencies": {
- "@ai-sdk/anthropic": "latest",
+ "@ai-sdk/anthropic": "^3.0.79",
"@ai-sdk/openai": "^3.0.65",
"@aws-sdk/client-s3": "^3.1054.0",
"@modelcontextprotocol/sdk": "^1.29.0",
- "ai": "latest",
- "hono": "^4.6.0",
+ "@streamdown/cjk": "^1.0.3",
+ "ai": "^6.0.191",
+ "hono": "^4.12.23",
"zod": "^4.4.3"
},
"devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.5.0"
+ "@types/bun": "^1.3.14",
+ "typescript": "^6.0.3"
},
"engines": {
"bun": ">=1.1.0"
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755
index 0000000..9a52812
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,242 @@
+#!/usr/bin/env bash
+# deploy.sh — install dependencies, configure the firewall, generate
+# secrets, and boot the augchatd stack on an Ubuntu/Debian VPS.
+#
+# Assumes:
+# - The augchatd repo is already on the VPS (git clone / scp / rsync).
+# - DNS for already points at this VPS (or you'll do that next).
+#
+# Usage:
+# sudo ./scripts/deploy.sh
+# sudo ./scripts/deploy.sh --letsencrypt
+#
+# Without --letsencrypt: nginx serves the self-signed bundle from cert-init.
+# With --letsencrypt: the bundle's server.{crt,key} are then overwritten
+# by a Let's Encrypt PROD cert (no staging mode —
+# see adr-0015), and a systemd timer is installed
+# to renew weekly.
+#
+# Idempotent: every step checks current state before changing anything.
+# Safe to re-run after a partial failure, or to update the domain.
+
+set -euo pipefail
+
+# Re-exec under sudo if the caller forgot. -E preserves env so $HOME/$USER
+# stay coherent for any logging that happens before we lose them.
+[ "$EUID" -eq 0 ] || exec sudo -E "$0" "$@"
+
+usage() {
+ echo "Usage:" >&2
+ echo " sudo ./scripts/deploy.sh " >&2
+ echo " sudo ./scripts/deploy.sh --letsencrypt " >&2
+ exit 64
+}
+
+[ $# -ge 1 ] || usage
+DOMAIN="$1"; shift
+LETSENCRYPT_EMAIL=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --letsencrypt)
+ [ $# -ge 2 ] || usage
+ LETSENCRYPT_EMAIL="$2"
+ shift 2
+ ;;
+ *) usage ;;
+ esac
+done
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$REPO_ROOT"
+
+step() { echo; echo "==> $*"; }
+
+# ---------------------------------------------------------------------------
+# [1] apt prerequisites
+# ---------------------------------------------------------------------------
+step "Installing apt prerequisites (ca-certificates, curl, gnupg, openssl, ufw, jq)"
+DEBIAN_FRONTEND=noninteractive apt-get update -qq
+DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
+ ca-certificates curl gnupg openssl ufw jq
+
+# ---------------------------------------------------------------------------
+# [2] Docker Engine + Compose plugin via the official docker.com apt repo.
+# Skip everything if `docker compose version` already responds.
+# ---------------------------------------------------------------------------
+if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
+ step "Installing Docker Engine + Compose plugin"
+ . /etc/os-release
+ case "$ID" in
+ ubuntu|debian) ;;
+ *) echo "Unsupported OS: $ID. This script supports Ubuntu/Debian only." >&2
+ exit 1 ;;
+ esac
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL "https://download.docker.com/linux/$ID/gpg" \
+ -o /etc/apt/keyrings/docker.asc
+ chmod a+r /etc/apt/keyrings/docker.asc
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$ID ${VERSION_CODENAME} stable" \
+ > /etc/apt/sources.list.d/docker.list
+ apt-get update -qq
+ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
+ docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+else
+ step "Docker already installed: $(docker --version)"
+fi
+
+# ---------------------------------------------------------------------------
+# [3] Enable docker at boot so `restart: unless-stopped` actually restores
+# the stack after a host reboot.
+# ---------------------------------------------------------------------------
+step "Enabling docker daemon at boot"
+systemctl enable --now docker
+
+# ---------------------------------------------------------------------------
+# [4] UFW: deny incoming by default, allow only the ports the stack needs.
+# 22 = SSH, 80 = HTTP (used briefly by letsencrypt-init standalone for
+# ACME HTTP-01), 443 = browser TLS, 8443 = mTLS control plane.
+# ---------------------------------------------------------------------------
+step "Configuring UFW (22, 80, 443, 8443)"
+ufw --force enable >/dev/null
+ufw default deny incoming >/dev/null
+ufw default allow outgoing >/dev/null
+ufw allow 22/tcp comment 'ssh' >/dev/null
+ufw allow 80/tcp comment 'http (letsencrypt http-01)' >/dev/null
+ufw allow 443/tcp comment 'augchatd browser TLS' >/dev/null
+ufw allow 8443/tcp comment 'augchatd mTLS control plane' >/dev/null
+
+# ---------------------------------------------------------------------------
+# [5] .env — generate JWT secret on first run; PRESERVE it on reruns
+# (regenerating would invalidate every open JWT session — irreversible).
+# Only AUGCHATD_DOMAIN is updated to match the argument.
+# ---------------------------------------------------------------------------
+ENV_FILE="$REPO_ROOT/.env"
+if [ -s "$ENV_FILE" ] && grep -q '^AUGCHATD_JWT_SECRET=' "$ENV_FILE"; then
+ step ".env present — preserving AUGCHATD_JWT_SECRET; syncing AUGCHATD_DOMAIN"
+ if grep -q '^AUGCHATD_DOMAIN=' "$ENV_FILE"; then
+ sed -i "s|^AUGCHATD_DOMAIN=.*|AUGCHATD_DOMAIN=$DOMAIN|" "$ENV_FILE"
+ else
+ echo "AUGCHATD_DOMAIN=$DOMAIN" >> "$ENV_FILE"
+ fi
+else
+ step "Generating .env with a fresh JWT secret"
+ JWT_SECRET=$(openssl rand -hex 32)
+ cat > "$ENV_FILE" < "$tmp_svc" < "$tmp_timer" <<'EOF'
+[Unit]
+Description=Weekly augchatd Let's Encrypt renewal
+
+[Timer]
+OnCalendar=Sun 03:00
+RandomizedDelaySec=1h
+Persistent=true
+
+[Install]
+WantedBy=timers.target
+EOF
+
+ if ! cmp -s "$tmp_svc" "$svc_path"; then
+ install -m 0644 "$tmp_svc" "$svc_path"
+ changed=1
+ fi
+ if ! cmp -s "$tmp_timer" "$timer_path"; then
+ install -m 0644 "$tmp_timer" "$timer_path"
+ changed=1
+ fi
+ if [ "$changed" = "1" ]; then
+ systemctl daemon-reload
+ fi
+ systemctl enable --now augchatd-letsencrypt-renew.timer
+}
+
+if [ -n "$LETSENCRYPT_EMAIL" ]; then
+ step "Issuing/refreshing Let's Encrypt cert for $DOMAIN ($LETSENCRYPT_EMAIL)"
+ docker compose run --rm --service-ports letsencrypt-init \
+ issue "$DOMAIN" "$LETSENCRYPT_EMAIL"
+ docker compose exec -T nginx nginx -s reload
+
+ step "Installing systemd timer for weekly cert renewal"
+ install_systemd_renewal
+fi
+
+# ---------------------------------------------------------------------------
+# [8] Wait for nginx healthy, then smoke-test through it.
+# ---------------------------------------------------------------------------
+step "Waiting for nginx healthy (up to 60s)"
+state=""
+for _ in $(seq 1 60); do
+ state=$(docker compose ps --format json nginx 2>/dev/null \
+ | jq -r 'if type=="array" then .[0].Health else .Health end // empty' \
+ 2>/dev/null || true)
+ [ "$state" = "healthy" ] && break
+ sleep 1
+done
+if [ "$state" != "healthy" ]; then
+ echo "nginx did not become healthy in 60s. Inspect: docker compose logs nginx" >&2
+ exit 1
+fi
+
+if [ -n "$LETSENCRYPT_EMAIL" ]; then
+ step "Smoke test: GET https://$DOMAIN/healthz (browser-trusted cert expected)"
+ smoke_url="https://$DOMAIN/healthz"
+ smoke_args=""
+else
+ step "Smoke test: GET https://localhost/healthz (self-signed)"
+ smoke_url="https://localhost/healthz"
+ smoke_args="-k"
+fi
+# shellcheck disable=SC2086
+body=$(curl -sS $smoke_args "$smoke_url" || true)
+echo " response: $body"
+echo "$body" | grep -q '"mode":"prod"' || {
+ echo "Unexpected /healthz response — investigate via 'docker compose logs augchatd'." >&2
+ exit 1
+}
+
+step "Done. Stack:"
+docker compose ps
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..f3df038
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,59 @@
+{
+ "version": 1,
+ "skills": {
+ "assistant-ui": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/assistant-ui/SKILL.md",
+ "computedHash": "cfce3ce8c4c2da38f0fc8e881af677efa8166d379862541f79c54e66f1a599d5"
+ },
+ "cloud": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/cloud/SKILL.md",
+ "computedHash": "cf6312de5be25e06c83c5a7012499686f6f2bb542594087c681cc67f9902b30c"
+ },
+ "primitives": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/primitives/SKILL.md",
+ "computedHash": "acdb5ddbdd066abf513a3f729198cee8ad79d03b18ba3329e609658d88bc09ee"
+ },
+ "runtime": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/runtime/SKILL.md",
+ "computedHash": "7aac755d83b8396ef149320fb5a8ad4073a828fafbbc6a759de0da4b78bb99c2"
+ },
+ "setup": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/setup/SKILL.md",
+ "computedHash": "283c7cbc08cbc1a3b58307083f505a24fcc8b5e5621608d6e1fb88034b1afce0"
+ },
+ "streaming": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/streaming/SKILL.md",
+ "computedHash": "31a090f9360343967f1189559c2b24da7eeacbf5b1b1c503c57c98c71106e326"
+ },
+ "thread-list": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/thread-list/SKILL.md",
+ "computedHash": "aaa49c0f8c1a3c5cf37498c8019f7e0d4f614053b17728593a27755b663e9d7c"
+ },
+ "tools": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/tools/SKILL.md",
+ "computedHash": "55a64b569cf4eac3bc8877e216e401af6b40d296e5180eb71174ac80b68d670d"
+ },
+ "update": {
+ "source": "assistant-ui/skills",
+ "sourceType": "github",
+ "skillPath": "assistant-ui/skills/update/SKILL.md",
+ "computedHash": "9ce8f6ff9d76fb1fa3101da8240b805da09836bf6aad9a1bc3f671060de461a7"
+ }
+ }
+}
diff --git a/spec/src/architecture/adrs/0009-react-vite-bundled-ui.md b/spec/src/architecture/adrs/0009-react-vite-bundled-ui.md
index 8a5f806..70c47c2 100644
--- a/spec/src/architecture/adrs/0009-react-vite-bundled-ui.md
+++ b/spec/src/architecture/adrs/0009-react-vite-bundled-ui.md
@@ -67,3 +67,18 @@ The UI subproject landed on branch `impl-demo-mode` with the following stack. Re
- **Component primitives: `@assistant-ui/react` primitives composed manually** (`ThreadPrimitive`, `MessagePrimitive`, `ComposerPrimitive`, etc.). No Radix / React Aria wrappers — the bundled UI styles the primitives directly with Tailwind utilities.
- **Routing: client-side via `window.history.replaceState`** (no `react-router` — the convention `/c/` is the entire surface; see [contract-ui-handshake#augchatd:route](../../contracts/browser-postmessage.md)).
- **Markdown rendering: `react-markdown` + `remark-gfm` + `remark-math` + `rehype-katex` + `rehype-highlight` + `rehype-raw` + `rehype-sanitize`** (with an extended schema allowing inline SVG). See [contract-ui-rendering](../../behavior/contracts/ui-rendering.md) for the full renderer catalog.
+
+> [!IMPORTANT] PENDING RECONCILIATION — shadcn layering on top of primitives
+> The bullet "**Component primitives: `@assistant-ui/react` primitives composed manually** … No Radix / React Aria wrappers — the bundled UI styles the primitives directly with Tailwind utilities" is partially outdated after the ThreadList refactor:
+>
+> - The sidebar shell is now `ThreadListSidebar` from the assistant-ui shadcn registry, copied into `ui/src/components/assistant-ui/threadlist-sidebar.tsx` and customized in place.
+> - It renders `` (also from the registry, in `thread-list.tsx`) which wraps `ThreadListPrimitive` / `ThreadListItemPrimitive` / `ThreadListItemMorePrimitive` with shadcn `Button`, `Skeleton`, and `Sidebar*` shells.
+> - Those shadcn components are built on Radix Primitives (`@radix-ui/react-*`), so the "No Radix" clause is no longer literally true.
+> - Thread-state ownership moved from custom App-level callbacks (`ConversationList.tsx`, `App.tsx:newConversation/switchConversation/deleteConversation`) into assistant-ui via `useRemoteThreadListRuntime` + a `RemoteThreadListAdapter` (`ui/src/lib/threadListAdapter.tsx`) that targets the same `/conversations*` REST surface.
+>
+> Proposed direction: **update the spec** — promote the layering to an explicit choice. Two options:
+>
+> 1. Rewrite this bullet to read: "Component primitives: `@assistant-ui/react` primitives composed manually, **with the `Thread*` family for the chat surface and the assistant-ui shadcn registry components (`threadlist-sidebar`, `thread-list`) for the thread list — those wrap `Sidebar` / `Button` / `Skeleton` from shadcn, which use Radix under the hood**. The bundled UI customizes installed registry files in place rather than re-skinning at the consumer side."
+> 2. Or, spin a fresh ADR (e.g. `0012-shadcn-for-non-thread-ui.md`) since "we now consume two registries (assistant-ui + shadcn)" is a coordination decision (component upgrades, customization model) and not just a styling tweak.
+>
+> Decision deferred to a human review pass.
diff --git a/spec/src/architecture/adrs/0012-out-of-process-tls.md b/spec/src/architecture/adrs/0012-out-of-process-tls.md
new file mode 100644
index 0000000..46704e6
--- /dev/null
+++ b/spec/src/architecture/adrs/0012-out-of-process-tls.md
@@ -0,0 +1,66 @@
+---
+id: adr-0012-out-of-process-tls
+type: adr
+status: proposed
+evidence:
+ - source: docker/nginx/nginx.conf.template
+ section: "two-server-block proxy (443 browser, 8443 mTLS)"
+ - source: src/mtls-trust.ts
+ section: "requireMtlsTrust middleware"
+ - source: src/identity.ts
+ section: "requireIdentity middleware"
+links:
+ - relation: supports
+ target: constraint-security
+ - relation: supports
+ target: contract-session-create
+---
+
+# ADR-0012 — TLS is terminated out-of-process
+
+## Context
+
+Constraint [security](../../constraints/security.md) and contract [session-create](../../behavior/contracts/session-create.md) require the control-plane endpoints (`POST /sessions`, `DELETE /sessions/:id`) to be authenticated by **mTLS**: the client (the integrator's backend) proves identity with a certificate signed by a CA the augchatd deployment trusts.
+
+The runtime we picked in [adr-0007-bun-hono-typescript](0007-bun-hono-typescript.md) is **Bun**. Bun's `Bun.serve` supports `tls: { requestCert, rejectUnauthorized, ca, ... }` at listen time, but Bun **does not expose the peer's certificate to the request handler** at the time of this ADR (see upstream issues oven-sh/bun#12822 and oven-sh/bun#16254). Without per-request access to the cert's Subject DN, augchatd cannot map an incoming session-create call to `{ tenantId, userId }`.
+
+## Decision
+
+augchatd does not terminate TLS. A reverse proxy (the deployment's choice; we ship a sample for **nginx**) sits in front of augchatd and performs the mTLS handshake. After validating the client certificate against its CA bundle, the proxy forwards two headers to augchatd over a private back-channel (loopback, unix socket, or a tightly-firewalled internal network):
+
+```
+X-Client-Cert-Verify: SUCCESS
+X-Client-Cert-Subject: CN=alice,OU=engineering,O=acme
+```
+
+augchatd consumes these via two middlewares — [`requireMtlsTrust`](../../../../src/mtls-trust.ts) (gates on `Verify == "SUCCESS"`, parses the Subject DN) and [`requireIdentity`](../../../../src/identity.ts) (maps `O` → `tenantId`, `CN` → `userId`, validates the alphabet).
+
+The middlewares are only mounted when **`TRUSTED_PROXY=true`** is set on the augchatd process. This env flag is the operator's explicit declaration that augchatd is reachable only through the proxy — without it, the headers carry no proof and the chain is fail-closed (the routes 404).
+
+## Consequences
+
+**+** Sidesteps the Bun limitation immediately. No patch, no FFI, no socket sniffing.
+
+**+** The boundary is explicit and inspectable: the nginx config is the single, reviewable place where mTLS happens. Operators already deploy nginx in front of services and know how to harden it.
+
+**+** Identity extraction (subject DN parsing) is a small, library-style module that we can test in isolation.
+
+**+** Browser-facing TLS (the `/chat`, `/conversations/*` routes that take a JWT) lands on the same proxy with a regular TLS leg — no separate ingress story.
+
+**−** The operator must deploy and harden the reverse proxy. We accept this; mTLS deployments are operationally complex regardless of where TLS lives.
+
+**−** Header forgery is a single misconfig away (anything that can reach augchatd's port can claim `SUCCESS`). The `TRUSTED_PROXY=true` flag is a declarative guard, not a verification. Operators MUST bind augchatd to loopback / unix socket / private network, never expose port 8080 to the public internet.
+
+**−** This ADR commits us to *any* mTLS-terminating proxy that can forward two headers — we picked nginx for the sample, but Caddy / Envoy / Traefik all work. Tracking the "Bun gains per-request peer-cert access" upstream issue lets us reconsider in-process termination later without rewriting the identity layer.
+
+## Alternatives considered
+
+1. **In-process termination via `Bun.serve` + FFI to read peer cert.** Rejected: fragile, no stable Bun API for this, and a Bun upgrade could break the daemon silently.
+
+2. **Patch Bun.** Out of scope for this project.
+
+3. **Switch runtime to Node + the built-in `https` module** (which exposes `request.socket.getPeerCertificate()`). Rejected: would invalidate [adr-0007-bun-hono-typescript](0007-bun-hono-typescript.md) and the bundled-UI single-binary story (Bun's embedding of static assets is part of the value proposition).
+
+## When this decision could change
+
+When Bun exposes the peer certificate to the request handler with a stable API, we may move TLS termination back in-process and delete the reverse-proxy requirement. The identity-extraction middleware would survive — the source of `mtlsSubject` would just change from a header to a Bun API.
diff --git a/spec/src/architecture/adrs/0013-model-id-is-server-persistent.md b/spec/src/architecture/adrs/0013-model-id-is-server-persistent.md
new file mode 100644
index 0000000..f462428
--- /dev/null
+++ b/spec/src/architecture/adrs/0013-model-id-is-server-persistent.md
@@ -0,0 +1,54 @@
+---
+id: adr-0013-model-id-is-server-persistent
+type: adr
+status: current
+evidence:
+ - source: src/routes/conversations.ts
+ section: "setConversationModelHandler"
+ - source: ui/src/ComposerOptionsMenu.tsx
+ section: "pickModel"
+links:
+ - relation: supports
+ target: contract-session-chat
+ - relation: supports
+ target: contract-ui-rendering
+---
+
+# ADR-0013 — `model_id` is per-conversation state, persisted server-side
+
+## Context
+
+augchatd allows the end-user to switch the LLM model mid-conversation. The UI shows a model picker in the composer; the next turn after a switch goes to the newly-selected model. The selection has to survive page reloads — opening `/c/` after F5 should put the picker on the same model the user chose before.
+
+assistant-ui ships an official `ModelSelector` shadcn component (see [doc](https://www.assistant-ui.com/docs/ui/model-selector)) that wires through its `ModelContext` system: the component calls `aui.modelContext().register()`, and the `AssistantChatTransport` adds the selected `id` to the `/chat` request body as `config.modelName`. The backend then reads `config.modelName` to pick the provider/model.
+
+This is the canonical assistant-ui pattern. It also conflicts with how augchatd persists state.
+
+## Decision
+
+The `model_id` is **per-`conversation_id` state, owned by the server and persisted in SQLite**. The UI is responsible only for surfacing the picker and writing the user's choice to the server before the next turn.
+
+Concretely:
+
+- **Source of truth**: the `conversations.model_id_override` column in the per-session SQLite database. The handler that mutates it is [`setConversationModelHandler` in `src/routes/conversations.ts`](../../../../src/routes/conversations.ts) (`PUT /conversations/:cid/model`, body `{ "model_id": "" }`, returns `204`).
+- **Read path**: `GET /conversations/:cid/model` returns the current selection (falling back to the session default).
+- **UI**: [`ui/src/ComposerOptionsMenu.tsx`](../../../../ui/src/ComposerOptionsMenu.tsx) reads via `GET`, writes via `PUT`. The `/chat` body does **not** carry the model id.
+- **Backend chat handler**: [`src/routes/chat.ts`](../../../../src/routes/chat.ts) calls `llmFor(session, modelId)` where `modelId` is read from the database at the start of each turn (not from the request body).
+
+## Alternatives considered
+
+### A. assistant-ui `ModelSelector` + `config.modelName` (the upstream-default path)
+
+Adopt the shadcn `model-selector`, register through `aui.modelContext()`, and let `AssistantChatTransport` serialize the selection into `config.modelName` on the `/chat` body.
+
+**Rejected.** The mode is non-durable: after a reload the client has no idea what was selected last, and the next turn defaults to whatever the runtime initialized to. To recover, the UI would need to fetch the last choice from somewhere before sending the next `/chat`, which means we'd be persisting server-side anyway *and also* shipping the choice in the body — two sources of truth.
+
+### B. Persist client-side (localStorage / cookie)
+
+Same durability gap as (A) once the user opens the conversation from a different browser, device, or after clearing storage. Also splits the choice across machines, which is undesirable when the integrator's backend is the actual identity boundary (the mTLS-provisioned session has one user; that user should see the same selection everywhere).
+
+## Consequences
+
+- We **do not adopt** `@assistant-ui/ui/model-selector` shadcn block. [`ui/src/ComposerOptionsMenu.tsx`](../../../../ui/src/ComposerOptionsMenu.tsx) remains the canonical UI for model + reasoning toggle. If the assistant-ui upstream adds a config knob to make `ModelSelector` purely presentational (without the `modelContext().register()` side effect), we can revisit.
+- Any future toolbar/control surfacing must follow the same pattern: write to the conversations row, read back on next session/page load.
+- The `/chat` request body stays minimal (`messages`, `trigger`, `messageId`, `id` = `conversation_id`). Auth is in the `Authorization` header. There's no per-request runtime config from the client.
diff --git a/spec/src/architecture/adrs/0014-docker-compose-prod-deployment.md b/spec/src/architecture/adrs/0014-docker-compose-prod-deployment.md
new file mode 100644
index 0000000..03b36f3
--- /dev/null
+++ b/spec/src/architecture/adrs/0014-docker-compose-prod-deployment.md
@@ -0,0 +1,84 @@
+---
+id: adr-0014-docker-compose-prod-deployment
+type: adr
+status: proposed
+evidence:
+ - source: Dockerfile
+ section: "multi-stage Bun build (ui-builder + deps + runtime)"
+ - source: docker-compose.yml
+ section: "augchatd + nginx + cert-init services"
+ - source: docker/nginx/Dockerfile
+ section: "nginx image with cert-required entrypoint"
+ - source: docker/nginx/nginx.conf.template
+ section: "two-server-block proxy: 443 browser TLS + 8443 mTLS"
+ - source: docker/cert-init/gen-certs.sh
+ section: "idempotent self-signed cert bundle"
+links:
+ - relation: supports
+ target: adr-0012-out-of-process-tls
+ - relation: supports
+ target: constraint-security
+---
+
+# ADR-0014 — Production deployment is a docker-compose stack
+
+## Context
+
+Before this ADR, the only documented production path was a sample nginx config plus a yet-to-be-published `augchatd/augchatd` image referenced from `README.md`. That left every operator reassembling four moving pieces by hand: nginx config rendered with the right `server_name`, a server cert + private key, a clients-CA for the mTLS leg, and a JWT secret. The most common foot-guns — accidentally exposing `:8080` to the public, starting nginx without certs, booting augchatd without `TRUSTED_PROXY` — were enforced only by documentation.
+
+[ADR-0012](0012-out-of-process-tls.md) is the constraint this ADR builds on: augchatd cannot terminate TLS itself, so any deploy must couple augchatd with a TLS-terminating proxy and never expose augchatd's HTTP port. The compose-level wiring is the smallest mechanism that turns those words into a repeatable boot.
+
+## Decision
+
+augchatd ships a `docker-compose.yml` at the repo root with three services:
+
+1. **`augchatd`** — built from a multi-stage Bun `Dockerfile` (UI build + runtime). Runs in mode `prod` with `TRUSTED_PROXY=true`. **No ports are published** to the host; the only reachable network endpoint is via the `augchatd_net` bridge, where nginx terminates TLS in front of it. `AUGCHATD_JWT_SECRET` is required from the environment (and validated by [`src/env.ts`](../../../../src/env.ts) as ≥ 32 chars + not a known placeholder).
+
+2. **`nginx`** — built from `docker/nginx/`. Two-server-block shape: 443 terminates browser TLS and forwards to augchatd with the mTLS headers stripped; 8443 terminates mTLS and forwards `X-Client-Cert-Verify` + `X-Client-Cert-Subject` to `/sessions` only. `server_name` parameterized by `AUGCHATD_DOMAIN` (rendered by the official image's `envsubst` template support); `proxy_pass` targets the docker service `augchatd:8080`; a `resolver 127.0.0.11` directive plus an upstream held in a `$augchatd_upstream` variable defers DNS for `augchatd` to *request* time rather than nginx boot. The variable indirection is load-bearing: because `depends_on: condition: service_healthy` blocks augchatd's startup until nginx is healthy, augchatd's hostname does not yet exist in docker's embedded DNS when nginx parses its config. A literal `proxy_pass http://augchatd:8080` deadlocks at boot ("host not found in upstream"). Mounts `./docker/certs` read-only at `/etc/ssl/augchatd`. The image's `00-require-certs.sh` entrypoint hook aborts with a non-zero exit if any of `server.crt`, `server.key`, `clients-ca.crt` is missing or empty.
+
+3. **`cert-init`** — built from `docker/cert-init/` (`alpine:3.20` + `openssl` + `gen-certs.sh`). Gated by `profiles: ["tools"]` so it never auto-starts with `docker compose up`. Invocation:
+
+ docker compose run --rm cert-init [--force]
+
+ Writes the full self-signed bundle (CA, server cert with SAN for `` + `localhost` + `127.0.0.1`, clients-CA, sample client cert with `/O=demo/CN=tester`) into the same `./docker/certs` directory the nginx service mounts. Idempotent per artifact: each file is regenerated only when missing, expiring within 30 days, or — for the server cert — when its SAN does not include the requested domain. `--force` regenerates everything.
+
+The dependency chain is encoded once in compose:
+
+```yaml
+depends_on:
+ nginx:
+ condition: service_healthy
+```
+
+This single line satisfies two of the user-facing requirements together: nginx is healthy iff its process is running with a valid config (`nginx -t && pidof nginx`), which is only possible if the cert files were present at boot. Hence "proxy starts only if a cert is available" and "augchatd starts only if the proxy starts successfully" reduce to one mechanism.
+
+This ADR is **prod-only**: the compose stack does not boot `AUGCHATD_MODE=demo`, does not mount `local/demo_session.json`, and does not expose `/demo/*`. Demo via Docker continues to work via the standalone `docker run` documented in `README.md`. Publishing the augchatd image to a registry is **out of scope** for this iteration.
+
+## Consequences
+
+**+** Zero host-side dependencies. No `openssl` on the operator's machine; the cert-init service brings its own. No nginx install; no per-OS config-path negotiation.
+
+**+** The TLS termination boundary stays exactly where [ADR-0012](0012-out-of-process-tls.md) put it. nginx alone holds the server key; augchatd cannot reach the public network even by accident — the compose file does not list a `ports:` block on it.
+
+**+** The dependency chain is declarative and reviewable in `docker-compose.yml`. There is no shell script wrapping `docker run` calls that has to reimplement healthcheck polling.
+
+**−** Operators who want Let's Encrypt (real, browser-trusted certs) cannot use `cert-init` as-is. They have to either extend it with an ACME mode or replace it with `certbot` orchestration. We accept this — Let's Encrypt requires port 80 reachability and a real DNS record, which is deployment-specific and would muddy this iteration.
+
+**−** The nginx healthcheck verifies the nginx process, not that augchatd is reachable through it. We chose this deliberately: a "GET /healthz through TLS" healthcheck would create a circular dependency (nginx healthy ⇒ augchatd up ⇒ … which depends on nginx healthy). The cost is that a misrouted upstream is invisible to compose's startup gate; operators verify end-to-end with `curl -k https://localhost/healthz` after `up` completes.
+
+**−** The compose stack is one more surface to keep in sync with the spec. When `src/env.ts` gains a new required env var, `docker-compose.yml` must learn about it; when `src/mtls-trust.ts` changes the header contract, `nginx.conf.template` must mirror it. The existing `/code-changed` routine in `CLAUDE.md` is the mechanism for that.
+
+## Alternatives considered
+
+1. **Two raw `Dockerfile`s plus a shell wrapper** (no compose). Rejected: the dependency chain (nginx healthy → augchatd starts) would have to be reimplemented with sleep+poll loops, and the "only this network can reach 8080" guarantee would devolve into an `--network` flag operators could forget.
+
+2. **Let's Encrypt as the default cert mechanism.** Rejected for this iteration: requires a publicly resolvable DNS record and port 80 reachable from the ACME server, which is fundamentally a deployment-environment decision. Pinning the default to self-signed keeps the bring-up offline; an `--letsencrypt` mode on the same `cert-init` script is a clean future extension.
+
+3. **A bind-mounted `gen-certs.sh` script run directly on the host.** Rejected per the user's explicit ask: the script should run as an ephemeral compose service so the host doesn't need `openssl` installed.
+
+4. **Compose stack also boots demo mode.** Rejected per the user's explicit ask. Demo's `local/demo_session.json` mount and `AUGCHATD_MODE=demo` env are different enough from prod that mixing them via env-overrides would muddy the "compose is the prod path" contract.
+
+## When this decision could change
+
+- When [ADR-0012](0012-out-of-process-tls.md) is itself revisited (Bun gains per-request peer-cert access), the nginx service may become optional, and the compose stack would collapse to a single service. The `cert-init` mechanism still makes sense as a self-signed-bundle generator until Let's Encrypt becomes the default.
+- When an official `augchatd/augchatd` image is published, the `build:` stanzas would migrate to `image:` pulls and the multi-stage Dockerfile would still drive the CI build.
diff --git a/spec/src/architecture/adrs/0015-letsencrypt-http01-standalone.md b/spec/src/architecture/adrs/0015-letsencrypt-http01-standalone.md
new file mode 100644
index 0000000..91da625
--- /dev/null
+++ b/spec/src/architecture/adrs/0015-letsencrypt-http01-standalone.md
@@ -0,0 +1,87 @@
+---
+id: adr-0015-letsencrypt-http01-standalone
+type: adr
+status: proposed
+evidence:
+ - source: docker/letsencrypt-init/Dockerfile
+ section: "alpine + certbot image"
+ - source: docker/letsencrypt-init/letsencrypt.sh
+ section: "issue / renew subcommands; LE PROD only"
+ - source: docker-compose.yml
+ section: "letsencrypt-init service (profiles: tools); augchatd_letsencrypt named volume"
+ - source: scripts/deploy.sh
+ section: "--letsencrypt flag; systemd timer install"
+links:
+ - relation: supports
+ target: adr-0014-docker-compose-prod-deployment
+ - relation: supports
+ target: constraint-security
+---
+
+# ADR-0015 — Let's Encrypt server cert via HTTP-01 standalone
+
+## Context
+
+After [ADR-0014](0014-docker-compose-prod-deployment.md), the only path to a server cert was the self-signed bundle produced by `docker/cert-init/gen-certs.sh`. That bundle is fine for local boot and for the **clients-CA** that gates the mTLS leg on port 8443 — but for the browser-facing leg on port 443 it forces every visitor through a "not trusted" warning and every operator through `curl -k`.
+
+Let's Encrypt issues real, browser-trusted certs via the ACME protocol. Two challenge types fit this stack:
+
+- **HTTP-01**: the ACME server hits `http:///.well-known/acme-challenge/...` over port 80. Two sub-flavors: **webroot** (challenge files served by an already-running web server) or **standalone** (certbot binds port 80 itself for the validation window).
+- **DNS-01**: the ACME server checks a `_acme-challenge.` TXT record. Required for wildcards; not needed for a single FQDN.
+
+The deploy script already opens port 80 in UFW, and the compose nginx never publishes 80 to the host. So nothing else is competing for the port — standalone mode is unblocked.
+
+## Decision
+
+A new compose service, **`letsencrypt-init`**, sits alongside the existing `cert-init` and **overwrites only `server.{crt,key}`** in `docker/certs/`. Everything else `cert-init` produces (`ca.crt`, `clients-ca.crt`, `client-sample.{crt,key}`) is untouched — LE does not issue CAs for verifying mTLS clients, so the port-8443 trust root stays local.
+
+Concrete shape:
+
+- **Image**: `alpine:3.20` + `apk add certbot`. No DNS plugin (we use HTTP-01).
+- **Mode**: `certbot certonly --standalone`. Webroot was rejected: it would require an extra port-80 server block in nginx purely for the validation window plus an unrelated HTTP→HTTPS redirect, and a shared bind-mount the operator now has to remember. Standalone keeps the change isolated to a single new service.
+- **Endpoint**: **Let's Encrypt PRODUCTION only**. No `--staging` mode and no flag to switch. The single code path is easier to reason about; staging certs would land in the same on-disk paths as prod ones, and an accidental left-over staging cert would silently serve a "Fake LE Intermediate" issuer to real browsers. The downside (rate-limit risk during operator mistakes) is mitigated by the `issue` subcommand's idempotency check: it skips the ACME call entirely when the current `server.crt` is already a Let's Encrypt cert for the requested domain with > 30 days left.
+- **Service posture**: `profiles: ["tools"]` so it never auto-starts with `docker compose up`. The compose declares `ports: ["80:80"]`, but `docker compose run` only publishes those ports when called with `--service-ports` — preventing an accidental `up` from holding port 80.
+- **Persistence**: `/etc/letsencrypt` lives in a named volume `augchatd_letsencrypt`. The ACME account key and the `renewal/augchatd.conf` config persist between runs and reinstalls of the working tree.
+- **Renewal**: a systemd `oneshot` service + weekly `timer` on the host, installed by `scripts/deploy.sh` when `--letsencrypt ` is passed. The service runs `docker compose run --rm --service-ports letsencrypt-init renew` and then `nginx -s reload`. `Persistent=true` on the timer covers the case where the VPS was off during the scheduled window. No webhook/email notification — operators read `journalctl -u augchatd-letsencrypt-renew.service`.
+
+The nginx config is **not** modified. nginx still listens only on 443 and 8443; there is no port-80 server block and **no HTTP→HTTPS redirect**. `http://` returns connection refused. Acceptable because the augchatd browser leg is loaded by integrators as an iframe with a hardcoded `https://` URL, and the mTLS leg is server-to-server — no organic browser traffic types `http://` for augchatd.
+
+If `letsencrypt-init issue` fails (DNS not pointed yet, port 80 blocked by something else, rate-limit hit), the stack is **not** left broken: `cert-init` has already laid down a working self-signed `server.{crt,key}`, so nginx serves that until the operator fixes the issue and reruns the deploy script.
+
+## Consequences
+
+**+** Real, browser-trusted cert with no extra moving parts: no DNS plugin, no extra port-80 server block in nginx, no shared bind-mount across services.
+
+**+** Concerns stay separated. `cert-init` keeps producing the immutable local bundle (CA + clients-CA + sample client + fallback server); `letsencrypt-init` is an optional outer layer that only touches `server.{crt,key}`. A user who never opts in pays nothing.
+
+**+** A failed ACME exchange does not take the stack down. The self-signed fallback is still on disk; nginx keeps serving.
+
+**+** The two service identities (cert-init vs letsencrypt-init) make the on-disk state predictable: an operator looking at `docker/certs/server.crt`'s issuer immediately knows which path produced it.
+
+**−** Nothing answers on `http://` — clients must use `https://`. We accept this; see Context.
+
+**−** Port 80 must be reachable from the public internet during issuance and renewals. A firewall change or an unrelated process holding port 80 breaks renewal. Surface area is small (renewal hits port 80 for seconds, weekly) but the failure mode exists.
+
+**−** Issuance hits the LE PROD endpoint directly. A bad operator-side test can consume the per-domain rate limit (50 certs/week). Mitigation: the `issue` subcommand is idempotent — reruns with a still-valid LE cert are no-ops; only `--force` skips that check. A staging mode would also mitigate but was deliberately excluded (single code path).
+
+**−** Renewal is wired through systemd on the host, not docker — so the deploy script gains a host-side mutation it must keep idempotent. The implementation uses `cmp` to avoid `daemon-reload` when the unit content has not changed.
+
+## Alternatives considered
+
+1. **Webroot HTTP-01** — rejected. Requires a port-80 server block in nginx with a `/.well-known/acme-challenge/` location and a shared bind-mount between certbot and nginx. More files to keep in sync; effectively forces a redundant HTTP→HTTPS redirect.
+
+2. **DNS-01** — rejected. Needs a per-provider certbot plugin (`python3-certbot-dns-cloudflare` etc.) plus stored API credentials. Useful only if wildcards are required, which this stack does not.
+
+3. **Caddy or Traefik with built-in ACME** — rejected. Replacing nginx invalidates the proxy design from ADR-0014 (two distinct server blocks, the `requireMtlsTrust` header contract, the `00-require-certs.sh` entrypoint). The boundary that ADR-0014 protects is exactly the one this ADR builds on.
+
+4. **Stay on self-signed forever** — rejected. UX cost in production is unacceptable (browser warnings, mandatory `-k` in tooling, third-party HTTP clients that won't trust an unknown CA at all).
+
+5. **Optional staging mode** — rejected (operator's explicit ask). The single code path is easier to reason about; a staging cert sitting in the same paths as prod is a foot-gun.
+
+6. **A separate `cert-init --letsencrypt` flag instead of a new service** — rejected. The runtime profile (alpine + openssl vs alpine + certbot + persistent ACME state) is different enough that bundling them grows `cert-init`'s image and conceptual scope.
+
+## When this decision could change
+
+- If wildcard certs become a requirement (sub-tenants on `.augchatd.example.com`), a sibling DNS-01 service makes more sense than retrofitting the standalone one.
+- If LE rate-limits become a recurring problem during operator iteration, an opt-in `--staging` path could be added — but only with a hard refusal to overwrite a current prod cert in `server.crt`, to keep the "what's on disk reflects what's in browsers" invariant.
+- If the augchatd image ever bundles its own ACME client (e.g. as an embedded `cert-magic`-style library), this whole service folds back into the daemon — but only after [ADR-0012](0012-out-of-process-tls.md) is reconsidered.
diff --git a/spec/src/architecture/components.md b/spec/src/architecture/components.md
index 72c9539..4303733 100644
--- a/spec/src/architecture/components.md
+++ b/spec/src/architecture/components.md
@@ -9,12 +9,13 @@ evidence:
# Components
-augchatd is a **single binary** that contains everything below.
+augchatd is a **single binary** that contains everything below. TLS is **not** terminated by augchatd itself — see [adr-0012-out-of-process-tls](adrs/0012-out-of-process-tls.md). A reverse proxy (the deployment's choice; we ship a containerized nginx setup at [`docker/nginx/`](../../../docker/nginx/), see [adr-0014](adrs/0014-docker-compose-prod-deployment.md)) terminates mTLS for the control-plane leg and forwards `X-Client-Cert-Verify` + `X-Client-Cert-Subject` to augchatd. The diagram below shows the augchatd process in isolation; in production it sits behind the reverse proxy.
```
augchatd process
├── HTTP API layer (Hono)
-│ ├── mTLS endpoints: POST /sessions, DELETE /sessions/:id
+│ ├── mTLS-protected endpoints: POST /sessions, DELETE /sessions/:id
+│ │ (mounted only when TRUSTED_PROXY=true; gated by requireMtlsTrust + requireIdentity)
│ ├── demo endpoints (mode=demo only):
│ │ GET /demo, /demo/* ← wrapper page (iframes the UI, runs the
│ │ postMessage handshake; wildcard so
@@ -86,3 +87,6 @@ See ADRs:
- [0008 — Demo mode shares the production binary](adrs/0008-demo-mode-shares-binary.md)
- [0009 — React + Vite bundled UI](adrs/0009-react-vite-bundled-ui.md)
- [0010 — Unified connector model](adrs/0010-unified-connector-model.md)
+- [0012 — TLS is terminated out-of-process](adrs/0012-out-of-process-tls.md)
+- [0014 — Production deployment is a docker-compose stack](adrs/0014-docker-compose-prod-deployment.md)
+- [0015 — Let's Encrypt server cert via HTTP-01 standalone](adrs/0015-letsencrypt-http01-standalone.md)
diff --git a/spec/src/behavior/contracts/rag-query.md b/spec/src/behavior/contracts/rag-query.md
index 5cde9c0..d686d3c 100644
--- a/spec/src/behavior/contracts/rag-query.md
+++ b/spec/src/behavior/contracts/rag-query.md
@@ -41,7 +41,7 @@ When a conversation has one or more **active RAG-type connectors** (active state
5. **Emits a `source-document` UI part per hit** into the chat stream, carrying `providerMetadata.augchatd = { source_descriptive_id, index, doc_id, score, snippet }`. The bundled UI renders each as a clickable chip beneath the assistant message — the LLM is therefore not expected to paste inline parenthetical citations (which become redundant noise).
> [!NOTE] Implementation pattern — per-part, not panel
-> An earlier attempt rendered RAG sources via a global `CitationsPanel` driven by a `useThread((t) => collectSources(t.messages))` selector — that selector returned a fresh array each render and tripped React error #185 ("Maximum update depth exceeded"). The shipped pattern emits the chips as ordinary message parts instead: the retrieve tool captures `RagHit[]` into a side-channel map keyed by `toolCallId` (`hitsByToolCall` in `src/rag.ts`); the chat handler's `onStepFinish` drains that map and writes one `source-document` `UIMessagePart` per hit into the stream. Each hit thus rides as a part on the assistant message it came from — the UI just renders parts in order, no thread-wide selectors needed.
+> An earlier attempt rendered RAG sources via a global `CitationsPanel` driven by a `useThread((t) => collectSources(t.messages))` selector — that selector returned a fresh array each render and tripped React error #185 ("Maximum update depth exceeded"). The shipped pattern emits the chips as ordinary message parts instead: the retrieve tool captures `RagHit[]` into a **per-session** side-channel map keyed by `toolCallId` (the session-owned `ragHitsByToolCall` Map; the retrieve tool's `execute` closure captures the session's Map at session-creation time, so credentials and intermediate results never leave the session boundary); the chat handler's `onStepFinish` drains that map and writes one `source-document` `UIMessagePart` per hit into the stream. Each hit thus rides as a part on the assistant message it came from — the UI just renders parts in order, no thread-wide selectors needed.
Scope is applied **before** query construction. The LLM cannot express a query that escapes the connector's `indexes[]`; the tool surface only exposes that set. RAG-type connectors **inactive for the current conversation** are not exposed to the LLM at the start of the turn (active state is per-conversation; see [contract-connector-toggle](connector-toggle.md)).
diff --git a/spec/src/behavior/contracts/reasoning-toggle.md b/spec/src/behavior/contracts/reasoning-toggle.md
new file mode 100644
index 0000000..0dccc13
--- /dev/null
+++ b/spec/src/behavior/contracts/reasoning-toggle.md
@@ -0,0 +1,69 @@
+---
+id: contract-reasoning-toggle
+type: behavior-contract
+status: proposed
+capability: cap-chat
+evidence: []
+links:
+ - relation: refines
+ target: contract-session-chat
+ - relation: depends_on
+ target: contract-storage-hot
+---
+
+# Contract — Reasoning toggle (per conversation)
+
+## Promise
+
+A conversation can opt out of having the LLM's internal reasoning surfaced to the user. The toggle is **per-conversation**, persisted in hot SQLite alongside `model_id_override`, and defaults to **enabled** (existing behavior).
+
+When **enabled** (default):
+
+- For reasoning-capable models (OpenAI `o[1-9]*` / `gpt-5*`; Anthropic `*opus* | *sonnet*`), augchatd sets the provider option that surfaces reasoning — OpenAI `{ reasoningSummary: "auto" }`, Anthropic `{ thinking: { type: "enabled", budgetTokens: 2048 } }`.
+- The chat stream emits `reasoning-*` UI parts the bundled UI renders as a collapsible "Reasoning" section.
+
+When **disabled**:
+
+- augchatd passes **no** reasoning-related provider option for that turn.
+- The chat stream contains no `reasoning-*` parts.
+- **Cost note (provider-specific):** Anthropic Opus/Sonnet no longer pay extended-thinking tokens (the API default is no thinking). OpenAI `o[1-9]` / `gpt-5` still incur `reasoning_tokens` server-side — those models always reason; disabling the toggle only suppresses the *summary stream*. The bundled UI surfaces this difference in a tooltip on the toggle so users do not mistake the toggle for a cost control on OpenAI.
+
+For models that are not reasoning-capable, the toggle has no effect and the bundled UI **hides** the toggle entirely.
+
+## HTTP surface
+
+- `GET /conversations/:cid/reasoning` → `200 { enabled: boolean }`. Implicitly creates the conversation (capture-on-first-observation) if it does not yet exist, mirroring `GET /conversations/:cid/connectors`.
+- `PUT /conversations/:cid/reasoning` body `{ enabled: boolean }` → `204` on success. Same `body_must_be_object` / `only_enabled_field_allowed` / `enabled_must_be_boolean` validation pattern as the connector and model PUTs. Same `503 X-Augchatd-Reason: hot-write-failed` on hot-DB write failure.
+
+Both require a valid JWT bearer (`requireSession`), and in demo mode are mounted on the same branch as the other per-conversation endpoints.
+
+## Capture rule
+
+- Read at the start of each `POST /chat` turn (alongside `resolveModelId`). A `PUT` arriving mid-turn does **not** affect the in-flight turn — same rule as connector toggles (see [contract-session-chat](session-chat.md), step 1).
+- The toggle state is **independent** of the model. Switching models on a conversation does **not** reset the toggle. If the user disables reasoning on a non-reasoning model and then switches to a reasoning model, the saved `disabled` state takes effect on the next turn.
+
+## Observable outcomes
+
+- Two sequential turns on the same `cid` with different toggle values produce streams with vs. without `reasoning-*` parts.
+- `PUT /conversations/:cid/reasoning { enabled: false }` then `POST /chat` against an OpenAI gpt-5 model: the response stream has no reasoning parts; the upstream request omits `reasoningSummary`.
+- `PUT /conversations/:cid/reasoning { enabled: false }` then `POST /chat` against an Anthropic Sonnet model: the response stream has no reasoning parts; the upstream request omits the `thinking` provider option.
+- `GET /conversations/:cid/reasoning` on a freshly-created conversation returns `{ enabled: true }`.
+- A reload of the bundled UI preserves the toggle state (hot-storage backed).
+
+## Non-promises
+
+- The toggle does not control how *the LLM* internally reasons — it only controls whether augchatd asks the provider to *surface* reasoning. For OpenAI o-series / gpt-5, the model continues to consume `reasoning_tokens` regardless.
+- The toggle does not affect cold-storage flushed history beyond the columns that already persist.
+- No analytics or audit-log entry beyond standard trace events.
+
+## Tests this contract implies
+
+- `GET /conversations/:cid/reasoning` on a new conversation → `200 { enabled: true }`.
+- `PUT /conversations/:cid/reasoning { enabled: false }` → `204`; subsequent `GET` returns `{ enabled: false }`.
+- `PUT` with `{ enabled: "no" }` → `400 enabled_must_be_boolean`.
+- `PUT` with extra fields → `400 only_enabled_field_allowed`.
+- After `PUT { enabled: false }`, a `POST /chat` turn on a reasoning model emits zero `reasoning-*` UI parts.
+- After `PUT { enabled: false }` then `PUT { enabled: true }`, a `POST /chat` turn on a reasoning model emits reasoning parts again.
+- A non-reasoning model + toggle disabled + a chat turn: behaves identically to a non-reasoning model with toggle enabled (no-op).
+- `GET /session/models` includes `supports_reasoning: boolean` per model — used by the UI to hide the toggle for unsupported models.
+- Hot-write failure on `PUT` → `503` with `X-Augchatd-Reason: hot-write-failed`.
diff --git a/spec/src/behavior/contracts/session-chat.md b/spec/src/behavior/contracts/session-chat.md
index d91b084..27e0f48 100644
--- a/spec/src/behavior/contracts/session-chat.md
+++ b/spec/src/behavior/contracts/session-chat.md
@@ -28,13 +28,14 @@ links:
Given a valid JWT, a target `conversation_id`, and an end-user message, augchatd runs a server-side **tool-use loop**:
1. **Snapshot** the **conversation's active connector set** at the start of the turn — read from the conversation's saved per-connector active flags, reconciled against the session's resolved scope (see [adr-0010](../../architecture/adrs/0010-unified-connector-model.md) and [contract-connector-toggle](connector-toggle.md)). The snapshot is captured **once per `POST /chat` call** and held for the **entire** tool-use loop of that request — including across multiple LLM round-trips for tool calls within the same request. Toggles arriving after the snapshot is taken do not affect the in-flight turn.
-2. Send conversation context + message to the LLM, exposing only the tools backed by **active connectors for this conversation**.
-3. If the LLM emits tool calls, augchatd dispatches each to the responsible connector server-side:
+2. **Fold user-message quote metadata** into the model input. When the bundled UI's selection-toolbar `Quote` button is used, the resulting user message carries `metadata.custom.quote = { text, messageId }` (see [contract-ui-rendering](ui-rendering.md) §User messages). Before invoking `convertToModelMessages`, augchatd prepends a markdown blockquote of `quote.text` as a leading `text` part on each affected user message so the LLM sees the quoted excerpt. The transform is idempotent and only mutates the model-input copy — the persisted user message keeps its original `parts` + `metadata.custom.quote`, so the UI re-renders the quote chip from metadata on reload.
+3. Send conversation context + message to the LLM, exposing only the tools backed by **active connectors for this conversation**.
+4. If the LLM emits tool calls, augchatd dispatches each to the responsible connector server-side:
- **MCP-type connectors** with that connector's credentials (see [mcp-invocation](mcp-invocation.md))
- **RAG-type connectors** scoped to that connector's allowed `indexes[]` (see [rag-query](rag-query.md))
-4. Feed tool results back to the LLM.
-5. Loop until the LLM produces a final assistant message OR a per-request **step cap** is hit (currently 100 steps). If the cap is hit before a final message is produced, augchatd emits a visible warning text part into the stream (so the user sees "hit the tool-use depth limit ... data is partial" instead of a silently-truncated conversation).
-6. **Stream** the reply to the browser using the assistant-ui native protocol (Vercel AI SDK data stream). Each assistant message in the stream carries `metadata.augchatd = { model_id, provider }` — the model and provider that produced that turn. The bundled UI renders this as a small per-message chip so a user who switched models mid-conversation can tell which model produced each reply.
+5. Feed tool results back to the LLM.
+6. Loop until the LLM produces a final assistant message OR a per-request **step cap** is hit (currently 100 steps). If the cap is hit before a final message is produced, augchatd emits a visible warning text part into the stream (so the user sees "hit the tool-use depth limit ... data is partial" instead of a silently-truncated conversation).
+7. **Stream** the reply to the browser using the assistant-ui native protocol (Vercel AI SDK data stream). Each assistant message in the stream carries `metadata.augchatd = { model_id, provider }` — the model and provider that produced that turn. The bundled UI renders this as a small per-message chip so a user who switched models mid-conversation can tell which model produced each reply.
Throughout, only the session's provisioned credentials and the **conversation's active scope captured at turn start** are used. Inactive connectors are not exposed to the LLM; toggling a connector mid-turn does **not** abort an in-flight tool call.
@@ -44,7 +45,7 @@ Throughout, only the session's provisioned credentials and the **conversation's
- A streamed reply reaches the browser with no LLM key, connector credentials, or upstream URLs exposed.
- A streamed reply survives multi-tens-of-seconds silent gaps between SSE frames — reasoning models routinely produce these between tool-call rounds. See [adr-0011](../../architecture/adrs/0011-tolerate-reasoning-model-stream-gaps.md).
-- When the active model is a reasoning model (OpenAI `o[1-9]*` / `gpt-5*`; Anthropic `claude *opus* | *sonnet*`), the stream includes `reasoning-*` UI parts carrying the provider's reasoning summary. The bundled UI renders them as a collapsible "Reasoning" section beneath the assistant message. Non-reasoning models do not emit these parts.
+- When the active model is a reasoning model (OpenAI `o[1-9]*` / `gpt-5*`; Anthropic `claude *opus* | *sonnet*`), the stream includes `reasoning-*` UI parts carrying the provider's reasoning summary. The bundled UI renders them as a collapsible "Reasoning" section beneath the assistant message. Non-reasoning models do not emit these parts. The conversation can opt out of reasoning surfacing via [contract-reasoning-toggle](reasoning-toggle.md) — when off, augchatd omits the provider option that requests the summary (OpenAI o-series / gpt-5) or extended thinking (Anthropic Opus / Sonnet) and the stream contains no `reasoning-*` parts.
- Tool-call indicators in the stream are sanitized — they show *what was called* (the connector's `descriptive_id` / display name) but not credentials or internal URLs.
- A second concurrent session calling the same MCP URL carries **that session's connector credentials**, not the first's.
- A connector toggled off for conversation `:cid` via `PUT /conversations/:cid/connectors/:descriptive_id { active: false }` is **not present** in the tool list of the next chat turn against that conversation — but it remains exposed for chat turns against other conversations where it is still active.
diff --git a/spec/src/behavior/contracts/session-create.md b/spec/src/behavior/contracts/session-create.md
index a581ae1..19bbdf1 100644
--- a/spec/src/behavior/contracts/session-create.md
+++ b/spec/src/behavior/contracts/session-create.md
@@ -6,6 +6,10 @@ capability: cap-session-mgmt
evidence:
- source: README.md@e562b2b
section: "README header / How it works (step 1) / Storage"
+ - source: src/routes/sessions.ts
+ section: "createSessionHandler (POST /sessions handler)"
+ - source: src/session-registry.ts
+ section: "bindSession (per-session connector ownership)"
links:
- relation: satisfies
target: req-001-per-user-credentials
diff --git a/spec/src/behavior/contracts/session-delete.md b/spec/src/behavior/contracts/session-delete.md
index 16915c1..8bdec71 100644
--- a/spec/src/behavior/contracts/session-delete.md
+++ b/spec/src/behavior/contracts/session-delete.md
@@ -6,6 +6,10 @@ capability: cap-session-mgmt
evidence:
- source: README.md
section: "Token & credential refresh — Forced logout"
+ - source: src/routes/sessions.ts
+ section: "deleteSessionHandler"
+ - source: src/flush-scheduler.ts
+ section: "flushAllForSession (returns false on failure → 503)"
links:
- relation: depends_on
target: contract-session-create
@@ -17,13 +21,6 @@ links:
# Contract — Session delete (forced logout)
-> [!WARNING] PENDING RECONCILIATION
-> - **Detected**: 2026-05-25 by /code-changed (audit consolidation, augchatd/augchatd#9)
-> - **Sources in conflict**: this contract + `adr-0005-jwt-signature-only:37` ("Immediate revocation available via DELETE /sessions/:id") vs `src/server.ts:33-34` (route explicitly `NOT mounted (returns 404 by default)`) and `src/session-registry.ts` (no `deleteSession()` export).
-> - **Nature**: the contract reads as if the route exists; the production HTTP surface mounts nothing — every `DELETE /sessions/*` returns Hono's default 404. The mTLS half is also unimplemented, so even mounting the route would not satisfy the "mTLS-authenticated delete" promise.
-> - **Proposed direction**: track behind production session minting (`POST /sessions` itself is unmounted). When that lands, add `deleteSession()` to `session-registry`, mount the route, and remove this block. Until then, the prescriptive prose is target state.
-> - **Decision owner**: project owner.
-
## Promise
Given a valid mTLS client cert and an existing `session_id`, augchatd:
diff --git a/spec/src/behavior/contracts/ui-handshake.md b/spec/src/behavior/contracts/ui-handshake.md
index ad1caac..98f8ed7 100644
--- a/spec/src/behavior/contracts/ui-handshake.md
+++ b/spec/src/behavior/contracts/ui-handshake.md
@@ -38,18 +38,19 @@ The same handshake runs in both:
## Observable outcomes
- A page following the README's snippet completes the initial handshake without modification.
-- The iframe ignores `augchatd:jwt` messages whose `origin` is not the expected parent origin (in demo: same-origin; in production: the integrator origin — discovery mechanism is out-of-band, currently a known gap, see Non-promises).
+- The iframe ignores `augchatd:jwt` messages whose `origin` is not the expected parent origin. The iframe learns the expected origin from `?parent_origin=` on its own `src` URL (set by the integrator); if missing, it falls back to `document.referrer` with a one-time console warning. See [browser-postmessage](../../contracts/browser-postmessage.md) §Origin checking.
- The parent ignores `augchatd:ready` from any origin other than the augchatd iframe.
- The JWT is never put in the iframe URL, in cookies, or in a query string.
- `augchatd:route` posts from the iframe land at the parent and (in the demo wrapper) update the parent's URL pathname; a subsequent hard reload of the parent URL seeds the iframe at the same route.
- A `401` from any chat-time endpoint causes the iframe to re-emit `augchatd:ready`; the parent's reply with a fresh JWT lets the chat continue.
+- Iframe-`src` query params are the integrator's other channel into the UI session. `?parent_origin=` controls postMessage origin checking (above); `?locale=` selects the UI chrome language — see [contract-ui-i18n](ui-i18n.md). Both are set on `src` by the integrator, distinct from the `augchatd:jwt` postMessage payload.
## Non-promises
- The handshake does not negotiate auth scheme; the JWT is opaque to the parent page.
- The handshake does not establish a heartbeat; expiry handling is the [jwt-refresh](jwt-refresh.md) contract (the iframe just re-emits `augchatd:ready`).
- The handshake is not a public API for custom UIs; the bundled UI is the only supported consumer (see [req-007](../requirements/req-007-bundled-ui.md)).
-- The cross-origin variant of the iframe's parent-origin verification requires the iframe to learn the expected parent origin out-of-band (e.g. a URL query param on the iframe `src`). The mechanism is not specified by this contract today — demo uses same-origin which sidesteps it. **Pending** — tracked as a gap to resolve before production `POST /sessions` is wired.
+- The cross-origin variant of the iframe's parent-origin verification reads `?parent_origin=` from the iframe `src` (see [browser-postmessage](../../contracts/browser-postmessage.md) §Origin checking). Integrators who don't pass it get the degraded `document.referrer` fallback — this is back-compat, not a promise.
## Tests this contract implies
diff --git a/spec/src/behavior/contracts/ui-i18n.md b/spec/src/behavior/contracts/ui-i18n.md
new file mode 100644
index 0000000..75e6bd6
--- /dev/null
+++ b/spec/src/behavior/contracts/ui-i18n.md
@@ -0,0 +1,61 @@
+---
+id: contract-ui-i18n
+type: behavior-contract
+status: proposed
+capability: cap-ui
+evidence: []
+links:
+ - relation: satisfies
+ target: req-007-bundled-ui
+ - relation: depends_on
+ target: contract-ui-handshake
+ - relation: refines
+ target: contract-ui-rendering
+---
+
+# Contract — UI localization (chrome only)
+
+## Promise
+
+The bundled UI's **chrome** (menus, buttons, tooltips, aria-labels, empty/loading/error states it controls itself) is rendered in the language selected by the integrator via the iframe `src` query parameter:
+
+- `?locale=en` (default; also the result when the param is absent)
+- `?locale=fr`
+
+The same channel already carries `?parent_origin=` for the postMessage handshake. `locale` is integrator-set, parallel to `theme` on the handshake reply — both are session-scoped properties chosen outside the UI.
+
+On boot the UI:
+
+1. Reads `?locale=` from `window.location.search`.
+2. Normalizes to one of the supported BCP 47 codes (`en`, `fr`). Anything else falls back to `en` with a one-time console warning naming the rejected value.
+3. Sets `document.documentElement.lang` to the resolved code.
+4. Renders all chrome strings from the selected catalog before first paint.
+
+## Observable outcomes
+
+- `