@terreno/mcp 57.9.0 → 57.11.0

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 (111) hide show
  1. package/dist/docs/upgrades/57.10.1.md +54 -0
  2. package/dist/docs/upgrades/57.11.0.md +80 -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/durable-agent-harness.md +544 -0
  10. package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
  11. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
  12. package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
  13. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -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 -0
  17. package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  18. package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  19. package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  20. package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  21. package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
  22. package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  23. package/dist/docs/versioned/next/how-to/circleci.md +5 -3
  24. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  25. package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  26. package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
  27. package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  28. package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  29. package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  30. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  31. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  32. package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  33. package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  34. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  35. package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  36. package/dist/docs/versioned/next/reference/README.md +3 -0
  37. package/dist/docs/versioned/next/reference/admin-config.md +2 -0
  38. package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
  39. package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  40. package/dist/docs/versioned/next/reference/ai-harness.md +1974 -0
  41. package/dist/docs/versioned/next/reference/ai.md +251 -25
  42. package/dist/docs/versioned/next/reference/api.md +110 -2
  43. package/dist/docs/versioned/next/reference/blocks.md +178 -0
  44. package/dist/docs/versioned/next/reference/environment-variables.md +2 -1
  45. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  46. package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
  47. package/dist/docs/versioned/next/reference/syncdb.md +156 -6
  48. package/dist/docs/versioned/next/reference/test.md +11 -2
  49. package/dist/docs/versioned/next/reference/ui.md +331 -6
  50. package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  51. package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  52. package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
  53. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
  54. package/dist/tools.d.ts +4 -0
  55. package/dist/tools.d.ts.map +1 -1
  56. package/dist/tools.js +37 -0
  57. package/dist/tools.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/docs/upgrades/57.10.1.md +54 -0
  60. package/src/docs/upgrades/57.11.0.md +80 -0
  61. package/src/docs/versioned/next/README.md +1 -0
  62. package/src/docs/versioned/next/explanation/README.md +4 -0
  63. package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  64. package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  65. package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  66. package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
  67. package/src/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
  68. package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
  69. package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
  70. package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
  71. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  72. package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  73. package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
  74. package/src/docs/versioned/next/how-to/README.md +7 -0
  75. package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  76. package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  77. package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  78. package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  79. package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
  80. package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  81. package/src/docs/versioned/next/how-to/circleci.md +5 -3
  82. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  83. package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  84. package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
  85. package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  86. package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  87. package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  88. package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  89. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  90. package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  91. package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  92. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  93. package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  94. package/src/docs/versioned/next/reference/README.md +3 -0
  95. package/src/docs/versioned/next/reference/admin-config.md +2 -0
  96. package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
  97. package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  98. package/src/docs/versioned/next/reference/ai-harness.md +1974 -0
  99. package/src/docs/versioned/next/reference/ai.md +251 -25
  100. package/src/docs/versioned/next/reference/api.md +110 -2
  101. package/src/docs/versioned/next/reference/blocks.md +178 -0
  102. package/src/docs/versioned/next/reference/environment-variables.md +2 -1
  103. package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  104. package/src/docs/versioned/next/reference/mcp-server.md +17 -0
  105. package/src/docs/versioned/next/reference/syncdb.md +156 -6
  106. package/src/docs/versioned/next/reference/test.md +11 -2
  107. package/src/docs/versioned/next/reference/ui.md +331 -6
  108. package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  109. package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  110. package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
  111. package/src/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
