@terreno/mcp 57.8.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 (115) hide show
  1. package/dist/docs/upgrades/57.10.1.md +54 -0
  2. package/dist/docs/upgrades/57.9.0.md +28 -0
  3. package/dist/docs/versioned/next/README.md +1 -0
  4. package/dist/docs/versioned/next/explanation/README.md +4 -0
  5. package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  6. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  7. package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  8. package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
  9. package/dist/docs/versioned/next/explanation/charts.md +12 -0
  10. package/dist/docs/versioned/next/explanation/durable-agent-harness.md +531 -0
  11. package/dist/docs/versioned/next/explanation/example-coverage.md +4 -2
  12. package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
  13. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +49 -1
  14. package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  15. package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
  16. package/dist/docs/versioned/next/how-to/README.md +7 -1
  17. package/dist/docs/versioned/next/how-to/admin-import-prebuilt.md +1 -1
  18. package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  19. package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  20. package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  21. package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  22. package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +7 -0
  23. package/dist/docs/versioned/next/how-to/circleci.md +9 -6
  24. package/dist/docs/versioned/next/how-to/compare-chart-rendered-snapshots.md +8 -0
  25. package/dist/docs/versioned/next/how-to/install-agent-skills.md +1 -1
  26. package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  27. package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  28. package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  29. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  30. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  31. package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  32. package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  33. package/dist/docs/versioned/next/implementationPlans/b2b-platform-program.md +1 -1
  34. package/dist/docs/versioned/next/implementationPlans/charts-and-dashboards.md +3 -2
  35. package/dist/docs/versioned/next/implementationPlans/dashboard-chart-parity.md +275 -0
  36. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  37. package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  38. package/dist/docs/versioned/next/reference/README.md +3 -0
  39. package/dist/docs/versioned/next/reference/admin-config.md +2 -0
  40. package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
  41. package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  42. package/dist/docs/versioned/next/reference/ai-harness.md +1881 -0
  43. package/dist/docs/versioned/next/reference/ai.md +283 -28
  44. package/dist/docs/versioned/next/reference/api.md +46 -0
  45. package/dist/docs/versioned/next/reference/blocks.md +178 -0
  46. package/dist/docs/versioned/next/reference/environment-variables.md +2 -1
  47. package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
  48. package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
  49. package/dist/docs/versioned/next/reference/test.md +11 -2
  50. package/dist/docs/versioned/next/reference/ui.md +418 -11
  51. package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  52. package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  53. package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
  54. package/dist/docs/versioned/next/tasks/dashboard-chart-parity.md +145 -0
  55. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +183 -0
  56. package/dist/tools.d.ts +4 -0
  57. package/dist/tools.d.ts.map +1 -1
  58. package/dist/tools.js +37 -0
  59. package/dist/tools.js.map +1 -1
  60. package/package.json +3 -2
  61. package/src/docs/upgrades/57.10.1.md +54 -0
  62. package/src/docs/upgrades/57.9.0.md +28 -0
  63. package/src/docs/versioned/next/README.md +1 -0
  64. package/src/docs/versioned/next/explanation/README.md +4 -0
  65. package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  66. package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  67. package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  68. package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
  69. package/src/docs/versioned/next/explanation/charts.md +12 -0
  70. package/src/docs/versioned/next/explanation/durable-agent-harness.md +531 -0
  71. package/src/docs/versioned/next/explanation/example-coverage.md +4 -2
  72. package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
  73. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +49 -1
  74. package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  75. package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
  76. package/src/docs/versioned/next/how-to/README.md +7 -1
  77. package/src/docs/versioned/next/how-to/admin-import-prebuilt.md +1 -1
  78. package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  79. package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  80. package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  81. package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  82. package/src/docs/versioned/next/how-to/charts-and-dashboards.md +7 -0
  83. package/src/docs/versioned/next/how-to/circleci.md +9 -6
  84. package/src/docs/versioned/next/how-to/compare-chart-rendered-snapshots.md +8 -0
  85. package/src/docs/versioned/next/how-to/install-agent-skills.md +1 -1
  86. package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  87. package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  88. package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  89. package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  90. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  91. package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  92. package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  93. package/src/docs/versioned/next/implementationPlans/b2b-platform-program.md +1 -1
  94. package/src/docs/versioned/next/implementationPlans/charts-and-dashboards.md +3 -2
  95. package/src/docs/versioned/next/implementationPlans/dashboard-chart-parity.md +275 -0
  96. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  97. package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  98. package/src/docs/versioned/next/reference/README.md +3 -0
  99. package/src/docs/versioned/next/reference/admin-config.md +2 -0
  100. package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
  101. package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  102. package/src/docs/versioned/next/reference/ai-harness.md +1881 -0
  103. package/src/docs/versioned/next/reference/ai.md +283 -28
  104. package/src/docs/versioned/next/reference/api.md +46 -0
  105. package/src/docs/versioned/next/reference/blocks.md +178 -0
  106. package/src/docs/versioned/next/reference/environment-variables.md +2 -1
  107. package/src/docs/versioned/next/reference/feature-flags.md +1 -1
  108. package/src/docs/versioned/next/reference/mcp-server.md +17 -0
  109. package/src/docs/versioned/next/reference/test.md +11 -2
  110. package/src/docs/versioned/next/reference/ui.md +418 -11
  111. package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  112. package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  113. package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
  114. package/src/docs/versioned/next/tasks/dashboard-chart-parity.md +145 -0
  115. package/src/docs/versioned/next/tasks/durable-agent-harness.md +183 -0
