@terreno/mcp 57.9.0 → 57.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/docs/upgrades/57.10.1.md +54 -0
  2. package/dist/docs/versioned/next/README.md +1 -0
  3. package/dist/docs/versioned/next/explanation/README.md +4 -0
  4. package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  5. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  6. package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  7. package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
  8. package/dist/docs/versioned/next/explanation/durable-agent-harness.md +531 -0
  9. package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
  10. package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
  11. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  12. package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  13. package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
  14. package/dist/docs/versioned/next/how-to/README.md +6 -0
  15. package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  16. package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  17. package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  18. package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  19. package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  20. package/dist/docs/versioned/next/how-to/circleci.md +5 -3
  21. package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  22. package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  23. package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  24. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  25. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  26. package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  27. package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  28. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  29. package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  30. package/dist/docs/versioned/next/reference/README.md +3 -0
  31. package/dist/docs/versioned/next/reference/admin-config.md +2 -0
  32. package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
  33. package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  34. package/dist/docs/versioned/next/reference/ai-harness.md +1881 -0
  35. package/dist/docs/versioned/next/reference/ai.md +251 -25
  36. package/dist/docs/versioned/next/reference/api.md +46 -0
  37. package/dist/docs/versioned/next/reference/blocks.md +178 -0
  38. package/dist/docs/versioned/next/reference/environment-variables.md +1 -0
  39. package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
  40. package/dist/docs/versioned/next/reference/test.md +11 -2
  41. package/dist/docs/versioned/next/reference/ui.md +325 -1
  42. package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  43. package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  44. package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
  45. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +183 -0
  46. package/dist/tools.d.ts +4 -0
  47. package/dist/tools.d.ts.map +1 -1
  48. package/dist/tools.js +37 -0
  49. package/dist/tools.js.map +1 -1
  50. package/package.json +3 -2
  51. package/src/docs/upgrades/57.10.1.md +54 -0
  52. package/src/docs/versioned/next/README.md +1 -0
  53. package/src/docs/versioned/next/explanation/README.md +4 -0
  54. package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  55. package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  56. package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  57. package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
  58. package/src/docs/versioned/next/explanation/durable-agent-harness.md +531 -0
  59. package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
  60. package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
  61. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  62. package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  63. package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
  64. package/src/docs/versioned/next/how-to/README.md +6 -0
  65. package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  66. package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  67. package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  68. package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  69. package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  70. package/src/docs/versioned/next/how-to/circleci.md +5 -3
  71. package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  72. package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  73. package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  74. package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  75. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  76. package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  77. package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  78. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  79. package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  80. package/src/docs/versioned/next/reference/README.md +3 -0
  81. package/src/docs/versioned/next/reference/admin-config.md +2 -0
  82. package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
  83. package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  84. package/src/docs/versioned/next/reference/ai-harness.md +1881 -0
  85. package/src/docs/versioned/next/reference/ai.md +251 -25
  86. package/src/docs/versioned/next/reference/api.md +46 -0
  87. package/src/docs/versioned/next/reference/blocks.md +178 -0
  88. package/src/docs/versioned/next/reference/environment-variables.md +1 -0
  89. package/src/docs/versioned/next/reference/mcp-server.md +17 -0
  90. package/src/docs/versioned/next/reference/test.md +11 -2
  91. package/src/docs/versioned/next/reference/ui.md +325 -1
  92. package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  93. package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  94. package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
  95. package/src/docs/versioned/next/tasks/durable-agent-harness.md +183 -0
