vessels 0.11.0 → 0.12.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/README.md CHANGED
@@ -53,6 +53,8 @@ vessels webhooks enable <id>
53
53
  vessels webhooks disable <id>
54
54
 
55
55
  vessels push --vessel <id> --message <text> --key <vsl_xxx>
56
+ vessels event --vessel <id> --title <text> [--body <markdown>] [--tone info|alert|success] --key <vsl_xxx>
57
+ vessels mark --vessel <id> --label <text> [--type <kind>] [--subtext <text>] [--tone neutral|success|warning|danger] [--url <link>] --key <vsl_xxx>
56
58
  vessels message --vessel <id> --message <text>
57
59
  ```
58
60
 
package/dist/index.js CHANGED
@@ -4056,7 +4056,7 @@ var coerce = {
4056
4056
  var NEVER = INVALID;
4057
4057
 
4058
4058
  // ../types/src/index.ts
4059
- var SourceSchema = external_exports.enum(["agent", "user", "system", "event"]);
4059
+ var SourceSchema = external_exports.enum(["agent", "user", "system", "event", "mark"]);
4060
4060
  var InteractionTypeSchema = external_exports.enum([
4061
4061
  "approval",
4062
4062
  "choice",
@@ -4064,18 +4064,53 @@ var InteractionTypeSchema = external_exports.enum([
4064
4064
  "text_input",
4065
4065
  "questions"
4066
4066
  ]);
4067
+ var AckStageSchema = external_exports.enum(["received", "processing", "completed", "dropped"]);
4068
+ var AckPayloadSchema = external_exports.object({
4069
+ stage: AckStageSchema,
4070
+ reason: external_exports.string().max(280).optional()
4071
+ }).strict();
4072
+ var ChoiceOptionSchema = external_exports.object({
4073
+ id: external_exports.string().min(1),
4074
+ label: external_exports.string().min(1)
4075
+ });
4076
+ var EditableTypeSchema = external_exports.enum(["text", "number", "currency", "date", "choice"]);
4077
+ var EditableSchema = external_exports.object({
4078
+ /** Stable id — the key this value uses in the response `edits`, and the
4079
+ * `editableId` a card field binds to. */
4080
+ id: external_exports.string().min(1),
4081
+ type: EditableTypeSchema,
4082
+ /** Initial value (number for number/currency, the option id for choice, a string
4083
+ * otherwise). Optional — a bound card field supplies the displayed value — but
4084
+ * recommended, so the agent has a baseline to diff the returned edit against. */
4085
+ value: external_exports.union([external_exports.string(), external_exports.number()]).optional(),
4086
+ /** Edit-field label; falls back to the bound card field's label. */
4087
+ label: external_exports.string().optional(),
4088
+ /** type:'choice' — the options the value is picked from. */
4089
+ options: external_exports.array(ChoiceOptionSchema).min(1).optional(),
4090
+ /** Numeric bounds for number/currency, validated on submit. */
4091
+ min: external_exports.number().optional(),
4092
+ max: external_exports.number().optional()
4093
+ }).refine((d) => {
4094
+ var _a;
4095
+ return d.type !== "choice" || (((_a = d.options) == null ? void 0 : _a.length) ?? 0) > 0;
4096
+ }, {
4097
+ message: "editable of type 'choice' requires non-empty options"
4098
+ });
4067
4099
  var ApprovalInteractionSchema = external_exports.object({
4068
4100
  type: external_exports.literal("approval"),
4069
4101
  prompt: external_exports.string().min(1),
4070
4102
  approveLabel: external_exports.string().optional(),
4071
4103
  rejectLabel: external_exports.string().optional(),
4072
4104
  reasonRequired: external_exports.boolean().optional(),
4105
+ // `false` ⇒ a one-way consent/proceed gate: a single full-width Approve button,
4106
+ // no reject path. Defaults to true (the two-way Approve/Reject decision).
4107
+ rejectable: external_exports.boolean().optional(),
4108
+ // Edit-then-approve: values the human may change before approving. Each binds to a
4109
+ // card field via that field's `editableId` (Phase 1); changed values return in the
4110
+ // response `edits`, keyed by editable id. The body markdown stays read-only.
4111
+ editables: external_exports.array(EditableSchema).max(20).optional(),
4073
4112
  metadata: external_exports.record(external_exports.unknown()).optional()
4074
4113
  });
4075
- var ChoiceOptionSchema = external_exports.object({
4076
- id: external_exports.string().min(1),
4077
- label: external_exports.string().min(1)
4078
- });
4079
4114
  var ChoiceInteractionSchema = external_exports.object({
4080
4115
  type: external_exports.literal("choice"),
4081
4116
  prompt: external_exports.string().min(1),
@@ -4116,7 +4151,7 @@ var QuestionSchema = external_exports.object({
4116
4151
  id: external_exports.string().min(1),
4117
4152
  /** The question text shown to the human. */
4118
4153
  question: external_exports.string().min(1),
4119
- /** Optional short chip label (≤12 chars) — e.g. "Date", "Guests". */
4154
+ /** Optional short chip label (≤24 chars) — e.g. "Date", "Guests". */
4120
4155
  header: external_exports.string().max(24).optional(),
4121
4156
  options: external_exports.array(QuestionOptionSchema).min(2).max(4),
4122
4157
  /** Allow selecting more than one option (checkboxes instead of radios). */
@@ -4158,25 +4193,43 @@ var CardFieldSchema = external_exports.object({
4158
4193
  label: external_exports.string().min(1),
4159
4194
  value: external_exports.string(),
4160
4195
  // 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.
4196
+ // your own web UI (e.g. an admin tray). Rendered on full-size surface cards,
4197
+ // not the compact vessel-list preview.
4163
4198
  url: external_exports.string().url().max(2048).optional(),
4164
4199
  // Optional colour hint — pure styling, no behaviour. 'default' === unset.
4165
- tone: external_exports.enum(["default", "success", "warning", "danger"]).optional()
4200
+ tone: external_exports.enum(["default", "success", "warning", "danger"]).optional(),
4201
+ // Edit-then-approve (Phase 1): binds this field to an approval `editables[]` entry
4202
+ // by id. When the message's interaction is an approval declaring that editable, the
4203
+ // field renders as an input in edit mode and the changed value returns in `edits`.
4204
+ // Inert everywhere else (plain surfaces, events).
4205
+ editableId: external_exports.string().optional()
4166
4206
  });
4167
4207
  var CardSchema = external_exports.object({
4168
4208
  // Optional: a glance-facts card under a surface takes its heading from the
4169
4209
  // surface `title`, so a card title is redundant there. Still allowed (e.g. a
4170
- // standalone card on a bubble, or a pinned card).
4210
+ // standalone card on a bubble).
4171
4211
  title: external_exports.string().min(1).optional(),
4172
4212
  fields: external_exports.array(CardFieldSchema)
4173
4213
  });
4214
+ var DetailFieldSchema = external_exports.object({
4215
+ label: external_exports.string().min(1),
4216
+ value: external_exports.string(),
4217
+ // Render the value as a tappable link, deep-linking the human into your own
4218
+ // web UI (e.g. their CRM record). Opens in a new tab.
4219
+ url: external_exports.string().url().max(2048).optional(),
4220
+ // Colour hint — pure styling, no behaviour. 'default' === unset.
4221
+ tone: external_exports.enum(["default", "success", "warning", "danger"]).optional(),
4222
+ // Show a copy button on the value (phone numbers, emails, ids the human grabs).
4223
+ copyable: external_exports.boolean().optional()
4224
+ });
4225
+ var DetailsSchema = external_exports.object({
4226
+ fields: external_exports.array(DetailFieldSchema).max(20)
4227
+ });
4174
4228
  var AttachmentSchema = external_exports.discriminatedUnion("type", [
4175
4229
  external_exports.object({ type: external_exports.literal("image"), url: external_exports.string().url() }),
4176
4230
  external_exports.object({ type: external_exports.literal("file"), url: external_exports.string().url(), filename: external_exports.string().optional() })
4177
4231
  ]);
4178
4232
  var VesselStatusSchema = external_exports.enum(["active", "waiting", "resolved"]);
4179
- var DisplaySchema = external_exports.enum(["bubble", "document"]);
4180
4233
  var KindSchema = external_exports.enum(["bubble", "surface"]);
4181
4234
  var PushPayloadSchema = external_exports.object({
4182
4235
  message: external_exports.string().min(1).max(1e4).optional(),
@@ -4189,7 +4242,8 @@ var PushPayloadSchema = external_exports.object({
4189
4242
  (v) => JSON.stringify(v).length < 16e3,
4190
4243
  "metadata exceeds 16KB limit"
4191
4244
  ).optional(),
4192
- pinCard: CardSchema.nullable().optional(),
4245
+ /** Vessel reference record (CRM-style identity), shown in the top bar. `null` clears. Replaces wholesale. */
4246
+ details: DetailsSchema.nullable().optional(),
4193
4247
  /** @deprecated `waiting` is now system-derived from the message's interaction; you don't set status. Still accepted for back-compat. */
4194
4248
  vesselStatus: VesselStatusSchema.optional(),
4195
4249
  labels: external_exports.array(external_exports.string().min(1).max(50)).max(10).optional(),
@@ -4207,9 +4261,7 @@ var PushPayloadSchema = external_exports.object({
4207
4261
  /** Bubble (chat) vs surface (composed artifact). Defaults from interaction/card. */
4208
4262
  kind: KindSchema.optional(),
4209
4263
  /** Surface heading. Ignored on bubbles. */
4210
- title: external_exports.string().max(200).optional(),
4211
- /** @deprecated legacy presentation hint — use `kind`. 'document' → surface. */
4212
- display: DisplaySchema.optional()
4264
+ title: external_exports.string().max(200).optional()
4213
4265
  }).refine((d) => d.message || d.agentActivity || d.tokenStream, {
4214
4266
  message: "One of message, agentActivity, or tokenStream is required"
4215
4267
  });
@@ -4219,7 +4271,8 @@ var PushManyPayloadSchema = external_exports.object({
4219
4271
  vesselTitle: external_exports.string().optional(),
4220
4272
  card: CardSchema.optional(),
4221
4273
  interaction: InteractionSchema.optional(),
4222
- pinCard: CardSchema.nullable().optional(),
4274
+ /** Vessel reference record (CRM-style identity), shown in the top bar. `null` clears. Replaces wholesale. */
4275
+ details: DetailsSchema.nullable().optional(),
4223
4276
  /** @deprecated `waiting` is now system-derived from the message's interaction; you don't set status. Still accepted for back-compat. */
4224
4277
  vesselStatus: VesselStatusSchema.optional(),
4225
4278
  attachments: external_exports.array(AttachmentSchema).max(10).optional(),
@@ -4229,9 +4282,7 @@ var PushManyPayloadSchema = external_exports.object({
4229
4282
  "metadata exceeds 16KB limit"
4230
4283
  ).optional(),
4231
4284
  kind: KindSchema.optional(),
4232
- title: external_exports.string().max(200).optional(),
4233
- /** @deprecated use `kind`. */
4234
- display: DisplaySchema.optional()
4285
+ title: external_exports.string().max(200).optional()
4235
4286
  });
4236
4287
  var EventToneSchema = external_exports.enum(["info", "alert", "success"]);
4237
4288
  var ButtonSchema = external_exports.object({
@@ -4275,6 +4326,46 @@ var EventDataSchema = external_exports.object({
4275
4326
  buttons: external_exports.array(ButtonSchema).optional(),
4276
4327
  sections: external_exports.array(EventSectionSchema).optional()
4277
4328
  });
4329
+ var MarkTypeSchema = external_exports.enum([
4330
+ "email_sent",
4331
+ "payment_recorded",
4332
+ "status_changed",
4333
+ "booking_created",
4334
+ "invoice_voided",
4335
+ "generic"
4336
+ ]);
4337
+ var MarkToneSchema = external_exports.enum(["neutral", "success", "warning", "danger"]);
4338
+ var MarkPayloadSchema = external_exports.object({
4339
+ /** The chip's primary line — what happened (e.g. "Confirmation email sent"). */
4340
+ label: external_exports.string().min(1).max(120),
4341
+ /** Semantic kind — selects a default icon + tone. Defaults to `generic`. */
4342
+ type: MarkTypeSchema.optional(),
4343
+ /** Optional small secondary line under the label (an amount, an id, a target). */
4344
+ subtext: external_exports.string().min(1).max(160).optional(),
4345
+ /** Override the default glyph with a literal icon/emoji (e.g. '✉️'). */
4346
+ icon: external_exports.string().min(1).max(8).optional(),
4347
+ /** Override the default tone. */
4348
+ tone: MarkToneSchema.optional(),
4349
+ /** Deep-link into your own UI — renders a right chevron, opens the URL. NOT an interaction. */
4350
+ url: external_exports.string().url().max(2048).optional(),
4351
+ /** Which vessel — your own external id (created on first write, like push). */
4352
+ vessel: external_exports.string().optional(),
4353
+ /** Set the vessel title (only on explicit send, like push). */
4354
+ vesselTitle: external_exports.string().optional(),
4355
+ labels: external_exports.array(external_exports.string().min(1).max(50)).max(10).optional(),
4356
+ metadata: external_exports.record(external_exports.unknown()).refine(
4357
+ (v) => JSON.stringify(v).length < 16e3,
4358
+ "metadata exceeds 16KB limit"
4359
+ ).optional()
4360
+ });
4361
+ var MarkDataSchema = external_exports.object({
4362
+ type: MarkTypeSchema,
4363
+ label: external_exports.string(),
4364
+ tone: MarkToneSchema,
4365
+ subtext: external_exports.string().optional(),
4366
+ icon: external_exports.string().optional(),
4367
+ url: external_exports.string().optional()
4368
+ });
4278
4369
  var MessagePatchSchema = external_exports.object({
4279
4370
  content: external_exports.string().min(1).max(1e4).optional(),
4280
4371
  card: CardSchema.nullable().optional(),
@@ -4286,15 +4377,17 @@ var MessagePatchSchema = external_exports.object({
4286
4377
  /** Switch kind: 'surface' = full-width artifact, 'bubble' or null = chat bubble. */
4287
4378
  kind: KindSchema.nullable().optional(),
4288
4379
  /** Update the surface heading, or `null` to clear it. */
4289
- title: external_exports.string().max(200).nullable().optional(),
4290
- /** @deprecated use `kind`. */
4291
- display: DisplaySchema.nullable().optional()
4380
+ title: external_exports.string().max(200).nullable().optional()
4292
4381
  }).refine((d) => Object.values(d).some((v) => v !== void 0), {
4293
4382
  message: "At least one field required"
4294
4383
  });
4295
4384
  var ApprovalResponseSchema = external_exports.object({
4296
4385
  action: external_exports.enum(["approved", "rejected"]),
4297
- reason: external_exports.string().optional()
4386
+ reason: external_exports.string().optional(),
4387
+ // Edit-then-approve: the values the human changed, keyed by editable id. Present
4388
+ // only on `approved`, and only for values that actually changed (no change ⇒ a
4389
+ // plain approve). Each value is validated against its declared editable server-side.
4390
+ edits: external_exports.record(external_exports.union([external_exports.string(), external_exports.number()])).optional()
4298
4391
  });
4299
4392
  var ChoiceResponseSchema = external_exports.object({
4300
4393
  selected: external_exports.string(),
@@ -4321,6 +4414,29 @@ var InteractionResponseSchema = external_exports.discriminatedUnion("interaction
4321
4414
  external_exports.object({ interactionType: external_exports.literal("text_input"), response: TextInputResponseSchema }),
4322
4415
  external_exports.object({ interactionType: external_exports.literal("questions"), response: QuestionsResponseSchema })
4323
4416
  ]);
4417
+ var InteractionOutcomeSchema = external_exports.enum([
4418
+ "approved",
4419
+ // green ✓
4420
+ "rejected",
4421
+ // red ✗
4422
+ "cancelled",
4423
+ // neutral — withdrawn before a decision
4424
+ "expired"
4425
+ // neutral — no longer relevant
4426
+ ]);
4427
+ var InteractionResolveSchema = external_exports.object({
4428
+ outcome: InteractionOutcomeSchema,
4429
+ /** Badge text shown in place of the buttons. Defaults to the outcome name. */
4430
+ label: external_exports.string().min(1).max(80).optional(),
4431
+ /** Optional note; surfaced when the resolved receipt expands. */
4432
+ reason: external_exports.string().max(2e3).optional()
4433
+ });
4434
+ var AgentResolutionSchema = external_exports.object({
4435
+ resolvedByAgent: external_exports.literal(true),
4436
+ outcome: InteractionOutcomeSchema,
4437
+ label: external_exports.string().optional(),
4438
+ reason: external_exports.string().optional()
4439
+ });
4324
4440
  var WebhookVesselSchema = external_exports.object({
4325
4441
  id: external_exports.string(),
4326
4442
  external_id: external_exports.string().nullable(),
@@ -4398,6 +4514,24 @@ var WebhookVesselCreatedPayloadSchema = external_exports.object({
4398
4514
  attachments: external_exports.array(WebhookEventAttachmentSchema).optional()
4399
4515
  })
4400
4516
  });
4517
+ var WebhookVesselArchivedPayloadSchema = external_exports.object({
4518
+ event: external_exports.literal("vessel.archived"),
4519
+ vessel_id: external_exports.string(),
4520
+ workspace_id: external_exports.string(),
4521
+ timestamp: external_exports.string(),
4522
+ data: external_exports.object({
4523
+ vessel: WebhookVesselSchema
4524
+ })
4525
+ });
4526
+ var WebhookVesselDeletedPayloadSchema = external_exports.object({
4527
+ event: external_exports.literal("vessel.deleted"),
4528
+ vessel_id: external_exports.string(),
4529
+ workspace_id: external_exports.string(),
4530
+ timestamp: external_exports.string(),
4531
+ data: external_exports.object({
4532
+ vessel: WebhookVesselSchema
4533
+ })
4534
+ });
4401
4535
  var WebhookMessageCancelledPayloadSchema = external_exports.object({
4402
4536
  event: external_exports.literal("message.cancelled"),
4403
4537
  vessel_id: external_exports.string(),
@@ -4412,6 +4546,8 @@ var WebhookPayloadSchema = external_exports.discriminatedUnion("event", [
4412
4546
  WebhookInteractionResponsePayloadSchema,
4413
4547
  WebhookUserMessagePayloadSchema,
4414
4548
  WebhookVesselCreatedPayloadSchema,
4549
+ WebhookVesselArchivedPayloadSchema,
4550
+ WebhookVesselDeletedPayloadSchema,
4415
4551
  WebhookMessageCancelledPayloadSchema
4416
4552
  ]);
4417
4553
 
@@ -4532,10 +4668,10 @@ async function cmdLogout() {
4532
4668
  console.log("Logged out.");
4533
4669
  }
4534
4670
  async function cmdWhoami() {
4535
- var _a, _b;
4671
+ var _a;
4536
4672
  const data = await api("/api/v1/me");
4537
4673
  console.log(`Email: ${readConfig().email ?? "unknown"}`);
4538
- console.log(`Workspace: ${(_a = data.workspace) == null ? void 0 : _a.name} (${(_b = data.workspace) == null ? void 0 : _b.plan})`);
4674
+ console.log(`Workspace: ${(_a = data.workspace) == null ? void 0 : _a.name}`);
4539
4675
  console.log(`User ID: ${data.userId}`);
4540
4676
  }
4541
4677
  async function cmdKeysList() {
@@ -4589,7 +4725,7 @@ async function cmdWebhooksCreate(args) {
4589
4725
  console.error("URL must start with https://");
4590
4726
  process.exit(1);
4591
4727
  }
4592
- const DEFAULT_EVENTS = ["interaction.response", "message.user", "vessel.created"];
4728
+ const DEFAULT_EVENTS = ["interaction.response", "message.user", "vessel.created", "vessel.archived", "vessel.deleted"];
4593
4729
  let events;
4594
4730
  if (flags.events) {
4595
4731
  events = flags.events.split(/[,\s]+/).filter(Boolean);
@@ -4759,6 +4895,45 @@ async function cmdEvent(args) {
4759
4895
  }
4760
4896
  console.log(`Event sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