@@ -0,0 +1,54 @@
1
+ # Upgrading to 57.10.1
2
+
3
+ **Action required:** Yes
4
+ **Affected packages:** @terreno/ai
5
+
6
+ ## Summary
7
+
8
+ Signed file URLs from `GET /files/*gcsKey` now require the caller's session and only resolve that caller's uploads. This release also adds agent asks, block documents, a durable task harness, and local AI observability. An app that already sends the user's token when fetching that user's own files needs no change. An app that fetched a file URL without a session, or that fetched another user's key, must change.
9
+
10
+ ## Breaking changes
11
+
12
+ ### File download URLs require the owner session
13
+
14
+ **What changed**
15
+
16
+ `GET /files/*gcsKey` (`addFileRoutes`, and `AiApp` when `gcsBucket` is set) now requires authentication. An unauthenticated request returns 401. The route returns a signed URL only when the stored file's `userId` is the caller. Another user's file, including a request from an admin, returns 404, the same response as a missing key. Keys look like `uploads/<userId>/<ms>-<name>` and were previously guessable.
17
+
18
+ **Why**
19
+
20
+ Anyone who knew or guessed a key could obtain a signed URL for that object.
21
+
22
+ **Migration**
23
+
24
+ ```typescript
25
+ // Before
26
+ const response = await fetch(`${apiBase}/files/${gcsKey}`);
27
+
28
+ // After
29
+ const response = await fetch(`${apiBase}/files/${gcsKey}`, {
30
+ headers: {authorization: `Bearer ${accessToken}`},
31
+ });
32
+ // 401: send the session token.
33
+ // 404: the file is missing or belongs to someone else. Do not walk other keys.
34
+ ```
35
+
36
+ `GET` and `DELETE /files/*gcsKey` also resolve the full key when it contains slashes. Express 5 passed the wildcard as path segments, so `uploads/<userId>/<ms>-<name>` used to 404.
37
+
38
+ ## Deprecations
39
+
40
+ None
41
+
42
+ ## New capabilities
43
+
44
+ - Turn on agent asks with `asks: true` (or `{kinds, approvals, maxFileSizeBytes}`) on `AiApp` or `addGptRoutes`. Asks stay off when the option is unset. See `docs/how-to/agent-ui-asks.md`.
45
+ - `@terreno/blocks` is the shared contract for asks and for block documents (headings, metrics, charts, tables, actions). `GPTChat` can render both.
46
+ - `@terreno/ai/harness` runs phased tasks that survive a process crash and can wait for a person. See `docs/how-to/build-a-durable-workflow.md`.
47
+ - `ObservabilityApp` adds local prompt versions, traces, evaluators, datasets, experiments, and a review queue, with admin screens in `@terreno/admin-frontend`. See `docs/how-to/observe-llm-calls.md`.
48
+
49
+ ## Verification
50
+
51
+ - `GET /files/<own key>` with the owner's session returns a signed URL.
52
+ - The same request without a session returns 401.
53
+ - A key owned by someone else returns 404.
54
+ - `bun run compile` and `bun run lint` at the app root.
@@ -0,0 +1,28 @@
1
+ # Upgrading to 57.9.0
2
+
3
+ **Action required:** No
4
+ **Affected packages:** @terreno/ai, @terreno/ui
5
+
6
+ ## Summary
7
+
8
+ This release adds durable and resumable GPT chat streams, safer attachment handling, operations-dashboard components, and improved web toast and file-picker behavior. Existing applications continue to work without migration.
9
+
10
+ ## Breaking changes
11
+
12
+ None
13
+
14
+ ## Deprecations
15
+
16
+ None
17
+
18
+ ## New capabilities
19
+
20
+ - Configure `fileStorageService` to persist chat attachments and resume in-flight replies through `GET /gpt/histories/:id/stream`.
21
+ - Compose operations dashboards with scorecards, comparison series, spanning items, and richer chart presentation.
22
+ - Set `maxHeight` on growing `TextField` and `TextArea` components.
23
+
24
+ ## Verification
25
+
26
+ - Run `bun run compile` and `bun run lint` at the app root.
27
+ - Exercise GPT chat attachment upload, reload during a response, and stream resumption when adopting the new AI capabilities.
28
+ - Check web toasts above an open modal and file selection through `FilePickerButton`.
@@ -37,6 +37,7 @@ Canonical copy, language rules, and the Django/Rails comparison:
37
37
  | [@terreno/ui](reference/ui.md) | React Native UI component library for iOS, Android, and web |
38
38
  | [@terreno/syncdb](reference/syncdb.md) | Local-first data layer with TinyBase, durable outbox, and delta sync |
39
39
  | [@terreno/ai](reference/ai.md) | Provider-agnostic AI service with streaming chat, request logging, and admin tools |
40
+ | [@terreno/blocks](reference/blocks.md) | Shared contracts for agent-driven UI: ask schemas and whole-reply block documents |
40
41
  | [@terreno/admin-backend](reference/admin-backend.md) | Admin panel backend plugin for `@terreno/api` |
41
42
  | [@terreno/admin-frontend](reference/admin-frontend.md) | Admin panel frontend screens for `@terreno/api` backends |
42
43
  | [@terreno/admin-spa](reference/admin-spa.md) | Standalone admin SPA (Expo Router web) plus Express serve plugin |
@@ -15,7 +15,11 @@ Understanding-oriented documentation: concepts, architecture, and context.
15
15
  - [Loop engineering](loop-engineering.md) — Fresh-invocation lifecycle, state, evidence, and orchestration boundaries
16
16
  - [GitHub issue lifecycle](../how-to/github-issue-lifecycle.md) — Pick-ready issues and plan comments for Pick/Roast
17
17
  - [Install agent skills](../how-to/install-agent-skills.md) — `npx skills add TerrenoLabs/terreno`
18
+ - [AI observability](ai-observability.md) — Two planes (telemetry vs control), plugins vs LangfuseApp, SOP loop
19
+ - [Durable agent harness](durable-agent-harness.md) — Phases, checkpoints, and why the audit span shares the checkpoint transaction
18
20
  - [AI-powered workflows](ai-workflows.md) — Autonomous documentation, testing, and maintenance workflows
21
+ - [Agent UI Asks](agent-ui-asks.md) — Why agent questions are client-side tool calls, the pause and resume round trip, why cards carry exact answers, the watch paths, asks vs blocks
22
+ - [Agent UI blocks](agent-ui-blocks.md) — Why chat replies use a closed YAML catalog instead of free-form UI
19
23
  - [Authentication architecture](authentication.md) — Better Auth, JWT, and optional MCP service tokens
20
24
  - [Organization tenancy](organizations.md) — Optional Membership-backed tenancy, context, and isolation boundaries
21
25
  - [Configuration system](configuration-system.md) — Runtime configuration with database persistence
@@ -8,8 +8,8 @@ implementations ship from `@terreno/admin-frontend`, under `src/widgets/`.
8
8
 
9
9
  This keeps the first-party integration in one frontend package and avoids making backend-only
10
10
  packages depend on React Native. The built-in registry includes consent field widgets, feature