@@ -15,6 +15,8 @@ AI service layer for Terreno backends: provider-agnostic chat via the Vercel AI
15
15
  - [Route registrars](#route-registrars)
16
16
  - [AiApp plugin](#aiapp-plugin)
17
17
  - [LangfuseApp plugin](#langfuseapp-plugin)
18
+ - [Observability](#observability)
19
+ - [Durable agent harness](#durable-agent-harness)
18
20
  - [Langfuse integration](#langfuse-integration)
19
21
  - [FileStorageService](#filestorageservice)
20
22
  - [MCPService](#mcpservice)
@@ -64,13 +66,15 @@ src/
64
66
  project.ts # GPT project + memories
65
67
  routes/
66
68
  gpt.ts # Streaming chat, remix, tools, ratings
67
- gptHistories.ts # History CRUD
69
+ gptHistories.ts # History CRUD, headless pendingAsks and turn actions
68
70
  aiRequestsExplorer.ts # Admin request explorer
69
71
  files.ts # File upload/signed URL/delete
70
72
  projects.ts # Project CRUD + memories
71
73
  mcp.ts # MCP server status and tools
72
74
  service/
73
75
  aiService.ts # Provider-agnostic AI service
76
+ asks.ts # Ask tools, reserved ask_ names, paused-turn replay
77
+ chatTurn.ts # Chat turn runner behind /gpt/prompt (SSE sink) and turn (buffered JSON)
74
78
  fileStorage.ts # GCS upload helper
75
79
  getMCPTools.ts # modelRouter MCP tools as Vercel AI SDK tools
76
80
  mcpService.ts # MCP client connections
@@ -92,8 +96,10 @@ src/
92
96
  - **Structured output:** `parseAiJson`, `normalizeLlmJsonTextForStructuredOutput`, re-exported `Output`, `jsonSchema`, `JSONValue`, `FlexibleSchema` from `ai`
93
97
  - **Langfuse:** `initLangfuseClient`, `getLangfuseClient`, `shutdownLangfuseClient`, `compilePrompt`, `createPrompt`, `getPrompt`, `createTelemetryConfig`, `preparePromptForAI`, `initTracing`, `shutdownTracing`, `LangfuseCache`, cache helpers
94
98
  - **Gemini / Vertex:** `listGeminiApiModels`, `normalizeGeminiModelId`, `GEMINI_API_BASE_URL`, `createVertexProvider`, `listEnabledVertexModels`, `verifyVertexModelsEnabled`, `assertVertexModelsEnabled`, `isVertexModelAllowed`, `normalizeVertexModelId`, `DEFAULT_VERTEX_LOCATION`
95
- - **Prompts:** `CONTENT_SUMMARY_PROMPT`, `DEFAULT_GPT_MEMORY`, `JSON_VALUE_SYSTEM_PROMPT`, `REMIX_PROMPT`, `TITLE_GENERATION_PROMPT`, `TRANSLATION_PROMPT`
99
+ - **Prompts:** `COMPACT_SURFACE_SYSTEM_PROMPT`, `CONTENT_SUMMARY_PROMPT`, `DEFAULT_GPT_MEMORY`, `JSON_VALUE_SYSTEM_PROMPT`, `REMIX_PROMPT`, `TERRENO_ASKS_SYSTEM_PROMPT`, `TITLE_GENERATION_PROMPT`, `TRANSLATION_PROMPT`
100
+ - **Asks:** `createAskTools({kinds, surface?})`, `TERRENO_ASKS_SYSTEM_PROMPT`, `COMPACT_SURFACE_SYSTEM_PROMPT`, types `AsksOptions`, `ApprovalAskInput`, `AskOrigin`, `GptHistoryPendingAsk`, `GptHistoryPromptAsk`, `GptHistoryAskStatus`, and `Ask`, `AskKind`, `AskResponse`, `AskValidationError`, `SimpleCard`, `SimpleCardButton` re-exported from `@terreno/blocks` ([Agent UI Asks](agent-ui-asks.md))
96
101
  - **Web search:** `WebSearchProvider`, `WebSearchResult` types
102
+ - **Harness (subpath `@terreno/ai/harness`):** `Harness`, `defineTask`, `defineAgent`, `defineTool`, `defineExtension`, `section`, `hook`, `wrapTool`, `HarnessExtensionError`, `HARNESS_HOOK_KINDS`, `HarnessConversationHandle`, `HarnessConversationBusyError`, `HarnessConversationOwnedError`, `HARNESS_EVENT_TYPES`, `HARNESS_SUBMIT_DISPOSITIONS`, `HARNESS_WHEN_BUSY`, `HarnessModelCallError`, `HarnessSubagentError`, `isRetryableModelError`, `AGENT_TURN_TASK_NAME`, `AGENT_TOOL_TASK_NAME`, `HARNESS_AGENT_DEFAULT_MAX_STEPS`, `HARNESS_CONVERSATION_STATUSES`, `HARNESS_INTERRUPT_ACTIONS`, `HARNESS_MESSAGE_ROLES`, `HARNESS_MODEL_RETRY_DEFAULTS`, `InProcessRunner`, `HarnessCommitConflictError`, `HARNESS_RESOLVE_ACTIONS`, `HARNESS_RETRY_DEFAULTS`, `HARNESS_TASK_STATUSES`, `HARNESS_WAIT_KINDS`, `HARNESS_WAIT_POLICIES`, `HARNESS_WAIT_RESOLUTIONS`, `IN_PROCESS_RUNNER_ROLES`, `approvalGate`, `approvalTaskInput`, `HarnessApp`, `HarnessApprovalConflictError`, `HARNESS_APPROVAL_STATUSES`, `HARNESS_DEFAULT_APPROVERS` — see [AI harness reference](ai-harness.md)
97
103
 
98
104
  ## AIService
99
105
 
@@ -131,18 +137,26 @@ const aiService = new AIService({
131
137
  | `generateText(options)` | Non-streaming text generation; logs as `requestType: "general"` |
132
138
  | `generateJsonValue(options)` | Any JSON value via `Output.json()`; logs as `"json_value"` |
133
139
  | `generateJsonObject(options)` | Typed object from schema/Zod via `Output.object()`; logs as `"json_object"` |
140
+ | `generateBlocks(options)` | One block document via `Output.object(blocksJsonSchema)`, then `validateBlocks`. Temperature is always `TemperaturePresets.DETERMINISTIC` (0). Logs as `"ui_blocks"`. |
134
141
  | `generateJsonArray(options)` | Typed array via `Output.array()`; logs as `"json_array"` |
135
142
  | `generateTextStream(options)` | Async generator of text chunks; logs full response after stream completes |
136
143
  | `generateRemix(options)` | Reword text using `REMIX_PROMPT` at `TemperaturePresets.BALANCED` |
137
144
  | `generateSummary(options)` | Summarize text using `CONTENT_SUMMARY_PROMPT` at `TemperaturePresets.LOW` |
138
145
  | `translateText(options)` | Translate text using `TRANSLATION_PROMPT` at `TemperaturePresets.LOW` |
139
- | `buildMessages(prompts)` | Convert `GptHistoryPrompt[]` to Vercel AI SDK `ModelMessage[]` (skips tool-call/result entries) |
146
+ | `buildMessages(prompts)` | Convert `GptHistoryPrompt[]` to Vercel AI SDK `ModelMessage[]`. Skips host tool-call/result rows and approval ask rows (`ask.origin: "approval"`). Keeps each answered or cancelled ask as an assistant tool call plus its tool result; consecutive ask calls share one assistant message. Skips asks still waiting for an answer. |
140
147
  | `generateChatStream(options)` | Stream multi-turn chat with optional tools; logs prompt as joined message text |
141
148
 
142
149
  All generation methods log to `AIRequest` via private `logRequest()`. Logging failures never throw.
143
150
 
144
151
  ## Structured JSON output
145
152
 
153
+ `generateBlocks({prompt, systemPrompt?, userId?, repair?})` asks for one block document:
154
+
155
+ - Uses `TERRENO_UI_BLOCKS_SYSTEM_PROMPT` when `systemPrompt` is omitted.
156
+ - Temperature is `TemperaturePresets.DETERMINISTIC` (0).
157
+ - Checks the object with `validateBlocks`. When `repair` is omitted or true, one retry appends the error list to the user prompt. `repair: false` skips that retry.
158
+ - A second validation failure throws `APIError` 422 (`title: "Block document failed validation"`, `meta.fields` keyed by error code) and stores `metadata.errorCodes` on the `AIRequest`. A model or network error throws 502 (`title: "Block generation failed"`) and is not repaired.
159
+
146
160
  `generateJsonValue`, `generateJsonObject`, and `generateJsonArray`:
147
161
 
148
162
  - Default to `TemperaturePresets.DETERMINISTIC` when `temperature` is omitted.
@@ -186,13 +200,13 @@ Logs all AI calls for monitoring and admin explorer.
186
200
  |-------|------|-------------|
187
201
  | `aiModel` | string | Model identifier (field name avoids Mongoose `model` conflict) |
188
202
  | `prompt` | string | Input prompt |
189
- | `requestType` | string | e.g. `general`, `remix`, `summarization`, `translation`, `json_value`, `json_object`, `json_array` |
203
+ | `requestType` | string | e.g. `general`, `remix`, `summarization`, `translation`, `json_value`, `json_object`, `json_array`, `ui_action`, `ui_blocks` |
190
204
  | `response` | string? | Response text |
191
205
  | `responseTime` | number? | Milliseconds |
192
206
  | `tokensUsed` | number? | Total tokens |
193
207
  | `userId` | ObjectId? | Requesting user |
194
208
  | `error` | string? | Error message |
195
- | `metadata` | Mixed? | Extra data (e.g. structured-output debug) |
209
+ | `metadata` | Mixed? | Extra data (e.g. structured-output debug; `ask` and `nextAsk` for [asks](agent-ui-asks.md#stored-state)) |
196
210
  | `parentRequestId` | ObjectId? | Parent in multi-agent workflow |
197
211
  | `subRequestIds` | ObjectId[]? | Child request refs |
198
212
  | `totalResponseTime` | number? | Combined sub-request time |
@@ -209,11 +223,12 @@ Conversation history with multi-modal prompts.
209
223
  | Field | Type | Description |
210
224
  |-------|------|-------------|
211
225
  | `userId` | ObjectId | Owner (required) |
212
- | `title` | string? | Auto-generated on first `/gpt/prompt` response when empty |
226
+ | `title` | string? | Auto-generated on the first chat turn's reply (`/gpt/prompt` or `turn`) when empty |
213
227
  | `projectId` | ObjectId? | Optional project association |
214
- | `prompts` | array | Messages: `text`, `type` (`user` \| `assistant` \| `system` \| `tool-call` \| `tool-result`), optional `content` parts, `model`, `rating`, tool fields, `status`, `streamId`. `text` is required unless `content` has parts or `status` is set; an image-only assistant response saves `text: ""` |
228
+ | `prompts` | array | Messages: `text`, `type` (`user` \| `assistant` \| `system` \| `tool-call` \| `tool-result`), optional `content` parts, `model`, `rating`, tool fields (`toolCallId`, `toolName`, `args`, `result`), `ask: {kind, status}` on ask `tool-call` rows (`status`: `pending` \| `answered` \| `cancelled`), and on assistant replies `status` (`streaming` \| `complete` \| `error`) plus `streamId`. `text` is required unless `content` has parts or `status` is set; an image-only assistant response saves `text: ""` |
229
+ | `pendingAsk` | object? | The ask the conversation waits on: `toolCallId`, `kind`, `input`, `simple`, `promptIndex`, `responseMessages`, `created`, and for an approval ask `origin`, `approvalId`, `toolName`. `/gpt/histories` responses leave out `promptIndex` and `responseMessages`. Only a chat turn (`/gpt/prompt` or the `turn` action) sets and clears it; see [Agent UI Asks](agent-ui-asks.md#stored-state). |
215
230
 
216
- Assistant replies from `/gpt/prompt` carry `streamId` and `status`: `streaming` while partial text is persisted, then `complete` or `error`. `buildMessages` skips `streaming` replies and `error` replies with no output. Attachment `content` parts carry an optional `gcsKey` when the file was uploaded to durable storage.
231
+ Assistant replies from `/gpt/prompt` carry `streamId` and `status`: `streaming` while partial text is persisted, then `complete` or `error`. A paused ask's reply is `complete`, and `pendingAsk` is set beside it. `buildMessages` skips `streaming` replies and `error` replies with no output. Attachment `content` parts carry an optional `gcsKey` when the file was uploaded to durable storage.
217
232
 
218
233
  **Virtual:** `ownerId` aliases `userId` for `Permissions.IsOwner`.
219
234
 
@@ -251,13 +266,61 @@ GPT project with persistent context and memories.
251
266
 
252
267
  | Endpoint | Method | Auth | Description |
253
268
  |----------|--------|------|-------------|
254
- | `/gpt/prompt` | POST | `IsAuthenticated` | SSE streaming chat; body: `prompt`, optional `historyId`, `systemPrompt`, `attachments`, `model`, `projectId` |
269
+ | `/gpt/prompt` | POST | `IsAuthenticated` | SSE streaming chat turn; [body](#gptprompt-body) and [events](#sse-events) below |
255
270
  | `/gpt/remix` | POST | `IsAuthenticated` | Non-streaming text remix; body: `{text}` |
256
271
  | `/gpt/histories/:id/rating` | PATCH | `IsAuthenticated` | Rate a prompt; body: `{promptIndex, rating: "up" \| "down" \| null}` |
257
272
  | `/gpt/histories/:id/stream` | GET | `IsAuthenticated` (owner) | SSE resume of an in-flight reply; query: optional `streamId`, `offset` |
258
- | `/gpt/tools` | GET | `IsAuthenticated` | List builtin + MCP tools |
273
+ | `/gpt/tools` | GET | `IsAuthenticated` | List builtin + MCP tools (ask tools are not listed) |
274
+ | `/gpt/datasets/:id` | GET | owner (`IsOwner`; another user is 404) | Read a stored dataset. Mounted only when `uiBlocks` is on. Query: `grain` (`hour` \| `day` \| `week` \| `month`), `limit` (default 500, max 1000), `page`. Response `data`: `{columns, rows, rowCount, page, more}`. `grain` buckets the first date column in UTC. An offset is converted before `startOf`. A date with no zone is that UTC day. Null date cells are skipped. Number columns are summed. Without `page`, a series longer than `limit` is LTTB-downsampled and `more` is false. With `page`, rows are a page and `more` is true when another page remains. |
275
+ | `/gpt/actions` | POST | `IsAuthenticated` plus history owner (another user is 403) | Run a host callback. Mounted only when `uiBlocks` is on, on the `/gpt` path. Body: `{historyId, messageId, blockId, elementId, name, payload?}`. Unknown `name` is 404. A payload that fails the host schema is 400 with `meta.fields`. The handler has 10 seconds (`actionTimeoutMs` can set another cap) and then 504. Response `data`: `{text?, blocks?, replace?}`. An invalid `blocks` document is 500. Logged as `AIRequest` `requestType: "ui_action"`. |
276
+
277
+ Generated images (image-output models such as `gemini-3-pro-image`) arrive as SSE `image` events: `{image: {mimeType, url}}` with a base64 data URL. Each image is sent once, even when the model reports it both as a stream file part and in the final `result.files`. The saved assistant prompt stores one `image` content part per image and `text: ""` when there is no text. On later turns, `buildMessages` sends an image-only assistant prompt to the model as the text `[Generated image]`, because providers reject empty assistant turns.
278
+
279
+ AI resolution order: `x-ai-api-key` header + `createModelFn` → `createServerModelFn(modelId)` → configured `aiService`. When none resolves, `/gpt/prompt` streams a canned demo reply and `/gpt/remix` returns it. This happens whether or not `demoMode` is set.
280
+
281
+ Pass `asks: true` (or `{kinds: ["choice"]}`) to let the model ask the user typed questions in the chat. Asks are off by default; with them off, tools, system prompt, and SSE events are unchanged. See [Agent UI Asks](agent-ui-asks.md).
259
282
 
260
- Generated images (image-output models such as `gemini-3-pro-image`) arrive as SSE `image` events: `{image: {mimeType, url}}` with a base64 data URL. Each image is sent once, even when the model reports it both as a stream file part and in the final `result.files`. The saved assistant prompt stores one `image` content part per image. On later turns, `buildMessages` sends an image-only assistant prompt to the model as the text `[Generated image]`, because providers reject empty assistant turns.
283
+ Pass `uiBlocks: true` (or `{hostActions, html, imageHosts, repair, datasetTtlDays, datasetMaxRows}`) to require each assistant reply to be a block document. `html: true` allows `html` blocks and sanitizes them before they are stored. `imageHosts` lists hostnames allowed on `https` image sources. The client receives that document once, after missing action ids are filled, `repair: true` rewrites it, and html is sanitized, and before `{ask}`. `{replace: "text", text}` is sent only when text was already streamed and then changed. `datasetTtlDays` defaults to `0` (keep the dataset). `datasetMaxRows` defaults to 50,000. Off by default; with it off, the system prompt, SSE events, `/gpt/datasets`, and `/gpt/actions` are unchanged. When it is on, the system prompt gains `TERRENO_UI_BLOCKS_SYSTEM_PROMPT` (host callback names included when `hostActions` is set). After the final text, the route validates it and sends `{blocks: {ok, errors, warnings}}` before `{done}`. `hostActions` is the callback allowlist: a name outside it fails with `UNKNOWN_HOST_ACTION`. `{repair: true}` runs one repair call when validation fails and stores that reply. A document that is still invalid is stored with a `Block validation errors:` note so the next turn sees it. See [Validate a block document locally](../how-to/agent-ui-blocks.md).
284
+
285
+ With asks on, a host tool with the AI SDK's `needsApproval: true` runs only after the user approves it: the turn pauses on a server-made `confirm` ask. `asks.approvals` sets that ask's input per tool name, as `(input) => ConfirmAskInput` (`ApprovalAskInput`); without an entry, the ask is "Allow <toolName>?" with the tool's description. With asks off, such a tool never runs. See [Approval asks](agent-ui-asks.md#approval-asks).
286
+
287
+ With `asks` on, pass the same options to `addGptHistoryRoutes` as `chat` to add the non-streaming [headless endpoints](#addgpthistoryroutesrouter-options) for clients that do not read server-sent events.
288
+
289
+ #### `/gpt/prompt` body
290
+
291
+ | Field | Type | Description |
292
+ |-------|------|-------------|
293
+ | `prompt` | string | The user's message. Required unless `askResponse` is sent. |
294
+ | `historyId` | string? | Continue this conversation; omit to start one. Required with `askResponse`. |
295
+ | `askResponse` | object? | The answer to the pending ask: `{toolCallId, action, content?, reason?}`. Read only when `asks` is on. Send it instead of `prompt`, without `attachments`. See [Answer an ask](agent-ui-asks.md#answer-an-ask). |
296
+ | `surface` | `"full"` \| `"compact"`? | Where the user reads and answers. Default `"full"`. `"compact"` is a watch or another small screen: the model gets only button-sized asks and is asked for replies of at most two short sentences. Any other value returns 400. See [Compact surface](agent-ui-asks.md#compact-surface). |
297
+ | `systemPrompt` | string? | System prompt for this turn; project context and the Langfuse prompt are prepended |
298
+ | `attachments` | array? | `{type: "image" \| "file", url, mimeType, filename?}` items added to the user message |
299
+ | `model` | string? | Model id passed to `createModelFn` or `createServerModelFn` |
300
+ | `projectId` | string? | Project whose context and memories are prepended to the system prompt; saved on a history that has none |
301
+
302
+ #### SSE events
303
+
304
+ `/gpt/prompt` streams `data: <json>` lines, one event object per line. `{askResolved}` is sent when an answer is stored, before the model runs. `{started}` is sent when the model produces its first part and the reply row is saved, before any text from that part. On a prompt that is not answering an ask, `{started}` is first. `{ask}` and `{done}` come last. With `uiBlocks` on, `{text}` is the finished document and comes before `{ask}`.
305
+
306
+ | Event | Shape | When |
307
+ |-------|-------|------|
308
+ | `{started}` | `{started: true, historyId, streamId}` | Sent when the model produces its first part and the reply row is saved. A reload can resume this `streamId`. It comes before text from that part. A model that fails before any part sends no `{started}`. The demo reply does not send it. |
309
+ | `{askResolved}` | `{askResolved: {toolCallId, action}}` | The turn answered the pending ask, or cancelled it because a new `prompt` arrived. Asks only. |
310
+ | `{text}` | `{text: string}` | A step's text, sent when the step ends. Text from a step that calls a tool is dropped, unless that text parses as a block document and the turn has no other text. A trailing JSON `"action"` blob is stripped. With `uiBlocks` on, this is the final document: sent once, after id-fill, repair, and sanitizing, and before `{ask}`. |
311
+ | `{toolCall}` | `{toolCall: {toolCallId, toolName, args}}` | The model called a host tool (route, request, or MCP). Never sent for ask tools. |
312
+ | `{file}` | `{file: {filename, mimeType, url}}` | A host tool result had a `fileData` data URL. Sent before its `{toolResult}`. `filename` defaults to `document` and `mimeType` to `application/octet-stream`. |
313
+ | `{toolResult}` | `{toolResult: {toolCallId, toolName, result}}` | A host tool returned. `fileData` is removed from `result`. For a tool whose approval was denied, `result` is `{approved: false, reason}`. Never sent for ask tools. |
314
+ | `{image}` | `{image: {mimeType, url}}` | The model generated an image; `url` is a `data:` URL. Each data URL is sent once. |
315
+ | `{ask}` | `{ask: {toolCallId, kind, input, simple, origin?, toolName?}, historyId}` | The turn paused on an ask. Sent after the turn is saved. `historyId` is the conversation that waits on the ask, so a new chat's ask can be answered before `{done}`. An [approval ask](agent-ui-asks.md#approval-asks) adds `origin: "approval"` and the host `toolName`. Asks only. |
316
+ | `{error}` | `{error: string}` | The model stream reported an error, or the turn failed after the stream started. `{done}` still follows. |
317
+ | `{replace}` | `{replace: "text", text}` | The assistant document after the server changed text the client already has: a missing actions `id` was filled in, `repair: true` rewrote the document, or `uiBlocks.html` sanitized it. Sent before `{blocks}`. With `uiBlocks` on, the first send is `{text}` of that finished document, so `{replace}` is not sent for it. |
318
+ | `{blocks}` | `{blocks: {ok, errors, warnings}}` | The final assistant text checked as a block document. Sent after the text and before `{done}`, only when `uiBlocks` is on and the turn produced text. |
319
+ | `{done}` | `{done: true, historyId?, title?, pendingAsk?}` | Last event of every turn that started streaming, also after `{error}`. `historyId` is missing only in the demo response and when a failed new chat could not be saved. `title` is set once the conversation has one. `pendingAsk: {toolCallId}` when the turn waits on an ask. |
320
+
321
+ When a turn fails after the stream starts, before the model's first chunk or partway through, the stream sends `{error}` then `{done}` with `historyId`. The turn keeps what the client already saw: the user's message, host tool rows, and any partial assistant text, saved with `status: "error"`. An empty placeholder is removed. An ask the failed stream had started is dropped. When the model call after an answer fails before the client gets any text, tool result, or ask, the answer is undone: the stream is `{askResolved}`, `{error}`, `{done, pendingAsk}`, the ask is pending again, and the same answer can be sent again. When it fails later, the stream is `{askResolved}`, `{error}`, `{done}` and the answer is kept: the ask stays answered and sending it again returns 409. Send a new `prompt` to continue. See [Agent UI Asks](agent-ui-asks.md#answer-an-ask).
322
+
323
+ Errors raised before the stream starts return JSON `{status, title, detail, fields?}` instead: 400 for an invalid body, 403 for another user's history, 404 for an unknown `historyId`, 409 for an answer to an ask that is not pending, and 500 otherwise. A `prompt` never gets 409: when another request resolved the ask it meant to cancel, it goes ahead as a normal message. [Agent UI Asks error responses](agent-ui-asks.md#error-responses) lists the ask cases.
261
324
 
262
325
  #### Attachments
263
326
 
@@ -269,11 +332,11 @@ When `fileStorageService` is set, `data:` attachments are uploaded with `FileSto
269
332
 
270
333
  #### Stream events and resume
271
334
 
272
- `/gpt/prompt` saves the user turn and a `status: "streaming"` assistant placeholder before streaming. While the reply streams, partial text is persisted about every second (`streamPersistIntervalMs`), plus a heartbeat every 10 seconds. The final reply replaces the placeholder with `status: "complete"`. On failure, partial text is kept with `status: "error"`; an empty placeholder is removed.
335
+ `/gpt/prompt` saves the user turn and a `status: "streaming"` assistant placeholder when the model produces its first part. Waiting until then lets two turns on one history both load it before either writes. While the reply streams, partial text is persisted about every second (`streamPersistIntervalMs`), plus a heartbeat every 10 seconds. A text reply replaces that placeholder in place with `status: "complete"`. On failure, partial text is kept with `status: "error"`; an empty placeholder is removed.
273
336
 
274
337
  | Event | Sent by | Meaning |
275
338
  |-------|---------|---------|
276
- | `{historyId, started: true, streamId}` | prompt | First event; the turn is saved and resumable |
339
+ | `{historyId, started: true, streamId}` | prompt | The reply row is saved and resumable; before any text from the model's first part |
277
340
  | `{historyId, resumed: true, streamId?}` | resume | First event; `streamId` is absent when nothing is streaming |
278
341
  | `{text}` | both | Text delta |
279
342
  | `{replace: true, text}` | resume | Authoritative whole reply: sent first when the client provides `offset`, and whenever persisted text was rewritten (for example, a step became a tool call) |
@@ -284,8 +347,6 @@ When `fileStorageService` is set, `data:` attachments are uploaded with `FileSto
284
347
 
285
348
  `GET /gpt/histories/:id/stream` re-attaches after a reload or remount. Pass `offset` as the number of characters the client already shows, usually the stored placeholder `text`. The endpoint polls the stored history (`streamResumePollIntervalMs`, default 500 ms), so it works across server instances. A `streaming` reply with no update for `streamStaleAfterMs` (default 60 s) is marked `error`.
286
349
 
287
- AI resolution order: `x-ai-api-key` header + `createModelFn` → `createServerModelFn(modelId)` → configured `aiService` → demo SSE response when `demoMode` and none available.
288
-
289
350
  ### addGptHistoryRoutes(router, options?)
290
351
 
291
352
  CRUD at `/gpt/histories` via `modelRouter`:
@@ -295,7 +356,16 @@ CRUD at `/gpt/histories` via `modelRouter`:
295
356
  | Create, List | `IsAuthenticated` |
296
357
  | Read, Update, Delete | `IsOwner` |
297
358
 
298
- Query filtered by `userId`; sort `-updated`; query fields `userId`, `projectId`.
359
+ Query filtered by `userId`; sort `-updated`; query fields `userId`, `projectId`. Create sets `userId` to the caller, so `{}` is a valid create body even when the app validates request bodies. Create and update bodies drop `pendingAsk` (including dotted `pendingAsk.*` paths), so only a chat turn writes it; the OpenAPI spec marks it `readOnly` on create and update.
360
+
361
+ Pass `chat`, the options given to `addGptRoutes`, to add two headless actions for clients that do not read server-sent events, such as a watch app. Both exist only when `chat` turns `asks` on: without `chat`, or with `asks` off, neither action exists, so a host that never turned asks on gets no new endpoints. `AiApp` adds them when its `asks` option is set.
362
+
363
+ | Endpoint | Method | Auth | Description |
364
+ |----------|--------|------|-------------|
365
+ | `/gpt/histories/pendingAsks` | GET | `IsAuthenticated` | The caller's pending asks with their simple cards, newest first |
366
+ | `/gpt/histories/:id/turn` | POST | `IsOwner`; admins who do not own the history get 403 | Runs one chat turn to completion with the `chat` options and returns it as JSON. Body: one of `{prompt}`, `{askResponse}`, or `{toolCallId, buttonId}`, plus `surface`. The turn finishes and saves even if the client disconnects. |
367
+
368
+ Bodies, results, and errors: [Headless endpoints](agent-ui-asks.md#headless-endpoints).
299
369
 
300
370
  ### addProjectRoutes(router, options?)
301
371
 
@@ -311,9 +381,11 @@ Requires `fileStorageService` and `gcsBucket` (registered by `AiApp` when both a
311
381
 
312
382
  | Endpoint | Method | Auth | Description |
313
383
  |----------|--------|------|-------------|
314
- | `/files/upload` | POST | `IsAuthenticated` | Multipart upload (`file` field); allowed MIME: images, PDF, plain text, CSV, JSON. Returns `403` when `fileUploadsEnabled` is off |
315
- | `/files/*gcsKey` | GET | None | Returns signed read URL (1 hour) |
316
- | `/files/*gcsKey` | DELETE | `IsAuthenticated` (owner) | Soft-delete attachment and remove from GCS |
384
+ | `/files/upload` | POST | `IsAuthenticated` | Multipart upload (`file` field); allowed MIME: images, PDF, plain text, CSV, JSON. Capped at `maxFileSize` (default 10 MB). Returns `403` when `fileUploadsEnabled` is off. Returns `{data: {id, filename, gcsKey, mimeType, size, url}}`; send `id` as the `fileId` of a [`files` ask](agent-ui-asks.md#files) answer. |
385
+ | `/files/*gcsKey` | GET | `IsAuthenticated` (owner) | Returns `{data: {url}}`, a signed read URL (1 hour), for the caller's own upload. Another user's file returns 404, the same as a missing one, so keys cannot be probed; admins get no exception. |
386
+ | `/files/*gcsKey` | DELETE | `IsAuthenticated` (owner) | Soft-delete attachment and remove from GCS. 404 for a missing file, 403 for another user's. |
387
+
388
+ `*gcsKey` is the full key with its slashes, such as `uploads/<userId>/<ms>-<name>`.
317
389
 
318
390
  ### addMcpRoutes(router, options)
319
391
 
@@ -356,10 +428,11 @@ new AiApp({
356
428
  | Option | Description |
357
429
  |--------|-------------|
358
430
  | `aiService` | Pre-configured server-wide AI service |
431
+ | `asks` | Let the model ask the user typed questions in chat: `true` or `{approvals, kinds, maxFileSizeBytes}`. `approvals` sets the [approval ask](agent-ui-asks.md#approval-asks) for host tools with `needsApproval`. Passed to `addGptRoutes`, and adds the headless `pendingAsks` and `turn` actions to `/gpt/histories`; see [Agent UI Asks](agent-ui-asks.md). `maxFileSizeBytes` also caps `/files/upload`. |
359
432
  | `createModelFn` | Build model from per-request `x-ai-api-key` |
360
433
  | `createServerModelFn` | Server-side model factory (e.g. Vertex ADC) without per-request key |
361
- | `demoMode` | Return canned responses when no AI service resolves |
362
- | `fileStorageService` + `gcsBucket` | Enable file upload routes and durable `/gpt/prompt` attachments |
434
+ | `demoMode` | Not read. The routes send a canned demo reply whenever no AI service resolves |
435
+ | `fileStorageService` + `gcsBucket` | Enable file upload routes, durable `/gpt/prompt` attachments, and `files` ask answers that name uploads by `fileId` |
363
436
  | `fileUploadsEnabled` | `false` or a function returning `false` rejects uploads and chat attachments with `403`. Omit to leave uploads enabled |
364
437
  | `mcpService` | Enable MCP routes and tool discovery in chat |
365
438
  | `tools` | Static Vercel AI SDK tool definitions for chat |
@@ -402,6 +475,158 @@ Client construction failures log a warning and skip the plugin so the API proces
402
475
 
403
476
  Calls `shutdownLangfuseClient()` and `shutdownTracing()` on `SIGTERM`.
404
477
 
478
+ ## Observability
479
+
480
+ In-app prompt versions, nested traces, evaluators, datasets, experiments, review queue, and in-app feedback. Operator loop: [Develop an AI feature](../how-to/ai-feature-development.md). Register plugins: [Observe LLM calls](../how-to/observe-llm-calls.md). Why two planes: [AI observability](../explanation/ai-observability.md). Locked design: [implementation plan](../implementationPlans/ai-observability.md).
481
+
482
+ Register `ObservabilityApp` with at least a local plugin. Construction throws if `experiments.primary !== datasets.primary`, if `reviewQueue` is not `local`, or if a control primary has no matching plugin. Defaults for all four primaries are `local`. Construction also registers the app as the process singleton (`getObservabilityApp()`) through the dependency-free observability registry, so routes do not import the plugin class that registers them. Call `resetObservabilityApp()` in tests. `createLocalObservabilityPlugin()` registers the local Mongo models (`ObsPrompt`, `ObsPromptVersion`, `ObsPromptLabel`, `ObsTrace`, `ObsSpan`, `ObsScore`) on the default connection.
483
+
484
+ The example backend always registers `createLocalObservabilityPlugin()` and passes the
485
+ validated `AI_OBS_PRICE_MAP_JSON` object as `priceMap`. `bun run backend:seed` idempotently
486
+ creates `examples/example-summarize` with production on v1 and an experimental v2, installs
487
+ `correctness-human` and `schema-assert`, and creates a two-item proofread `example-gold`
488
+ dataset bound to the prompt input schema. The same seed creates `examples/chat-safety-screen`
489
+ (production v1 scores the new message alone; v2 reads earlier turns), `examples/chat-safety-judge`,
490
+ the `chat-safety-agreement` llm-judge, and the 12-item proofread synthetic dataset
491
+ `chat-safety-synthetic`. Each row is one new message plus earlier turns from a two-person chat,
492
+ labeled for 988 vs care-team routing, toxicity, a privacy leak, and a dismissive reply.
493
+ Admin → Scripts → `seedChatSafetyDataset`, or `bun run script seedChatSafetyDataset --wet` from
494
+ `example-backend`, loads that set into an already-running database. `SEED_DEFAULTS=true` loads
495
+ it on boot, including PR preview. Invalid price JSON or negative/non-numeric prices
496
+ fail startup with `AI_OBS_PRICE_MAP_JSON` in the error.
497
+
498
+ `POST /ai/example-summarize` (example backend) runs that seeded prompt with
499
+ `promptLabel: "production"`, `userId`, and `sessionId` from `x-ai-session-id`. It uses the
500
+ server `AIService` when configured, otherwise a request-scoped service built from
501
+ `x-ai-api-key`, and returns **503** when neither exists. The example frontend calls it from
502
+ **Todos → Summarize**.
503
+
504
+ ### Local observability models
505
+
506
+ | Model | Role |
507
+ | --- | --- |
508
+ | `ObsPrompt` | Named prompt (`name` unique) with `folder` and `tags[]` |
509
+ | `ObsPromptVersion` | Immutable `vN` body, `variables[]`, schemas, `sensitive` (default false), `config` |
510
+ | `ObsPromptLabel` | Movable labels; unique `(promptId, label)` |
511
+ | `ObsTrace` | Root trace: user, session, status, `errorSummary`, `sensitive`, `prompts[]`, usage |
512
+ | `ObsSpan` | Nested span with `kind`, `status`, optional `error`, offsets, usage |
513
+ | `ObsScore` | Scores on a trace/span; many per trace, **no unique index** |
514
+ | `ObsEvaluator` | Evaluator: `type` (`human` \| `llm-judge` \| `json-assert`), `target`, `dimensions[]`, `runModes`, `instructions`, `judgePromptName` (judge), `assertion` (json-assert), `confidenceAlertBelow` (default 0.7) |
515
+ | `ObsReviewItem` | Review queue item: status, evaluator, trace, reason, scores, comment |
516
+ | `ObsDataset` | Named dataset with optional `inputSchemaPromptName` and `expectedOutputSchema` |
517
+ | `ObsDatasetItem` | Item with `input`, `expectedOutput`, `origin`, `proofread`, `tags`, `outcomeClass`, `sourceTraceId`, `metadata` |
518
+ | `ObsExperiment` | Compares 2–3 prompt versions on a dataset with thresholds and aggregates |
519
+ | `ObsExperimentItem` | Per dataset row: outputs per version, evaluator score maps, gate failure flags |
520
+
521
+ `POST /ai/observability/traces/review` requires a `human` `ObsEvaluator`. Its
522
+ `dimensions[]` render as reviewer score fields and `instructions` render above the review form.
523
+ Automatic evaluator types return **400** instead of entering the human queue. Submitting a review
524
+ requires every dimension marked `required`; omitted optional dimensions do not create empty score
525
+ rows. The local score store remains the fallback when no external score sink is configured.
526
+
527
+ `AIService` generate methods:
528
+
529
+ | Option | Default | Behavior |
530
+ | --- | --- | --- |
531
+ | `promptName` | unset | Resolve `PromptRegistry.get({name, label})` **before** the model call, even when `skipTrace` is true |
532
+ | `promptLabel` | `"production"` | Label used with `promptName` |
533
+ | `skipTrace` | `false` | Skip `TraceSink.export` only; prompt resolve and `AIRequest` still run |
534
+ | `sensitive` | inherited | Explicit value wins; otherwise the resolved prompt version's `sensitive` |
535
+ | `sessionId` / `userId` | unset | Copied onto the exported trace |
536
+ | `priceMap` | app `priceMap` | Per-call override; `costUsd` is omitted when the model is unpriced |
537
+
538
+ Missing registry, missing prompt, or missing label throws `APIError` 400 and does not call the model. Sink `export` failures are logged and never fail generate.
539
+
540
+ **GPT tool spans:** `/gpt/prompt` collects streamed `tool-call` / `tool-result` events into `TOOL` child spans (name = tool name, input = args, output = cleaned result with large `fileData` stripped). When any tool span is present, `AIService.recordGenerate` exports one trace with a `CHAIN` root plus `TOOL` children linked by `parentSpanId`. Ordinary `generateText` / JSON helpers without `childSpans` still emit a single `LLM` root span.
541
+
542
+ `ObservabilityApp.exportTrace(trace)` fans out to every `TraceSink` (best-effort) and returns the first persisted `{id}` from sinks that support it (for example `LocalTraceSink`). `TraceSink.export` may return `TraceExportResult` (`{id?: string}`) or `void`; `MemoryTraceSink` remains in-memory only.
543
+
544
+ When `prompts.primary` is `local`, `ObservabilityApp.register` mounts admin-only prompt routes at `/ai/observability`. Pass `aiService` on `ObservabilityApp` for playground runs and the multi-stage trace smoke endpoint. Apps that let an admin supply a per-request provider key may instead set `requestAiServiceFactory`; both the playground and the multi-stage smoke endpoint read the key from `x-ai-api-key`, while a configured server `aiService` remains preferred. `GET /ai/observability/status` is always mounted so admin chrome can read plugin ids, capabilities, primaries, `localOn`, and `playgroundAi.source` (`server` \| `request-key` \| `unavailable`). Admin UIs use that field to distinguish “save a provider key” from true backend misconfiguration.
545
+
546
+ | Method | Path | Behavior |
547
+ | --- | --- | --- |
548
+ | GET | `/ai/observability/status` | Admin chrome. `{plugins, primaries, localOn, playgroundAi}` — drives the status chip, hides Review when `localOn` is false, and tells the prompt playground whether AI comes from `ObservabilityApp.aiService` (`server`), per-request `x-ai-api-key` via `requestAiServiceFactory` (`request-key`), or is not configured (`unavailable`) |
549
+
550
+ | Method | Path | Behavior |
551
+ | --- | --- | --- |
552
+ | GET | `/ai/observability/prompts` | List. Query `folder`, `search`, `include=usage7d` (7-day calls/cost). `production` is `"—"` until a production label exists |
553
+ | POST | `/ai/observability/prompts` | Create prompt in a folder as immutable v1 (`latest` label) |
554
+ | GET | `/ai/observability/prompts/:name` | Prompt + versions + labels |
555
+ | POST | `/ai/observability/prompts/:name/versions` | Create `vN+1`; never mutates an existing version |
556
+ | POST | `/ai/observability/prompts/:name/labels` | Move `production` or `staging`; `outgoingVersion` is the previous pointer |
557
+ | POST | `/ai/observability/prompts/:name/playground` | Compile `{{var}}` + one `AIService` call; returns compiled messages, output, latency, tokens, cost; creates no version. Uses `ObservabilityApp.aiService`, or `requestAiServiceFactory({apiKey, modelId})` when the server service is absent (`apiKey` comes from `x-ai-api-key`) |
558
+
559
+ `PromptRegistry.get({name, label})` (default label `production`) reads the labelled local version. `createLocalObservabilityPlugin()` wires `LocalPromptStore` as that registry and local `TraceSink` / `ScoreSink`.
560
+
561
+ | Method | Path | Behavior |
562
+ | --- | --- | --- |
563
+ | GET | `/ai/observability/traces` | Admin list. Query `from`, `to`, `prompt`, `status`, `userId`, `sessionId`, `hasScore`, `sensitive`, `flaggedForDataset`, `page`, `limit`. Body is `{data, page, limit, more, total}` so pagination survives RTK `{data}` unwrap. Each row includes `spanCount` and `scoreCount`. `prompts.length` is the `N prompts` count |
564
+ | GET | `/ai/observability/traces/:id` | Span tree (kind, offsets, durations, I/O, cost) plus scores. `errorSummary` is the first span with `status: "error"` |
565
+ | POST | `/ai/observability/traces/:id/scores` | Persist a score and fan out to every `ScoreSink` |
566
+ | POST | `/ai/observability/traces/test-multi-stage` | Admin-only smoke workflow, registered only with the local trace sink. Uses `ObservabilityApp.aiService`, or `requestAiServiceFactory({apiKey})` when the server service is absent (`apiKey` comes from `x-ai-api-key`); answers **503** with the same missing-key title as playground when neither exists. Body `{input?: string}` (defaults to a built-in sample). Runs two `AIService.generateJsonObject` calls with `skipTrace: true` and named JSON output schemas (`obs-test-multi-stage-call-1` / `call-2`), a deterministic local `text-metrics` `TOOL` stage, then a final `generateJsonObject` synthesis against `obs-test-multi-stage-final`; exports exactly one parent trace with ordered child spans `LLM`, `LLM`, `TOOL`, `LLM` under a `CHAIN` root. LLM span input includes `outputSchema`. Returns `{traceId, output, stages[]}` where `output` is the final schema object (`sentence`, `phrase`, `keywords`, `metrics`). Child LLM failures export an error trace then rethrow |
567
+
568
+ `createLocalObservabilityPlugin()` registers `ObsEvaluator` with the other local models.
569
+
570
+ | Method | Path | Behavior |
571
+ | --- | --- | --- |
572
+ | GET | `/ai/observability/evaluators/templates` | Seeded templates: `llm-judge` (`correctness`, `hallucination`, `helpfulness`, `toxicity`), `json-assert` (`schema-assert`), and human queue variants (`correctness-human`, …) |
573
+ | POST | `/ai/observability/evaluators/templates/:name` | Install a template by name as an immutable-named evaluator |
574
+ | GET/POST | `/ai/observability/evaluators` | List / create. Create accepts `target: "full trace"` only (`generation span` and `dataset item` → 400). Seeded template install can still store other targets. `llm-judge` requires `judgePromptName`; create rejects when the judge prompt `outputSchema` omits a required dimension (400 names the key). `json-assert` supports `assertion` (`path` + `constraint`) or built-in output-schema mode. Human + `liveSampleRate > 0` → 400. Numeric dimension `range` is `min-max` (for example `0-1`); categorical `range` is `label|label` |
575
+ | GET/PATCH/DELETE | `/ai/observability/evaluators/:id` | Read / update / soft-delete |
576
+ | POST | `/ai/observability/traces/review` | Enqueue one or many traces against a human evaluator (`reason: "manual"`) |
577
+ | GET | `/ai/observability/review` | Queue by `status` with counts; oldest-first. Response includes `more: false` so RTK preserves the count envelope. Rows include `traceName`, `promptName`, assignee, reason, and enqueue time |
578
+ | GET | `/ai/observability/review/:id` | Item + evaluator dimensions + `given` / `wrote` panels and `rawInput` / `rawOutput` for the Raw JSON disclosure |
579
+ | POST | `/ai/observability/review/:id` | `submit` (scores via ScoreSinks, status `done`), `skip`, or `assign` |
580
+
581
+ `createLocalObservabilityPlugin()` wires `LocalDatasetStore` and `LocalExperimentRunner` when datasets/experiments primaries are `local`.
582
+
583
+ | Method | Path | Behavior |
584
+ | --- | --- | --- |
585
+ | GET/POST | `/ai/observability/datasets` | List (includes `humanCount` for proofread items, `autoCount` for unreviewed trace or synthetic items, `needsReviewCount`) / create |
586
+ | GET/PATCH/DELETE | `/ai/observability/datasets/:id` | Detail (with counts) / update / soft-delete. PATCH `null` clears optional dataset fields; omitted fields stay unchanged |
587
+ | GET/POST | `/ai/observability/datasets/:id/items` | List / create items |
588
+ | PATCH/DELETE | `/ai/observability/datasets/:id/items/:itemId` | Update labels (`expectedOutput`, `proofread`, `tags`, `outcomeClass`) / delete (does not touch the source trace). PATCH `null` clears optional item fields |
589
+ | POST | `/ai/observability/datasets/:id/import` | **JSON:** body is an array of bare input objects, or structured rows with `input` / `expectedOutput` / `proofread` / `tags` / `outcomeClass` / `metadata`. **CSV:** `Content-Type: text/csv` with raw CSV body, or JSON `{format: "csv", content: "..."}`. Plain columns map to `input`; plain `input` / `expectedOutput` cells accept JSON values; `input.foo` and `expectedOutput.foo` nest fields; reserved `proofread`, `tags`, `outcomeClass` map metadata. Nested paths reject `__proto__`, `constructor`, and `prototype` segments. Rows validate against the dataset's bound prompt `inputSchema` when `inputSchemaPromptName` is set; 400 reports row number and JSON path |
590
+ | POST | `/ai/observability/traces/add-to-dataset` | `{datasetId, traceId \| traceIds[]}`. Copies span I/O; `origin: "trace"`; `sourceTraceId` set; **sensitive traces always `proofread: false`** |
591
+
592
+ | Method | Path | Behavior |
593
+ | --- | --- | --- |
594
+ | POST | `/ai/observability/experiments/estimate` | `{datasetId, promptName, versions[], evaluatorIds[], modelOverride?}` → generation count, USD, wall-clock estimate |
595
+ | GET/POST | `/ai/observability/experiments` | List / create. Body: dataset, 2–3 version numbers, evaluator ids, optional `thresholds[]` (defaults to `SOP_DEFAULT_THRESHOLDS`), `modelOverride`, `includeUnproofread` (default false). Evaluator ids must be `llm-judge` or `json-assert` (human → 400). Local primary always enqueues `BackgroundTask` (even one item) |
596
+ | GET | `/ai/observability/experiments/:id` | Status, progress, per-version aggregates, gate pass/fail (`gates[].version`), `outlierItemIds`, `lowConfidenceItemIds`, per-item side-by-side (**failed rows first**) |
597
+ | POST | `/ai/observability/experiments/:id/promote` | `{version}` moves the `production` label when **that version's** gates pass; **409** while any gate for the selected version fails |
598
+
599
+ Authenticated `POST /ai/observability/traces/:id/feedback` records thumbs, outcome class, and flag-for-dataset (phase 2.6).
600
+
601
+ ## Durable agent harness
602
+
603
+ `@terreno/ai/harness` runs multi-phase tasks that survive restarts. Each phase commits a
604
+ checkpoint and its `ObsSpan` audit record in one Mongo transaction, so it needs a replica
605
+ set and `createLocalObservabilityPlugin()`. Owner and task leases resume crashed work on a
606
+ fresh process; phases that are not replay-safe park as `interrupted` until an operator calls
607
+ `resolveInterrupted`. A thrown phase retries with exponential backoff before it fails.
608
+ Phases can start child tasks (`rt.createTask`), wait on them (`rt.waitForTasks`), and
609
+ `harness.abort` stops a whole ownership tree bottom-up, running compensation handlers.
610
+ `defineAgent` / `defineTool` run durable agent conversations: each turn is a task whose
611
+ model requests retry 429/5xx and fall back to other models, and whose tool calls run as
612
+ child tasks with per-tool replay rules. `rt.runAgent` runs an agent as a subagent from a
613
+ phase (a task-owned conversation) and returns its text or schema-checked output.
614
+ `defineExtension` bundles prompt sections, tools, tool wraps, and hooks
615
+ (`beforeModelRequest`, `beforeTool` with block or rewrite, `afterTool`); `rt.memo` stores
616
+ first-write-wins decisions that survive restarts. `rt.waitFor(event, {timeout})` and
617
+ `rt.sleep(duration)` park a task without a lease; `harness.sendEvent` stores the event
618
+ durably and wakes the task, even when no runner is up. `rt.approval(key, {...})` waits for
619
+ a human decision; `approvalGate` requires one before named tool calls; `HarnessApp` serves
620
+ `GET /harness/approvals` (only what the caller may approve) and `approve` / `reject`
621
+ instance actions, each decision audited in an `approval:<key>` span. Clients watch runs
622
+ over SSE (`GET /harness/conversations/:id/events`, `GET /harness/tasks/:id/events`),
623
+ resumable from any instance with `Last-Event-ID`; turns stream coalesced text deltas, and
624
+ `POST /harness/conversations/:id/submit` queues or steers a message while a turn runs.
625
+ API: [AI harness reference](ai-harness.md).
626
+ Why: [Durable agent harness](../explanation/durable-agent-harness.md).
627
+ How-to: [Build a durable workflow](../how-to/build-a-durable-workflow.md),
628
+ [Ship a new task version](../how-to/ship-a-new-task-version.md).
629
+
405
630
  ## Langfuse integration
406
631
 
407
632
  Low-level exports (also used by `addGptRoutes` when `langfuseSystemPromptName` is set):
@@ -423,7 +648,8 @@ const storage = new FileStorageService({
423
648
  storageOptions: {}, // optional @google-cloud/storage options
424
649
  });
425
650
 
426
- await storage.upload({buffer, filename, mimeType, userId});
651
+ await storage.upload({buffer, filename, mimeType, userId}); // {id, filename, gcsKey, mimeType, size, url}
652
+ await storage.download(gcsKey); // the upload's bytes, as a Buffer
427
653
  await storage.getSignedUrl(gcsKey); // 1-hour v4 signed URL
428
654
  await storage.delete(gcsKey); // GCS delete + soft-delete FileAttachment
429
655
  ```
@@ -505,7 +731,7 @@ new TerrenoApp({userModel: User})
505
731
  .start();
506
732
  ```
507
733
 
508
- Legacy `setupServer` pattern: call `addGptHistoryRoutes`, `addGptRoutes`, etc. inside `addRoutes`.
734
+ Legacy `setupServer` pattern: call `addGptHistoryRoutes`, `addGptRoutes`, etc. inside `addRoutes`. With `asks` on, pass the chat options to `addGptHistoryRoutes` as `chat` to keep the headless endpoints.
509
735
 
510
736
  ## Environment variables
511
737
 
@@ -513,7 +739,7 @@ Legacy `setupServer` pattern: call `addGptHistoryRoutes`, `addGptRoutes`, etc. i
513
739
  |----------|---------|-------------|
514
740
  | `GOOGLE_VERTEX_PROJECT` | `createVertexProvider` | GCP project for Vertex models |
515
741
  | `GOOGLE_VERTEX_LOCATION` | `createVertexProvider` | Vertex region (default `global`) |
516
- | `LANGFUSE_PUBLIC_KEY` | `LangfuseApp` | Langfuse public key |
742
+ | `AI_OBS_PRICE_MAP_JSON` | `ObservabilityApp` | JSON model map with non-negative `inputPerMTok` / `outputPerMTok`; omitted models have tokens but no USD cost || `LANGFUSE_PUBLIC_KEY` | `LangfuseApp` | Langfuse public key |
517
743
  | `LANGFUSE_SECRET_KEY` | `LangfuseApp` | Langfuse secret key |
518
744
  | `LANGFUSE_BASE_URL` | Langfuse client | Langfuse host URL |
519
745
 
@@ -532,7 +758,7 @@ GCS credentials use standard Google Cloud Application Default Credentials for `F
532
758
 
533
759
  - Framework: `bun test` with preload `./src/tests/bunSetup.ts`
534
760
  - HTTP: supertest against real routes
535
- - DB: memory Mongo via `@terreno/test` (`TERRENO_TEST_USE_MEMORY_MONGO` or `TERRENO_TEST_MONGODB_URI`)
761
+ - DB: in-memory single-node replica set via `@terreno/test` (transactions work); `TERRENO_TEST_MONGODB_URI` overrides it and must point at a replica set
536
762
  - Mock AI model: implement `doGenerate` and `doStream` on a fake `LanguageModel`
537
763
 
538
764
  ```typescript
@@ -235,6 +235,10 @@ updating, and removing array items:
235
235
  | `PATCH` | `/:id/:field/:itemId` | Replace or merge an item |
236
236
  | `DELETE` | `/:id/:field/:itemId` | Remove an item |
237
237
 
238
+ `:id` must be an ObjectId. Any other id leaves this router so a later route on the same
239
+ prefix can handle it. `POST /ai/observability/evaluators` is not an array push on a model
240
+ mounted at `/ai`.
241
+
238
242
  The `field` path must resolve to an array on the document. Requests for scalar, object,
239
243
  or unknown fields run the normal update transform and `preUpdate` hook first so consumer
240
244
  authorization remains authoritative, then return `400` with code
@@ -834,6 +838,48 @@ await Membership.create({
834
838
  });
835
839
  ``````
836
840
 
841
+ ### AI observability RBAC
842
+
843
+ `terrenoStatements` includes domain-neutral resources for `@terreno/ai` `ObservabilityApp`
844
+ routes. Pass `accessControl` from `createAccess` on `ObservabilityApp` to enforce them; omit
845
+ `accessControl` to keep legacy `user.admin` / `Permissions.IsAdmin` behavior.
846
+
847
+ | Resource | Actions |
848
+ | --- | --- |
849
+ | `aiPrompt` | `list`, `read`, `create`, `update`, `promote`, `playground` |
850
+ | `aiTrace` | `list`, `read` |
851
+ | `aiReview` | `list`, `read`, `score`, `assign` |
852
+ | `aiDataset` | `create`, `list`, `read`, `update`, `delete` |
853
+ | `aiEvaluator` | `create`, `list`, `read`, `update`, `delete` |
854
+ | `aiExperiment` | `list`, `read`, `create`, `promote` |
855
+
856
+ Human-readable labels for the admin role editor live in `terrenoStatementDescriptions`
857
+ (optional on `createAccess({ statementDescriptions })`).
858
+
859
+ | Role | Observability grants |
860
+ | --- | --- |
861
+ | `superadmin` | `*` (every action on every resource) |
862
+ | `admin` (seeded) | Full grants on all six resources |
863
+ | `auditor` | Read-only sentinel expands to `list` and `read` on each observability resource (no `admin:access`) |
864
+ | `aiObservabilityViewer` (example-backend only) | `admin:access` plus `list` and `read` on each observability resource |
865
+ | `aiObservabilityOperator` (example-backend only) | `admin:access` plus full grants on each observability resource |
866
+
867
+ The seeded `auditor` role does **not** include `admin:access`. Observability `list` / `read`
868
+ grants alone do not open the admin shell — compose a consumer role (for example
869
+ `auditor` + `admin: ["access"]`, or a custom role) when operators should browse observability
870
+ screens.
871
+
872
+ Re-seeding default roles does not overwrite customized unsealed roles, except seeded
873
+ `admin` and `auditor`: on each `seedDefaults`, Terreno **merges** missing observability
874
+ actions into stored permissions (`admin` — full grants; `auditor` — `list` and `read` per
875
+ resource) without removing consumer-defined grants on other resources.
876
+
877
+ When `accessControl` is configured, each observability HTTP route checks `admin:access` and
878
+ the mapped resource action. Callers with `user.admin === true` still receive full access
879
+ (legacy compatibility). RBAC-only operators need `admin:access` plus the exact action for
880
+ mutations. Route mapping (for example prompt label moves → `aiPrompt:promote`, review
881
+ submit → `aiReview:score`) is documented in [AI reference — Observability](ai.md#observability).
882
+
837
883
  ## Request Validation
838
884
 
839
885
  @terreno/api provides runtime validation of incoming requests against OpenAPI schemas using [AJV](https://ajv.js.org/). Validation is opt-in and can be enabled globally or per-route.