vessels 0.7.0 → 0.9.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/index.js CHANGED
@@ -4061,7 +4061,8 @@ var InteractionTypeSchema = external_exports.enum([
4061
4061
  "approval",
4062
4062
  "choice",
4063
4063
  "checklist",
4064
- "text_input"
4064
+ "text_input",
4065
+ "questions"
4065
4066
  ]);
4066
4067
  var ApprovalInteractionSchema = external_exports.object({
4067
4068
  type: external_exports.literal("approval"),
@@ -4104,11 +4105,39 @@ var TextInputInteractionSchema = external_exports.object({
4104
4105
  submitLabel: external_exports.string().optional(),
4105
4106
  metadata: external_exports.record(external_exports.unknown()).optional()
4106
4107
  });
4108
+ var QuestionOptionSchema = external_exports.object({
4109
+ id: external_exports.string().min(1),
4110
+ label: external_exports.string().min(1),
4111
+ /** Optional one-line explanation of what this option means. */
4112
+ description: external_exports.string().optional()
4113
+ });
4114
+ var QuestionSchema = external_exports.object({
4115
+ /** Stable id used to key this question's answer in the response. */
4116
+ id: external_exports.string().min(1),
4117
+ /** The question text shown to the human. */
4118
+ question: external_exports.string().min(1),
4119
+ /** Optional short chip label (≤12 chars) — e.g. "Date", "Guests". */
4120
+ header: external_exports.string().max(24).optional(),
4121
+ options: external_exports.array(QuestionOptionSchema).min(2).max(4),
4122
+ /** Allow selecting more than one option (checkboxes instead of radios). */
4123
+ multiSelect: external_exports.boolean().optional(),
4124
+ /** Offer a free-text "Other" field alongside the options (default true). */
4125
+ allowOther: external_exports.boolean().optional()
4126
+ });
4127
+ var QuestionsInteractionSchema = external_exports.object({
4128
+ type: external_exports.literal("questions"),
4129
+ /** Overall heading / context for the batch (the surface prompt). */
4130
+ prompt: external_exports.string().min(1),
4131
+ questions: external_exports.array(QuestionSchema).min(1).max(4),
4132
+ submitLabel: external_exports.string().optional(),
4133
+ metadata: external_exports.record(external_exports.unknown()).optional()
4134
+ });
4107
4135
  var InteractionSchema = external_exports.discriminatedUnion("type", [
4108
4136
  ApprovalInteractionSchema,
4109
4137
  ChoiceInteractionSchema,
4110
4138
  ChecklistInteractionSchema,
4111
- TextInputInteractionSchema
4139
+ TextInputInteractionSchema,
4140
+ QuestionsInteractionSchema
4112
4141
  ]);
4113
4142
  var AgentActivityTypeSchema = external_exports.enum(["thinking", "searching", "tool_use", "browsing", "processing"]);
4114
4143
  var AgentTodoStatusSchema = external_exports.enum(["pending", "in_progress", "done"]);
@@ -4116,12 +4145,14 @@ var AgentTodoInputSchema = external_exports.object({
4116
4145
  label: external_exports.string().min(1).max(200),
4117
4146
  status: AgentTodoStatusSchema.optional()
4118
4147
  });
4148
+ var AgentActivityStatusInputSchema = external_exports.enum(["working", "awaiting_input"]);
4119
4149
  var AgentActivitySchema = external_exports.object({
4120
4150
  type: AgentActivityTypeSchema.optional(),
4121
4151
  label: external_exports.string().max(200).optional(),
4122
- todos: external_exports.array(AgentTodoInputSchema).max(50).optional()
4123
- }).refine((d) => d.type != null || d.todos != null, {
4124
- message: "agentActivity requires `type` (a step) or `todos` (a plan)"
4152
+ todos: external_exports.array(AgentTodoInputSchema).max(50).optional(),
4153
+ status: AgentActivityStatusInputSchema.optional()
4154
+ }).refine((d) => d.type != null || d.todos != null || d.status != null, {
4155
+ message: "agentActivity requires `type` (a step), `todos` (a plan), or `status`"
4125
4156
  });
4126
4157
  var CardFieldSchema = external_exports.object({
4127
4158
  label: external_exports.string().min(1),
@@ -4227,11 +4258,20 @@ var ChecklistResponseSchema = external_exports.object({
4227
4258
  var TextInputResponseSchema = external_exports.object({
4228
4259
  text: external_exports.string()
4229
4260
  });
4261
+ var QuestionAnswerSchema = external_exports.object({
4262
+ questionId: external_exports.string().min(1),
4263
+ selected: external_exports.array(external_exports.string()),
4264
+ other: external_exports.string().optional()
4265
+ });
4266
+ var QuestionsResponseSchema = external_exports.object({
4267
+ answers: external_exports.array(QuestionAnswerSchema)
4268
+ });
4230
4269
  var InteractionResponseSchema = external_exports.discriminatedUnion("interactionType", [
4231
4270
  external_exports.object({ interactionType: external_exports.literal("approval"), response: ApprovalResponseSchema }),
4232
4271
  external_exports.object({ interactionType: external_exports.literal("choice"), response: ChoiceResponseSchema }),
4233
4272
  external_exports.object({ interactionType: external_exports.literal("checklist"), response: ChecklistResponseSchema }),
4234
- external_exports.object({ interactionType: external_exports.literal("text_input"), response: TextInputResponseSchema })
4273
+ external_exports.object({ interactionType: external_exports.literal("text_input"), response: TextInputResponseSchema }),
4274
+ external_exports.object({ interactionType: external_exports.literal("questions"), response: QuestionsResponseSchema })
4235
4275
  ]);
4236
4276
  var WebhookVesselSchema = external_exports.object({
4237
4277
  id: external_exports.string(),
@@ -4274,6 +4314,13 @@ var SupersededInteractionSchema = external_exports.object({
4274
4314
  interaction_type: InteractionTypeSchema,
4275
4315
  prompt: external_exports.string().nullable()
4276
4316
  });
4317
+ var WebhookEventAttachmentSchema = external_exports.object({
4318
+ type: external_exports.enum(["image", "file"]),
4319
+ filename: external_exports.string().nullish(),
4320
+ fileId: external_exports.string().optional(),
4321
+ downloadUrl: external_exports.string().optional(),
4322
+ url: external_exports.string().optional()
4323
+ });
4277
4324
  var WebhookUserMessagePayloadSchema = external_exports.object({
4278
4325
  event: external_exports.literal("message.user"),
4279
4326
  vessel_id: external_exports.string(),
@@ -4284,6 +4331,7 @@ var WebhookUserMessagePayloadSchema = external_exports.object({
4284
4331
  content: external_exports.string(),
4285
4332
  vessel: WebhookVesselSchema,
4286
4333
  context: external_exports.array(WebhookContextMessageSchema),
4334
+ attachments: external_exports.array(WebhookEventAttachmentSchema).optional(),
4287
4335
  superseded_interaction: SupersededInteractionSchema.optional()
4288
4336
  })
4289
4337
  });
@@ -4298,7 +4346,8 @@ var WebhookVesselCreatedPayloadSchema = external_exports.object({
4298
4346
  message_id: external_exports.string(),
4299
4347
  content: external_exports.string().nullable(),
4300
4348
  created_at: external_exports.string()
4301
- })
4349
+ }),
4350
+ attachments: external_exports.array(WebhookEventAttachmentSchema).optional()
4302
4351
  })
4303
4352
  });
4304
4353
  var WebhookMessageCancelledPayloadSchema = external_exports.object({
@@ -4631,6 +4680,37 @@ async function cmdPush(args) {
4631
4680
  }
4632
4681
  console.log(`Message sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
4633
4682
  }
4683
+ var FEEDBACK_TYPES = ["bug", "feature", "other"];
4684
+ async function cmdFeedback(args) {
4685
+ const flags = parseFlags(args);
4686
+ const positionals = [];
4687
+ for (let i = 0; i < args.length; i++) {
4688
+ if (args[i].startsWith("--")) {
4689
+ i++;
4690
+ continue;
4691
+ }
4692
+ positionals.push(args[i]);
4693
+ }
4694
+ let message = (flags.message || positionals.join(" ")).trim();
4695
+ if (!message) message = (await prompt("Your feedback (bug or feature request): ")).trim();
4696
+ if (!message) {
4697
+ console.error("Nothing to submit.");
4698
+ process.exit(1);
4699
+ }
4700
+ const type = (flags.type || "other").toLowerCase();
4701
+ if (!FEEDBACK_TYPES.includes(type)) {
4702
+ console.error(`Type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
4703
+ process.exit(1);
4704
+ }
4705
+ const data = await api("/api/v1/feedback", {
4706
+ method: "POST",
4707
+ body: JSON.stringify({ type, message })
4708
+ });
4709
+ const label = type === "other" ? "Feedback" : type === "bug" ? "Bug report" : "Feature request";
4710
+ console.log(`
4711
+ ${label} submitted \u2014 thank you!`);
4712
+ console.log(` id ${data.id}`);
4713
+ }
4634
4714
  function readStdin() {
4635
4715
  return new Promise((resolve2) => {
4636
4716
  const chunks = [];
@@ -4936,6 +5016,10 @@ Commands:
4936
5016
  vessels types disable
4937
5017
  Manage vessel types and the user-initiated-vessels feature flag.
4938
5018
 
5019
+ vessels feedback <message> [--type bug|feature|other]
5020
+ Send a bug report or feature request to the Vessels team. Requires login.
5021
+ Message can be positional or --message; --type defaults to "other".
5022
+
4939
5023
  vessels push --vessel <id> --message <text> --key <api_key>
4940
5024
  (--key can be omitted if VESSELS_API_KEY is set)
4941
5025
 
@@ -5019,6 +5103,7 @@ Run: vessels help`);
5019
5103
  Run: vessels help`);
5020
5104
  process.exit(1);
5021
5105
  }
5106
+ if (cmd === "feedback") return cmdFeedback([sub, ...rest].filter(Boolean));
5022
5107
  if (cmd === "push") return cmdPush([sub, ...rest].filter(Boolean));
5023
5108
  if (cmd === "message") return cmdMessage([sub, ...rest].filter(Boolean));
5024
5109
  if (cmd === "validate") return cmdValidate([sub, ...rest].filter(Boolean));
package/package.json CHANGED
@@ -1,31 +1,31 @@
1
1
  {
2
- "name": "vessels",
3
- "version": "0.7.0",
4
- "description": "Vessels CLI — manage your agent communication layer from the terminal",
5
- "type": "module",
6
- "bin": {
7
- "vessels": "./dist/index.js"
8
- },
9
- "files": [
10
- "dist",
11
- "template"
12
- ],
13
- "scripts": {
14
- "build": "tsup",
15
- "dev": "tsup --watch"
16
- },
17
- "license": "MIT",
18
- "keywords": [
19
- "ai",
20
- "agents",
21
- "vessels",
22
- "cli"
23
- ],
24
- "devDependencies": {
25
- "tsup": "^8.5.1",
26
- "typescript": "^5",
27
- "@types/node": "^25.5.2",
28
- "@vessels/types": "workspace:*"
29
- },
30
- "dependencies": {}
2
+ "name": "vessels",
3
+ "version": "0.9.0",
4
+ "description": "Vessels CLI — manage your agent communication layer from the terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "vessels": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "template"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "dev": "tsup --watch"
16
+ },
17
+ "license": "MIT",
18
+ "keywords": [
19
+ "ai",
20
+ "agents",
21
+ "vessels",
22
+ "cli"
23
+ ],
24
+ "devDependencies": {
25
+ "tsup": "^8.5.1",
26
+ "typescript": "^5",
27
+ "@types/node": "^25.5.2",
28
+ "@vessels/types": "workspace:*"
29
+ },
30
+ "dependencies": {}
31
31
  }
@@ -92,9 +92,25 @@ chat **bubbles** and full-width **surfaces**; all four **interactions** (`approv
92
92
  **metadata** that rides back to you for routing); the live **working card** with a ticking **plan**,
93
93
  auto-narrated **steps**, and **token streaming**; **pinned cards**, **labels**, **attachments**
94
94
  (images/files you host), **preview links**, **suggested replies**, vessel **naming/renaming**, and
95
- user-initiated vessel **types**. Idempotency keys, the per-vessel lock, and the resolve-before-ask
96
- discipline are handled for you. (It deliberately keeps the notification rule too: the working card
97
- stays silent, only your reply and outcome buzz the human's phone.)
95
+ user-initiated vessel **types**; plus **inbound files** the human sends you — downloaded, stored on
96
+ your infra, resolved, and (for images) read by the model via **vision** (see below). Idempotency
97
+ keys, the per-vessel lock, **one card per turn**, and the resolve-before-ask discipline are handled
98
+ for you. (It deliberately keeps the notification rule too: the working card stays silent, only your
99
+ reply and outcome buzz the human's phone.)
100
+
101
+ ## Receiving files (inbound)
102
+
103
+ Vessels is **not a file store** — when the human sends a photo/document, it relays the bytes
104
+ transiently and hands you a signed, short-lived `downloadUrl`. The engine does the full handshake in
105
+ `src/inbound.ts`: **download** it → **store** it on your infra (`store.putInboundFile`) → **resolve**
106
+ the permanent link back (`vessels.resolveInboundFile`) so the human sees your hosted copy and Vessels
107
+ drops its copy → for images, hand the bytes to the model as a **vision** block so your agent actually
108
+ sees them.
109
+
110
+ The zero-infra default stores files in the same `MemoryStore`/`PostgresStore` and serves them from the
111
+ agent's own `GET /files/:id` route, so the resolved link points back at you. Set **`PUBLIC_URL`** to
112
+ your externally-reachable base (your tunnel in dev) so that link is fetchable. In production, point
113
+ `putInboundFile` at object storage (S3/R2/GCS) and return a CDN URL — nothing else changes.
98
114
 
99
115
  A few Vessels features live on **your backend**, not inside a turn — call them on the `vessels`
100
116
  SDK directly:
@@ -11,6 +11,10 @@ ANTHROPIC_API_KEY=sk-ant-...
11
11
  # ANTHROPIC_MODEL=claude-sonnet-4-6
12
12
  # Port the webhook server listens on (default: 3000).
13
13
  # PORT=3000
14
+ # Your agent's externally-reachable base URL — where stored INBOUND files are served from
15
+ # (the human's app fetches the link you resolve). In local dev this is your tunnel, the same
16
+ # host as your webhook. Defaults to http://localhost:<PORT> (only reachable on this machine).
17
+ # PUBLIC_URL=https://your-tunnel.example.com
14
18
  # Override the Vessels base URL (default: https://vessels.app).
15
19
  # VESSELS_BASE_URL=https://vessels.app
16
20
  # Set DEBUG=1 to log the turn flow (model calls, tools, pushes).
@@ -13,7 +13,7 @@
13
13
  "@anthropic-ai/sdk": "^0.85.0",
14
14
  "dotenv": "^16.4.5",
15
15
  "pg": "^8.13.0",
16
- "vessels-sdk": "^0.14.0"
16
+ "vessels-sdk": "^0.17.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^22.10.0",
@@ -16,9 +16,9 @@
16
16
  * You usually don't edit this file. The pieces worth knowing are flagged inline.
17
17
  */
18
18
  import Anthropic from '@anthropic-ai/sdk';
19
- import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages';
19
+ import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages';
20
20
  import { Vessels } from 'vessels-sdk';
21
- import type { PushOptions, AgentActivityType, AgentTodoStatus } from 'vessels-sdk';
21
+ import type { PushOptions, AgentActivityType, AgentTodoStatus, InboundAttachment } from 'vessels-sdk';
22
22
  import { ROLE } from './role.js';
23
23
  import { VESSELS_PROTOCOL, NAME_RULE } from './protocol.js';
24
24
  import { BACKEND_TOOLS } from './tools.js';
@@ -36,6 +36,7 @@ import {
36
36
  cleanAttachments,
37
37
  } from './vessels-tools.js';
38
38
  import type { AgentStore } from './store.js';
39
+ import { handleInboundFiles, attachImagesToLastUserMessage } from './inbound.js';
39
40
 
40
41
  const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
41
42
  const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
@@ -100,6 +101,7 @@ export interface RunTurnOpts {
100
101
  openingMessage?: string; // text for the working card (proactive triggers pass the headline)
101
102
  vesselTitle?: string; // set on the first push (creates the vessel) for agent-initiated triggers
102
103
  nameVessel?: boolean; // freshly-opened vessel with a placeholder title — agent names it (vessel.created)
104
+ attachments?: InboundAttachment[]; // files the human sent — download → store on your infra → resolve → view (inbound.ts)
103
105
  }
104
106
 
105
107
  /** A best-effort push that never throws — a failed push must not crash the turn. */
@@ -123,7 +125,7 @@ async function safePatch(vessels: Vessels, messageId: string, patch: Record<stri
123
125
  }
124
126
 
125
127
  export async function runTurn(opts: RunTurnOpts): Promise<void> {
126
- const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel } = opts;
128
+ const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel, attachments } = opts;
127
129
  log('turn_start', { vessel, humanInput: humanInput.slice(0, 120) });
128
130
 
129
131
  // Per-vessel mutex: serialise turns on this vessel. A blocked turn must NOT be dropped —
@@ -156,7 +158,27 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
156
158
  // Recover state AFTER the lock: if we waited for a prior turn, its work is now persisted,
157
159
  // so we thread on top of it instead of clobbering it.
158
160
  const messages: MessageParam[] = await store.loadState(vessel);
159
- appendHumanTurn(messages, humanInput);
161
+
162
+ // Inbound files (human → agent). Before the model runs, do the handshake for any attachments
163
+ // on this event: download each from the Vessels relay, store it on YOUR infra, resolve the
164
+ // permanent link back (the human now sees it), and collect viewable images as vision blocks.
165
+ // The text note rides the persisted history; the image bytes ride ONLY this live model call.
166
+ let effectiveHumanInput = humanInput;
167
+ let inboundImages: ImageBlockParam[] = [];
168
+ if (attachments?.length) {
169
+ try {
170
+ const inbound = await handleInboundFiles({ vessels, store, attachments });
171
+ inboundImages = inbound.imageBlocks;
172
+ if (inbound.note) effectiveHumanInput = humanInput ? `${humanInput}\n\n${inbound.note}` : inbound.note;
173
+ } catch (e) {
174
+ log('inbound files failed', e instanceof Error ? e.message : e);
175
+ }
176
+ }
177
+
178
+ appendHumanTurn(messages, effectiveHumanInput);
179
+ // Attach the downloaded image bytes to the current user turn so the model can SEE them
180
+ // (vision). Only on this live call — the persisted history stays text-only.
181
+ attachImagesToLastUserMessage(messages, inboundImages);
160
182
 
161
183
  const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
162
184
  const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
@@ -188,9 +210,15 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
188
210
  activityId = r.messageId ?? null;
189
211
  };
190
212
 
191
- // Authoritative todo list for the working card; we PATCH the full list each update.
213
+ // Authoritative todo list for THIS turn's working card; we PATCH the full list each update.
214
+ // ONE CARD PER TURN: always starts empty — the model declares the turn's plan with plan().
215
+ // We never re-attach to a prior turn's card. A card is anchored at one point in the stream;
216
+ // mutating it on a later turn renders its new steps ABOVE messages that already followed it
217
+ // (chronological inversion) and mis-files them under a stale task. So every turn opens its
218
+ // own card at the bottom and seals it on end. Continuity lives in the recovered history.
192
219
  type Todo = { label: string; status: AgentTodoStatus };
193
220
  let todos: Todo[] = [];
221
+
194
222
  const patchActivity = async (body: Record<string, unknown>) => {
195
223
  if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
196
224
  };
@@ -270,6 +298,9 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
270
298
  pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
271
299
  } else {
272
300
  const interaction = buildInteraction(name, input);
301
+ // keepWorking is purely semantic — it tells the model the task isn't finished, so the
302
+ // NEXT turn continues it in a fresh card. It does NOT keep this card alive: every turn
303
+ // seals its own card on end (one card per turn).
273
304
  pushes.push({
274
305
  message: msg || String(input.prompt ?? 'Please respond.'),
275
306
  kind: 'surface',
@@ -372,7 +403,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
372
403
  const input = (tu.input ?? {}) as Record<string, unknown>;
373
404
  if (tu.name === 'plan') {
374
405
  const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
375
- todos = labels.map((label) => ({ label, status: 'pending' as AgentTodoStatus }));
406
+ // Merge by label, preserving the status of tasks already in flight — matters on
407
+ // a same-turn re-plan (a tweaked plan keeps the steps it already ticked).
408
+ todos = labels.map((label) => {
409
+ const prev = todos.find((t) => t.label.toLowerCase() === label.toLowerCase());
410
+ return { label, status: prev?.status ?? ('pending' as AgentTodoStatus) };
411
+ });
376
412
  await patchActivity({ todos });
377
413
  toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'plan set' });
378
414
  } else if (tu.name === 'step') {
@@ -472,10 +508,14 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
472
508
  log('turn error', err);
473
509
  pushes.push({ message: 'I hit a snag and had to stop early.' });
474
510
  } finally {
475
- // Guarantee the seal the working card this turn opened MUST resolve, even on an error.
511
+ // ALWAYS seal this turn's working card (one card per turn) even when the turn ends on
512
+ // a keepWorking checkpoint, the card resolves here as a faithful record of THIS turn's
513
+ // work; the next turn opens its own fresh card. Never orphans, even on an error.
476
514
  try {
477
515
  await stopStream();
478
- if (activityId) await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
516
+ if (activityId) {
517
+ await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
518
+ }
479
519
  } catch (e) {
480
520
  log('seal failed', e);
481
521
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * INBOUND FILES — the human → agent handshake.
3
+ *
4
+ * When the human attaches a photo/document, Vessels holds the bytes TRANSIENTLY and hands
5
+ * you a signed, short-lived `downloadUrl` on the `message.user` / `vessel.created` event
6
+ * (Vessels is not a file store). You must fetch it, store it on YOUR own infra, and hand
7
+ * back a permanent link — then Vessels swaps in your link and drops its copy. Per file:
8
+ *
9
+ * 1. download — fetch the signed downloadUrl (plain HTTP)
10
+ * 2. store — `store.putInboundFile(...)` → a permanent URL on YOUR infra (store.ts)
11
+ * 3. resolve — `vessels.resolveInboundFile(fileId, url)` so the human sees your copy
12
+ * 4. view — for supported images, hand the bytes to the model as a vision block so
13
+ * your agent can ACTUALLY SEE the image
14
+ *
15
+ * You usually don't edit this file — point `store.putInboundFile` at S3/R2/GCS and you're done.
16
+ */
17
+ import type { ImageBlockParam, MessageParam, ContentBlockParam } from '@anthropic-ai/sdk/resources/messages';
18
+ import type { Vessels, InboundAttachment } from 'vessels-sdk';
19
+ import type { AgentStore } from './store.js';
20
+
21
+ // Anthropic vision accepts these as base64 image blocks. Other image kinds and all
22
+ // non-images are still stored + resolved, just not shown to the model inline.
23
+ const VISION_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
24
+ const MAX_VISION_BYTES = 4 * 1024 * 1024; // ~Anthropic's practical per-image base64 ceiling
25
+
26
+ function extToMime(filename?: string | null): string | undefined {
27
+ const ext = (filename ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
28
+ switch (ext) {
29
+ case 'jpg':
30
+ case 'jpeg': return 'image/jpeg';
31
+ case 'png': return 'image/png';
32
+ case 'gif': return 'image/gif';
33
+ case 'webp': return 'image/webp';
34
+ case 'pdf': return 'application/pdf';
35
+ default: return undefined;
36
+ }
37
+ }
38
+
39
+ export interface InboundResult {
40
+ /** Image blocks to append to the model's current user turn (vision). */
41
+ imageBlocks: ImageBlockParam[];
42
+ /** A text note describing what arrived + where it landed, appended to the user turn. */
43
+ note: string;
44
+ }
45
+
46
+ /**
47
+ * Run the full inbound handshake for every attachment on this event, in parallel. Returns
48
+ * the image blocks to show the model and a note for the user turn. Best-effort per file —
49
+ * one file's failure never sinks the others (or the turn).
50
+ */
51
+ export async function handleInboundFiles(opts: {
52
+ vessels: Vessels;
53
+ store: AgentStore;
54
+ attachments: InboundAttachment[];
55
+ }): Promise<InboundResult> {
56
+ const pending = opts.attachments.filter((a) => a.fileId && a.downloadUrl);
57
+ if (!pending.length) return { imageBlocks: [], note: '' };
58
+
59
+ const imageBlocks: ImageBlockParam[] = [];
60
+ const lines: string[] = [];
61
+
62
+ const results = await Promise.all(
63
+ pending.map(async (att) => {
64
+ try {
65
+ // 1 — download from the Vessels relay (signed, short-lived URL).
66
+ const res = await fetch(att.downloadUrl!);
67
+ if (!res.ok) throw new Error(`download ${res.status}`);
68
+ const headerMime = res.headers.get('content-type')?.split(';')[0]?.trim();
69
+ const mime = headerMime && headerMime !== 'application/octet-stream'
70
+ ? headerMime
71
+ : extToMime(att.filename) ?? (att.type === 'image' ? 'image/jpeg' : 'application/octet-stream');
72
+ const bytes = new Uint8Array(await res.arrayBuffer());
73
+
74
+ // 2 — store on YOUR infra, 3 — resolve the permanent link back to Vessels.
75
+ const url = await opts.store.putInboundFile({ fileId: att.fileId, bytes, contentType: mime, filename: att.filename ?? undefined });
76
+ await opts.vessels.resolveInboundFile(att.fileId, url);
77
+
78
+ // 4 — supported, in-budget image → vision block.
79
+ const viewable = att.type === 'image' && VISION_MIME.has(mime) && bytes.byteLength <= MAX_VISION_BYTES;
80
+ if (viewable) {
81
+ imageBlocks.push({
82
+ type: 'image',
83
+ source: { type: 'base64', media_type: mime as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: Buffer.from(bytes).toString('base64') },
84
+ });
85
+ }
86
+ return { att, url, viewable, ok: true as const };
87
+ } catch {
88
+ return { att, ok: false as const };
89
+ }
90
+ })
91
+ );
92
+
93
+ for (const r of results) {
94
+ const label = `${r.att.filename ?? r.att.fileId} (${r.att.type})`;
95
+ if (!r.ok) lines.push(`- ${label} → could not be retrieved (the relay link may have expired)`);
96
+ else if (r.viewable) lines.push(`- ${label} → stored at ${r.url}; the image is attached to this message for you to view`);
97
+ else lines.push(`- ${label} → stored at ${r.url}`);
98
+ }
99
+
100
+ const one = pending.length === 1;
101
+ const note =
102
+ `[The operator attached ${one ? 'a file' : `${pending.length} files`}. Your backend downloaded ${one ? 'it' : 'them'} from ` +
103
+ `Vessels, stored ${one ? 'it' : 'them'} on your own storage, and confirmed the permanent link(s) back (the operator now ` +
104
+ `sees your hosted file(s)):\n${lines.join('\n')}\n` +
105
+ (imageBlocks.length
106
+ ? `The image${imageBlocks.length === 1 ? ' is' : 's are'} included for you to read directly — act on what you actually see, don't guess.`
107
+ : `No previewable image was included; reference the stored file(s) by name.`);
108
+
109
+ return { imageBlocks, note };
110
+ }
111
+
112
+ /** Append image blocks to the most recent user message, converting its text to a block array. */
113
+ export function attachImagesToLastUserMessage(messages: MessageParam[], imageBlocks: ImageBlockParam[]): void {
114
+ if (!imageBlocks.length) return;
115
+ for (let i = messages.length - 1; i >= 0; i--) {
116
+ if (messages[i].role !== 'user') continue;
117
+ const existing = messages[i].content;
118
+ const textBlocks: ContentBlockParam[] =
119
+ typeof existing === 'string'
120
+ ? existing ? [{ type: 'text', text: existing }] : []
121
+ : (existing as ContentBlockParam[]);
122
+ messages[i] = { role: 'user', content: [...textBlocks, ...imageBlocks] };
123
+ return;
124
+ }
125
+ }
@@ -52,6 +52,21 @@ function runInBackground(work: Promise<unknown>): void {
52
52
 
53
53
  const server = http.createServer(async (req, res) => {
54
54
  if (req.method === 'GET') {
55
+ // Serve inbound files we stored on our own infra. After the human sends a photo/doc, the
56
+ // engine downloads it from Vessels, stores it here, and resolves a permanent link pointing
57
+ // back at this route (see inbound.ts + store.ts). The app fetches THIS url to render it.
58
+ // In production you'd store in object storage and serve from a CDN instead.
59
+ const fileMatch = req.url && /^\/files\/([^/?]+)/.exec(req.url);
60
+ if (fileMatch) {
61
+ const file = await store.getInboundFile(decodeURIComponent(fileMatch[1])).catch(() => null);
62
+ if (!file) {
63
+ res.writeHead(404).end();
64
+ return;
65
+ }
66
+ res.writeHead(200, { 'Content-Type': file.contentType, 'Content-Length': String(file.bytes.byteLength) });
67
+ res.end(Buffer.from(file.bytes));
68
+ return;
69
+ }
55
70
  res.writeHead(200, { 'Content-Type': 'text/plain' });
56
71
  res.end('Vessels agent is alive. POST webhook events here.');
57
72
  return;
@@ -89,26 +104,33 @@ const server = http.createServer(async (req, res) => {
89
104
  vessel,
90
105
  humanInput: `${typeNote}${first || '(the operator opened a new vessel)'}`,
91
106
  nameVessel: true,
107
+ // Files attached to the first message ride the vessel.created event too.
108
+ attachments: event.attachments,
92
109
  idempotencyKeyBase: `vc:${event.message.id}`,
93
110
  });
94
111
  } else if (event.type === 'message.user') {
95
112
  const content = (event.message.content ?? '').trim();
96
- if (content) {
113
+ // Files the human attached (signed downloadUrls) — the engine does the download → store →
114
+ // resolve handshake before the model runs (inbound.ts). A message can be attachments-only.
115
+ const attachments = event.attachments;
116
+ if (content || attachments?.length) {
97
117
  // If this message expired a live interaction, tell the agent so it reacts to what they
98
118
  // actually said instead of waiting on the now-dead card.
99
119
  const sup = event.supersededInteraction;
100
- const humanInput = sup
120
+ const base = content || '(the operator sent the attached file(s) with no message)';
121
+ const humanInput = sup && content
101
122
  ? `[The operator did not answer your ${sup.interactionType}${sup.prompt ? ` "${sup.prompt}"` : ''} — that card expired because they sent a message instead. Respond to what they actually said; re-offer or adjust only if it still makes sense.]\n${content}`
102
- : content;
103
- work = runTurn({ vessels, store, vessel, humanInput, idempotencyKeyBase: `mu:${event.message.id}` });
123
+ : base;
124
+ work = runTurn({ vessels, store, vessel, humanInput, attachments, idempotencyKeyBase: `mu:${event.message.id}` });
104
125
  }
105
126
  } else if (event.type === 'interaction.response') {
106
- const prompt = (event.originMessage?.interaction?.prompt as string | undefined) ?? undefined;
127
+ const originInteraction = event.originMessage?.interaction ?? null;
128
+ const prompt = (originInteraction?.prompt as string | undefined) ?? undefined;
107
129
  work = runTurn({
108
130
  vessels,
109
131
  store,
110
132
  vessel,
111
- humanInput: renderInteractionResponse(event.interactionType, event.response, prompt),
133
+ humanInput: renderInteractionResponse(event.interactionType, event.response, prompt, originInteraction),
112
134
  idempotencyKeyBase: `ir:${event.id}`,
113
135
  });
114
136
  }
@@ -88,8 +88,16 @@ Tools:
88
88
  - request_choice — pick one option (with options[])
89
89
  - request_checklist — pick several options (with options[])
90
90
  - request_text — free-text answer
91
+ - request_questions — SEVERAL questions at once, answered together (a short form)
91
92
  - finish — wrap up; no further human action needed
92
93
 
94
+ MID-PLAN CHECKPOINTS — keepWorking: when a multi-step task needs the operator's input
95
+ PART-WAY THROUGH (e.g. Draft → Get approval → Send), raise the request_* with
96
+ keepWorking:true. It signals the task ISN'T finished — this turn's working card seals as a
97
+ clean record of what you just did, and when they answer you continue the remaining steps in
98
+ a FRESH card next turn (each turn is its own card, in order). Omit keepWorking on the FINAL
99
+ question/decision, where nothing follows.
100
+
93
101
  0. quick_reply(message, done?) — ALWAYS your first action (see the lead-with-a-reply rule
94
102
  above): one conversational line, pushed instantly. done:true → it's the whole answer and
95
103
  the turn ends. done false/omitted → it's your "on it" line; the working card opens right
@@ -112,9 +120,20 @@ Flow:
112
120
  - ONE closing line per turn. The finishing tool's message IS the wrap-up — do NOT also send a
113
121
  near-duplicate finish/send_update saying the same thing.
114
122
 
123
+ FILES the operator sends you (images, PDFs, docs):
124
+ - Your backend has ALREADY handled them before you read this turn: it downloaded each from
125
+ Vessels, stored it on YOUR storage, and confirmed the permanent link back — the operator
126
+ already sees your hosted copy. A bracketed note tells you what arrived and where it landed.
127
+ So NEVER say you "can't receive files" or ask them to re-send — the file is in hand.
128
+ - IMAGES are attached to this turn for you to actually SEE — read what's genuinely there and
129
+ act on it (a receipt → the amount; a form → the fields). Reference real details you observe;
130
+ do NOT invent contents. Non-image files are stored but not shown inline — work from the
131
+ filename and the operator's words.
132
+
115
133
  More you can attach (use when they genuinely help — don't decorate):
116
- - ATTACHMENTS: images render inline, files as a download link. Pass {type, url, filename?} on
117
- send_update or show_document — only URLs you already host (e.g. one a backend tool returned).
134
+ - ATTACHMENTS (outbound): images render inline, files as a download link. Pass {type, url,
135
+ filename?} on send_update or show_document — only URLs you already host (e.g. one a backend
136
+ tool returned, or a stored inbound file).
118
137
  - PREVIEW LINK: a single tappable link card under a message (previewUrl) — a draft/dashboard to
119
138
  open. Presentation only, no response. Pair it with a request_* when they should look THEN decide.
120
139
  - INTERACTION METADATA: attach metadata to any request_* and it rides back to you verbatim in the
@@ -128,6 +147,11 @@ Be efficient — every assistant turn is a slow round-trip, so do MORE per turn:
128
147
  - You MUST end with an ending tool (request_* or finish). When you reach the task that needs the
129
148
  human, call its work tools AND the request_* tool in the SAME response — do not tick that task
130
149
  and stop. If you trail off without an ending tool the turn dies as a bare "Done."
150
+ - Once you've called plan(), a real DECISION you need from the operator goes through a STRUCTURED
151
+ interaction, not prose: a clean either/or is request_choice; a single value is request_text —
152
+ with keepWorking:true when steps remain after the answer. Do NOT write the question as a plain
153
+ message and stop; that strands an unworked plan and denies the operator the action bar. (A bare
154
+ clarifying question with NO plan can still be a quick_reply done:true.)
131
155
  - Never repeat a tool call with identical arguments — reuse the result you already have.
132
156
  - In task:"…" use the EXACT task label from your plan() — never invent a new name.`;
133
157
 
@@ -1,24 +1,41 @@
1
1
  /**
2
2
  * THE STORE SEAM — your agent's runtime state, on YOUR infrastructure.
3
3
  *
4
- * Two things live here, and both are facets of "the agent owns its own runtime":
4
+ * Three things live here, and all are facets of "the agent owns its own runtime":
5
5
  * 1. Conversation state — the real Anthropic message history per vessel (including
6
6
  * tool_use / tool_result blocks). This is your agent's memory. Vessels is NOT
7
7
  * your memory; it only shows the human what happened.
8
8
  * 2. A per-vessel lock — "don't run two turns for one vessel at once." That's a
9
9
  * property of YOUR deployment, so it lives here too, never in Vessels.
10
+ * 3. Inbound files — when a human sends a photo/document, Vessels relays it
11
+ * TRANSIENTLY and you must store it on your OWN infra, then hand back a permanent
12
+ * link (see inbound.ts). The bytes live here; the link points back at your agent.
10
13
  *
11
14
  * Durability is an UPGRADE, not a prerequisite:
12
15
  * • MemoryStore (default) — zero infra. Correct for a single long-lived process.
13
- * State lives in RAM and resets on restart; the lock is an in-process mutex.
14
- * • PostgresStore — set DATABASE_URL and you get durable state + a cross-process
15
- * lock. It self-provisions (CREATE TABLE IF NOT EXISTS on init) — no migration
16
- * to run. Horizontally scaled? This is the lock that keeps turns serialised.
16
+ * State + files live in RAM and reset on restart; the lock is an in-process mutex.
17
+ * • PostgresStore — set DATABASE_URL and you get durable state, files, and a
18
+ * cross-process lock. Self-provisions (CREATE TABLE IF NOT EXISTS on init).
17
19
  *
18
- * Swap in Redis/Dynamo/your-DB by implementing the same `AgentStore` interface.
20
+ * Swap in Redis/Dynamo/S3/your-DB by implementing the same `AgentStore` interface.
21
+ * In production you'd typically store inbound files in object storage (S3/R2/GCS) and
22
+ * return a CDN URL from `putInboundFile` — here we serve them off the agent itself.
19
23
  */
20
24
  import type { MessageParam } from '@anthropic-ai/sdk/resources/messages';
21
25
 
26
+ /** A stored inbound file, served back by the agent's own `GET /files/:id` route. */
27
+ export interface StoredFile {
28
+ bytes: Uint8Array;
29
+ contentType: string;
30
+ filename?: string;
31
+ }
32
+
33
+ /** The agent's externally-reachable base URL — where resolved inbound files are served
34
+ * from. In local dev this is your tunnel (same host as your webhook). */
35
+ export function publicBaseUrl(): string {
36
+ return (process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`).replace(/\/$/, '');
37
+ }
38
+
22
39
  export interface AgentStore {
23
40
  /** The agent's conversation history for this vessel (empty array if new). */
24
41
  loadState(vessel: string): Promise<MessageParam[]>;
@@ -28,6 +45,11 @@ export interface AgentStore {
28
45
  acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;
29
46
  /** Release the per-vessel lock. */
30
47
  releaseLock(vessel: string): Promise<void>;
48
+ /** Store an inbound file on YOUR infra; returns a permanent, publicly-fetchable URL.
49
+ * (Swap the body for S3/R2/GCS in production and return the CDN URL.) */
50
+ putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string>;
51
+ /** Read a stored inbound file back — the `GET /files/:id` route serves it to the app. */
52
+ getInboundFile(fileId: string): Promise<StoredFile | null>;
31
53
  /** Optional one-time setup (e.g. create tables). Called once at boot. */
32
54
  init?(): Promise<void>;
33
55
  }
@@ -37,6 +59,7 @@ export interface AgentStore {
37
59
  export class MemoryStore implements AgentStore {
38
60
  private state = new Map<string, MessageParam[]>();
39
61
  private locks = new Map<string, number>(); // vessel → expiry (ms epoch)
62
+ private files = new Map<string, StoredFile>();
40
63
 
41
64
  async loadState(vessel: string): Promise<MessageParam[]> {
42
65
  return this.state.get(vessel) ?? [];
@@ -57,6 +80,15 @@ export class MemoryStore implements AgentStore {
57
80
  async releaseLock(vessel: string): Promise<void> {
58
81
  this.locks.delete(vessel);
59
82
  }
83
+
84
+ async putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string> {
85
+ this.files.set(file.fileId, { bytes: file.bytes, contentType: file.contentType, filename: file.filename });
86
+ return `${publicBaseUrl()}/files/${file.fileId}`;
87
+ }
88
+
89
+ async getInboundFile(fileId: string): Promise<StoredFile | null> {
90
+ return this.files.get(fileId) ?? null;
91
+ }
60
92
  }
61
93
 
62
94
  // ─── PostgresStore — durable, self-provisioning ─────────────────────────────────
@@ -79,6 +111,13 @@ export class PostgresStore implements AgentStore {
79
111
  vessel TEXT PRIMARY KEY,
80
112
  expires_at TIMESTAMPTZ NOT NULL
81
113
  );
114
+ CREATE TABLE IF NOT EXISTS agent_files (
115
+ file_id TEXT PRIMARY KEY,
116
+ bytes BYTEA NOT NULL,
117
+ content_type TEXT NOT NULL,
118
+ filename TEXT,
119
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
120
+ );
82
121
  `);
83
122
  }
84
123
 
@@ -121,6 +160,25 @@ export class PostgresStore implements AgentStore {
121
160
  async releaseLock(vessel: string): Promise<void> {
122
161
  await this.db.query('DELETE FROM agent_locks WHERE vessel = $1', [vessel]);
123
162
  }
163
+
164
+ async putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string> {
165
+ await this.db.query(
166
+ `INSERT INTO agent_files (file_id, bytes, content_type, filename)
167
+ VALUES ($1, $2, $3, $4)
168
+ ON CONFLICT (file_id) DO UPDATE SET bytes = EXCLUDED.bytes, content_type = EXCLUDED.content_type, filename = EXCLUDED.filename`,
169
+ [file.fileId, Buffer.from(file.bytes), file.contentType, file.filename ?? null]
170
+ );
171
+ return `${publicBaseUrl()}/files/${file.fileId}`;
172
+ }
173
+
174
+ async getInboundFile(fileId: string): Promise<StoredFile | null> {
175
+ const { rows } = await this.db.query<{ bytes: Buffer; content_type: string; filename: string | null }>(
176
+ 'SELECT bytes, content_type, filename FROM agent_files WHERE file_id = $1',
177
+ [fileId]
178
+ );
179
+ const r = rows[0];
180
+ return r ? { bytes: new Uint8Array(r.bytes), contentType: r.content_type, filename: r.filename ?? undefined } : null;
181
+ }
124
182
  }
125
183
 
126
184
  /**
@@ -82,6 +82,12 @@ const METADATA_FIELD = {
82
82
  additionalProperties: true,
83
83
  };
84
84
 
85
+ const KEEP_WORKING_FIELD = {
86
+ type: 'boolean' as const,
87
+ description:
88
+ 'TRUE when this question is a MID-PLAN checkpoint — you still have remaining plan steps to do AFTER you get the answer. The working card stays live (paused on the operator, plan intact, not greyed out), and you pick the SAME plan back up on their reply instead of starting over. Use it for a multi-step plan where one step needs a sign-off before the next. OMIT (or false) for the FINAL decision of the turn — that seals the plan and hands back.',
89
+ };
90
+
85
91
  // ─── The control tools ──────────────────────────────────────────────────────────
86
92
 
87
93
  export const CONTROL_TOOLS: Tool[] = [
@@ -189,6 +195,7 @@ export const CONTROL_TOOLS: Tool[] = [
189
195
  pinCard: PIN_CARD_FIELD,
190
196
  labels: LABELS_FIELD,
191
197
  metadata: METADATA_FIELD,
198
+ keepWorking: KEEP_WORKING_FIELD,
192
199
  },
193
200
  required: ['message', 'prompt'],
194
201
  },
@@ -209,6 +216,7 @@ export const CONTROL_TOOLS: Tool[] = [
209
216
  pinCard: PIN_CARD_FIELD,
210
217
  labels: LABELS_FIELD,
211
218
  metadata: METADATA_FIELD,
219
+ keepWorking: KEEP_WORKING_FIELD,
212
220
  },
213
221
  required: ['message', 'prompt', 'options'],
214
222
  },
@@ -229,6 +237,7 @@ export const CONTROL_TOOLS: Tool[] = [
229
237
  pinCard: PIN_CARD_FIELD,
230
238
  labels: LABELS_FIELD,
231
239
  metadata: METADATA_FIELD,
240
+ keepWorking: KEEP_WORKING_FIELD,
232
241
  },
233
242
  required: ['message', 'prompt', 'options'],
234
243
  },
@@ -248,10 +257,60 @@ export const CONTROL_TOOLS: Tool[] = [
248
257
  pinCard: PIN_CARD_FIELD,
249
258
  labels: LABELS_FIELD,
250
259
  metadata: METADATA_FIELD,
260
+ keepWorking: KEEP_WORKING_FIELD,
251
261
  },
252
262
  required: ['message', 'prompt'],
253
263
  },
254
264
  },
265
+ {
266
+ name: 'request_questions',
267
+ description:
268
+ 'Ask the operator SEVERAL questions AT ONCE — a short form they fill in and submit together. Each question is a single- or multi-select over 2–4 options, with an optional free-text Other. Use when a step needs a few answers at once instead of a back-and-forth. A full-width surface; ends your turn (or pauses it mid-plan with keepWorking).',
269
+ input_schema: {
270
+ type: 'object',
271
+ properties: {
272
+ title: { type: 'string', description: 'The surface heading' },
273
+ message: { type: 'string', description: 'Optional context body (block markdown) above the questions' },
274
+ prompt: { type: 'string', description: 'One line framing the batch of questions' },
275
+ questions: {
276
+ type: 'array',
277
+ minItems: 1,
278
+ maxItems: 4,
279
+ items: {
280
+ type: 'object',
281
+ properties: {
282
+ id: { type: 'string', description: 'Stable id used to key this answer' },
283
+ question: { type: 'string', description: 'The question text' },
284
+ header: { type: 'string', description: 'Short chip label, ≤12 chars (e.g. "Date", "Guests")' },
285
+ options: {
286
+ type: 'array',
287
+ minItems: 2,
288
+ maxItems: 4,
289
+ items: {
290
+ type: 'object',
291
+ properties: {
292
+ id: { type: 'string' },
293
+ label: { type: 'string' },
294
+ description: { type: 'string', description: 'Optional one-line explanation' },
295
+ },
296
+ required: ['id', 'label'],
297
+ },
298
+ },
299
+ multiSelect: { type: 'boolean', description: 'Allow more than one option (checkboxes).' },
300
+ allowOther: { type: 'boolean', description: 'Offer a free-text Other field (default true).' },
301
+ },
302
+ required: ['id', 'question', 'options'],
303
+ },
304
+ },
305
+ submitLabel: { type: 'string' },
306
+ pinCard: PIN_CARD_FIELD,
307
+ labels: LABELS_FIELD,
308
+ metadata: METADATA_FIELD,
309
+ keepWorking: KEEP_WORKING_FIELD,
310
+ },
311
+ required: ['prompt', 'questions'],
312
+ },
313
+ },
255
314
  {
256
315
  name: 'finish',
257
316
  description: 'Conclude — no further human action needed. Ends your turn.',
@@ -293,7 +352,7 @@ export const CONTROL_TOOLS: Tool[] = [
293
352
  export const CONTROL_TOOL_NAMES = new Set(CONTROL_TOOLS.map((t) => t.name));
294
353
 
295
354
  /** The tools that END a turn — exactly one is the final action. */
296
- export const ENDING_TOOLS = new Set(['request_approval', 'request_choice', 'request_checklist', 'request_text', 'finish']);
355
+ export const ENDING_TOOLS = new Set(['request_approval', 'request_choice', 'request_checklist', 'request_text', 'request_questions', 'finish']);
297
356
 
298
357
  // ─── Default narration ──────────────────────────────────────────────────────────
299
358
 
@@ -347,16 +406,26 @@ export function buildInteraction(toolName: string, input: Record<string, unknown
347
406
  ...(input.multiline ? { multiline: true } : {}),
348
407
  ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
349
408
  });
409
+ case 'request_questions':
410
+ return withMeta({
411
+ type: 'questions',
412
+ prompt: String(input.prompt),
413
+ questions: input.questions,
414
+ ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
415
+ });
350
416
  default:
351
417
  return null;
352
418
  }
353
419
  }
354
420
 
355
- /** Render a human's interaction response as a readable user turn for the model. */
421
+ /** Render a human's interaction response as a readable user turn for the model.
422
+ * `interaction` (the original interaction object) is optional but lets the questions
423
+ * renderer map option ids back to their human labels. */
356
424
  export function renderInteractionResponse(
357
425
  interactionType: string,
358
426
  response: Record<string, unknown>,
359
- prompt?: string
427
+ prompt?: string,
428
+ interaction?: Record<string, unknown> | null
360
429
  ): string {
361
430
  const head = prompt ? `Re: "${prompt}" — ` : '';
362
431
  switch (interactionType) {
@@ -375,6 +444,23 @@ export function renderInteractionResponse(
375
444
  }
376
445
  case 'text_input':
377
446
  return `${head}${response.text ?? ''}`;
447
+ case 'questions': {
448
+ const qs = Array.isArray(interaction?.questions)
449
+ ? (interaction!.questions as Array<{ id: string; question?: string; header?: string; options?: Array<{ id: string; label: string }> }>)
450
+ : [];
451
+ const answers = Array.isArray(response.answers)
452
+ ? (response.answers as Array<{ questionId: string; selected?: string[]; other?: string }>)
453
+ : [];
454
+ const lines = answers.map((a) => {
455
+ const q = qs.find((x) => x.id === a.questionId);
456
+ const label = q?.question ?? q?.header ?? a.questionId;
457
+ const opts = q?.options ?? [];
458
+ const picked = (a.selected ?? []).map((id) => opts.find((o) => o.id === id)?.label ?? id);
459
+ if (a.other) picked.push(a.other);
460
+ return `• ${label}: ${picked.length ? picked.join(', ') : '(none)'}`;
461
+ });
462
+ return `${head}I answered:\n${lines.join('\n')}`;
463
+ }
378
464
  default:
379
465
  return `${head}${JSON.stringify(response)}`;
380
466
  }