11
- flag home widgets, and Documents/AI/Comms custom screens. Hosts can override any ID through
12
- `AdminProvider.widgets`.
11
+ flag home widgets, and Documents/AI Requests/AI Observability/Comms custom screens. Hosts can
12
+ override any ID through `AdminProvider.widgets`.
13
13
 
14
14
  Third-party plugins should publish their own frontend package and ask the host to spread its
15
15
  registry into `AdminProvider`; `@terreno/admin-frontend` only owns first-party Terreno widgets.
@@ -0,0 +1,299 @@
1
+ # Agent UI Asks
2
+
3
+ An ask lets an agent stop in the middle of a turn, ask the user a typed question inside the chat,
4
+ and continue with the answer as a tool result. This page explains why asks are client-side tool
5
+ calls on the existing chat stream, how one round trip works, how a watch or another small client
6
+ answers them, and how asks and Agent UI Blocks divide the work. Fields, limits, events, and error
7
+ codes are in the [reference](../reference/agent-ui-asks.md).
8
+
9
+ Asks ship today as five kinds: `choice`, to pick one option or several with an optional answer
10
+ of the user's own (Other), `confirm`, to approve or deny one action, `markdown`, to edit a draft
11
+ the agent wrote and send it back, `form`, to fill in a few typed fields at once, and `files`, to
12
+ send images or documents the agent reads in the same turn. The chat asks
13
+ on `POST /gpt/prompt` and shows asks in `GPTChat`. Watches and other small clients answer
14
+ select-one choices and confirms on two JSON endpoints with the compact surface, and can approve a
15
+ markdown draft or submit a form's defaults as is. A host tool can also require an approval that
16
+ the server enforces, shown as a `confirm`. The sandboxed HTML block and the `callout`, `image`,
17
+ and `details` display blocks follow the
18
+ [implementation plan](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/agent-ui-asks.md).
19
+
20
+ ## The problem
21
+
22
+ Agents often need one decision before they can go on: which plan, which record, whether to
23
+ proceed. In plain chat the agent asks in prose and the user types a reply. The agent then parses
24
+ free text that may not match any option, nothing ties the reply to the question, and nothing stops
25
+ the user from answering with something that was never offered.
26
+
27
+ ## Why client-side tool calls
28
+
29
+ Each ask kind is an AI SDK tool, such as `ask_choice`, with a Zod input schema, an output schema,
30
+ and no `execute` function. Most of the design follows from that choice:
31
+
32
+ - **The model is held to the schema.** Provider function calling makes the model write a JSON
33
+ object for the tool, and the AI SDK checks it against the Zod schema. An invalid ask goes back
34
+ to the model as a tool error it can fix in its next step. It never reaches the user.
35
+ - **The turn pauses by itself.** A tool without `execute` ends the AI SDK step loop, so the
36
+ server needs no waiting state inside the model call. It saves the paused turn and ends the
37
+ stream.
38
+ - **The answer is tied to the question.** The tool call id is the key. The answer names it, and
39
+ the server accepts an answer only for the ask the conversation waits on.
40
+ - **The model already knows how to read the answer.** It comes back as the tool's result, which
41
+ every provider represents natively, so no prompt wording has to explain what the user chose.
42
+
43
+ This is the same pattern as AI SDK client-side tools, AG-UI interrupts, and CopilotKit
44
+ human-in-the-loop. The answer envelope (`accept`, `decline`, `cancel`) is the MCP elicitation
45
+ shape.
46
+
47
+ Other approaches were rejected:
48
+
49
+ | Approach | Why not |
50
+ | --- | --- |
51
+ | Move the chat to the AI SDK `useChat` UI message stream | Rewrites the SSE contract every host parses, and the history storage |
52
+ | Form blocks inside the reply document, like Slack Block Kit inputs | Values come back as user text with nothing tying them to a question, and they need blocks turned on |
53
+ | Interactive HTML mini-apps, like MCP Apps | No React Native host exists, and they open the widest phishing and data-leak surface |
54
+
55
+ ## One round trip
56
+
57
+ 1. **Ask.** The model calls `ask_choice` with a prompt and options. The AI SDK checks the input;
58
+ an invalid ask goes back to the model as a tool error.
59
+ 2. **Pause.** The step loop ends. The server derives the ask's simple card and saves the paused
60
+ turn on the history as `pendingAsk`, including the AI SDK messages the turn produced. It adds a
61
+ row for the ask with status `pending`, then sends `{ask}` with the conversation's `historyId`
62
+ and `{done}` with `pendingAsk`. Because `{ask}` names the conversation, a client can answer a
63
+ new chat's ask before `{done}` arrives.
64
+ 3. **Answer.** The client shows the ask and posts the answer with the ask's tool call id to
65
+ `POST /gpt/prompt`.
66
+ 4. **Check.** The server confirms the history belongs to the caller (403), that this is the ask
67
+ it waits on (409), and that the answer fits the ask (400, and the model is not called).
68
+ 5. **Resume.** One atomic update stores the answer row, marks the ask answered, and clears
69
+ `pendingAsk`. The server replays the saved messages with the answer as the tool result and
70
+ streams the rest of the turn, starting with `{askResolved}`.
71
+
72
+ The server replays the saved messages instead of rebuilding them from history rows because the AI
73
+ SDK messages carry provider metadata, such as Gemini thought signatures, that the rows do not
74
+ keep. Replaying them resumes the exact turn the provider started. In later turns, the history goes
75
+ through `buildMessages`, which keeps each ask and its answer so the model remembers what it asked
76
+ and what the user chose. Host tool rows stay out of the model's messages, as before.
77
+
78
+ The update is atomic because two answers to one ask, from a double tap or two devices, must resume
79
+ the turn once. It matches on the pending ask's tool call id, so the second answer finds nothing to
80
+ update and gets 409.
81
+
82
+ The answer stays stored even when the model call that continues the turn fails. The user did
83
+ answer, so the next message continues from the ask and its answer instead of asking again. Any
84
+ turn that fails after its stream starts still ends with `{error}` then `{done}` and saves what the
85
+ user saw, so a client always learns the turn ended and which conversation holds it.
86
+
87
+ ## One ask at a time
88
+
89
+ A conversation waits on at most one ask. When the model asks twice in one step, the first ask
90
+ pauses the turn and the others are answered with `cancel` and the reason `one_ask_at_a_time`,
91
+ which the model sees when the turn resumes. Two turns on one conversation, say from two open tabs,
92
+ follow the same rule. Setting the pending ask is a conditional update, so the ask saved first
93
+ keeps waiting and the other turn's ask gets the same `cancel` instead of overwriting it.
94
+
95
+ The user is never stuck behind a question. Typing a message instead of answering records `cancel`
96
+ with the reason `user_sent_message`, then adds the message. The model sees both and can respond to
97
+ what the user actually said. A message is never refused because its ask is gone: when another tab
98
+ answered the ask first, the message goes ahead on the conversation as that answer left it. Only an
99
+ answer can be stale, so only an answer gets 409.
100
+
101
+ Turns never write back a whole copy of the conversation. Each turn appends its rows with one
102
+ atomic update when it ends, and ratings set one field, so two turns or a rating racing a turn
103
+ cannot drop each other's rows. A paused turn remembers how many rows come before and include its
104
+ own user message, and its resume replays exactly those.
105
+
106
+ ## Approvals the server enforces
107
+
108
+ `ask_confirm` works only when the model remembers to call it. A prompt injection, or a model that
109
+ skips the step, can still call a destructive tool directly. So a host tool can set the AI SDK's
110
+ `needsApproval: true`, and the server, not the model, makes the confirm.
111
+
112
+ When the model calls such a tool, the AI SDK stops before `execute` and emits a
113
+ `tool-approval-request`. The server turns it into a `confirm` ask with `origin: "approval"` and
114
+ pauses the turn exactly as for a model ask: same pending slot, same simple card, same answers
115
+ through `/gpt/prompt` and `turn`. The answer becomes the SDK's `tool-approval-response`. On
116
+ resume the SDK runs the tool once when approved, or gives the model a denial when not, so the
117
+ tool's own code never has to check.
118
+
119
+ The approval uses the one pending slot rather than a second one. A client then has one question
120
+ to show and one way to answer, and a watch needs no new card type. When a step holds a model ask
121
+ and an approval, or two approvals, the first pauses the turn and each other approval is denied
122
+ with `one_ask_at_a_time`. The model sees the denial and can ask again in its next turn.
123
+
124
+ The host words the card with `asks.approvals`, because only the host knows what a call changes.
125
+ The default, "Allow &lt;toolName&gt;?" with the tool's description, is safe but vague, so a
126
+ destructive tool should name the effect and set `destructive: true`.
127
+
128
+ Approval rows are for display. Later turns do not replay them, just as they do not replay host
129
+ tool rows: the model's memory of the call is what it wrote in its reply.
130
+
131
+ ## Strict by design
132
+
133
+ Every ask kind is a strict schema with hard limits. The limits live in one constant,
134
+ `ASK_LIMITS`, which the schemas, the system prompt section, the tests, and a doc-parity test all
135
+ read. The model can only ask for controls a client can render, and the user can only send answers
136
+ the ask allows. The same pure validators run on the server and in clients, so a client can enable
137
+ Submit exactly when the server will accept the answer.
138
+
139
+ ## Simple cards
140
+
141
+ Every ask comes with a simple card: short text and at most three buttons, each holding the exact
142
+ answer it sends. A client that knows nothing about ask kinds, such as a watch, a notification, or
143
+ a chat bot, can render the card and answer with one tap by sending back the tapped button's answer.
144
+ When the buttons cannot show every option the ask offers, the card sets `handoff` so the client
145
+ can send the user to the full app. A card with a button for every option does not set it, even
146
+ when Skip is left out to make room.
147
+
148
+ The server derives the card once, when the ask is made, and stores it with the pending ask. A card
149
+ already on a screen never changes, even if the derivation rules change later.
150
+
151
+ Each button carries its exact answer, not a label for the client to turn into one. A client that
152
+ knows no ask kinds cannot build a valid answer: it would need each kind's answer shape and rules.
153
+ With the answer on the button, those rules stay on the server, and a test checks that every
154
+ button's answer passes `validateAskResponse` for its ask. A client can also send only the pressed
155
+ button's `id`. The server looks the id up on the card it stored and answers with that button's
156
+ answer, so a client that sends only ids cannot send an answer the card did not offer.
157
+
158
+ ## In the chat
159
+
160
+ `GPTChat` shows an ask where the model asked it: the ask's tool call row becomes an `AskCard` in
161
+ the transcript, and the continuation streams in below it. The host still owns the network.
162
+ `GPTChat` calls `onAskSubmit`, and the host posts the answer and reads the continuation with the
163
+ same stream reader it uses for prompts. An answer the server rejects comes back to the card as
164
+ `askErrors`, next to the control that caused it.
165
+
166
+ A `choice` whose options all fit its simple card renders as that card's buttons, and a tap answers.
167
+ A phone then shows the same buttons a watch would, and the most common ask, two or three short
168
+ options, takes one tap. Longer lists get radio buttons or a searchable select with Submit.
169
+
170
+ A `select: "many"` choice renders as checkboxes with Submit, because one tap cannot pick several.
171
+ Its Other field lets the user answer with something the agent did not list, without leaving the
172
+ ask for free text: the answer still names the offered ids in `selected`, and only the typed part
173
+ is in `other`. Other counts as one choice against `maxSelected`, so "pick up to three" means three
174
+ things in total, however the user picks them. Other is offered only with `"many"`; an agent that
175
+ wants "one option or your own" asks `"many"` with `maxSelected: 1`, so every ask with free text
176
+ goes through the same control and the same count rule. On a small screen a many-select card can
177
+ offer only its suggested set and Skip, so it always hands off, and the compact surface does not
178
+ offer `"many"` at all.
179
+
180
+ A `confirm` guards one action the agent is about to take, such as deleting data or sending a
181
+ message for the user. Its answer is `{confirmed: true}` or `{confirmed: false}`, so "no" is a real
182
+ answer the agent must respect, not a skip: deny is the second button, and Skip appears only when
183
+ the ask sets `allowDecline`. The card shows the simple card's two buttons in the same order on a
184
+ phone and a watch, approve first and deny last. A `destructive` ask draws the approve button in
185
+ the destructive style, and a watch never makes it the Double Tap button, so a gesture cannot
186
+ approve an action that cannot be undone. The confirm is a pause in the turn, not a lock: the host's
187
+ tool should still check that the action is allowed.
188
+
189
+ A `markdown` ask hands the user a draft, such as an announcement or an email, in a markdown editor
190
+ with a preview. It is one round trip, not a shared document: the user edits and sends the text
191
+ back, and the agent continues with it. The answer carries `changed`, and the server checks it
192
+ against the text, so `changed: false` reliably means "approved as is" and the agent need not diff
193
+ the text itself. Editing needs a keyboard and room, so the compact surface never offers `markdown`.
194
+ A watch still sees a markdown ask the phone's chat made: its card offers Approve draft, when the
195
+ draft already meets the length rules, and Cancel, and it always hands off to the phone. A long
196
+ answer collapses to a preview in the transcript, so one draft does not push the conversation out
197
+ of view.
198
+
199
+ A `form` asks for several values in one step, such as the details for an invoice, instead of a
200
+ string of questions. Its fields are flat and typed, and the same rules check a default when the
201
+ ask is made and a value when the answer arrives, so the agent never receives a value its own form
202
+ would reject and never offers a default the user could not send back. Blank means unanswered.
203
+ Dates travel as ISO strings in fixed shapes (`YYYY-MM-DD`, 24-hour `HH:mm`, and datetimes with an
204
+ offset) so the agent never has to guess a time zone. Each error names its field
205
+ (`content.values.<id>`), so the card shows it where the user can fix it. A small screen cannot
206
+ fill in fields, so the compact surface never offers `form`; a watch can still send the defaults
207
+ when every required field has one, and otherwise hands off to the phone.
208
+
209
+ A `files` ask gets the user's files to the model as the model's own input types: images as image
210
+ parts, PDFs as file parts, and text, CSV, and JSON as text cut at 100 KB. The declared type is not
211
+ trusted. The server reads the bytes and rejects a file whose bytes are another type, so a renamed
212
+ file cannot reach the model as something it is not. Files travel as uploads when the host has
213
+ file storage and as data URLs when it does not, so an app without a bucket still works. Either
214
+ way the history keeps only each file's name, type, and size: the bytes reach the model once, on
215
+ the answering turn, and a long conversation does not grow by the size of its files. Picking files
216
+ needs a phone or a computer, so the compact surface never offers `files`.
217
+
218
+ After an ask ends, the card collapses to one line, such as "You chose: Team" or "You declined:
219
+ Keep them". A long conversation
220
+ then reads as a record of what the agent asked and what the user decided. The answer's
221
+ `tool-result` row stays in the message list but is hidden. Ratings address messages by their index
222
+ in the stored rows, so the client keeps one message per row, and after a reload the summary reads
223
+ the answer from that row.
224
+
225
+ A pending ask takes focus when it appears, so keyboard and screen reader users land on the question
226
+ instead of hunting for it.
227
+
228
+ ## Small screens and the watch
229
+
230
+ A watch shows about three short buttons. A full `choice` can offer 50 options, and on a watch its
231
+ card can only hand off to the phone. So a small client says where the user is with
232
+ `surface: "compact"`, and the server narrows what the agent may ask on that turn: a `choice`
233
+ with `select: "one"` and 2–3 options whose labels fit a button uncut and differ from each other,
234
+ or a `confirm`, whose two labels already fit. Every card made on a
235
+ compact turn can be answered from the watch, and the system prompt asks for replies of at most two
236
+ short sentences. The narrowing is in the tool's input schema, not only in the prompt, so the model
237
+ cannot ask what the watch cannot show. A compact ask that breaks the rules goes back to the model
238
+ as a tool error, like any invalid ask.
239
+
240
+ The surface belongs to a turn, not to the conversation. One conversation can start in the phone's
241
+ chat and continue on the watch, and each turn gets the rules of the screen it was sent from.
242
+
243
+ A watch app, a notification action, or a chat bot wants to send one request and read one
244
+ response, not a server-sent event stream. The `turn` action runs the same turn as `/gpt/prompt`,
245
+ through the same turn runner, and collects its events into one JSON result. Both save the same
246
+ rows, so the phone's chat shows what was answered on the watch. The turn keeps running when the
247
+ client disconnects, which matters on a watch, where the system can suspend the app mid-request.
248
+ The turn is saved, and the client reads the reply or the next ask later. `pendingAsks` lists what
249
+ waits on the user across every conversation, so a watch can show it without opening each one.
250
+
251
+ How an Apple Watch can plug in:
252
+
253
+ | Path | What it needs | Status |
254
+ | --- | --- | --- |
255
+ | SwiftUI watch app | A native watchOS target, because React Native does not run on watchOS. `@bacons/apple-targets` adds one to an Expo app. The phone hands the session token over WatchConnectivity, and the app calls the two endpoints with `URLSession`, always with `surface: "compact"`. The [how-to](../how-to/agent-ui-asks.md#answer-asks-from-an-apple-watch-or-another-small-client) sketches one. | Protocol ready; a sample app is Future Work |
256
+ | Actionable push, no watch app | iOS forwards iPhone notifications with up to 4 action buttons to the watch. It needs `categoryId` on comms push, `data` on `notify()`, templated categories (iOS fixes action labels when the app registers a category), actions that do not open the app, a small native iOS handler (expo-notifications completes before a JavaScript `fetch` finishes), and a one-time action token per ask. | Future Work |
257
+
258
+ Other follow-ups: finishing long turns in a `@terreno/jobs` background job, so a headless answer
259
+ returns at once instead of waiting for the agent, and Wear OS.
260
+
261
+ ## Asks and blocks
262
+
263
+ Asks and [UI blocks](agent-ui-blocks.md) split the work by where the
264
+ result goes. Body components are Phase 5 of the asks plan; the design record remains
265
+ [agent-ui-blocks](../implementationPlans/agent-ui-blocks.md).
266
+
267
+ | Need | Mechanism |
268
+ | --- | --- |
269
+ | Show data, such as a chart, table, or metric | A block in the reply |
270
+ | Suggest a follow-up message | A block button that sends a reply |
271
+ | Run host code, such as an export | A block button with a host callback |
272
+ | Get an answer the agent needs to continue | An ask: tool call, pause, answer, resume |
273
+ | Answer from a watch or another small client | The ask's simple card, through `pendingAsks` and `turn` |
274
+
275
+ Blocks display, and their buttons start something new: a message or host code. Asks collect, and
276
+ their answer goes back to the agent as the result of the call that asked. An ask's `prompt` is
277
+ plain text, so an ask cannot show a chart or table above its control yet. Both live in
278
+ `@terreno/blocks`, which holds the schemas for what the agent can show and ask.
279
+
280
+ ## Trust boundaries
281
+
282
+ - **Only the server writes the paused turn.** The saved messages are replayed to the model
283
+ verbatim, so a client must not be able to plant or edit them. The history REST API drops
284
+ `pendingAsk` from create and update bodies, and the answer a client sends is only the envelope,
285
+ checked against the stored ask.
286
+ - **A button id answers only with the stored card.** The server resolves `buttonId` on the card
287
+ it saved when the ask was made. An id that is not on the card gets 400 `UNKNOWN_BUTTON`, and the
288
+ ask stays pending.
289
+ - **A client can only answer an approval, never make one.** The approval request and the
290
+ `tool-approval-response` live only in the server's stored messages. A client sends the answer
291
+ envelope for the pending ask's `toolCallId`, and the server builds the response for the
292
+ `approvalId` it stored. A crafted approval response in the body gets 400, and another tool
293
+ call's id gets 409.
294
+ - **A turn speaks as the conversation's owner.** Only the owner can send a `turn`. An admin gets
295
+ 403, because the model would read the admin's message or answer as the owner's.
296
+ - **Asks do not collect secrets.** The system prompt tells the model never to ask for passwords,
297
+ payment card numbers, API keys, or other secrets.
298
+ - **HTML is display-only and opt-in.** An `html` block can be used for XSS, phishing, exfiltration, or clickjacking if it runs as part of the host page. The server allows it only when `uiBlocks.html` is on, strips scripts, event handlers, forms, frames, `meta`/`base`/`link`, anchor `href`s, and every URL except `data:image`, then stores that cleaned document. The client draws it in a sandboxed frame with no JavaScript and no navigation, labeled as an agent-generated preview, and only after the reply has finished.
299
+ - **Image hosts are an allowlist.** An `image` block can leak conversation data by requesting a URL the model chooses. `data:image` URLs and `file:` refs are always allowed. An `https` URL loads only when its hostname is in `uiBlocks.imageHosts`. The default list is empty, so a hostile prompt cannot point the image at a server the app did not name.
@@ -0,0 +1,67 @@
1
+ # Agent UI blocks
2
+
3
+ When a chat turns UI blocks on, the assistant reply is one YAML document of Terreno
4
+ components, not free HTML or JSX. This page explains why the catalog is closed and why the
5
+ whole reply is the document. Field tables and error codes are in the
6
+ [reference](../reference/blocks.md). Asks, which return a typed answer to the agent, stay
7
+ in [Agent UI Asks](agent-ui-asks.md).
8
+
9
+ The design record is
10
+ [agent-ui-blocks.md](../implementationPlans/agent-ui-blocks.md). Pick executes that work as
11
+ Phase 5 of [Agent UI Asks](../implementationPlans/agent-ui-asks.md).
12
+
13
+ ## Why a closed catalog
14
+
15
+ An agent that can emit arbitrary components can also emit styles, scripts, and layout the
16
+ app theme does not own. The catalog is the list of `@terreno/ui` components the chat can
17
+ paint: heading, text, metric, badge, divider, context, chart, table, actions, and the two
18
+ layout blocks `columns` and `card`. Unknown
19
+ keys and unknown block types fail validation, so a reply cannot smuggle a `style` object
20
+ or a hex color.
21
+
22
+ The same pure functions run wherever a document is checked. `parseBlocks` and
23
+ `validateBlocks` live in `@terreno/blocks`, which has no React and no Express. The renderer
24
+ and the chat route will call them; they do not grow a second grammar.
25
+
26
+ ## Why the whole reply is YAML
27
+
28
+ The document is the reply. There is no prose before it and no fenced block inside
29
+ markdown. A leading or trailing fence is stripped so a model that adds one still parses.
30
+ JSON is accepted because it is a YAML 1.2 subset and is what a structured-output call
31
+ emits.
32
+
33
+ Key order is `v`, then `datasets`, then `blocks`. Datasets come first so a streaming
34
+ renderer can know the data before the first block. `parseBlocksPartial` returns the
35
+ top-level blocks that have already finished and marks the cut tail pending. The finished
36
+ document is still checked with `validateBlocks`.
37
+
38
+ Text in the same step as a tool call is not the reply, except when that text is a block document and no later step has text. An actions block that left off `id` receives one before storage, and the chat is sent the corrected document.
39
+
40
+ A reply that is not a mapping with `v` is not a document. The chat can still show it:
41
+ `wrapAsTextDocument` turns the raw text into one `text` block. The error
42
+ `NOT_A_DOCUMENT` is how the caller tells that case apart from a document that failed a
43
+ field check.
44
+
45
+ YAML anchors, aliases, and explicit tags are rejected. They let one node stand in for
46
+ another, which makes a small document able to expand in ways the limits cannot see.
47
+
48
+ ## Ownership
49
+
50
+ | Layer | Owns |
51
+ | --- | --- |
52
+ | `@terreno/blocks` | Schema, parse, partial parse, validate, JSON Schema, prompt section, CLI, limits, error codes |
53
+ | `@terreno/ui` | `BlocksView` for heading, text, metric, badge, divider, context, chart, table, actions, columns, and card. A select action changes the target chart or table dataset in that view. A segmented control starts on the option that matches that dataset. A callback stays disabled when `hostActions` is set and does not include its name. `GPTChat` `uiBlocks` renders assistant messages through `BlocksView`, including a spinner while the reply is still streaming. A `file:` image stays as alt text until `resolveImage` returns a URL. |
54
+ | `@terreno/ai` | When `uiBlocks` is on: the chat system prompt, the post-stream `{blocks}` check, `AIDataset` storage, and `POST /gpt/actions`. `AIService.generateBlocks` returns one validated document outside a chat turn. |
55
+
56
+ Asks use the same package under `src/asks/`. A block shows something. An ask collects an
57
+ answer and returns it to the agent.
58
+
59
+ A callback button is the Block Kit `block_actions` path. The document names the callback.
60
+ The host owns the code.
61
+
62
+ | | Block Kit | Terreno |
63
+ | --- | --- | --- |
64
+ | Who runs the click | Slack posts `block_actions` to the app | The client posts `POST /gpt/actions` |
65
+ | Payload check | The app trusts the platform body | The host Zod schema runs before the handler |
66
+ | What comes back | `chat.update` replaces the message | `{replace, blocks}` replaces the block, or `{text}` appends a message |
67
+ | Time limit | The platform's request window | 10 seconds, then 504 |
@@ -0,0 +1,101 @@
1
+ # AI observability
2
+
3
+ Terreno ships Langfuse-like prompt versioning, nested traces, evaluators, datasets, experiments, and a local human review queue **inside `@terreno/ai`**, with operator UI in **`admin-frontend` only**.
4
+
5
+ ## Why not Langfuse-only
6
+
7
+ Apps must iterate prompts without a required vendor account. `LangfuseApp` stays a second plugin. Traces and scores **fan out** to every registered sink (local Mongo, Langfuse, OTLP/OpenInference). Prompts, datasets, experiments, and the review queue have **one writer** each (a primary). The review queue is **local-only**.
8
+
9
+ That split is the two planes:
10
+
11
+ | Plane | Write | Admin read |
12
+ | --- | --- | --- |
13
+ | Telemetry (traces, scores) | All sinks, best-effort | Local if the local plugin is on, else Langfuse |
14
+ | Control (prompts, datasets, experiments, queue) | One primary per capability | That primary |
15
+
16
+ `experiments.primary` must equal `datasets.primary`. Defaults are all `local`.
17
+
18
+ `ObservabilityApp` fails boot (constructor throws) when:
19
+
20
+ | Error | Cause |
21
+ | --- | --- |
22
+ | `experiments.primary must equal datasets.primary` | Mixed dataset/experiment writers |
23
+ | `reviewQueue.primary must be local` | `reviewQueue` set to `langfuse` |
24
+ | `<capability> primary "<id>" has no plugin` | No registered plugin with that `id`, capability, and store |
25
+
26
+ Telemetry sinks still fan out even when a control primary is local-only.
27
+
28
+ Admin chrome lives in `admin-frontend`. One sidebar group **AI Observability** holds Prompts,
29
+ Traces, Evaluators, Datasets, Experiments, and Review (Review is omitted when the local plugin is
30
+ off). Existing **AI Requests** stays under **Screens**. Every observability screen shows
31
+ breadcrumbs `Admin / AI Observability / <Section> / <leaf>` and a status chip from
32
+ `GET /ai/observability/status`. Prompt detail is a hub (Overview, Versions, Traces, Experiments);
33
+ versions are immutable in admin (`Save as vN+1`); apps never inline the string.
34
+
35
+ ## Why this shape for product work
36
+
37
+ Flourish AI features follow an 8-step loop: gold dataset → labels → prompt versions → evaluators → experiments with gates → `production` label → live traces and in-app feedback → weak traces back into the dataset.
38
+
39
+ That loop is the product requirement, not an optional dashboard. Operator steps: [Develop an AI feature](../how-to/ai-feature-development.md). Registration and env: [Observe LLM calls](../how-to/observe-llm-calls.md). Models and routes: [AI reference](../reference/ai.md). Design lock: [implementation plan](../implementationPlans/ai-observability.md).
40
+
41
+ ## RBAC (domain-neutral)
42
+
43
+ Observability HTTP routes use Terreno RBAC resources (`aiPrompt`, `aiTrace`, `aiReview`,
44
+ `aiDataset`, `aiExperiment`, `aiEvaluator`) instead of domain-specific approval vocabulary.
45
+ Pass the same `accessControl` instance on `ObservabilityApp` and `AdminApp` so
46
+ `GET /ai/observability/status` permissions match `/admin/config` screen filtering. Admin widgets
47
+ hide write controls from status flags (fail closed while status is loading or errored). Seeded
48
+ `admin` receives every observability action; `auditor` receives `list` and
49
+ `read` on each observability resource through the read-only sentinel (but not `admin:access` —
50
+ pair auditor with a shell grant in a composed consumer role); `superadmin` receives `*`. Existing
51
+ customized `admin` and `auditor` roles gain only missing observability actions on re-seed. Legacy
52
+ `user.admin` remains a full-access fallback when RBAC is enabled. See
53
+ [API reference](../reference/api.md#ai-observability-rbac). The example backend registers
54
+ `accessControl: access` on `ObservabilityApp` and seeds optional `aiObservabilityViewer` and
55
+ `aiObservabilityOperator` roles (not auto-assigned) in `example-backend/src/rbacRoles.ts`.
56
+
57
+ `AIRequest` remains the cheap per-call log. Observability traces are the nested, scored, user/session/cost record used in the SOP.
58
+
59
+ ## Prompt hub relationships (phase 4)
60
+
61
+ Prompts may carry an optional domain-neutral `description` (folder semantics stay consumer-defined). `GET /ai/observability/prompts/:name` composes bounded recent **traces** and **experiments** for that prompt name so the admin hub does not scan full trace or experiment lists on the client. Trace evidence is version-aware: list filters accept `prompt` plus optional `promptVersion`, and relationship trace rows include the matching `promptVersion` from `ObsTrace.prompts[]`. The same optional positive integer `promptVersion` on prompt detail filters hub traces with `$elemMatch` on `prompts` (name and version). Omit it to keep the unfiltered prompt-name match. Invalid or non-positive versions return **400**. Experiment relationships filter server-side by `promptName` on `GET /ai/observability/experiments?promptName=…` (omit or pass an empty value for no filter). The experiment list endpoint returns summary rows without per-item hydration; use experiment detail for full item results. Reading relationships uses the same `aiPrompt:read` grant as prompt detail.
62
+
63
+ ## Phase 1 reference loop
64
+
65
+ The example backend always registers the local plugin. Its idempotent seed creates
66
+ `examples/example-summarize` with production on v1 and an experimental v2, the human
67
+ `correctness-human` evaluator, the automatic `schema-assert` evaluator, and a two-item
68
+ `example-gold` dataset. It also creates `chat-safety-synthetic`, a proofread set of
69
+ two-person chats for routing, toxicity, privacy, and dismissive-reply experiments.
70
+ This makes the review and experiment loops walkable without Langfuse:
71
+ resolve the production prompt → emit a trace → inspect spans and sensitive I/O → send the
72
+ trace to Review → record a human score.
73
+
74
+ The example frontend supplies that first step from real product usage: **Todos → Summarize**
75
+ calls `/ai/example-summarize`, which resolves `example-summarize` by name and `production`
76
+ label server-side. Prompt selection stays on the server, while the client only contributes
77
+ identity (`x-ai-session-id`) and, when the backend has no provider credentials, the user's own
78
+ key.
79
+
80
+ `AI_OBS_PRICE_MAP_JSON` belongs to deployment configuration because prices change
81
+ independently of prompt versions. A missing model price preserves token counts and omits
82
+ USD cost; it never invents `$0`.
83
+
84
+ ## Phase 2 backend (evaluators, datasets, experiments)
85
+
86
+ With the local plugin registered and primaries set to `local`, the control plane now includes:
87
+
88
+ - **`llm-judge` and `json-assert` evaluators** — judges call `AIService.generateJsonObject` through a named registry prompt (`judgePromptName`); create/update rejects a judge when the prompt `outputSchema` omits a required dimension (the error names that key). `json-assert` supports path/constraint checks and a built-in mode that validates output against the prompt version `outputSchema`. Parse or generation failures record an error outcome instead of throwing to the caller. Custom creates score a full trace; generation span and dataset item targets are reserved. Seeded templates install `correctness` / `hallucination` / `helpfulness` / `toxicity` as `llm-judge`, `schema-assert` as `json-assert`, and `*-human` variants for the review queue.
89
+ - **Human review evaluators** — sending traces to review requires a `human` evaluator. Its dimensions become the review item's score fields and its instructions tell the reviewer how to apply them. `llm-judge` and `json-assert` evaluators run automatically and are rejected from the human queue.
90
+ - **Datasets** — CRUD plus item provenance (`origin`, `proofread`, `sourceTraceId`, tags, outcome class). Import accepts **JSON** (bare objects or structured rows) and **CSV** (quoted fields; `input.*` / `expectedOutput.*` column prefixes). Rows validate against the bound prompt input schema when configured. Adding from a trace copies I/O; sensitive traces always land `proofread: false`. Deleting an item never mutates the trace.
91
+ - **Experiments** — compare 2–3 prompt versions on a dataset with optional `modelOverride` (requires `aiServiceFactory` on `ObservabilityApp`), evaluator thresholds (defaulting to the SOP gates), **per-version gate tiles** (`gates[].version`), outlier/low-confidence item ids, and version-scoped promote (**409** when the selected version's gates fail). Unproofread items are excluded unless `includeUnproofread` is true. Local runs compile exact prompt versions and call `AIService.generateText` with compiled `prompt` + `systemPrompt` (no `promptName`/`promptLabel`). `ObservabilityApp` wires the local experiment runner from `aiService` at register time.
92
+
93
+ Operator UI for datasets and experiments ships in tasks 2.7–2.9; routes and stores are live for API clients and tests today.
94
+
95
+ ## GPT tool spans and nested traces
96
+
97
+ `/gpt/prompt` streams tool rounds to the client **and** records them in observability traces. Each provider `tool-call` / `tool-result` pair becomes a `TOOL` span (input args, cleaned output, timing, `parentSpanId` on the chain root). When tools run, the exported trace uses a `CHAIN` root wrapping those `TOOL` children instead of a lone `LLM` span. Plain `AIService.generate*` calls without tool children still log a single `LLM` root.
98
+
99
+ ## Admin multi-stage trace smoke test
100
+
101
+ `POST /ai/observability/traces/test-multi-stage` (admin-only) exercises a real multi-stage workflow through the configured `ObservabilityApp.aiService`: two `generateJsonObject` LLM passes (`skipTrace: true`) with named output schemas, a deterministic local `text-metrics` tool stage, then a final synthesis `generateJsonObject` call against a combining schema. System prompts live in `OBS_TEST_MULTI_STAGE_*` constants in `ai/src/service/prompts.ts` (never inline). Each LLM span records `{prompt, schemaName, outputSchema}` as input and the parsed object as output. The handler exports one parent `TraceRecord` via `ObservabilityApp.exportTrace` with child span order `LLM`, `LLM`, `TOOL`, `LLM` and returns `{traceId, output, stages}` so operators can open the persisted trace in admin. Missing `aiService` → **503**; child failure exports an error trace then propagates the error.
@@ -20,6 +20,14 @@ Height works the same way: `height` is the whole chart, and the tick row, toolti
20
20
  row come out of that budget. A chart therefore fits a fixed-height slot, and reserving the tooltip
21
21
  row even when no tooltip is showing keeps hovering from reflowing the page.
22
22
 
23
+ Cartesian facades use `xTickPolicy="auto"` by default: up to seven categorical labels
24
+ stay horizontal, while denser sets rotate 45 degrees and reserve a taller axis row.
25
+ That taller row comes out of `height`; the plot shrinks so the chart still fits the
26
+ requested slot. `SparklineChart` scales to the data range so a small period-over-period
27
+ change stays visible.
28
+ Callers may force `"truncate"` or `"rotate"`. `title` and `periodLabel` are convenience
29
+ props backed by the same `ChartCard` used for explicit chart/table composition.
30
+
23
31
  `DashboardGrid` is layout only, so it stays on the eager root export. Chart implementations sit behind the same lazy root boundary as `GPTChat`. Cell width subtracts flex `gap` so `md`/`lg` column counts actually fit.
24
32
 
25
33
  Chart paint is proven with **rendered PNG goldens**, not React test-renderer JSON.
@@ -27,3 +35,7 @@ Chart paint is proven with **rendered PNG goldens**, not React test-renderer JSO
27
35
  (`/demo/chart-visual-gallery`), screenshots each fixture from easy to hard, and
28
36
  pixel-diffs against `demo/rendered-snapshots/`. Agents use the `review-chart-visuals`
29
37
  skill to read golden / actual / diff images when a compare fails.
38
+
39
+ The example app admin home (`/admin`) mounts `AdminCharts` so scorecards, a multi-series line,
40
+ bars, an area, and a donut with a center label render together. See
41
+ [Compose charts in a dashboard grid](../how-to/charts-and-dashboards.md).