vessels 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4156,7 +4156,13 @@ var AgentActivitySchema = external_exports.object({
4156
4156
  });
4157
4157
  var CardFieldSchema = external_exports.object({
4158
4158
  label: external_exports.string().min(1),
4159
- value: external_exports.string()
4159
+ value: external_exports.string(),
4160
+ // Optional: render the value as a tappable link, deep-linking the human into
4161
+ // your own web UI (e.g. an admin tray). Rendered on full-size cards (surface /
4162
+ // pinned-card detail), not the compact vessel-list preview.
4163
+ url: external_exports.string().url().max(2048).optional(),
4164
+ // Optional colour hint — pure styling, no behaviour. 'default' === unset.
4165
+ tone: external_exports.enum(["default", "success", "warning", "danger"]).optional()
4160
4166
  });
4161
4167
  var CardSchema = external_exports.object({
4162
4168
  // Optional: a glance-facts card under a surface takes its heading from the
@@ -4314,6 +4320,13 @@ var SupersededInteractionSchema = external_exports.object({
4314
4320
  interaction_type: InteractionTypeSchema,
4315
4321
  prompt: external_exports.string().nullable()
4316
4322
  });
4323
+ var WebhookEventAttachmentSchema = external_exports.object({
4324
+ type: external_exports.enum(["image", "file"]),
4325
+ filename: external_exports.string().nullish(),
4326
+ fileId: external_exports.string().optional(),
4327
+ downloadUrl: external_exports.string().optional(),
4328
+ url: external_exports.string().optional()
4329
+ });
4317
4330
  var WebhookUserMessagePayloadSchema = external_exports.object({
4318
4331
  event: external_exports.literal("message.user"),
4319
4332
  vessel_id: external_exports.string(),
@@ -4324,6 +4337,7 @@ var WebhookUserMessagePayloadSchema = external_exports.object({
4324
4337
  content: external_exports.string(),
4325
4338
  vessel: WebhookVesselSchema,
4326
4339
  context: external_exports.array(WebhookContextMessageSchema),
4340
+ attachments: external_exports.array(WebhookEventAttachmentSchema).optional(),
4327
4341
  superseded_interaction: SupersededInteractionSchema.optional()
4328
4342
  })
4329
4343
  });
@@ -4338,7 +4352,8 @@ var WebhookVesselCreatedPayloadSchema = external_exports.object({
4338
4352
  message_id: external_exports.string(),
4339
4353
  content: external_exports.string().nullable(),
4340
4354
  created_at: external_exports.string()
4341
- })
4355
+ }),
4356
+ attachments: external_exports.array(WebhookEventAttachmentSchema).optional()
4342
4357
  })
4343
4358
  });