4761
4897
  }
4898
+ var MARK_TYPES = ["email_sent", "payment_recorded", "status_changed", "booking_created", "invoice_voided", "generic"];
4899
+ var MARK_TONES = ["neutral", "success", "warning", "danger"];
4900
+ async function cmdMark(args) {
4901
+ const flags = parseFlags(args);
4902
+ const apiKey = flags.key ?? process.env.VESSELS_API_KEY;
4903
+ if (!apiKey) {
4904
+ console.error("Provide an API key via --key or VESSELS_API_KEY env var");
4905
+ process.exit(1);
4906
+ }
4907
+ if (!flags.label) {
4908
+ console.error("Usage: vessels mark --vessel <id> --label <text> [--type <kind>] [--subtext <text>] [--icon <emoji>] [--tone neutral|success|warning|danger] [--url <link>] --key <api_key>");
4909
+ process.exit(1);
4910
+ }
4911
+ if (flags.type && !MARK_TYPES.includes(flags.type)) {
4912
+ console.error(`--type must be one of: ${MARK_TYPES.join(", ")}`);
4913
+ process.exit(1);
4914
+ }
4915
+ if (flags.tone && !MARK_TONES.includes(flags.tone)) {
4916
+ console.error(`--tone must be one of: ${MARK_TONES.join(", ")}`);
4917
+ process.exit(1);
4918
+ }
4919
+ const payload = { label: flags.label, vessel: flags.vessel || void 0 };
4920
+ if (flags.type) payload.type = flags.type;
4921
+ if (flags.subtext) payload.subtext = flags.subtext;
4922
+ if (flags.icon) payload.icon = flags.icon;
4923
+ if (flags.tone) payload.tone = flags.tone;
4924
+ if (flags.url) payload.url = flags.url;
4925
+ const res = await fetch(`${BASE_URL}/api/v1/mark`, {
4926
+ method: "POST",
4927
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
4928
+ body: JSON.stringify(payload)
4929
+ });
4930
+ const data = await res.json();
4931
+ if (!res.ok) {
4932
+ console.error("Mark failed:", data.error);
4933
+ process.exit(1);
4934
+ }
4935
+ console.log(`Mark sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
4936
+ }
4762
4937
  var FEEDBACK_TYPES = ["bug", "feature", "other"];
4763
4938
  async function cmdFeedback(args) {
4764
4939
  const flags = parseFlags(args);
@@ -4801,7 +4976,7 @@ function zodIssueLines(err) {
4801
4976
  return err.issues.map((i) => `${i.path.join(".") || "(payload)"}: ${i.message}`);
4802
4977
  }
4803
4978
  function summarisePayload(p, isMany) {
4804
- var _a, _b;
4979
+ var _a, _b, _c;
4805
4980
  const lines = [];
4806
4981
  if (isMany) lines.push(` vessels: ${p.vessels.length}`);
4807
4982
  else if (p.vessel) lines.push(` vessel: ${p.vessel}`);
@@ -4811,7 +4986,7 @@ function summarisePayload(p, isMany) {
4811
4986
  }
4812
4987
  if ((_a = p.interaction) == null ? void 0 : _a.type) lines.push(` interaction: ${p.interaction.type}`);
4813
4988
  if (p.card) lines.push(` card: "${p.card.title}" (${((_b = p.card.fields) == null ? void 0 : _b.length) ?? 0} fields)`);
4814
- if (p.pinCard !== void 0) lines.push(` pinCard: ${p.pinCard === null ? "clear" : `"${p.pinCard.title}"`}`);
4989
+ if (p.details !== void 0) lines.push(` details: ${p.details === null ? "clear" : `${((_c = p.details.fields) == null ? void 0 : _c.length) ?? 0} field(s)`}`);
4815
4990
  if (p.vesselStatus) lines.push(` vesselStatus: ${p.vesselStatus}`);
4816
4991
  if (Array.isArray(p.attachments)) lines.push(` attachments: ${p.attachments.length}`);
4817
4992
  if (Array.isArray(p.suggestions)) lines.push(` suggestions: ${p.suggestions.length}`);
@@ -5028,7 +5203,7 @@ Setup complete.
5028
5203
  if (flags["webhook-url"]) {
5029
5204
  const webhookData = await api("/api/v1/webhooks", {
5030
5205
  method: "POST",
5031
- body: JSON.stringify({ url: flags["webhook-url"], events: ["interaction.response", "message.user", "vessel.created"] })
5206
+ body: JSON.stringify({ url: flags["webhook-url"], events: ["interaction.response", "message.user", "vessel.created", "vessel.archived", "vessel.deleted"] })
5032
5207
  });
5033
5208
  console.log(` VESSELS_WEBHOOK_SECRET=${webhookData.endpoint.secret}`);
5034
5209
  }
@@ -5139,6 +5314,11 @@ Commands:
5139
5314
  Paint a human-facing event banner (a fact from your backend \u2014 booking, alert).
5140
5315
  Fires a notification, no webhook. Renders as a full-width tinted artifact.
5141
5316
 
5317
+ vessels mark --vessel <id> --label <text> [--type <kind>] [--subtext <text>] [--icon <emoji>] [--tone neutral|success|warning|danger] [--url <link>] --key <api_key>
5318
+ Drop a slim timeline chip recording a committed action (email sent, payment
5319
+ recorded). --type \u2208 email_sent|payment_recorded|status_changed|booking_created|
5320
+ invoice_voided|generic. Fires a notification, no webhook.
5321
+
5142
5322
  vessels message --vessel <id> --message <text>
5143
5323
  Send a message as the logged-in user and print webhook delivery logs.
5144
5324
  Accepts vessel UUID or external_id (e.g. booking-123).
@@ -5230,6 +5410,7 @@ Run: vessels help`);
5230
5410
  if (cmd === "feedback") return cmdFeedback([sub, ...rest].filter(Boolean));
5231
5411
  if (cmd === "push") return cmdPush([sub, ...rest].filter(Boolean));
5232
5412
  if (cmd === "event") return cmdEvent([sub, ...rest].filter(Boolean));
5413
+ if (cmd === "mark") return cmdMark([sub, ...rest].filter(Boolean));
5233
5414
  if (cmd === "message") return cmdMessage([sub, ...rest].filter(Boolean));
5234
5415
  if (cmd === "validate") return cmdValidate([sub, ...rest].filter(Boolean));
5235
5416
  console.error(`Unknown command: ${cmd}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vessels",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Vessels CLI — manage your agent communication layer from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@ Everything else is the **engine** and you rarely touch it:
54
54
  2. The server verifies the signature, **ACKs 200 immediately**, and runs the turn in the background.
55
55
  3. The engine leads with a one-line reply, opens a live working card, plans, calls **your** tools,
56
56
  and ends with exactly one finishing tool — a message (`finish`) or a human decision
57
- (`request_approval` / `choice` / `checklist` / `text`).
57
+ (`request_approval` / `choice` / `checklist` / `text` / `questions`).
58
58
  4. When the human answers, Vessels sends another webhook and the loop continues — the engine loads
59
59
  the prior conversation from your store, so the agent picks up exactly where it left off.
60
60
 
@@ -87,10 +87,14 @@ runTurn` shape stays the same; only the server wrapper changes.
87
87
  ## The full Vessels surface
88
88
 
89
89
  The engine already exposes the breadth of Vessels to the model, so your agent can use all of it:
90
- chat **bubbles** and full-width **surfaces**; all four **interactions** (`approval` / `choice` /
91
- `checklist` / `text`, with labels, `allowCustom`, `minSelections`, `reasonRequired`, and
92
- **metadata** that rides back to you for routing); the live **working card** with a ticking **plan**,
93
- auto-narrated **steps**, and **token streaming**; **pinned cards**, **labels**, **attachments**
90
+ chat **bubbles** and full-width **surfaces**; all five **interactions** (`approval` / `choice` /
91
+ `checklist` / `text` / `questions` — the multi-question form, with labels, `allowCustom`,
92
+ `minSelections`, `reasonRequired`, and **metadata** that rides back to you for routing);
93
+ **edit-then-approve** (the operator changes a value a price, a date — and approves, via
94
+ `editables` bound to card fields or inline `{{id}}` body tokens; only the changes come back) and
95
+ one-way **consent gates** (`rejectable:false`); the live **working card** with a ticking **plan**,
96
+ auto-narrated **steps**, and **token streaming**; the vessel's persistent **details** record
97
+ (CRM-style reference facts, shown in the top bar), **labels** (triage/status tags), **attachments**
94
98
  (images/files you host), **preview links**, **suggested replies**, vessel **naming/renaming**, and
95
99
  user-initiated vessel **types**; plus **inbound files** the human sends you — downloaded, stored on
96
100
  your infra, resolved, and (for images) read by the model via **vision** (see below). Idempotency
@@ -113,10 +117,24 @@ your externally-reachable base (your tunnel in dev) so that link is fetchable. I
113
117
  `putInboundFile` at object storage (S3/R2/GCS) and return a CDN URL — nothing else changes.
114
118
 
115
119
  A few Vessels features live on **your backend**, not inside a turn — call them on the `vessels`
116
- SDK directly:
117
-
120
+ SDK directly (none of these round-trip through the agent's turn loop):
121
+
122
+ - `vessels.event({ vessel, title, tone, card?, buttons?, sections? })` — paint a **backend event**
123
+ on the human's timeline: a fact from *your* system (a booking landed, an alert fired), not your
124
+ agent's voice. A full-width tinted banner with deep-link buttons and collapsible sections. It is
125
+ purely presentational — it fires **no webhook and no poll event**, so the agent never hears about
126
+ it through Vessels. Fire it in parallel with triggering the agent off the same fact (e.g. POST a
127
+ `vessel.created`/proactive trigger to this server), and the agent works and replies in its own
128
+ voice alongside the banner.
118
129
  - `vessels.pushMany({ vessels: [...], message, interaction })` — broadcast the same message/decision
119
130
  to many vessels at once (e.g. "course closed Saturday" to every affected booking).
131
+ - `vessels.updateVessel(externalId, { title?, labels?, details?, archived?, pinned? })` — change
132
+ vessel-level state **without** posting a message; `vessels.archiveVessel(id)` / `listVessels()` /
133
+ `deleteVessel(id)` are the lifecycle helpers around it.
134
+ - `vessels.clearMessages(vessel, { beforeMessageId? | afterMessageId? | before? | after? })` /
135
+ `vessels.deleteMessage(id)` — the durable primitive behind an agent's **/rewind**: trim the
136
+ human-visible feed back to a point (it does **not** touch your agent's own context, which lives
137
+ in your store). Resolve the operator's "rewind to yesterday" to a timestamp/id, then call this.
120
138
  - `vessels.getMessages({ vessel })` — re-read a vessel's human-facing history to reconcile a
121
139
  stateless/restarted worker (not your memory — that's your store).
122
140
  - `vessels.validatePush(payload)` — check a payload against the server schema without sending.
@@ -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.17.0"
16
+ "vessels-sdk": "^0.23.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^22.10.0",
@@ -16,7 +16,7 @@
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, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages';
19
+ import type { MessageParam, Tool, ToolUseBlock, TextBlock, ToolResultBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages';
20
20
  import { Vessels } from 'vessels-sdk';
21
21
  import type { PushOptions, AgentActivityType, AgentTodoStatus, InboundAttachment } from 'vessels-sdk';
22
22
  import { ROLE } from './role.js';
@@ -29,6 +29,7 @@ import {
29
29
  buildInteraction,
30
30
  defaultNarrate,
31
31
  sanitizeCard,
32
+ sanitizeDetails,
32
33
  cleanPreviewUrl,
33
34
  cleanTitle,
34
35
  cleanSuggestions,
@@ -40,8 +41,7 @@ import { handleInboundFiles, attachImagesToLastUserMessage } from './inbound.js'
40
41
 
41
42
  const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
42
43
  const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
43
- const THINKING_BUDGET = 1024; // API minimum; streamed live into the card then discarded
44
- const MAX_TOKENS = 4096; // think + response combined
44
+ const MAX_TOKENS = 4096;
45
45
  const TURN_BUDGET_MS = 75_000; // safety cap — stop starting model calls past this and finish gracefully
46
46
  const MAX_HISTORY = 80; // cap persisted conversation messages (trimmed at a safe boundary)
47
47
  const DEBUG = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
@@ -190,7 +190,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
190
190
  let cardOpened = false;
191
191
  let acknowledged = false; // the agent already sent its "on it" bubble → card needs no message
192
192
  let pendingTitle = vesselTitle; // rides whichever push lands first
193
- let pendingPinCard: ReturnType<typeof sanitizeCard> | undefined;
193
+ let pendingDetails: ReturnType<typeof sanitizeDetails> | undefined;
194
194
  let pendingLabels: string[] | undefined;
195
195
  const ensureWorkingCard = async (): Promise<void> => {
196
196
  if (cardOpened) return;
@@ -198,14 +198,14 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
198
198
  const r = await safePush(vessels, {
199
199
  vessel,
200
200
  ...(pendingTitle ? { vesselTitle: pendingTitle } : {}),
201
- ...(pendingPinCard ? { pinCard: pendingPinCard } : {}),
201
+ ...(pendingDetails !== undefined ? { details: pendingDetails } : {}),
202
202
  ...(pendingLabels ? { labels: pendingLabels } : {}),
203
203
  ...(acknowledged ? {} : { message: opening }),
204
204
  agentActivity: { type: 'thinking', label: 'Getting started' },
205
205
  idempotencyKey: `${idempotencyKeyBase}:work`,
206
206
  });
207
207
  pendingTitle = undefined;
208
- pendingPinCard = undefined;
208
+ pendingDetails = undefined;
209
209
  pendingLabels = undefined;
210
210
  activityId = r.messageId ?? null;
211
211
  };
@@ -278,7 +278,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
278
278
  kind?: 'bubble' | 'surface';
279
279
  title?: string;
280
280
  card?: unknown;
281
- pinCard?: unknown;
281
+ details?: unknown;
282
282
  labels?: string[];
283
283
  interaction?: Record<string, unknown> | null;
284
284
  suggestions?: string[];
@@ -290,12 +290,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
290
290
  let ended = false;
291
291
  const recordEnding = (name: string, input: Record<string, unknown>) => {
292
292
  const msg = String(input.message ?? '');
293
- const endPin = sanitizeCard(input.pinCard);
293
+ const endDetails = sanitizeDetails(input.details);
294
294
  const endLabels = cleanLabels(input.labels);
295
295
  const endTitle = cleanTitle(input.vesselTitle);
296
296
  if (endTitle && !pendingTitle) pendingTitle = endTitle;
297
297
  if (name === 'finish') {
298
- pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
298
+ pushes.push({ message: msg || 'All done.', details: endDetails, labels: endLabels });
299
299
  } else {
300
300
  const interaction = buildInteraction(name, input);
301
301
  // keepWorking is purely semantic — it tells the model the task isn't finished, so the
@@ -306,7 +306,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
306
306
  kind: 'surface',
307
307
  title: cleanTitle(input.title),
308
308
  card: sanitizeCard(input.card, input),
309
- pinCard: endPin,
309
+ details: endDetails,
310
310
  labels: endLabels,
311
311
  interaction,
312
312
  previewUrl: cleanPreviewUrl(input.previewUrl),
@@ -328,12 +328,10 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
328
328
  const ms = anthropic.messages.stream({
329
329
  model: MODEL,
330
330
  max_tokens: MAX_TOKENS,
331
- thinking: { type: 'enabled', budget_tokens: THINKING_BUDGET },
332
331
  system: systemPrompt,
333
332
  tools: ALL_TOOLS,
334
333
  messages,
335
334
  });
336
- ms.on('thinking', (delta) => writeStream(delta));
337
335
  ms.on('text', (delta) => writeStream(delta));
338
336
  ms.on('error', () => {});
339
337
  const resp = await ms.finalMessage();
@@ -342,8 +340,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
342
340
 
343
341
  const toolUses = resp.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
344
342
  if (DEBUG) {
345
- const thinking = resp.content.filter((b): b is ThinkingBlock => b.type === 'thinking').map((b) => b.thinking).join('\n');
346
- log('model_done', { step, stop: resp.stop_reason, tools: toolUses.map((t) => t.name), thinking: thinking.slice(0, 200) });
343
+ log('model_done', { step, stop: resp.stop_reason, tools: toolUses.map((t) => t.name) });
347
344
  }
348
345
 
349
346
  // No tool call — treat any plain text as a final message and stop.
@@ -366,7 +363,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
366
363
  const named = cleanTitle(pIn.vesselTitle);
367
364
  if (named) pendingTitle = named;
368
365
  }
369
- if (!pendingPinCard) pendingPinCard = sanitizeCard(pIn.pinCard);
366
+ if (pendingDetails === undefined) pendingDetails = sanitizeDetails(pIn.details);
370
367
  if (!pendingLabels) pendingLabels = cleanLabels(pIn.labels);
371
368
  }
372
369
 
@@ -429,12 +426,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
429
426
  } else if (tu.name === 'show_document') {
430
427
  const title = cleanTitle(input.title);
431
428
  const body = String(input.body ?? '').trim();
432
- const docPin = sanitizeCard(input.pinCard);
429
+ const docDetails = sanitizeDetails(input.details);
433
430
  const docLabels = cleanLabels(input.labels);
434
431
  if (body) {
435
- pushes.push({ message: body, kind: 'surface', title, card: sanitizeCard(input.card, input), pinCard: docPin, labels: docLabels, attachments: cleanAttachments(input.attachments) });
436
- } else if (docPin || docLabels) {
437
- if (docPin) pendingPinCard = docPin;
432
+ pushes.push({ message: body, kind: 'surface', title, card: sanitizeCard(input.card, input), details: docDetails, labels: docLabels, attachments: cleanAttachments(input.attachments) });
433
+ } else if (docDetails !== undefined || docLabels) {
434
+ if (docDetails !== undefined) pendingDetails = docDetails;
438
435
  if (docLabels) pendingLabels = docLabels;
439
436
  }
440
437
  toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'shown' });
@@ -529,10 +526,10 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
529
526
  const isLast = i === pushes.length - 1;
530
527
  const titleForThisPush = i === 0 && pendingTitle ? pendingTitle : undefined;
531
528
  if (titleForThisPush) pendingTitle = undefined;
532
- const pinForThisPush = p.pinCard ?? (i === 0 ? pendingPinCard : undefined);
529
+ const detailsForThisPush = p.details !== undefined ? p.details : (i === 0 ? pendingDetails : undefined);
533
530
  const labelsForThisPush = p.labels ?? (i === 0 ? pendingLabels : undefined);
534
531
  if (i === 0) {
535
- pendingPinCard = undefined;
532
+ pendingDetails = undefined;
536
533
  pendingLabels = undefined;
537
534
  }
538
535
  const r = await safePush(vessels, {
@@ -542,7 +539,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
542
539
  kind: p.kind,
543
540
  title: p.title,
544
541
  card: p.card as PushOptions['card'],
545
- ...(pinForThisPush ? { pinCard: pinForThisPush as PushOptions['pinCard'] } : {}),
542
+ ...(detailsForThisPush !== undefined ? { details: detailsForThisPush as PushOptions['details'] } : {}),
546
543
  ...(labelsForThisPush ? { labels: labelsForThisPush } : {}),
547
544
  interaction: (p.interaction ?? undefined) as PushOptions['interaction'],
548
545
  suggestions: p.suggestions,
@@ -63,11 +63,15 @@ Tools:
63
63
  1. Plan + triage — drive the live ticking checklist AND surface the vessel's state:
64
64
  - plan(todos) — declare 3–4 tasks up front. On a vessel's FIRST plan(), ALSO set:
65
65
  • labels: 1–3 triage tags in your own vocabulary — how this vessel shows up in the
66
- operator's list. Replace-semantics (send the full set).
67
- pinCard: a compact {title, fields} pinning the entity's state at a glance — it stays
68
- in the header as the chat scrolls.
69
- Re-send pinCard (on a later plan/finish/request_*) to UPDATE it as state changes. Set
70
- labels once unless they change.
66
+ operator's list. This is where STATUS lives — re-send to retag as the situation
67
+ moves. Replace-semantics (send the full set).
68
+ details: {fields:[{label, value, url?, tone?, copyable?}]} — the vessel's persistent
69
+ REFERENCE record: stable identity facts about who/what this vessel is about (client
70
+ name, phone/email set copyable:true on those; a url to deep-link into your own
71
+ admin). It is NOT status — never re-send details just to reflect a state change (that's
72
+ what labels are for); only rewrite it when the underlying facts change. Replaces
73
+ wholesale; null clears.
74
+ Set labels and details on the first plan(); leave them unless the facts/status change.
71
75
  - Advance the plan by passing task:"<exact label>" ON the work tool itself (your backend
72
76
  tools and send_update take it) — that ticks the plan in the SAME call. Do NOT waste a
73
77
  whole turn on a lone step(); only use step() when you advance with no tool to run. You do
@@ -82,6 +86,16 @@ Tools:
82
86
  summarise it as "draft is ready"; show the real text.
83
87
  - Reserve a previewUrl for something too long or rich to inline (a multi-page document, a
84
88
  PDF) — a link to open, not a substitute for showing short text.
89
+ - EDIT-THEN-APPROVE: when the operator is more likely to TWEAK a value than to reject (a
90
+ price, a date, a quantity), declare editables on request_approval instead of forcing a
91
+ reject-and-re-ask. Each editable is {id, type (text|number|currency|date|choice), value}.
92
+ Bind each to where it shows: either a card field carrying the same editableId, OR an inline
93
+ {{id}} token written straight into the message body (works in a table cell). "Edit & approve"
94
+ turns those into inputs; only the values they CHANGED come back to you in the response, and
95
+ you apply the new values in your backend. The body prose stays read-only — only the bound
96
+ values are editable. Don't over-use it: most approvals are a clean yes/no.
97
+ - CONSENT GATE: for a one-way "proceed?" with no real reject path (a confirmation before an
98
+ irreversible action), set rejectable:false — it renders a single full-width Approve button.
85
99
 
86
100
  3. Finishing tools — pick EXACTLY ONE as the FINAL action of your turn:
87
101
  - request_approval — yes/no sign-off (optionally with a previewUrl to review)
@@ -108,7 +122,7 @@ Flow:
108
122
  - A [SYSTEM EVENT] means you are PROACTIVELY reaching the operator. They ALREADY see your
109
123
  opening line and a live ticking plan — so DO NOT re-announce the event or restate its
110
124
  details in a chat message. Get straight to work.
111
- - plan(todos, labels, pinCard) → call each task's backend tool WITH task:"<label>" (it ticks
125
+ - plan(todos, labels, details) → call each task's backend tool WITH task:"<label>" (it ticks
112
126
  the plan + auto-narrates) → then ONE finishing tool. END the turn with a single, complete
113
127
  result: the finishing message (one sentence) plus, for approvals, a compact card. Never drop
114
128
  a bare card and trail off.
@@ -17,17 +17,45 @@ import type { AgentActivityType } from 'vessels-sdk';
17
17
  const CARD_SCHEMA = {
18
18
  type: 'object' as const,
19
19
  properties: {
20
- title: { type: 'string' },
20
+ title: { type: 'string', description: 'Optional heading. Redundant on a surface (the surface title is the heading); useful on a standalone bubble card.' },
21
21
  fields: {
22
22
  type: 'array',
23
23
  items: {
24
24
  type: 'object',
25
- properties: { label: { type: 'string' }, value: { type: 'string' } },
25
+ properties: {
26
+ label: { type: 'string' },
27
+ value: { type: 'string' },
28
+ url: { type: 'string', description: 'Render the value as a link deep into YOUR own admin (opens a new tab). Shown on surface cards, not the list preview.' },
29
+ tone: { type: 'string', enum: ['default', 'success', 'warning', 'danger'], description: 'Colour hint only — no behaviour.' },
30
+ editableId: { type: 'string', description: 'Edit-then-approve: bind this field to an approval `editables[]` entry of the same id, so it becomes an input on "Edit & approve". Inert without that approval.' },
31
+ },
26
32
  required: ['label', 'value'],
27
33
  },
28
34
  },
29
35
  },
30
- required: ['title', 'fields'],
36
+ required: ['fields'],
37
+ };
38
+
39
+ /** Edit-then-approve: the values the human may change before approving. Each binds to a
40
+ * card field (by matching `editableId`) OR to an inline `{{id}}` token in the body. */
41
+ const EDITABLES_SCHEMA = {
42
+ type: 'array' as const,
43
+ maxItems: 20,
44
+ description:
45
+ 'Edit-then-approve — let the operator change specific VALUES before approving (e.g. "make it 1700, not 1500"), instead of rejecting and re-asking. Each entry binds to a card field with a matching `editableId`, or to an inline `{{id}}` token in the message body. On approve, only the values they changed come back in the response `edits` (keyed by id) — apply them in YOUR system. The body prose stays read-only.',
46
+ items: {
47
+ type: 'object',
48
+ properties: {
49
+ id: { type: 'string', description: 'Stable id — the response `edits` key and the `editableId`/`{{id}}` it binds to.' },
50
+ type: { type: 'string', enum: ['text', 'number', 'currency', 'date', 'choice'], description: 'text | number | currency (money) | date (YYYY-MM-DD) | choice (pick an option id).' },
51
+ value: { description: 'The current/baseline value (a number for number/currency, the option id for choice, else a string). Recommended so you can diff the returned edit.' },
52
+ label: { type: 'string', description: 'Edit-field label; falls back to the bound card field label.' },
53
+ options: { type: 'array', description: "type:'choice' only — the options to pick from.", items: { type: 'object', properties: { id: { type: 'string' }, label: { type: 'string' } }, required: ['id', 'label'] }, minItems: 1 },
54
+ min: { type: 'number', description: 'Numeric lower bound (number/currency), validated on submit.' },
55
+ max: { type: 'number', description: 'Numeric upper bound (number/currency), validated on submit.' },
56
+ },
57
+ required: ['id', 'type'],
58
+ },
31
59
  };
32
60
 
33
61
  /** Inject this into a tool's input schema so the model can tick the plan in the same call. */
@@ -47,10 +75,28 @@ const OPTIONS_SCHEMA = {
47
75
  minItems: 1,
48
76
  };
49
77
 
50
- const PIN_CARD_FIELD = {
51
- ...CARD_SCHEMA,
78
+ const DETAILS_FIELD = {
79
+ type: 'object' as const,
80
+ properties: {
81
+ fields: {
82
+ type: 'array',
83
+ maxItems: 20,
84
+ items: {
85
+ type: 'object',
86
+ properties: {
87
+ label: { type: 'string' },
88
+ value: { type: 'string' },
89
+ url: { type: 'string', description: 'Deep-link the value into YOUR own admin/CRM (opens in a new tab).' },
90
+ tone: { type: 'string', enum: ['default', 'success', 'warning', 'danger'], description: 'Colour hint only — no behaviour.' },
91
+ copyable: { type: 'boolean', description: "Show a copy button (use on phone numbers, emails, ids the human grabs)." },
92
+ },
93
+ required: ['label', 'value'],
94
+ },
95
+ },
96
+ },
97
+ required: ['fields'],
52
98
  description:
53
- "Update the vessel's pinned header card to the entity's current state. Re-send the full card to replace it.",
99
+ "The vessel's persistent reference record CRM-style identity facts about who/what this vessel is about (client name, phone/email with copyable:true, links into your admin via url). NOT status (status lives in labels). Replaces wholesale on each write; send null to clear.",
54
100
  };
55
101
 
56
102
  const LABELS_FIELD = {
@@ -116,11 +162,7 @@ export const CONTROL_TOOLS: Tool[] = [
116
162
  description:
117
163
  'Triage tags for this vessel, in your own vocabulary. REPLACE-semantics — send the full set. Set 1–3 on the first plan() of a vessel; omit later unless they change.',
118
164
  },
119
- pinCard: {
120
- ...CARD_SCHEMA,
121
- description:
122
- "A persistent header card pinning the entity's current state at a glance — stays put as the chat scrolls. Set it when you first have the key facts; re-send to UPDATE it as state changes.",
123
- },
165
+ details: DETAILS_FIELD,
124
166
  },
125
167
  required: ['todos'],
126
168
  },
@@ -168,7 +210,7 @@ export const CONTROL_TOOLS: Tool[] = [
168
210
  },
169
211
  card: CARD_SCHEMA,
170
212
  attachments: ATTACHMENTS_FIELD,
171
- pinCard: PIN_CARD_FIELD,
213
+ details: DETAILS_FIELD,
172
214
  labels: LABELS_FIELD,
173
215
  },
174
216
  required: ['title', 'body'],
@@ -190,9 +232,11 @@ export const CONTROL_TOOLS: Tool[] = [
190
232
  approveLabel: { type: 'string' },
191
233
  rejectLabel: { type: 'string' },
192
234
  reasonRequired: { type: 'boolean', description: 'Require the human to type a reason with their approve/reject.' },
235
+ rejectable: { type: 'boolean', description: 'Set FALSE for a one-way consent/proceed gate — a single full-width Approve button, no reject path. Defaults true (the two-way Approve/Reject decision).' },
236
+ editables: EDITABLES_SCHEMA,
193
237
  previewUrl: { type: 'string', description: 'Optional link for an artifact too rich to inline. For text, inline it in `message`.' },
194
238
  card: CARD_SCHEMA,
195
- pinCard: PIN_CARD_FIELD,
239
+ details: DETAILS_FIELD,
196
240
  labels: LABELS_FIELD,
197
241
  metadata: METADATA_FIELD,
198
242
  keepWorking: KEEP_WORKING_FIELD,
@@ -213,7 +257,7 @@ export const CONTROL_TOOLS: Tool[] = [
213
257
  allowCustom: { type: 'boolean', description: 'Let the human type their own answer instead of picking an option.' },
214
258
  customPlaceholder: { type: 'string', description: 'Placeholder for the custom-answer field (when allowCustom).' },
215
259
  card: CARD_SCHEMA,
216
- pinCard: PIN_CARD_FIELD,
260
+ details: DETAILS_FIELD,
217
261
  labels: LABELS_FIELD,
218
262
  metadata: METADATA_FIELD,
219
263
  keepWorking: KEEP_WORKING_FIELD,
@@ -234,7 +278,7 @@ export const CONTROL_TOOLS: Tool[] = [
234
278
  minSelections: { type: 'number', description: 'Minimum number the human must select.' },
235
279
  submitLabel: { type: 'string', description: 'Label for the submit button.' },
236
280
  card: CARD_SCHEMA,
237
- pinCard: PIN_CARD_FIELD,
281
+ details: DETAILS_FIELD,
238
282
  labels: LABELS_FIELD,
239
283
  metadata: METADATA_FIELD,
240
284
  keepWorking: KEEP_WORKING_FIELD,
@@ -254,7 +298,7 @@ export const CONTROL_TOOLS: Tool[] = [
254
298
  placeholder: { type: 'string' },
255
299
  multiline: { type: 'boolean' },
256
300
  submitLabel: { type: 'string', description: 'Label for the submit button.' },
257
- pinCard: PIN_CARD_FIELD,
301
+ details: DETAILS_FIELD,
258
302
  labels: LABELS_FIELD,
259
303
  metadata: METADATA_FIELD,
260
304
  keepWorking: KEEP_WORKING_FIELD,
@@ -303,7 +347,7 @@ export const CONTROL_TOOLS: Tool[] = [
303
347
  },
304
348
  },
305
349
  submitLabel: { type: 'string' },
306
- pinCard: PIN_CARD_FIELD,
350
+ details: DETAILS_FIELD,
307
351
  labels: LABELS_FIELD,
308
352
  metadata: METADATA_FIELD,
309
353
  keepWorking: KEEP_WORKING_FIELD,
@@ -319,7 +363,7 @@ export const CONTROL_TOOLS: Tool[] = [
319
363
  properties: {
320
364
  message: { type: 'string' },
321
365
  vesselTitle: { type: 'string', description: 'Rename the vessel as you wrap up (≤6 words). Omit otherwise.' },
322
- pinCard: PIN_CARD_FIELD,
366
+ details: DETAILS_FIELD,
323
367
  labels: LABELS_FIELD,
324
368
  },
325
369
  required: ['message'],
@@ -374,14 +418,18 @@ export function buildInteraction(toolName: string, input: Record<string, unknown
374
418
  const meta = cleanMetadata(input.metadata);
375
419
  const withMeta = (obj: Record<string, unknown>) => (meta ? { ...obj, metadata: meta } : obj);
376
420
  switch (toolName) {
377
- case 'request_approval':
421
+ case 'request_approval': {
422
+ const editables = sanitizeEditables(input.editables);
378
423
  return withMeta({
379
424
  type: 'approval',
380
425
  prompt: String(input.prompt),
381
426
  ...(input.approveLabel ? { approveLabel: String(input.approveLabel) } : {}),
382
427
  ...(input.rejectLabel ? { rejectLabel: String(input.rejectLabel) } : {}),
383
428
  ...(input.reasonRequired ? { reasonRequired: true } : {}),
429
+ ...(input.rejectable === false ? { rejectable: false } : {}),
430
+ ...(editables ? { editables } : {}),
384
431
  });
432
+ }
385
433
  case 'request_choice':
386
434
  return withMeta({
387
435
  type: 'choice',
@@ -432,7 +480,15 @@ export function renderInteractionResponse(
432
480
  case 'approval': {
433
481
  const action = String(response.action ?? '');
434
482
  const reason = response.reason ? ` (${response.reason})` : '';
435
- return `${head}I ${action === 'approved' ? 'APPROVED' : 'REJECTED'}${reason}.`;
483
+ // Edit-then-approve: the operator changed one or more editable values before approving.
484
+ // Surface them so the agent applies the NEW values in its backend, not the originals.
485
+ const edits = response.edits && typeof response.edits === 'object' && !Array.isArray(response.edits)
486
+ ? (response.edits as Record<string, unknown>)
487
+ : null;
488
+ const editLine = edits && Object.keys(edits).length
489
+ ? ` — and changed: ${Object.entries(edits).map(([k, v]) => `${k} → ${String(v)}`).join(', ')} (apply these new values)`
490
+ : '';
491
+ return `${head}I ${action === 'approved' ? 'APPROVED' : 'REJECTED'}${reason}${editLine}.`;
436
492
  }
437
493
  case 'choice': {
438
494
  const sel = response.customValue ? `${response.selected} → "${response.customValue}"` : response.selected;
@@ -468,11 +524,18 @@ export function renderInteractionResponse(
468
524
 
469
525
  // ─── Defensive payload sanitisers ────────────────────────────────────────────────
470
526
 
471
- /** Return a valid card {title, fields:[{label,value}]}, salvaging a sibling `fields`, or undefined. */
527
+ type CardField = { label: string; value: string; url?: string; tone?: 'default' | 'success' | 'warning' | 'danger'; editableId?: string };
528
+
529
+ /**
530
+ * Return a valid card {title?, fields:[{label,value,url?,tone?,editableId?}]}, salvaging a
531
+ * sibling `fields`, or undefined. Title is optional (redundant on a surface). Per-field
532
+ * `url`/`tone`/`editableId` are preserved — `editableId` is what binds a field to an
533
+ * approval's `editables[]` for edit-then-approve.
534
+ */
472
535
  export function sanitizeCard(
473
536
  raw: unknown,
474
537
  input?: Record<string, unknown>
475
- ): { title: string; fields: { label: string; value: string }[] } | undefined {
538
+ ): { title?: string; fields: CardField[] } | undefined {
476
539
  const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
477
540
  const titleRaw = typeof obj.title === 'string' ? obj.title : typeof raw === 'string' ? raw : '';
478
541
  const title = String(titleRaw).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
@@ -483,10 +546,89 @@ export function sanitizeCard(
483
546
  : [];
484
547
  const fields = fieldsSrc
485
548
  .filter((f): f is Record<string, unknown> => !!f && typeof f === 'object')
486
- .map((f) => ({ label: String(f.label ?? '').trim(), value: String(f.value ?? '').trim() }))
549
+ .map((f) => {
550
+ const field: CardField = { label: String(f.label ?? '').trim(), value: String(f.value ?? '').trim() };
551
+ if (typeof f.url === 'string' && /^https?:\/\/\S+$/.test(f.url.trim())) field.url = f.url.trim();
552
+ if (f.tone === 'success' || f.tone === 'warning' || f.tone === 'danger' || f.tone === 'default') field.tone = f.tone;
553
+ if (typeof f.editableId === 'string' && f.editableId.trim()) field.editableId = f.editableId.trim();
554
+ return field;
555
+ })
487
556
  .filter((f) => f.label && f.value);
488
- if (!title || fields.length === 0) return undefined;
489
- return { title, fields };
557
+ if (fields.length === 0) return undefined;
558
+ return { ...(title ? { title } : {}), fields };
559
+ }
560
+
561
+ type EditableField = {
562
+ id: string;
563
+ type: 'text' | 'number' | 'currency' | 'date' | 'choice';
564
+ value?: string | number;
565
+ label?: string;
566
+ options?: { id: string; label: string }[];
567
+ min?: number;
568
+ max?: number;
569
+ };
570
+
571
+ /**
572
+ * Edit-then-approve: sanitise the `editables` an approval declares. A malformed entry is
573
+ * dropped (never 400s the whole push); a `choice` with no valid options is dropped (the API
574
+ * rejects it). Returns undefined when nothing usable survives.
575
+ */
576
+ export function sanitizeEditables(raw: unknown): EditableField[] | undefined {
577
+ if (!Array.isArray(raw)) return undefined;
578
+ const TYPES = new Set(['text', 'number', 'currency', 'date', 'choice']);
579
+ const out: EditableField[] = [];
580
+ for (const e of raw) {
581
+ if (!e || typeof e !== 'object') continue;
582
+ const o = e as Record<string, unknown>;
583
+ const id = String(o.id ?? '').trim();
584
+ const type = String(o.type ?? '');
585
+ if (!id || !TYPES.has(type)) continue;
586
+ const field: EditableField = { id, type: type as EditableField['type'] };
587
+ if (typeof o.value === 'number' || typeof o.value === 'string') field.value = o.value;
588
+ if (typeof o.label === 'string' && o.label.trim()) field.label = o.label.trim();
589
+ if (typeof o.min === 'number') field.min = o.min;
590
+ if (typeof o.max === 'number') field.max = o.max;
591
+ if (type === 'choice') {
592
+ const opts = Array.isArray(o.options)
593
+ ? (o.options as unknown[])
594
+ .filter((x): x is Record<string, unknown> => !!x && typeof x === 'object')
595
+ .map((x) => ({ id: String(x.id ?? '').trim(), label: String(x.label ?? '').trim() }))
596
+ .filter((x) => x.id && x.label)
597
+ : [];
598
+ if (!opts.length) continue; // a choice editable requires options — else the API rejects it
599
+ field.options = opts;
600
+ }
601
+ out.push(field);
602
+ if (out.length >= 20) break;
603
+ }
604
+ return out.length ? out : undefined;
605
+ }
606
+
607
+ type DetailField = { label: string; value: string; url?: string; tone?: 'default' | 'success' | 'warning' | 'danger'; copyable?: boolean };
608
+
609
+ /**
610
+ * The vessel's reference record. `null` clears it (passes through). A malformed
611
+ * shape collapses to undefined (omit) so it never 400s the whole push.
612
+ */
613
+ export function sanitizeDetails(raw: unknown): { fields: DetailField[] } | null | undefined {
614
+ if (raw === null) return null;
615
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
616
+ const fieldsSrc = Array.isArray((raw as Record<string, unknown>).fields) ? ((raw as Record<string, unknown>).fields as unknown[]) : [];
617
+ const fields: DetailField[] = [];
618
+ for (const f of fieldsSrc) {
619
+ if (!f || typeof f !== 'object') continue;
620
+ const o = f as Record<string, unknown>;
621
+ const label = String(o.label ?? '').trim();
622
+ const value = String(o.value ?? '').trim();
623
+ if (!label || !value) continue;
624
+ const field: DetailField = { label, value };
625
+ if (typeof o.url === 'string' && /^https?:\/\/\S+$/.test(o.url.trim())) field.url = o.url.trim();
626
+ if (o.tone === 'success' || o.tone === 'warning' || o.tone === 'danger' || o.tone === 'default') field.tone = o.tone;
627
+ if (o.copyable === true) field.copyable = true;
628
+ fields.push(field);
629
+ if (fields.length >= 20) break;
630
+ }
631
+ return fields.length ? { fields } : undefined;
490
632
  }
491
633
 
492
634
  /** Only pass a previewUrl the API will accept (http/https), else undefined. */