@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.
- package/dist/docs/upgrades/57.10.1.md +54 -0
- package/dist/docs/upgrades/57.11.0.md +80 -0
- package/dist/docs/versioned/next/README.md +1 -0
- package/dist/docs/versioned/next/explanation/README.md +4 -0
- package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
- package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
- package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
- package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
- package/dist/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
- package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
- package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
- package/dist/docs/versioned/next/how-to/README.md +7 -0
- package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
- package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
- package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
- package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
- package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
- package/dist/docs/versioned/next/how-to/circleci.md +5 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
- package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
- package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
- package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
- package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
- package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
- package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
- package/dist/docs/versioned/next/reference/README.md +3 -0
- package/dist/docs/versioned/next/reference/admin-config.md +2 -0
- package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
- package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
- package/dist/docs/versioned/next/reference/ai-harness.md +1974 -0
- package/dist/docs/versioned/next/reference/ai.md +251 -25
- package/dist/docs/versioned/next/reference/api.md +110 -2
- package/dist/docs/versioned/next/reference/blocks.md +178 -0
- package/dist/docs/versioned/next/reference/environment-variables.md +2 -1
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
- package/dist/docs/versioned/next/reference/syncdb.md +156 -6
- package/dist/docs/versioned/next/reference/test.md +11 -2
- package/dist/docs/versioned/next/reference/ui.md +331 -6
- package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
- package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
- package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
- package/dist/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -1
- package/package.json +3 -2
- package/src/docs/upgrades/57.10.1.md +54 -0
- package/src/docs/upgrades/57.11.0.md +80 -0
- package/src/docs/versioned/next/README.md +1 -0
- package/src/docs/versioned/next/explanation/README.md +4 -0
- package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
- package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
- package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
- package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
- package/src/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
- package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
- package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
- package/src/docs/versioned/next/how-to/README.md +7 -0
- package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
- package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
- package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
- package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
- package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
- package/src/docs/versioned/next/how-to/circleci.md +5 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
- package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
- package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
- package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
- package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
- package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
- package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
- package/src/docs/versioned/next/reference/README.md +3 -0
- package/src/docs/versioned/next/reference/admin-config.md +2 -0
- package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
- package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
- package/src/docs/versioned/next/reference/ai-harness.md +1974 -0
- package/src/docs/versioned/next/reference/ai.md +251 -25
- package/src/docs/versioned/next/reference/api.md +110 -2
- package/src/docs/versioned/next/reference/blocks.md +178 -0
- package/src/docs/versioned/next/reference/environment-variables.md +2 -1
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/src/docs/versioned/next/reference/mcp-server.md +17 -0
- package/src/docs/versioned/next/reference/syncdb.md +156 -6
- package/src/docs/versioned/next/reference/test.md +11 -2
- package/src/docs/versioned/next/reference/ui.md +331 -6
- package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
- package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
- package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
- 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 |
|