4344
4359
  var WebhookMessageCancelledPayloadSchema = external_exports.object({
@@ -4671,6 +4686,37 @@ async function cmdPush(args) {
4671
4686
  }
4672
4687
  console.log(`Message sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
4673
4688
  }
4689
+ var FEEDBACK_TYPES = ["bug", "feature", "other"];
4690
+ async function cmdFeedback(args) {
4691
+ const flags = parseFlags(args);
4692
+ const positionals = [];
4693
+ for (let i = 0; i < args.length; i++) {
4694
+ if (args[i].startsWith("--")) {
4695
+ i++;
4696
+ continue;
4697
+ }
4698
+ positionals.push(args[i]);
4699
+ }
4700
+ let message = (flags.message || positionals.join(" ")).trim();
4701
+ if (!message) message = (await prompt("Your feedback (bug or feature request): ")).trim();
4702
+ if (!message) {
4703
+ console.error("Nothing to submit.");
4704
+ process.exit(1);
4705
+ }
4706
+ const type = (flags.type || "other").toLowerCase();
4707
+ if (!FEEDBACK_TYPES.includes(type)) {
4708
+ console.error(`Type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
4709
+ process.exit(1);
4710
+ }
4711
+ const data = await api("/api/v1/feedback", {
4712
+ method: "POST",
4713
+ body: JSON.stringify({ type, message })
4714
+ });
4715
+ const label = type === "other" ? "Feedback" : type === "bug" ? "Bug report" : "Feature request";
4716
+ console.log(`
4717
+ ${label} submitted \u2014 thank you!`);
4718
+ console.log(` id ${data.id}`);
4719
+ }
4674
4720
  function readStdin() {
4675
4721
  return new Promise((resolve2) => {
4676
4722
  const chunks = [];
@@ -4976,6 +5022,10 @@ Commands:
4976
5022
  vessels types disable
4977
5023
  Manage vessel types and the user-initiated-vessels feature flag.
4978
5024
 
5025
+ vessels feedback <message> [--type bug|feature|other]
5026
+ Send a bug report or feature request to the Vessels team. Requires login.
5027
+ Message can be positional or --message; --type defaults to "other".
5028
+
4979
5029
  vessels push --vessel <id> --message <text> --key <api_key>
4980
5030
  (--key can be omitted if VESSELS_API_KEY is set)
4981
5031
 
@@ -5059,6 +5109,7 @@ Run: vessels help`);
5059
5109
  Run: vessels help`);
5060
5110
  process.exit(1);
5061
5111
  }
5112
+ if (cmd === "feedback") return cmdFeedback([sub, ...rest].filter(Boolean));
5062
5113
  if (cmd === "push") return cmdPush([sub, ...rest].filter(Boolean));
5063
5114
  if (cmd === "message") return cmdMessage([sub, ...rest].filter(Boolean));
5064
5115
  if (cmd === "validate") return cmdValidate([sub, ...rest].filter(Boolean));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vessels",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Vessels CLI — manage your agent communication layer from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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';
@@ -35,7 +35,8 @@ import {
35
35
  cleanLabels,
36
36
  cleanAttachments,
37
37
  } from './vessels-tools.js';
38
- import type { AgentStore, ResumeMarker } from './store.js';
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,10 +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);
160
- // A prior turn may have paused a plan on a mid-plan checkpoint recover its handle
161
- // so we re-attach to the SAME working card below instead of opening a new one.
162
- const resume = await store.loadResume(vessel);
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);
163
182
 
164
183
  const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
165
184
  const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
@@ -191,19 +210,15 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
191
210
  activityId = r.messageId ?? null;
192
211
  };
193
212
 
194
- // 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.
195
219
  type Todo = { label: string; status: AgentTodoStatus };
196
220
  let todos: Todo[] = [];
197
221
 
198
- // RESUME a paused plan: re-attach to the SAME working card and its plan instead of
199
- // opening a fresh one, so the operator sees ONE continuous card across the whole
200
- // multi-step flow. The first work patch flips it from awaiting_input back to working.
201
- if (resume?.activityId) {
202
- activityId = resume.activityId;
203
- cardOpened = true;
204
- if (Array.isArray(resume.todos)) todos = resume.todos.map((t) => ({ label: String(t.label), status: t.status }));
205
- log('resume', { activityId, todos: todos.length });
206
- }
207
222
  const patchActivity = async (body: Record<string, unknown>) => {
208
223
  if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
209
224
  };
@@ -273,9 +288,6 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
273
288
  const pushes: PendingPush[] = [];
274
289
 
275
290
  let ended = false;