@@ -0,0 +1,1059 @@
1
+ # Agent UI Asks
2
+
3
+ Asks let an agent ask the user a typed question inside the chat and get the answer back as a
4
+ tool result in the same turn. `@terreno/blocks` owns the contract: ask schemas, validators,
5
+ simple cards, limits, error codes, and JSON Schemas for native clients. `@terreno/ai` owns the
6
+ producer: the `ask_<kind>` tools, the pause and resume on `POST /gpt/prompt` and the headless
7
+ `turn` action, and `GptHistory.pendingAsk`. `@terreno/ui` owns the renderers: `AskCard`, which
8
+ `GPTChat` shows in the transcript, and `SimpleAskCard` for small screens. For why asks work this
9
+ way, see [Agent UI Asks explained](../explanation/agent-ui-asks.md). To add asks to an app, see
10
+ [Add agent asks to a chat](../how-to/agent-ui-asks.md).
11
+
12
+ Shipped: the `choice` kind with `select: "one"` and `select: "many"` (with an optional Other
13
+ answer), the `confirm` kind (approve or deny, optionally destructive), the `markdown` kind
14
+ (edit a draft and send it back), the `form` kind (a few typed fields, one submit), and the
15
+ `files` kind (upload images or documents that the model then reads), asked and answered
16
+ through `POST /gpt/prompt`
17
+ and shown in `GPTChat` ([props and controls](ui.md#asks)),
18
+ [approval asks](#approval-asks) that the server makes before a host tool with `needsApproval`
19
+ runs, and the small-screen path: the
20
+ [compact surface](#compact-surface), the [headless endpoints](#headless-endpoints),
21
+ [JSON Schemas](#json-schemas-and-fixtures), and [`SimpleAskCard`](ui.md#simpleaskcard). The
22
+ sandboxed HTML block and the `callout`, `image`, and `details` display blocks are planned in the
23
+ [implementation plan](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/agent-ui-asks.md).
24
+
25
+ ## Table of Contents
26
+
27
+ - [Enable asks](#enable-asks)
28
+ - [Answer envelope](#answer-envelope)
29
+ - [Shared ask fields](#shared-ask-fields)
30
+ - [choice](#choice)
31
+ - [confirm](#confirm)
32
+ - [markdown](#markdown)
33
+ - [form](#form)
34
+ - [files](#files)
35
+ - [Approval asks](#approval-asks)
36
+ - [Simple cards](#simple-cards)
37
+ - [Compact surface](#compact-surface)
38
+ - [Validation](#validation)
39
+ - [Error codes](#error-codes)
40
+ - [Limits](#limits)
41
+ - [SSE events](#sse-events)
42
+ - [Answer an ask](#answer-an-ask)
43
+ - [Error responses](#error-responses)
44
+ - [Headless endpoints](#headless-endpoints)
45
+ - [JSON Schemas and fixtures](#json-schemas-and-fixtures)
46
+ - [Stored state](#stored-state)
47
+ - [Wire example](#wire-example)
48
+ - [Exports](#exports)
49
+
50
+ ## Enable asks
51
+
52
+ ```typescript
53
+ import {AiApp} from "@terreno/ai";
54
+
55
+ new AiApp({aiService, asks: true}).register(app);
56
+ ```
57
+
58
+ `AiApp` also registers the [headless endpoints](#headless-endpoints) with the same options. With
59
+ the route registrars, pass the same chat options to both:
60
+
61
+ ```typescript
62
+ const chat = {aiService, asks: true};
63
+ addGptHistoryRoutes(router, {chat});
64
+ addGptRoutes(router, chat);
65
+ ```
66
+
67
+ | `asks` | Ask kinds offered to the model |
68
+ | --- | --- |
69
+ | unset or `false` | None. Tools, system prompt, and SSE events are unchanged. |
70
+ | `true` or `{}` | Every kind in `ASK_KINDS` (today `choice`, `confirm`, `markdown`, `form`, and `files`) |
71
+ | `{kinds: ["choice"]}` | The listed kinds. An unknown kind throws when the routes are registered. An empty list offers none. |
72
+ | `{maxFileSizeBytes: 5_000_000}` | Every kind, with a per-file cap for `files` answers. Defaults to 10 MB (`files.defaultMaxFileSizeBytes`). `AiApp` also caps `POST /files/upload` at it. |
73
+
74
+ With asks on, each chat turn:
75
+
76
+ - Adds one tool per kind, named `ask_<kind>` (`ask_choice`, `ask_confirm`, `ask_markdown`, `ask_form`, `ask_files`). A turn on the
77
+ [compact surface](#compact-surface) offers only the compact kinds, with narrowed schemas.
78
+ - Appends `TERRENO_ASKS_SYSTEM_PROMPT` and the `askPromptSection` for the offered kinds to the
79
+ system prompt. Every number in that section comes from `ASK_LIMITS`.
80
+ - Throws at registration when a `tools` entry starts with `ask_`. Request tools and MCP tools
81
+ that start with `ask_` are dropped with a warning.
82
+ - Offers no ask tools to models that cannot call tools (model ids containing `image`).
83
+
84
+ ## Answer envelope
85
+
86
+ Every ask is answered with one of three shapes (`askResponseSchema`, the MCP elicitation shape):
87
+
88
+ | `action` | Shape | Meaning |
89
+ | --- | --- | --- |
90
+ | `accept` | `{action: "accept", content}` | The user answered. `content` holds the kind's answer. |
91
+ | `decline` | `{action: "decline"}` | The user pressed Skip. Rejected with `DECLINE_NOT_ALLOWED` when the ask does not allow skipping (`askAllowsDecline`): `allowDecline: false`, or a `confirm` without `allowDecline: true`. |
92
+ | `cancel` | `{action: "cancel", reason?}` | The ask was dropped. `reason` is optional, at most 200 characters. |
93
+
94
+ The server sends `cancel` for the user in two cases (`ASK_CANCEL_REASONS`):
95
+
96
+ | `reason` | When |
97
+ | --- | --- |
98
+ | `user_sent_message` | The user sent a `prompt` while the ask was pending. |
99
+ | `one_ask_at_a_time` | The model called more than one ask tool in one step: the first ask pauses the turn, and each later one is cancelled. Also sent when a turn asks while another turn's ask on the same history is already pending: that ask stays pending, and the new one is cancelled. [Approval asks](#approval-asks) share the one slot: an extra approval in the step is denied with this reason. |
100
+
101
+ ## Shared ask fields
102
+
103
+ Every ask input has these fields:
104
+
105
+ | Field | Type | Rule |
106
+ | --- | --- | --- |
107
+ | `prompt` | string | Required. The question in plain text (no markdown, no links), 1–500 characters. |
108
+ | `title` | string | Optional heading, at most 80 characters. |
109
+ | `submitLabel` | string | Optional, at most 24 characters. Clients default to "Submit". Not a field of `confirm`, which names its buttons with `confirmLabel` and `denyLabel`. |
110
+ | `allowDecline` | boolean | Optional, default `true` (the client shows Skip). `confirm` defaults to `false`, because deny is its negative answer. |
111
+
112
+ Every object is strict: a field the schema does not define fails with `UNKNOWN_KEY`. Every text
113
+ field must contain visible characters, not only whitespace (`TOO_SHORT`).
114
+
115
+ ## choice
116
+
117
+ Tool: `ask_choice`. The user picks one option (`select: "one"`) or several (`select: "many"`).
118
+ A `"many"` ask can also let the user type an answer of their own (Other).
119
+
120
+ | Field | Type | Rule |
121
+ | --- | --- | --- |
122
+ | `select` | `"one"` or `"many"` | Required (`CHOICE_SELECT_MODES`). Any other value fails with `INVALID_ENUM`. |
123
+ | `options` | `{id, label, description?}[]` | Required, 2–50 items, in display order. |
124
+ | `options[].id` | string | Matches `^[a-z0-9][a-z0-9_-]{0,63}$`. Unique within the ask (`DUPLICATE_ID`). |
125
+ | `options[].label` | string | 1–120 characters. |
126
+ | `options[].description` | string | Optional, at most 280 characters. |
127
+ | `default` | string[] | Optional. Each id must be an option id (`DEFAULT_NOT_IN_OPTIONS`) and listed once (`DUPLICATE_ID`). With `"one"`, at most one id; with `"many"`, at most `maxSelected` ids (`SELECTION_COUNT`). |
128
+ | `minSelected` | integer | Optional, `"many"` only. The fewest choices the answer holds, at least 0. Default 1. With `"one"` it must be 1 (`RANGE_INVALID`). |
129
+ | `maxSelected` | integer | Optional, `"many"` only. The most choices the answer holds, at least 1. Default: every choice the ask offers. With `"one"` it must be 1 (`RANGE_INVALID`). |
130
+ | `allowOther` | boolean | Optional, `"many"` only. `true` adds an Other text field. With `"one"` it fails with `OTHER_NOT_ALLOWED`; for "one option or Other", use `"many"` with `maxSelected: 1`. |
131
+ | `otherLabel` | string | Optional label of the Other field, at most 120 characters. Clients default to "Other". Needs `allowOther: true` (`OTHER_NOT_ALLOWED`). |
132
+
133
+ Other counts as one choice. The choices an ask offers are its options, plus one when
134
+ `allowOther` is `true`. `choiceSelectionBounds(input)` returns the `{min, max}` an answer must
135
+ meet:
136
+
137
+ | `select` | `min` | `max` |
138
+ | --- | --- | --- |
139
+ | `"one"` | 1 | 1 |
140
+ | `"many"` | `minSelected`, default 1 | `maxSelected`, default the number of choices the ask offers |
141
+
142
+ A `"many"` ask fails with `RANGE_INVALID` when `maxSelected` is more than the choices it offers,
143
+ or `minSelected` is more than `maxSelected` or the choices it offers. A `default` with fewer ids
144
+ than `minSelected` is valid: the client preselects it, and the user adds the rest.
145
+
146
+ ```json
147
+ {
148
+ "title": "Choose a plan",
149
+ "prompt": "Which plan should I set up?",
150
+ "select": "one",
151
+ "options": [
152
+ {"id": "starter", "label": "Starter", "description": "$0, one seat"},
153
+ {"id": "team", "label": "Team", "description": "$20 per seat"},
154
+ {"id": "enterprise", "label": "Enterprise"}
155
+ ],
156
+ "default": ["team"],
157
+ "submitLabel": "Set up plan"
158
+ }
159
+ ```
160
+
161
+ Answer: `{"action": "accept", "content": {"selected": ["team"]}}`. With `"one"`, `selected`
162
+ holds exactly one id (`SELECTION_COUNT` otherwise), and that id must be one the ask offered
163
+ (`OPTION_NOT_OFFERED`).
164
+
165
+ A `"many"` ask with Other:
166
+
167
+ ```json
168
+ {
169
+ "title": "Build your pizza",
170
+ "prompt": "Which toppings should I add? Pick up to three.",
171
+ "select": "many",
172
+ "options": [
173
+ {"id": "cheese", "label": "Extra cheese"},
174
+ {"id": "mushrooms", "label": "Mushrooms"},
175
+ {"id": "olives", "label": "Olives"},
176
+ {"id": "peppers", "label": "Peppers"},
177
+ {"id": "pineapple", "label": "Pineapple"}
178
+ ],
179
+ "default": ["cheese", "mushrooms"],
180
+ "maxSelected": 3,
181
+ "allowOther": true,
182
+ "otherLabel": "Another topping",
183
+ "submitLabel": "Add toppings"
184
+ }
185
+ ```
186
+
187
+ Answer: `{"action": "accept", "content": {"selected": ["cheese", "olives"], "other": "Basil"}}`.
188
+
189
+ | Answer field | Rule | Error |
190
+ | --- | --- | --- |
191
+ | `selected` | Offered ids (`OPTION_NOT_OFFERED`), each once (`DUPLICATE_ID`), at most 50 | `TOO_MANY` |
192
+ | `other` | Optional, 1–500 visible characters. Only when the ask sets `allowOther: true`. | `TOO_LONG`, `TOO_SHORT`, `OTHER_NOT_ALLOWED` |
193
+ | `selected` and `other` | Together hold `min` to `max` choices from `choiceSelectionBounds`, with `other` counting as one | `SELECTION_COUNT` |
194
+
195
+ ## confirm
196
+
197
+ Tool: `ask_confirm`. The user approves or denies one action the prompt describes. The prompt
198
+ section tells the model to confirm before a tool call that deletes data, sends something on the
199
+ user's behalf, spends money, or cannot be undone, and to make the call only after
200
+ `{"confirmed": true}`.
201
+
202
+ | Field | Type | Rule |
203
+ | --- | --- | --- |
204
+ | `prompt`, `title` | string | The [shared fields](#shared-ask-fields) |
205
+ | `confirmLabel` | string | Optional label of the approve button, at most 20 UTF-16 code units (`confirm.labelMaxLength`), so a simple card shows it uncut (`TOO_LONG`). Default "Confirm". |
206
+ | `denyLabel` | string | Optional label of the deny button, with the same limit. Default "Cancel". It must differ from the approve label, defaults included and ignoring spaces at either end (`DUPLICATE_LABEL`). |
207
+ | `destructive` | boolean | Optional, default `false`. `true` when the action deletes data or cannot be undone: the approve button shows as destructive. |
208
+ | `allowDecline` | boolean | Optional, default `false`. `true` adds Skip next to deny in the full chat. |
209
+
210
+ `confirm` has no `submitLabel` (`UNKNOWN_KEY`). `confirmButtonLabels(input)` returns
211
+ `{confirm, deny}` with the defaults filled in.
212
+
213
+ ```json
214
+ {
215
+ "title": "Clean up todos",
216
+ "prompt": "Delete 14 completed todos? This cannot be undone.",
217
+ "confirmLabel": "Delete 14 todos",
218
+ "denyLabel": "Keep them",
219
+ "destructive": true
220
+ }
221
+ ```
222
+
223
+ Answer: `{"action": "accept", "content": {"confirmed": true}}` approves, and
224
+ `{"confirmed": false}` denies. `confirmed` is required (`MISSING_REQUIRED`) and must be a
225
+ boolean (`INVALID_TYPE`). A `decline` returns `DECLINE_NOT_ALLOWED` unless the ask sets
226
+ `allowDecline: true`.
227
+
228
+ ## markdown
229
+
230
+ Tool: `ask_markdown`. The user edits a markdown draft the agent wrote, or writes one from
231
+ scratch, and sends it back. The full chat shows `MarkdownEditorField` (edit and preview). The
232
+ [compact surface](#compact-surface) does not offer it, because a draft cannot be edited on a
233
+ small screen.
234
+
235
+ | Field | Type | Rule |
236
+ | --- | --- | --- |
237
+ | `prompt`, `title`, `submitLabel`, `allowDecline` | | The [shared fields](#shared-ask-fields). `allowDecline` defaults to `true`. |
238
+ | `initial` | string | Optional draft in markdown, at most 20,000 UTF-16 code units (`markdown.maxLength`, `TOO_LONG`). Default `""`. It may break `minLength` or `maxLength`: the user then edits it to fit, and the simple card offers no Approve draft. |
239
+ | `placeholder` | string | Optional hint shown while the editor is empty, at most 120 characters (`markdown.placeholderMaxLength`). |
240
+ | `minLength` | integer | Optional, 0–20,000. The fewest characters the answer holds, not counting spaces at either end. Default 0. |
241
+ | `maxLength` | integer | Optional, 1–20,000. The most characters the answer holds, in UTF-16 code units. Default 20,000. A `minLength` above it fails with `RANGE_INVALID`. |
242
+
243
+ `markdownLengthBounds(input)` returns the `{min, max}` an answer must meet, with the defaults
244
+ filled in.
245
+
246
+ ```json
247
+ {
248
+ "title": "Launch announcement",
249
+ "prompt": "Here is a draft announcement. Edit anything, then send it back.",
250
+ "initial": "# We're live\n\nToday we launched **Terreno Asks**.",
251
+ "maxLength": 2000,
252
+ "submitLabel": "Send it back"
253
+ }
254
+ ```
255
+
256
+ Answer: `{"action": "accept", "content": {"markdown": "# We're live\n\nWe launched today.", "changed": true}}`.
257
+
258
+ | Answer field | Rule | Error |
259
+ | --- | --- | --- |
260
+ | `markdown` | Required string, at most `maxLength` UTF-16 code units (never more than 20,000), and at least `minLength` characters without spaces at either end | `TOO_LONG`, `TOO_SHORT`, `MISSING_REQUIRED`, `INVALID_TYPE` |
261
+ | `changed` | Required boolean: `true` exactly when `markdown` differs from `initial` (or from `""` when the ask has no `initial`) | `CHANGED_MISMATCH`, `MISSING_REQUIRED`, `INVALID_TYPE` |
262
+
263
+ `changed: false` means the user approved the draft as is, so the agent can rely on it.
264
+
265
+ ## form
266
+
267
+ Tool: `ask_form`. The user fills in a few typed fields and submits them at once. The full chat
268
+ shows one `@terreno/ui` field per entry in `fields`. The [compact surface](#compact-surface) does
269
+ not offer it, because fields cannot be filled in on a small screen.
270
+
271
+ | Field | Type | Rule |
272
+ | --- | --- | --- |
273
+ | `prompt`, `title`, `submitLabel`, `allowDecline` | | The [shared fields](#shared-ask-fields). `allowDecline` defaults to `true`. |
274
+ | `fields` | object[] | Required, 1–8 items (`form.fieldsMin`, `form.fieldsMax`), in display order. Fields are flat: no nesting, no conditional fields, and no password type. |
275
+ | `fields[].type` | string | Required. One of `FORM_FIELD_TYPES` (below). Any other value fails with `INVALID_ENUM`. |
276
+ | `fields[].id` | string | Matches `^[a-z0-9][a-z0-9_-]{0,63}$`, like an option id. Unique within the form (`DUPLICATE_ID`). It keys the field's value in the answer. |
277
+ | `fields[].label` | string | 1–120 characters (`form.labelMaxLength`). |
278
+ | `fields[].helperText` | string | Optional line under the field, at most 280 characters (`form.helperTextMaxLength`). |
279
+ | `fields[].required` | boolean | Optional, default `false`. `true` means the answer must hold a non-blank value for the field (`REQUIRED_FIELD`). |
280
+ | `fields[].default` | by type | Optional value the field starts with. It must be a value the field accepts, checked with the same rules as an answer: a wrong JSON type fails with `INVALID_TYPE`, and a value that breaks the type's rules with that rule's code at `fields[i].default`. A `select` or `multiselect` default that is not an option id fails with `DEFAULT_NOT_IN_OPTIONS`. |
281
+
282
+ Each field type takes its own keys; a key of another type fails with `UNKNOWN_KEY`:
283
+
284
+ | `type` | Extra keys | Value in the answer | Value rules |
285
+ | --- | --- | --- | --- |
286
+ | `text` | `minLength`, `maxLength` | string | One line. At most `maxLength` UTF-16 code units (default and cap 2,000, `form.textMaxLength`, `TOO_LONG`), and at least `minLength` characters without spaces at either end (`TOO_SHORT`). |
287
+ | `textarea` | `minLength`, `maxLength` | string | Several lines. The same rules, with a default and cap of 10,000 (`form.textareaMaxLength`). |
288
+ | `email` | | string | An email address (`FIELD_TYPE_MISMATCH`), at most 2,000 characters |
289
+ | `url` | | string | An `http` or `https` URL (`FIELD_TYPE_MISMATCH`), at most 2,000 characters |
290
+ | `phone` | | string | 7–15 digits (`form.phoneDigitsMin`, `form.phoneDigitsMax`), optionally starting with `+`, with only spaces, dots, dashes, and parentheses between them (`FIELD_TYPE_MISMATCH`). The full chat sends E.164, such as `"+14155552671"`. |
291
+ | `number` | `min`, `max`, `integer` | number | A finite JSON number (`FIELD_TYPE_MISMATCH`). A whole number when `integer` is `true` (`FIELD_TYPE_MISMATCH`). At least `min` and at most `max` (`OUT_OF_RANGE`). |
292
+ | `date` | | string | A real calendar date as `YYYY-MM-DD`, such as `"2026-10-01"` (`INVALID_DATE`) |
293
+ | `time` | | string | A 24-hour time as `HH:mm`, such as `"09:30"` (`INVALID_DATE`) |
294
+ | `datetime` | | string | An ISO 8601 date and time with `Z` or an offset, such as `"2026-10-01T09:30Z"` or `"2026-10-01T09:30:00+02:00"`; seconds and fractions of a second are optional (`INVALID_DATE`). A time without an offset is refused, so the agent never guesses the time zone. |
295
+ | `boolean` | | `true` or `false` | A JSON boolean (`FIELD_TYPE_MISMATCH`). The full chat shows a switch and always sends it, since off is an answer. |
296
+ | `select` | `options` | string | The id of one of `options` (`OPTION_NOT_OFFERED`) |
297
+ | `multiselect` | `options` | string[] | Ids of `options` (`OPTION_NOT_OFFERED`), each once (`DUPLICATE_ID`) |
298
+
299
+ `options` holds 2–50 items (`choice.optionsMin`, `choice.optionsMax`), each `{id, label}` with the
300
+ [choice](#choice) option id and label rules; ids are unique within the field (`DUPLICATE_ID`).
301
+ `minLength` above `maxLength` (or above the cap), or `min` above `max`, fails with
302
+ `RANGE_INVALID`. In an answer, a value of the wrong JSON type, including `null`, fails with
303
+ `FIELD_TYPE_MISMATCH`.
304
+
305
+ ```json
306
+ {
307
+ "title": "Invoice details",
308
+ "prompt": "A few details for the invoice.",
309
+ "fields": [
310
+ {"id": "company", "type": "text", "label": "Company name", "required": true, "maxLength": 120},
311
+ {"id": "seats", "type": "number", "label": "Seats", "min": 1, "max": 500, "integer": true},
312
+ {"id": "start", "type": "date", "label": "Start date"},
313
+ {"id": "region", "type": "select", "label": "Region", "options": [{"id": "us", "label": "US"}, {"id": "eu", "label": "EU"}]},
314
+ {"id": "notify", "type": "boolean", "label": "Email me the invoice", "default": true}
315
+ ],
316
+ "submitLabel": "Send details"
317
+ }
318
+ ```
319
+
320
+ Answer: `{"action": "accept", "content": {"values": {"company": "Acme", "seats": 12, "start": "2026-10-01", "region": "us", "notify": true}}}`.
321
+
322
+ | Answer field | Rule | Error |
323
+ | --- | --- | --- |
324
+ | `values` | Required object, keyed by field id. A key that is no field's id fails. | `MISSING_REQUIRED`, `UNKNOWN_KEY` |
325
+ | `values.<id>` | A value the field accepts (table above). A blank string or an empty list counts as unanswered: allowed on an optional field, `REQUIRED_FIELD` on a required one. Unanswered optional fields may be left out. | `REQUIRED_FIELD`, `FIELD_TYPE_MISMATCH`, `OUT_OF_RANGE`, `INVALID_DATE`, `TOO_LONG`, `TOO_SHORT`, `OPTION_NOT_OFFERED`, `DUPLICATE_ID` |
326
+
327
+ Errors on a value have the path `content.values.<id>`, so a client can show each one under its
328
+ field. `formDefaultValues(input)` returns the defaults keyed by field id, leaving out fields
329
+ without one. `formTextMaxLength(field)` returns the longest string value a field accepts.
330
+
331
+ ## files
332
+
333
+ Tool: `ask_files`. The user picks one or more files and sends them, and the model reads them in
334
+ the same turn. The full chat shows a picker limited to the accepted types, the picked files, and
335
+ Submit. The [compact surface](#compact-surface) does not offer it, because files cannot be picked
336
+ on a small screen.
337
+
338
+ | Field | Type | Rule |
339
+ | --- | --- | --- |
340
+ | `prompt`, `title`, `submitLabel`, `allowDecline` | | The [shared fields](#shared-ask-fields). `allowDecline` defaults to `true`. |
341
+ | `accept` | string[] | Required, 1–5 values from `ASK_FILE_ACCEPT`, each listed once (`DUPLICATE_ID`). Any other value fails with `INVALID_ENUM`. |
342
+ | `minFiles` | integer | Optional, 1–10 (`files.minFiles`, `files.maxFiles`). Default 1. Above `maxFiles` fails with `RANGE_INVALID`. |
343
+ | `maxFiles` | integer | Optional, 1–10. Default 10. |
344
+
345
+ | `accept` value | MIME types (`ASK_FILE_ACCEPT_MIME_TYPES`) | The model sees |
346
+ | --- | --- | --- |
347
+ | `image` | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | An `image-data` part |
348
+ | `pdf` | `application/pdf` | A `file-data` part |
349
+ | `text` | `text/plain` | Text, cut to 100 KB (`files.textMaxBytes`) with a note |
350
+ | `csv` | `text/csv` | Text, cut the same way |
351
+ | `json` | `application/json` | Text, cut the same way |
352
+
353
+ ```json
354
+ {
355
+ "title": "Upload a receipt",
356
+ "prompt": "Upload the receipt for your expense report.",
357
+ "accept": ["image", "pdf", "text", "csv"],
358
+ "maxFiles": 3,
359
+ "submitLabel": "Send receipt"
360
+ }
361
+ ```
362
+
363
+ Answer: `{"action": "accept", "content": {"files": [{"fileId": "66f0c0ffee0000000000000a", "filename": "receipt.png", "mimeType": "image/png", "size": 48213}]}}`.
364
+ Each file (`askFileRefSchema`) names its bytes one of two ways:
365
+
366
+ | Ref | When | Rule |
367
+ | --- | --- | --- |
368
+ | `{fileId}` | The host has file storage ([`FileStorageService`](ai.md), a GCS bucket) | The id `POST /files/upload` returns. It must name an upload of the caller that is not deleted, and storage must return its bytes (`FILE_NOT_OWNED`; a failed download is logged as a warning). |
369
+ | `{url}` | The host has no file storage | A base64 `data:` URL whose media type is the declared `mimeType` (`MIME_MISMATCH`). The server never fetches a remote URL (`INVALID_FORMAT`). |
370
+
371
+ | Answer field | Rule | Error |
372
+ | --- | --- | --- |
373
+ | `files` | Required, from `minFiles` to `maxFiles` files, in the order the user picked them | `MISSING_REQUIRED`, `FILE_COUNT` |
374
+ | `files[].fileId`, `files[].url` | Exactly one of the two | `MISSING_REQUIRED`, `INVALID_FORMAT` |
375
+ | `files[].filename` | 1–255 characters (`files.filenameMaxLength`) | `TOO_SHORT`, `TOO_LONG` |
376
+ | `files[].mimeType` | One of the types `accept` allows | `FILE_TYPE_NOT_ACCEPTED` |
377
+ | `files[].size` | Whole bytes, at most the host's cap (default 10 MB) | `FILE_TOO_LARGE` |
378
+
379
+ Before it resumes the turn, the server loads each file's bytes: it decodes a data URL, or reads
380
+ the caller's upload from storage. Then `checkAskFileBytes` sniffs the first bytes. Images and PDFs
381
+ must carry their type's signature, and text, CSV, and JSON must be UTF-8 without NUL characters
382
+ (JSON must also parse). A file declared as one type with the bytes of another fails with
383
+ `MIME_MISMATCH`, and bytes over the cap with `FILE_TOO_LARGE`. Errors have paths such as
384
+ `content.files[0].mimeType`. Any error returns 400, and the ask stays pending.
385
+
386
+ The model gets the answer as a `content` tool result: the stored answer as JSON, then, for each
387
+ file, a line such as `File 1 of 2: receipt.png (image/png, 48213 bytes)` followed by the file. A
388
+ text file over 100 KB is cut on a character boundary and followed by
389
+ `[The file is cut to its first N of M bytes.]`. Only the answering turn sends the bytes; see
390
+ [Stored state](#stored-state).
391
+
392
+ ## Approval asks
393
+
394
+ A host tool with the AI SDK's `needsApproval` never runs before the user approves it. When the
395
+ model calls one, the SDK emits `tool-approval-request` and stops without calling `execute`. The
396
+ server then pauses the turn on a `confirm` ask it makes itself, not one the model wrote:
397
+
398
+ ```typescript
399
+ import {tool, zodSchema} from "ai";
400
+
401
+ const deleteCompletedTodos = tool({
402
+ description: "Delete all of the signed-in user's completed todos.",
403
+ execute: async () => deleteTodos(),
404
+ inputSchema: zodSchema(z.object({}).strict()),
405
+ needsApproval: true,
406
+ });
407
+
408
+ const chat = {
409
+ aiService,
410
+ asks: {
411
+ approvals: {
412
+ deleteCompletedTodos: () => ({
413
+ confirmLabel: "Delete",
414
+ denyLabel: "Keep them",
415
+ destructive: true,
416
+ prompt: "Delete all of your completed todos? You can't undo this.",
417
+ }),
418
+ },
419
+ },
420
+ tools: {deleteCompletedTodos},
421
+ };
422
+ ```
423
+
424
+ | Rule | Behavior |
425
+ | --- | --- |
426
+ | Input | `asks.approvals[toolName](input)` returns the `confirm` input, from the call's input. Without an entry, or when it throws or returns an input that fails `confirm` validation, the input is `{prompt: "Allow <toolName>? <description>", confirmLabel: "Allow", denyLabel: "Deny"}` (the prompt cut to 500 characters). Set `destructive: true` for a tool that deletes data, so the approve button shows as destructive (D26). |
427
+ | Ask | `kind: "confirm"`, `origin: "approval"`, `toolName` (the host tool), and `toolCallId` equal to the SDK's `approvalId`. The ask works on both surfaces, including [compact](#compact-surface), because a confirm always fits a simple card. |
428
+ | Needs | Asks on (`asks: true` or an object). The offered `kinds` do not matter. With asks off the tool does not run, the SDK stops the step, and the server logs a warning. |
429
+ | Approve | `{action: "accept", content: {confirmed: true}}`, or the `approve` button. The SDK runs the tool once and the model gets its result. |
430
+ | Deny | `{confirmed: false}` or the `deny` button (`user_denied`), `decline` (`user_declined`; only when the input sets `allowDecline: true`), or `cancel` (its `reason`, else `user_cancelled`). The tool does not run. The model gets `{type: "execution-denied", reason}` as the tool's result, and the stream sends `{toolResult: {toolCallId, toolName, result: {approved: false, reason}}}`. |
431
+ | New message | A `prompt` sent while an approval is pending cancels it with `user_sent_message`, like any ask. The tool does not run. |
432
+ | One slot | An approval shares the single pending ask with model asks. In one step the first ask or approval pauses the turn. Every other approval is denied with `one_ask_at_a_time` when the turn resumes, and every other model ask is cancelled as before. |
433
+ | Trust | The approval request and the answer live only in the server's stored `responseMessages`. A client names only the pending ask's `toolCallId`, and the server matches it against `pendingAsk.approvalId`; a crafted `tool-approval-response` or another tool's id gets 400 or 409. |
434
+
435
+ The answer is stored like any ask answer: the approval's row is a `tool-call` with the host
436
+ `toolName`, `args` (the confirm input), and `ask: {kind: "confirm", origin: "approval", status}`,
437
+ and its `tool-result` row holds the answer envelope. The host tool's own call and result rows are
438
+ stored as they are for any host tool. Approval rows are for display only: like host tool rows,
439
+ later turns do not replay them to the model.
440
+
441
+ ## Simple cards
442
+
443
+ Every ask comes with a simple card: short text and up to three buttons, each holding the exact
444
+ answer it sends. A client that does not know the ask kind renders the card and sends the tapped
445
+ button's `response` as the answer. `toSimpleCard({kind, input, toolCallId})` derives the card when
446
+ the ask is made. The server stores it on `pendingAsk.simple` and sends it in the `{ask}` event.
447
+
448
+ | Field | Type | Rule |
449
+ | --- | --- | --- |
450
+ | `toolCallId` | string | The ask's tool call id |
451
+ | `kind` | string | The ask kind |
452
+ | `title` | string? | The ask's `title`, cut to 40 characters |
453
+ | `text` | string | The ask's `prompt`, cut to 140 characters |
454
+ | `buttons` | `{id, label, style, response}[]` | 0–3 buttons. `label` is at most 20 characters. `style` is `default`, `primary`, `destructive`, or `cancel`. `response` is an answer envelope. |
455
+ | `handoff` | boolean | `true` when the buttons cannot show every option the ask offers, so the user needs the full app to answer. A card with a button for every option has `handoff: false`, even when Skip is left out to make room. |
456
+
457
+ Button labels lose spaces at either end, so " Go " shows as "Go", and the card compares labels
458
+ after that trim. Text over a limit is cut to fit and ends in "…". The cut falls on a word boundary when one is in
459
+ the second half of the kept text, and never splits an emoji. `toSimpleCard` measures text in
460
+ UTF-16 code units, the `length` of a JavaScript string, so most emoji count as 2 or more.
461
+
462
+ `choice` card rules:
463
+
464
+ | Case | Buttons | `handoff` |
465
+ | --- | --- | --- |
466
+ | At most 3 options, and their cut labels are all different | One button per option: id `option:<id>`, response `{action: "accept", content: {selected: [id]}}`. The `default` option comes first with style `primary`, and the others follow in option order with style `default`. Without a `default`, the buttons keep option order, all with style `default`. Then `skip` (label "Skip", style `cancel`, response `{action: "decline"}`) when `allowDecline` is not `false` and there are fewer than 3 options. | `false` |
467
+ | More than 3 options | `use-default` (label `Use "<label>"` with the default's label cut to 14 characters, style `primary`, response selecting the default) when the ask has a `default`, then `skip` when `allowDecline` is not `false` | `true` |
468
+ | The buttons cannot tell options apart: two of at most 3 options have the same cut label, or another option's label cut to 14 characters matches the one in `Use "<label>"` | Only `skip`, when `allowDecline` is not `false` | `true` |
469
+ | `select: "many"` | `use-default` (label "Use suggested", style `primary`, response `{action: "accept", content: {selected: default}}`) when `default` is not empty and is a valid answer on its own (it meets `minSelected`), then `skip` when `allowDecline` is not `false` | `true` |
470
+
471
+ The first three rows are for `select: "one"`. A `"many"` card never has a button per option,
472
+ because one tap cannot pick several, so the user answers in the full app unless the suggested
473
+ set or Skip is enough.
474
+
475
+ `confirm` card rule:
476
+
477
+ | Case | Buttons | `handoff` |
478
+ | --- | --- | --- |
479
+ | Every `confirm` | `approve` (the approve label, style `destructive` when the ask sets `destructive: true`, else `primary`, response `{action: "accept", content: {confirmed: true}}`), then `deny` (the deny label, style `cancel`, response `{action: "accept", content: {confirmed: false}}`). No `skip`, even with `allowDecline: true`: deny is the negative answer. | `false` |
480
+
481
+ The primary answer comes first and `cancel` last. A destructive approve is never the first
482
+ non-destructive button, so the Apple Watch Double Tap gesture, which presses that button, can
483
+ only deny.
484
+
485
+ `markdown` card rule:
486
+
487
+ | Case | Buttons | `handoff` |
488
+ | --- | --- | --- |
489
+ | Every `markdown` | `approve` (label "Approve draft", style `primary`, response `{action: "accept", content: {markdown: initial, changed: false}}`) when `initial` meets `minLength` and `maxLength`, then `cancel` (label "Cancel", style `cancel`, response `{action: "decline"}`) when `allowDecline` is not `false` | `true` |
490
+
491
+ A markdown card always hands off: editing needs the full app. `SimpleAskCard` shows "Edit on your
492
+ phone" on a `markdown` card instead of "Continue on your phone".
493
+
494
+ `form` card rule:
495
+
496
+ | Case | Buttons | `handoff` |
497
+ | --- | --- | --- |
498
+ | Every `form` | `submit-defaults` (label "Submit defaults", style `primary`, response `{action: "accept", content: {values: formDefaultValues(input)}}`) when at least one field has a `default` and every required field has one, then `cancel` (label "Cancel", style `cancel`, response `{action: "decline"}`) when `allowDecline` is not `false` | `true` |
499
+
500
+ A form card always hands off: filling in fields needs the full app. `SimpleAskCard` shows "Fill it
501
+ in on your phone" on a `form` card.
502
+
503
+ `files` card rule:
504
+
505
+ | Case | Buttons | `handoff` |
506
+ | --- | --- | --- |
507
+ | Every `files` | `skip` (label "Skip", style `cancel`, response `{action: "decline"}`) when `allowDecline` is not `false`, else none | `true` |
508
+
509
+ A files card always hands off: picking files needs the full app. `SimpleAskCard` shows "Upload on
510
+ your phone" on a `files` card.
511
+
512
+ Every button's `response` passes `validateAskResponse` for its ask. `simpleCardSchema` checks a
513
+ card's shape, limits, and unique button ids.
514
+
515
+ A client answers from a card in one of two ways:
516
+
517
+ - Send the tapped button's `response` as the answer, like any other answer.
518
+ - Send only the button's id to the [`turn` action](#headless-endpoints): `{toolCallId, buttonId}`.
519
+ The server finds the button on the stored card with `resolveButtonAnswer({card, buttonId})` and
520
+ answers with its `response`. An id that is not on the card returns 400 `UNKNOWN_BUTTON`.
521
+
522
+ `SimpleAskCard` in `@terreno/ui` renders any card ([props](ui.md#simpleaskcard)).
523
+
524
+ ## Compact surface
525
+
526
+ A client on a small screen, such as a watch, sends `surface: "compact"` to `POST /gpt/prompt` or
527
+ the [`turn` action](#headless-endpoints). `surface` is `"full"` (the default) or `"compact"`
528
+ (`ASK_SURFACES`). Any other value returns 400. On a compact turn:
529
+
530
+ - The model is offered only the kinds in `COMPACT_ASK_KINDS` (`["choice", "confirm"]`) that `asks` enables,
531
+ each with its narrowed input schema (`compactAskInputSchemas`). `markdown`, `form`, and `files` are never offered.
532
+ - The asks section of the system prompt is `askPromptSection({kinds, surface: "compact"})`, which
533
+ states the narrowed limits.
534
+ - `COMPACT_SURFACE_SYSTEM_PROMPT` is appended to the system prompt, even when asks are off: "The
535
+ user is on a small screen, such as a watch. Keep each reply to at most two short sentences, and
536
+ ask only yes-or-no questions or questions with up to three short options."
537
+ - Every ask's simple card has `handoff: false`, because the narrowed schemas accept only asks whose
538
+ buttons show every option.
539
+
540
+ The surface applies to one turn. An ask made on a compact turn can be answered from any client,
541
+ and the next full turn offers every kind again.
542
+
543
+ A compact `choice` (`compactChoiceAskInputSchema`) follows the [choice](#choice) rules plus these:
544
+
545
+ | Field | Compact rule | Error |
546
+ | --- | --- | --- |
547
+ | `select` | Only `"one"`. `minSelected`, `maxSelected`, `allowOther`, and `otherLabel` are not defined. | `INVALID_ENUM`, `UNKNOWN_KEY` |
548
+ | `options` | 2–3 items (`simpleCard.buttonsMax`) | `TOO_MANY` |
549
+ | `options[].label` | At most 20 UTF-16 code units (`simpleCard.buttonLabelMaxLength`), so its button shows it uncut. Most emoji count as 2 or more. | `TOO_LONG` |
550
+ | `options[].label` | Different from every other option's label, ignoring spaces at either end | `DUPLICATE_LABEL` |
551
+
552
+ A compact `confirm` uses the full [confirm](#confirm) schema (`confirmAskInputSchema`): its
553
+ two labels already fit a button uncut, so every confirm card has `handoff: false`.
554
+
555
+ Every compact ask is also a valid full ask. `validateAskInput({kind, input, surface: "compact"})`
556
+ checks the compact rules. A compact ask that breaks them goes back to the model as a tool error,
557
+ like any invalid ask, and never reaches the client.
558
+
559
+ ## Validation
560
+
561
+ The model's ask and the user's answer are checked with pure functions from `@terreno/blocks`:
562
+
563
+ | Function | Checks | Where it runs |
564
+ | --- | --- | --- |
565
+ | `validateAskInput({kind, input, surface?})` | The ask against its schema and rules: unique option ids, the `select` bounds, Other fields only on `"many"` asks, defaults among the options and within the bounds, `confirm` labels that fit a button and differ, `markdown` length bounds, `form` field ids, bounds, and defaults, and with `surface: "compact"` the [compact rules](#compact-surface) | The same schema is the tool's `inputSchema`, so the AI SDK checks every ask call. An invalid ask goes back to the model as a tool error and never reaches the client. |
566
+ | `validateAskResponse({kind, input, response, maxFileSizeBytes?})` | The answer envelope, then the kind's answer against the ask. For `files`, the declared types, sizes, and data URL media types; `maxFileSizeBytes` defaults to 10 MB. | The server, before it resumes the turn. Clients can run it before they enable Submit. |
567
+ | `checkAskFileBytes({bytes, index, mimeType, maxFileSizeBytes})` | One file's bytes of a `files` answer against its declared type and the cap | The server, after it loads the file |
568
+
569
+ Both return `AskValidationError[]`, empty when valid, sorted by path and then code:
570
+
571
+ | Field | Meaning |
572
+ | --- | --- |
573
+ | `path` | Where the error is, such as `options[2].id` or `content.selected[0]`. The root is `""`. |
574
+ | `code` | A key of `ASK_ERROR_CODES` |
575
+ | `message` | What is wrong, in one sentence |
576
+ | `fix` | One instruction that fixes it |
577
+
578
+ ## Error codes
579
+
580
+ `ASK_ERROR_CODES` maps each code to its meaning. A doc-parity test fails when this table and
581
+ `ASK_ERROR_CODES` disagree.
582
+
583
+ | Code | Meaning | Returned by |
584
+ | --- | --- | --- |
585
+ | `CHANGED_MISMATCH` | A markdown answer's changed flag does not match whether its text differs from the draft. | `validateAskResponse` |
586
+ | `DECLINE_NOT_ALLOWED` | The answer skips an ask that does not allow skipping. | `validateAskResponse` |
587
+ | `DEFAULT_NOT_IN_OPTIONS` | A default names an option id that the ask does not offer. | `validateAskInput` |
588
+ | `DUPLICATE_ID` | An id appears twice where ids must be unique: options, default, accept, or an answer. | Both |
589
+ | `DUPLICATE_LABEL` | Two buttons would share a label: options of a compact ask, or a confirm's approve and deny. | `validateAskInput` (a `choice` only with `surface: "compact"`) |
590
+ | `FIELD_TYPE_MISMATCH` | A form value or default does not fit its field's type: the wrong JSON type, not a whole number, or not a valid email, URL, or phone number. | Both |
591
+ | `FILE_COUNT` | A files answer has fewer files than minFiles or more than maxFiles. | `validateAskResponse` |
592
+ | `FILE_NOT_OWNED` | A files answer names a fileId that is not an upload of the caller, or the host has no file storage. | The server, when it loads an upload (`fileNotOwnedError`) |
593
+ | `FILE_TOO_LARGE` | A file is larger than the host's per-file upload cap. | `validateAskResponse` and the server byte check (`checkAskFileBytes`) |
594
+ | `FILE_TYPE_NOT_ACCEPTED` | A file's declared type is not one the ask's accept list allows. | `validateAskResponse` |
595
+ | `INVALID_DATE` | A form date, time, or datetime value or default is not a real ISO 8601 value in the field's format. | Both |
596
+ | `INVALID_ENUM` | A value is not one of the allowed values. | Both |
597
+ | `INVALID_FORMAT` | A string does not match its required format. | Both |
598
+ | `INVALID_TYPE` | A value has the wrong type. | Both |
599
+ | `MIME_MISMATCH` | A file's bytes, or its data URL's media type, do not match the type the answer declares. | `validateAskResponse` (data URL media type) and the server byte check (`checkAskFileBytes`) |
600
+ | `MISSING_REQUIRED` | A required field is missing. | Both |
601
+ | `OPTION_NOT_OFFERED` | The answer selects an option id that the ask did not offer. | `validateAskResponse` |
602
+ | `OTHER_NOT_ALLOWED` | An ask or an answer uses Other where the ask does not allow it. | Both |
603
+ | `OUT_OF_RANGE` | A form number value or default is below the field's min or above its max. | Both |
604
+ | `RANGE_INVALID` | A count or length bound is out of range: below its minimum, above what the ask offers, or a minimum above its maximum. | `validateAskInput` |
605
+ | `REQUIRED_FIELD` | A form answer leaves a required field missing or blank. | `validateAskResponse` |
606
+ | `SELECTION_COUNT` | A default or an answer selects the wrong number of options. | Both |
607
+ | `TOO_FEW` | A list has fewer items than allowed. | `validateAskInput` |
608
+ | `TOO_LONG` | A string is longer than allowed. | Both |
609
+ | `TOO_MANY` | A list has more items than allowed. | Both |
610
+ | `TOO_SHORT` | A string is empty, only whitespace, or shorter than its minimum. | Both |
611
+ | `UNKNOWN_BUTTON` | The pressed button is not on the pending ask's simple card. | `resolveButtonAnswer` and the `turn` endpoint |
612
+ | `UNKNOWN_KEY` | An object has a field that its schema does not define. | Both |
613
+
614
+ For `DUPLICATE_LABEL`, labels that differ only in spaces at either end, such as "Go " and "Go",
615
+ count as the same.
616
+
617
+ ## Limits
618
+
619
+ `ASK_LIMITS` is the one source for these values: the schemas, the prompt section, and the tests
620
+ read it. Keys are paths into `ASK_LIMITS`. A doc-parity test fails when this table and
621
+ `ASK_LIMITS` disagree.
622
+
623
+ | Key | Value | Applies to |
624
+ | --- | --- | --- |
625
+ | `cancelReasonMaxLength` | 200 | `reason` on a `cancel` answer |
626
+ | `choice.optionDescriptionMaxLength` | 280 | `options[].description` |
627
+ | `choice.optionIdMaxLength` | 64 | `options[].id`, and a form's `fields[].id` and `fields[].options[].id` |
628
+ | `choice.optionIdPattern` | `^[a-z0-9][a-z0-9_-]{0,63}$` | `options[].id`, and a form's `fields[].id` and `fields[].options[].id` |
629
+ | `choice.optionLabelMaxLength` | 120 | `options[].label`, and a form's `fields[].options[].label` |
630
+ | `choice.optionsMax` | 50 | `options`, `default`, and `content.selected`, and a form field's `options` and `multiselect` value |
631
+ | `choice.optionsMin` | 2 | `options`, and a form field's `options` |
632
+ | `choice.otherMaxLength` | 500 | `content.other` |
633
+ | `confirm.labelMaxLength` | 20 | `confirmLabel` and `denyLabel`, in UTF-16 code units |
634
+ | `files.defaultMaxFileSizeBytes` | 10485760 | Each file of a `files` answer (10 MB) when the host sets no `asks.maxFileSizeBytes` |
635
+ | `files.filenameMaxLength` | 255 | `content.files[].filename` |
636
+ | `files.maxFiles` | 10 | `maxFiles` and `minFiles` on a `files` ask, and the default `maxFiles` |
637
+ | `files.minFiles` | 1 | `maxFiles` and `minFiles` on a `files` ask, and the default `minFiles` |
638
+ | `files.textMaxBytes` | 100000 | A text, CSV, or JSON file as the model sees it (100 KB) |
639
+ | `form.fieldsMax` | 8 | `fields` on a `form` ask |
640
+ | `form.fieldsMin` | 1 | `fields` on a `form` ask |
641
+ | `form.helperTextMaxLength` | 280 | `fields[].helperText` |
642
+ | `form.labelMaxLength` | 120 | `fields[].label` |
643
+ | `form.phoneDigitsMax` | 15 | Digits in a `phone` value |
644
+ | `form.phoneDigitsMin` | 7 | Digits in a `phone` value |
645
+ | `form.textMaxLength` | 2000 | A `text`, `email`, `url`, or `phone` value, and a `text` field's `maxLength`, in UTF-16 code units |
646
+ | `form.textareaMaxLength` | 10000 | A `textarea` value and a `textarea` field's `maxLength`, in UTF-16 code units |
647
+ | `markdown.maxLength` | 20000 | `initial`, `maxLength`, and `content.markdown` on a `markdown` ask, in UTF-16 code units |
648
+ | `markdown.placeholderMaxLength` | 120 | `placeholder` on a `markdown` ask |
649
+ | `pendingAsksPerHistory` | 1 | Asks one conversation can wait on at a time |
650
+ | `promptMaxLength` | 500 | `prompt` |
651
+ | `simpleCard.buttonLabelMaxLength` | 20 | Simple card button `label` |
652
+ | `simpleCard.buttonsMax` | 3 | Simple card `buttons` |
653
+ | `simpleCard.textMaxLength` | 140 | Simple card `text` |
654
+ | `simpleCard.titleMaxLength` | 40 | Simple card `title` |
655
+ | `submitLabelMaxLength` | 24 | `submitLabel` |
656
+ | `titleMaxLength` | 80 | `title` |
657
+
658
+ ## SSE events
659
+
660
+ With asks on, `POST /gpt/prompt` adds these events to the stream. The full event list is in
661
+ [`@terreno/ai` SSE events](ai.md#sse-events).
662
+
663
+ | Event | When | Shape |
664
+ | --- | --- | --- |
665
+ | `{ask}` | The turn paused on a valid ask. Sent after the turn is saved, just before `{done}`. `historyId` names the conversation that waits on the ask, so a client can answer before `{done}` arrives, even on a new chat. | `{ask: {toolCallId, kind, input, simple, origin?, toolName?}, historyId}`. An [approval ask](#approval-asks) adds `origin: "approval"` and the host `toolName`. |
666
+ | `{askResolved}` | First event of a turn that answered the pending ask or cancelled it with a new `prompt` | `{askResolved: {toolCallId, action}}` |
667
+ | `pendingAsk` on `{done}` | The turn ended waiting on an answer. After `{askResolved}` and `{error}`, it names the answered ask when the answer was [undone](#answer-an-ask); the stream sent no `{ask}` for it, so reload the conversation to show it again. | `{done: true, historyId, title?, pendingAsk: {toolCallId}}` |
668
+
669
+ Ask tool calls never produce `{toolCall}` or `{toolResult}` events, and an invalid ask produces
670
+ no event. A host tool that needs approval sends its `{toolCall}` when the model calls it, and its
671
+ `{toolResult}` after the answer: the tool's output when approved, or
672
+ `{approved: false, reason}` when denied. Text the model writes in the same step as an ask is dropped, like text in any step that
673
+ calls a tool, so the question belongs in the ask's `prompt`.
674
+
675
+ ## Answer an ask
676
+
677
+ Send `historyId` and `askResponse` instead of `prompt` to `POST /gpt/prompt`:
678
+
679
+ ```json
680
+ {
681
+ "historyId": "6710c2a1f1e2d3c4b5a69701",
682
+ "askResponse": {"toolCallId": "call_8f2c1", "action": "accept", "content": {"selected": ["team"]}}
683
+ }
684
+ ```
685
+
686
+ Clients that do not read server-sent events send the same `askResponse`, or a simple card's
687
+ `buttonId`, to the [`turn` action](#headless-endpoints), which runs the same steps and returns the
688
+ result as JSON.
689
+
690
+ `askResponse` is the answer envelope plus the pending ask's `toolCallId`. The server then:
691
+
692
+ 1. Loads the history and checks that it belongs to the caller (403) and waits on this
693
+ `toolCallId` (409).
694
+ 2. Checks the answer with `validateAskResponse`. For a `files` answer it then loads and checks
695
+ each file's bytes ([files](#files)). An invalid answer returns 400 with `fields`, and the model
696
+ is not called.
697
+ 3. Builds the system prompt and loads the host's tools. If that fails, the request returns 500
698
+ JSON and the ask stays pending, so the same answer can be sent again.
699
+ 4. In one atomic update, stores the answer as a `tool-result` row, marks the ask's row
700
+ `answered` (`cancelled` for a `cancel` answer), and clears `pendingAsk`. When two answers race,
701
+ one resumes the turn and the other gets 409.
702
+ 5. Replays the paused turn's stored messages with the answer as the ask's tool result, and
703
+ streams the continuation, starting with `{askResolved}`.
704
+
705
+ If the continuation fails before anything but `{askResolved}` reaches the client (no text, tool
706
+ result, or ask), the answer is undone. The stream sends `{askResolved}`, `{error}`, and
707
+ `{done, pendingAsk: {toolCallId}}`; the ask is pending again, its row is back to `pending`, the
708
+ answer's `tool-result` row is removed, and the same answer can be sent again. A client reloads the
709
+ conversation to show the ask again. The undo is skipped when the conversation changed in the
710
+ meantime, such as a `prompt` from another tab, or when the turn saved rows after the answer, such
711
+ as the call row of a next ask it then failed to pause on. Then the answer is kept, and that ask
712
+ is cancelled as below.
713
+
714
+ Once the continuation has sent a text, a tool result, or an ask, the answer is kept even when the
715
+ model call fails later. The stream sends `{askResolved}`, `{error}`, and `{done}` without
716
+ `pendingAsk`. An ask the failed stream had started is dropped, not paused on. The ask's row
717
+ stays `answered`, and sending the answer again returns 409. To continue, send a new `prompt`: the
718
+ model sees the ask, its answer, and the new message.
719
+
720
+ A turn that fails after saving an ask's `tool-call` row but before pausing on it marks that row
721
+ `cancelled` with a `{action: "cancel"}` result, so no row is left `pending` without a
722
+ `pendingAsk`.
723
+
724
+ A `prompt` sent while an ask is pending first records `{action: "cancel", reason:
725
+ "user_sent_message"}` for the ask, then adds the message. The model sees both, and the stream
726
+ starts with `{askResolved}` with `action: "cancel"`. A `prompt` is never rejected because the ask
727
+ it meant to cancel is gone: when an answer from another tab or device resolved the ask first, the
728
+ prompt goes ahead as a normal message on the conversation as that answer left it, without
729
+ `{askResolved}`. Only an ask that is still pending is cancelled, and it is cancelled once when two
730
+ prompts arrive together. A client that showed the ask as pending and gets no `{askResolved}` for
731
+ it can reload the conversation to show how the ask ended.
732
+
733
+ Every turn saves its rows with one atomic append when it ends, so two turns on one conversation
734
+ keep both turns' rows, each after its own user message. A paused turn's `promptIndex` counts the
735
+ rows before and including its own user message, so its resume replays exactly that history, its
736
+ stored response messages, and the answer.
737
+
738
+ With asks off, `askResponse` is ignored and `prompt` is required, as before.
739
+
740
+ ## Error responses
741
+
742
+ Errors raised before the stream starts return JSON with `status`, `title`, and `detail`:
743
+
744
+ | Status | `title` | When |
745
+ | --- | --- | --- |
746
+ | 400 | `prompt is required` | Neither `prompt` nor `askResponse` was sent |
747
+ | 400 | `Send either prompt or askResponse, not both` | Both were sent |
748
+ | 400 | `askResponse must be an object` | `askResponse` is not a JSON object |
749
+ | 400 | `askResponse.toolCallId is required` | `toolCallId` is missing or empty |
750
+ | 400 | `attachments cannot be sent with askResponse` | An answer came with `attachments` |
751
+ | 400 | `historyId is required with askResponse` | An answer came without `historyId` |
752
+ | 400 | `surface is not a known surface` | `surface` is not `full` or `compact`. The detail lists the surfaces. |
753
+ | 400 | `Invalid askResponse` | The answer fails `validateAskResponse`. `fields` lists the errors. |
754
+ | 403 | `Not authorized to access this history` | The history belongs to another user |
755
+ | 404 | `History not found` | No history has that id |
756
+ | 409 | `This ask is no longer pending` | `toolCallId` is not the ask the history waits on: it was answered, cancelled, or never asked. Only answers get 409; a `prompt` is never rejected this way. |
757
+
758
+ An invalid answer:
759
+
760
+ ```json
761
+ {
762
+ "detail": "The answer does not match the ask. See fields.",
763
+ "fields": [
764
+ {
765
+ "code": "OPTION_NOT_OFFERED",
766
+ "fix": "Use the id of one of the ask's options.",
767
+ "message": "\"gold\" is not one of the offered options.",
768
+ "path": "content.selected[0]"
769
+ }
770
+ ],
771
+ "requestId": "3f0c9a52-7d1e-4b8a-9c65-2a4e1b7d9f30",
772
+ "status": 400,
773
+ "title": "Invalid askResponse"
774
+ }
775
+ ```
776
+
777
+ Apps built with `TerrenoApp` add `requestId` to every JSON object response.
778
+
779
+ ## Headless endpoints
780
+
781
+ Two actions on `/gpt/histories` serve clients that do not read server-sent events, such as a watch
782
+ app, a notification action, or a chat bot. They exist only with asks on, so a host that never
783
+ turned asks on gets no new endpoints: `AiApp` registers them when its `asks` option is set, and
784
+ `addGptHistoryRoutes` when its `chat` option turns `asks` on (see [Enable asks](#enable-asks)).
785
+
786
+ | Endpoint | Permission | Returns in `data` |
787
+ | --- | --- | --- |
788
+ | `GET /gpt/histories/pendingAsks` | `IsAuthenticated` | The caller's pending asks, newest first: `[{historyId, title?, toolCallId, kind, simple, created}]`. `created` is an ISO 8601 UTC timestamp. Deleted conversations are left out. |
789
+ | `POST /gpt/histories/:id/turn` | `IsOwner`. An admin who does not own the history gets 403, because a turn speaks as the conversation's owner. | `{historyId, text, title?, pendingAsk?: {toolCallId, kind, simple}, error?}`, once the turn finishes |
790
+
791
+ An [approval ask](#approval-asks) appears here as a plain `confirm` with its simple card; these
792
+ summaries leave out `origin` and `toolName`, so the published JSON Schemas are unchanged.
793
+
794
+ The `turn` body holds exactly one of three shapes, plus an optional `surface`
795
+ ([compact surface](#compact-surface)). Any other field returns 400.
796
+
797
+ | Body | Runs the same turn as `POST /gpt/prompt` with |
798
+ | --- | --- |
799
+ | `{prompt}` | `{historyId, prompt}`. A pending ask is first cancelled with `user_sent_message`. |
800
+ | `{askResponse: {toolCallId, action, content?, reason?}}` | `{historyId, askResponse}` |
801
+ | `{toolCallId, buttonId}` | `{historyId, askResponse}`, where the answer is the `response` stored on that button of the pending ask's simple card |
802
+
803
+ `turn` uses the same system prompt, tools, stored rows, and `AIRequest` log as `/gpt/prompt`. In
804
+ the result:
805
+
806
+ - `text` joins the text the turn produced. It is `""` when the agent only asked.
807
+ - `pendingAsk` is set when the turn paused on an ask. Answer it with its `toolCallId` and the `id`
808
+ of one of `simple.buttons`.
809
+ - `error` is set, with status 200, when the turn failed after it started. `text` holds what the
810
+ agent said before the error. An answer is kept even then, as on `/gpt/prompt`.
811
+
812
+ The server writes nothing until the turn ends, so a client that disconnects does not stop it: the
813
+ turn finishes and is saved. Read it later with `GET /gpt/histories/:id`, or list what still waits
814
+ with `pendingAsks`.
815
+
816
+ Start a conversation with `POST /gpt/histories` and the body `{}`. The server sets `userId` to the
817
+ caller, even when the app validates request bodies.
818
+
819
+ | Status | `title` | `code` | When |
820
+ | --- | --- | --- | --- |
821
+ | 400 | `Validation failed` | `action-body-validation-failed` | The body is not one of the three shapes, `surface` is unknown, or a field is not defined. `meta.fields` maps each field to its message. |
822
+ | 400 | `Unknown buttonId` | `UNKNOWN_BUTTON` | `buttonId` is not on the pending ask's simple card. `meta.fields` holds the error, whose `fix` lists the card's button ids. The ask stays pending. |
823
+ | 400 | `Invalid askResponse` | | `askResponse` fails `validateAskResponse`. `meta.fields` lists the errors. |
824
+ | 401 | `Unauthorized` | | No session or bearer token |
825
+ | 403 | `Access denied` | `action-access-denied` | The history belongs to another user |
826
+ | 403 | `Not authorized to access this history` | | An admin sent a turn to another user's history |
827
+ | 404 | `Document not found` | `document-not-found` | No history has that id |
828
+ | 409 | `This ask is no longer pending` | | `toolCallId` is not the ask the history waits on: it was answered, cancelled, or never asked. A `prompt` never gets 409. |
829
+
830
+ A small client asks and answers with the button's id:
831
+
832
+ ```text
833
+ POST /gpt/histories/6710c2a1f1e2d3c4b5a69701/turn
834
+ {"prompt": "Set up billing for my team", "surface": "compact"}
835
+
836
+ {"data": {"historyId": "6710c2a1f1e2d3c4b5a69701", "pendingAsk": {"kind": "choice", "simple": {…}, "toolCallId": "call_8f2c1"}, "text": ""}, "requestId": "…"}
837
+
838
+ POST /gpt/histories/6710c2a1f1e2d3c4b5a69701/turn
839
+ {"toolCallId": "call_8f2c1", "buttonId": "option:team", "surface": "compact"}
840
+
841
+ {"data": {"historyId": "6710c2a1f1e2d3c4b5a69701", "text": "Done. Your team is on the Team plan.", "title": "Team plan setup"}, "requestId": "…"}
842
+ ```
843
+
844
+ `simple` is the card in the [wire example](#wire-example).
845
+
846
+ ## JSON Schemas and fixtures
847
+
848
+ `@terreno/blocks` publishes JSON Schema (draft-07) documents for clients that do not run
849
+ TypeScript, such as a watch app that generates Swift `Codable` types. Read them from
850
+ `@terreno/blocks/schemas/<file>`, or from `blocks/schemas/` in the Terreno repo:
851
+
852
+ | File | Describes |
853
+ | --- | --- |
854
+ | `simpleCard.schema.json` | A simple card, with its buttons and their answer envelopes |
855
+ | `turnRequest.schema.json` | The body of `POST /gpt/histories/{id}/turn` |
856
+ | `turnResponse.schema.json` | The response of `turn`: `{data, requestId?}` |
857
+ | `pendingAsksResponse.schema.json` | The response of `GET /gpt/histories/pendingAsks`: `{data, requestId?}` |
858
+
859
+ Nested types have titles (`SimpleCard`, `SimpleCardButton`, `AskResponse`, `AskAnswer`,
860
+ `PendingAsk`, `PendingAskSummary`, `TurnResult`), so generated code gets readable names. JSON
861
+ Schema cannot express two rules, so the server enforces them: button ids are unique within a card,
862
+ and a turn body sends exactly one of `prompt`, `askResponse`, or `toolCallId` with `buttonId`.
863
+
864
+ `askJsonSchemas()` builds the documents from `simpleCardSchema`, `turnRequestSchema`,
865
+ `turnResultSchema`, and `pendingAskListSchema`. After changing one of those, run
866
+ `bun run schemas` in `blocks/`. A test fails while the committed files are out of date.
867
+
868
+ The ask fixtures are published too, for testing a client's rendering and validation:
869
+
870
+ | Path | Shape |
871
+ | --- | --- |
872
+ | `@terreno/blocks/fixtures/valid/<name>.json` | `{kind, input, simple}`: a valid ask and the card `toSimpleCard` derives for it, with `toolCallId: "call_fixture"` |
873
+ | `@terreno/blocks/fixtures/invalid/<name>.json` | `{kind, input, errors}`: an invalid ask and the `{path, code}` of every error `validateAskInput` returns for it |
874
+
875
+ ## Stored state
876
+
877
+ `GptHistory.pendingAsk` holds the one ask a conversation waits on:
878
+
879
+ | Field | Description |
880
+ | --- | --- |
881
+ | `toolCallId` | The ask's tool call id; answers must name it |
882
+ | `kind` | The ask kind |
883
+ | `input` | The validated ask input |
884
+ | `simple` | The simple card, as sent in the `{ask}` event |
885
+ | `promptIndex` | How many leading `prompts` rows are replayed before `responseMessages`: every row up to and including the paused turn's user message |
886
+ | `responseMessages` | The paused turn's AI SDK messages, replayed verbatim when the user answers. A tool result's `fileData` is left out, as in the stored rows. |
887
+ | `created` | When the ask was made |
888
+ | `origin` | `"approval"` for an [approval ask](#approval-asks); unset when the model asked |
889
+ | `approvalId` | An approval ask's AI SDK approval id, the same as `toolCallId`. The resume answers only this approval. |
890
+ | `toolName` | An approval ask's host tool |
891
+
892
+ Rows in `GptHistory.prompts`:
893
+
894
+ | Row | Fields |
895
+ | --- | --- |
896
+ | Ask call | `type: "tool-call"`, `toolName: "ask_choice"`, `toolCallId`, `args` (the ask input), `ask: {kind, status}`. `status` is `pending` until the ask is answered (`answered`) or cancelled (`cancelled`). An approval ask's row has the host `toolName` and `ask: {kind: "confirm", origin: "approval", status}`. |
897
+ | Ask answer | `type: "tool-result"`, `toolName`, `toolCallId`, `result` (the answer envelope) |
898
+
899
+ A `files` answer is stored without its bytes: `result` is
900
+ `{action: "accept", content: {files: [{fileId?, filename, mimeType, size}]}}`, with `size` the real
901
+ byte count and no `url`. The `AIRequest` log holds the same answer. So a data URL never reaches
902
+ the database, and later turns replay only these names, types, and sizes as a JSON tool result:
903
+ the model sees the files themselves only on the turn that answers. The stored answer is a valid
904
+ `askResponseSchema` envelope but not a valid `filesAnswerSchema` answer, which requires `fileId`
905
+ or `url`. Uploads stay in storage as `FileAttachment` rows.
906
+
907
+ The history REST API (`/gpt/histories`) drops `pendingAsk` from create and update bodies, so only
908
+ a chat turn writes it. Its OpenAPI spec marks `pendingAsk` `readOnly`, so generated SDKs leave it
909
+ out of their request types. Every response the router serializes, including list and read, leaves
910
+ out `promptIndex` and `responseMessages`: they are for the model only. A host's own `responseHandler` runs first, and the fields are removed from its
911
+ result. The OpenAPI response schemas leave them out too.
912
+
913
+ `AIRequest.metadata` records asks (`requestType` stays `general`):
914
+
915
+ | Key | When | Value |
916
+ | --- | --- | --- |
917
+ | `ask` | The turn asked | `{kind, phase: "asked", toolCallId}` |
918
+ | `ask` | The turn answered an ask | `{action, kind, phase: "answered", toolCallId}`. `prompt` is the answer envelope as JSON. |
919
+ | `nextAsk` | The turn answered one ask and asked another | `{kind, phase: "asked", toolCallId}` |
920
+
921
+ For an approval ask, each of these values adds `origin: "approval"` and the host `toolName`.
922
+
923
+ ## Wire example
924
+
925
+ Turn 1 asks:
926
+
927
+ ```text
928
+ POST /gpt/prompt
929
+ {"prompt": "Set up billing for my team"}
930
+
931
+ data: {"ask": …}
932
+
933
+ data: {"done":true,"historyId":"6710c2a1f1e2d3c4b5a69701","pendingAsk":{"toolCallId":"call_8f2c1"}}
934
+ ```
935
+
936
+ The `{ask}` event, formatted:
937
+
938
+ ```json
939
+ {
940
+ "ask": {
941
+ "input": {
942
+ "prompt": "Which plan should I set up?",
943
+ "submitLabel": "Set up plan",
944
+ "title": "Choose a plan",
945
+ "default": ["team"],
946
+ "options": [
947
+ {"description": "$0, one seat", "id": "starter", "label": "Starter"},
948
+ {"description": "$20 per seat", "id": "team", "label": "Team"},
949
+ {"id": "enterprise", "label": "Enterprise"}
950
+ ],
951
+ "select": "one"
952
+ },
953
+ "kind": "choice",
954
+ "simple": {
955
+ "buttons": [
956
+ {
957
+ "id": "option:team",
958
+ "label": "Team",
959
+ "response": {"action": "accept", "content": {"selected": ["team"]}},
960
+ "style": "primary"
961
+ },
962
+ {
963
+ "id": "option:starter",
964
+ "label": "Starter",
965
+ "response": {"action": "accept", "content": {"selected": ["starter"]}},
966
+ "style": "default"
967
+ },
968
+ {
969
+ "id": "option:enterprise",
970
+ "label": "Enterprise",
971
+ "response": {"action": "accept", "content": {"selected": ["enterprise"]}},
972
+ "style": "default"
973
+ }
974
+ ],
975
+ "handoff": false,
976
+ "kind": "choice",
977
+ "text": "Which plan should I set up?",
978
+ "title": "Choose a plan",
979
+ "toolCallId": "call_8f2c1"
980
+ },
981
+ "toolCallId": "call_8f2c1"
982
+ },
983
+ "historyId": "6710c2a1f1e2d3c4b5a69701"
984
+ }
985
+ ```
986
+
987
+ Turn 2 answers:
988
+
989
+ ```text
990
+ POST /gpt/prompt
991
+ {"historyId": "6710c2a1f1e2d3c4b5a69701", "askResponse": {"toolCallId": "call_8f2c1", "action": "accept", "content": {"selected": ["team"]}}}
992
+
993
+ data: {"askResolved":{"action":"accept","toolCallId":"call_8f2c1"}}
994
+
995
+ data: {"text":"Done. Your team is on the Team plan."}
996
+
997
+ data: {"done":true,"historyId":"6710c2a1f1e2d3c4b5a69701","title":"Team plan setup"}
998
+ ```
999
+
1000
+ The model call in turn 2 receives the history before turn 1, the assistant message with the
1001
+ `ask_choice` call, and a tool message whose result is
1002
+ `{"action": "accept", "content": {"selected": ["team"]}}`. The history then holds four rows: the
1003
+ user message, the ask call (`status: "answered"`), the ask answer, and the assistant reply.
1004
+
1005
+ ## Exports
1006
+
1007
+ `@terreno/blocks`:
1008
+
1009
+ | Export | Description |
1010
+ | --- | --- |
1011
+ | `ASK_KINDS`, `AskKind` | The ask kinds (`["choice", "confirm", "markdown", "form", "files"]`) |
1012
+ | `ASK_SURFACES`, `AskSurface`, `askSurfaceSchema` | The surfaces (`["full", "compact"]`) and the schema of a request's `surface` |
1013
+ | `COMPACT_ASK_KINDS`, `CompactAskKind`, `isCompactAskKind(kind)`, `askKindsForSurface({kinds, surface})` | The kinds the compact surface offers (`["choice", "confirm"]`), whether it offers a kind, and the ones a surface offers from a list |
1014
+ | `choiceAskInputSchema`, `compactChoiceAskInputSchema`, `choiceOptionSchema`, `choiceAnswerSchema`, `choiceAskResponseSchema` | `choice` schemas and their types (`ChoiceAskInput`, `ChoiceOption`, `ChoiceAnswer`, `ChoiceAskResponse`) |
1015
+ | `confirmAskInputSchema`, `confirmAnswerSchema`, `confirmAskResponseSchema` | `confirm` schemas and their types (`ConfirmAskInput`, `ConfirmAnswer`, `ConfirmAskResponse`) |
1016
+ | `markdownAskInputSchema`, `markdownAnswerSchema`, `markdownAskResponseSchema`, `markdownLengthBounds(input)` | `markdown` schemas and their types (`MarkdownAskInput`, `MarkdownAnswer`, `MarkdownAskResponse`), and the `{min, max}` length an answer must meet |
1017
+ | `formAskInputSchema`, `formFieldSchema`, `formAnswerSchema`, `formAskResponseSchema`, `FORM_FIELD_TYPES`, `formDefaultValues(input)`, `formTextMaxLength(field)` | `form` schemas and their types (`FormAskInput`, `FormField`, `FormFieldType`, `FormAnswer`, `FormAskResponse`, `FormValue`), the field types, the defaults keyed by field id, and a field's longest string value |
1018
+ | `filesAskInputSchema`, `askFileRefSchema`, `filesAnswerSchema`, `filesAskResponseSchema`, `filesCountBounds(input)` | `files` schemas and their types (`FilesAskInput`, `AskFileRef`, `FilesAnswer`, `FilesAskResponse`), and the `{min, max}` files an answer must hold |
1019
+ | `ASK_FILE_ACCEPT`, `ASK_FILE_ACCEPT_MIME_TYPES`, `acceptedFileMimeTypes(accept)`, `isTextFileMimeType(mimeType)` | The `accept` values (`AskFileAccept`), the MIME types each allows (`AskFileMimeType`), the types an `accept` list allows, and whether a type reaches the model as text |
1020
+ | `parseAskDataUrl(url)`, `sniffFileBytes(bytes)`, `checkAskFileBytes(...)`, `fileNotOwnedError({index})` | The media type and payload of a base64 data URL, the type a file's first bytes show (`SniffedFileType`), the server byte check, and the `FILE_NOT_OWNED` error |
1021
+ | `confirmButtonLabels(input)` | The `{confirm, deny}` labels of a `confirm` ask, with the defaults `"Confirm"` and `"Cancel"` |
1022
+ | `askAllowsDecline(ask)` | Whether an ask accepts `decline`: `confirm` defaults to no, other kinds to yes |
1023
+ | `CHOICE_SELECT_MODES`, `ChoiceSelectMode`, `choiceSelectionBounds(input)` | The `select` values (`["one", "many"]`), and the `{min, max}` choices an answer to a `choice` ask must hold |
1024
+ | `askInputSchemas`, `compactAskInputSchemas`, `askOutputSchemas`, `askInputSchemaFor({kind, surface?})` | Input and answer envelope schemas by kind, and the input schema for a kind on a surface |
1025
+ | `askResponseSchema`, `askAcceptResponseSchema`, `askDeclineResponseSchema`, `askCancelResponseSchema`, `AskResponse` | The answer envelope |
1026
+ | `Ask`, `ChoiceAsk`, `ConfirmAsk`, `MarkdownAsk`, `FormAsk`, `FilesAsk` | A validated ask: `{kind, input}` |
1027
+ | `ASK_CANCEL_REASONS` | Reasons the server records with `cancel` |
1028
+ | `validateAskInput`, `validateAskResponse`, `AskValidationError`, `AskErrorCode` | Validators and their errors |
1029
+ | `toSimpleCard`, `resolveButtonAnswer`, `simpleCardSchema`, `simpleCardButtonSchema`, `SIMPLE_CARD_BUTTON_STYLES`, `SimpleCard`, `SimpleCardButton` | Simple cards, and the answer a card's button sends |
1030
+ | `turnRequestSchema`, `turnResultSchema`, `pendingAskSummarySchema`, `pendingAskListItemSchema`, `pendingAskListSchema`, `askResponseWithToolCallIdSchema` | The bodies of the [headless endpoints](#headless-endpoints), with types `TurnRequest`, `TurnResult`, `PendingAskSummary`, `PendingAskListItem`, `AskResponseWithToolCallId` |
1031
+ | `askJsonSchemas()` | The [JSON Schema documents](#json-schemas-and-fixtures), keyed by file name |
1032
+ | `askPromptSection({kinds, surface?})` | The system prompt section for the kinds a surface offers |
1033
+ | `ASK_LIMITS`, `ASK_ERROR_CODES` | Limits and error codes |
1034
+
1035
+ `@terreno/ai`:
1036
+
1037
+ | Export | Description |
1038
+ | --- | --- |
1039
+ | `createAskTools({kinds, surface?})` | The ask tools, such as `{ask_choice, ask_confirm, ask_markdown, ask_form}`: Zod input and output schemas, no `execute`. With `surface: "compact"`, only the compact kinds, with narrowed input schemas. `/gpt/prompt` and `turn` handle the pause and the answer; code that calls `streamText` itself must handle both. |
1040
+ | `TERRENO_ASKS_SYSTEM_PROMPT` | System prompt text added when asks are on, before the `askPromptSection` |
1041
+ | `COMPACT_SURFACE_SYSTEM_PROMPT` | System prompt line added on compact turns |
1042
+ | `GptHistoryRouteOptions.chat` | Chat options for `addGptHistoryRoutes`. When they turn `asks` on, it adds the headless endpoints; otherwise it adds neither. |
1043
+ | `AsksOptions` | `{approvals?: Record<string, ApprovalAskInput>, kinds?: AskKind[], maxFileSizeBytes?: number}` |
1044
+ | `ApprovalAskInput`, `AskOrigin` | `(input: unknown) => ConfirmAskInput`, the [approval ask](#approval-asks) input for one host tool call; and `"approval"`, the `origin` of an ask the server made |
1045
+ | `GptRouteOptions.fileStorageService` | Loads the uploads a `files` answer names (`AskFileDownloader`: `{download(gcsKey)}`). `AiApp` passes its `FileStorageService` when `gcsBucket` is set. Without it, a `fileId` fails with `FILE_NOT_OWNED` and answers must use data URLs. |
1046
+ | `FileStorageService.download(gcsKey)`, `upload(...)` | `download` returns an upload's bytes. `upload` now also returns the `FileAttachment` `id`, which `POST /files/upload` sends back for a `files` answer. |
1047
+ | `GptHistoryPendingAsk`, `GptHistoryPromptAsk`, `GptHistoryAskStatus` | Stored ask types |
1048
+ | `Ask`, `AskKind`, `AskResponse`, `AskValidationError`, `SimpleCard`, `SimpleCardButton` | Re-exported from `@terreno/blocks` |
1049
+
1050
+ `@terreno/ui` ([UI reference](ui.md#askcard)):
1051
+
1052
+ | Export | Description |
1053
+ | --- | --- |
1054
+ | `AskCard`, `AskCardProps` | One ask in a transcript: controls while pending, a summary after |
1055
+ | `SimpleAskCard`, `SimpleAskCardProps` | Any ask's simple card, for narrow layouts ([props](ui.md#simpleaskcard)) |
1056
+ | `ChatAsk`, `ChatAskState`, `ChatAskStatus` | An ask as the chat shows it: the ask, its `toolCallId`, `status`, and optional `response` and `simple` |
1057
+ | `AskSubmission`, `AskSubmitHandler` | What `onAskSubmit` and `AskCard`'s `onSubmit` receive: `{toolCallId, response}` |
1058
+ | `GPTChatMessage.ask`, `GPTChatProps.onAskSubmit`, `GPTChatProps.askErrors`, `GPTChatProps.resolveAskFiles` | Asks in `GPTChat` |
1059
+ | `AskFilesResolver`, `resolveAskFilesAsDataUrls`, `selectedFileToDataUrlRef(file)`, `selectedFileMimeType(file)`, `normalizeMimeType(mimeType)` | How picked files become `files` refs: the resolver type, the default that sends data URLs, one file as a data URL ref, a picked file's type (from its extension when the picker reports a generic one), and a MIME type without parameters |