276
- // A mid-plan checkpoint (request_* with keepWorking) ends the MODEL loop but does NOT
277
- // seal the card: the plan pauses on the human and resumes on their reply (see finally).
278
- let pauseForInput = false;
279
291
  const recordEnding = (name: string, input: Record<string, unknown>) => {
280
292
  const msg = String(input.message ?? '');
281
293
  const endPin = sanitizeCard(input.pinCard);
@@ -286,7 +298,9 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
286
298
  pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
287
299
  } else {
288
300
  const interaction = buildInteraction(name, input);
289
- if (input.keepWorking === true) pauseForInput = true;
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).
290
304
  pushes.push({
291
305
  message: msg || String(input.prompt ?? 'Please respond.'),
292
306
  kind: 'surface',
@@ -390,7 +404,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
390
404
  if (tu.name === 'plan') {
391
405
  const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
392
406
  // Merge by label, preserving the status of tasks already in flight — matters on
393
- // a RESUME (the seeded plan keeps its done steps) and any same-turn re-plan.
407
+ // a same-turn re-plan (a tweaked plan keeps the steps it already ticked).
394
408
  todos = labels.map((label) => {
395
409
  const prev = todos.find((t) => t.label.toLowerCase() === label.toLowerCase());
396
410
  return { label, status: prev?.status ?? ('pending' as AgentTodoStatus) };
@@ -494,19 +508,14 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
494
508
  log('turn error', err);
495
509
  pushes.push({ message: 'I hit a snag and had to stop early.' });
496
510
  } finally {
497
- // Resolve the working card UNLESS this turn paused on a mid-plan checkpoint, in
498
- // which case we leave it live (awaiting_input) for the next turn to resume. Either way
499
- // the card never orphans, 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.
500
514
  try {
501
515
  await stopStream();
502
516
  if (activityId) {
503
- await safePatch(vessels, activityId, {
504
- agentActivity: pauseForInput ? { status: 'awaiting_input' } : null,
505
- tokenStream: null,
506
- });
517
+ await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
507
518
  }
508
- // Persist the paused-plan handle (or clear a prior one now the plan resumed/ended).
509
- await store.saveResume(vessel, pauseForInput && activityId ? { activityId, todos } : null);
510
519
  } catch (e) {
511
520
  log('seal failed', e);
512
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,18 +104,24 @@ 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
127
  const originInteraction = event.originMessage?.interaction ?? null;
@@ -91,12 +91,12 @@ Tools:
91
91
  - request_questions — SEVERAL questions at once, answered together (a short form)
92
92
  - finish — wrap up; no further human action needed
93
93
 
94
- MID-PLAN CHECKPOINTS — keepWorking: when a multi-step plan needs the operator's input
95
- PART-WAY THROUGH (e.g. plan = Draft → Get approval → Send), raise the request_* with
96
- keepWorking:true. The plan card stays LIVE and paused on them pending tasks intact, not
97
- greyed out and when they answer you pick the SAME plan back up and finish the remaining
98
- steps, one continuous card. Omit keepWorking on the FINAL question/decision, which seals
99
- the plan and ends the turn.
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
100
 
101
101
  0. quick_reply(message, done?) — ALWAYS your first action (see the lead-with-a-reply rule
102
102
  above): one conversational line, pushed instantly. done:true → it's the whole answer and
@@ -120,9 +120,20 @@ Flow:
120
120
  - ONE closing line per turn. The finishing tool's message IS the wrap-up — do NOT also send a
121
121
  near-duplicate finish/send_update saying the same thing.
122
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
+
123
133
  More you can attach (use when they genuinely help — don't decorate):
124
- - ATTACHMENTS: images render inline, files as a download link. Pass {type, url, filename?} on
125
- 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).
126
137
  - PREVIEW LINK: a single tappable link card under a message (previewUrl) — a draft/dashboard to
127
138
  open. Presentation only, no response. Pair it with a request_* when they should look THEN decide.
128
139
  - INTERACTION METADATA: attach metadata to any request_* and it rides back to you verbatim in the
@@ -136,6 +147,11 @@ Be efficient — every assistant turn is a slow round-trip, so do MORE per turn:
136
147
  - You MUST end with an ending tool (request_* or finish). When you reach the task that needs the
137
148
  human, call its work tools AND the request_* tool in the SAME response — do not tick that task
138
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.)
139
155
  - Never repeat a tool call with identical arguments — reuse the result you already have.
140
156
  - In task:"…" use the EXACT task label from your plan() — never invent a new name.`;
141
157
 
@@ -1,47 +1,55 @@
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
- import type { AgentTodoStatus } from 'vessels-sdk';
22
25
 
23
- /**
24
- * A paused plan's handle, saved when a turn ends on a MID-PLAN checkpoint
25
- * (a request_* with keepWorking). The next turn re-attaches to the SAME working
26
- * card (`activityId`) and its `todos` instead of opening a new one — so the
27
- * operator sees one continuous card across a multi-step flow. Like everything in
28
- * the store, this is the agent's own runtime state, never Vessels'.
29
- */
30
- export type ResumeMarker = { activityId: string; todos: { label: string; status: AgentTodoStatus }[] };
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
+ }
31
38
 
32
39
  export interface AgentStore {
33
40
  /** The agent's conversation history for this vessel (empty array if new). */
34
41
  loadState(vessel: string): Promise<MessageParam[]>;
35
42
  /** Persist the full conversation history for this vessel. */
36
43
  saveState(vessel: string, messages: MessageParam[]): Promise<void>;
37
- /** The paused-plan handle for this vessel, or null when no plan is paused. */
38
- loadResume(vessel: string): Promise<ResumeMarker | null>;
39
- /** Save the paused-plan handle (or null to clear it once the plan resumes/ends). */
40
- saveResume(vessel: string, marker: ResumeMarker | null): Promise<void>;
41
44
  /** Try to take the per-vessel lock. Returns false if someone else holds it. TTL bounds a crash. */
42
45
  acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;
43
46
  /** Release the per-vessel lock. */
44
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>;
45
53
  /** Optional one-time setup (e.g. create tables). Called once at boot. */
46
54
  init?(): Promise<void>;
47
55
  }
@@ -50,8 +58,8 @@ export interface AgentStore {
50
58
 
51
59
  export class MemoryStore implements AgentStore {
52
60
  private state = new Map<string, MessageParam[]>();
53
- private resume = new Map<string, ResumeMarker>();
54
61
  private locks = new Map<string, number>(); // vessel → expiry (ms epoch)
62
+ private files = new Map<string, StoredFile>();
55
63
 
56
64
  async loadState(vessel: string): Promise<MessageParam[]> {
57
65
  return this.state.get(vessel) ?? [];
@@ -61,15 +69,6 @@ export class MemoryStore implements AgentStore {
61
69
  this.state.set(vessel, messages);
62
70
  }
63
71
 
64
- async loadResume(vessel: string): Promise<ResumeMarker | null> {
65
- return this.resume.get(vessel) ?? null;
66
- }
67
-
68
- async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
69
- if (marker) this.resume.set(vessel, marker);
70
- else this.resume.delete(vessel);
71
- }
72
-
73
72
  async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
74
73
  const now = Date.now();
75
74
  const until = this.locks.get(vessel);
@@ -81,6 +80,15 @@ export class MemoryStore implements AgentStore {
81
80
  async releaseLock(vessel: string): Promise<void> {
82
81
  this.locks.delete(vessel);
83
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
+ }
84
92
  }
85
93
 
86
94
  // ─── PostgresStore — durable, self-provisioning ─────────────────────────────────
@@ -103,10 +111,12 @@ export class PostgresStore implements AgentStore {
103
111
  vessel TEXT PRIMARY KEY,
104
112
  expires_at TIMESTAMPTZ NOT NULL
105
113
  );
106
- CREATE TABLE IF NOT EXISTS agent_resume (
107
- vessel TEXT PRIMARY KEY,
108
- marker JSONB NOT NULL,
109
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
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()
110
120
  );
111
121
  `);
112
122
  }
@@ -133,27 +143,6 @@ export class PostgresStore implements AgentStore {
133
143
  );
134
144
  }
135
145
 
136
- async loadResume(vessel: string): Promise<ResumeMarker | null> {
137
- const { rows } = await this.db.query<{ marker: ResumeMarker }>(
138
- 'SELECT marker FROM agent_resume WHERE vessel = $1',
139
- [vessel]
140
- );
141
- return rows[0]?.marker ?? null;
142
- }
143
-
144
- async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
145
- if (!marker) {
146
- await this.db.query('DELETE FROM agent_resume WHERE vessel = $1', [vessel]);
147
- return;
148
- }
149
- await this.db.query(
150
- `INSERT INTO agent_resume (vessel, marker, updated_at)
151
- VALUES ($1, $2, now())
152
- ON CONFLICT (vessel) DO UPDATE SET marker = EXCLUDED.marker, updated_at = now()`,
153
- [vessel, JSON.stringify(marker)]
154
- );
155
- }
156
-
157
146
  async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
158
147
  // Atomic: take the row if free, OR steal it if the prior holder's TTL has lapsed.
159
148
  const { rows } = await this.db.query(
@@ -171,6 +160,25 @@ export class PostgresStore implements AgentStore {
171
160
  async releaseLock(vessel: string): Promise<void> {
172
161
  await this.db.query('DELETE FROM agent_locks WHERE vessel = $1', [vessel]);
173
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
+ }
174
182
  }
175
183
 
176
184
  /**