vessels-sdk
v0.28.0
Published
Let your agent reach you. Official Vessels SDK.
Downloads
3,686
Maintainers
Readme
vessels-sdk
Node.js SDK for Vessels — let your agent reach you.
Vessels is the communication layer between AI agents and their human operators. Your agent pushes structured messages to a vessel; the human responds via the web or mobile app; your agent receives the answer via polling or webhooks.
Installation
npm install vessels-sdkQuick start
Get an API key (Claude Code / AI assistants — fully non-interactive):
npm install -g vessels
vessels init --email [email protected] # sends a 6-digit OTP
vessels init --email [email protected] --otp 847293 # prints VESSELS_API_KEY=vsl_xxxOr sign in at vessels.app → Settings → API Keys.
import { Vessels } from 'vessels-sdk';
const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY! });1. Send a message
The simplest case — a chat bubble in a vessel (created on first push):
await vessels.push({ vessel: 'booking-123', vesselTitle: 'Sarah — Saturday', message: 'New booking request.' });2. Do real work → the activity harness (the recommended pattern)
For anything beyond a one-liner, open a working card and drive it with a plan + steps. The human watches progress fill in live; the card seals when you finish. This is the SDK's headline feature.
import { Vessels, type AgentActivityType } from 'vessels-sdk';
// Open the card with a plan (todos default to `pending`)
const { messageId } = await vessels.push({
vessel: 'booking-123',
message: 'Checking availability…',
agentActivity: { todos: [
{ label: 'Look up the customer' },
{ label: 'Check the calendar' },
{ label: 'Draft a reply' },
]},
});
const act = vessels.activity(messageId); // a live handle for that card
await act.start('Look up the customer'); // mark a todo in-progress
await act.step('tool_use', 'Queried CRM'); // a step, filed under the active todo
await act.complete('Look up the customer'); // finish it
// …start/step/complete each remaining todo…
await act.done(); // seal the cardact.awaitInput() pauses the card on the human mid-plan (push an interaction, then resume with another start/step/complete). step()'s first arg is a typed AgentActivityType — 'thinking' | 'searching' | 'tool_use' | 'browsing' | 'processing' (or use the exported AgentActivityTypes constant for autocomplete).
3. Surfaces — artifacts vs chat bubbles
surface() is a full-width artifact (a draft, quote, report, diff) — a title, block-markdown body (headings/lists/tables/quotes), an optional glance-card, and an action bar. Use it when the human is reviewing something, not chatting.
await vessels.surface({
vessel: 'booking-123',
title: 'Draft reply to Sarah',
body: '## Hi Sarah\n\nSaturday 2 PM is open — shall I lock it in?\n\n| Slot | Status |\n|---|---|\n| Sat 2 PM | open |',
interaction: vessels.approval({ prompt: 'Send this reply?' }),
});4. Live token streaming
Stream model output into a card in real time (a monospace window that clears when sealed):
const { messageId } = await vessels.push({ vessel: 'booking-123', message: 'Drafting…' });
const stream = vessels.stream(messageId);
for await (const chunk of llm) stream.write(chunk); // throttled PATCHes under the hood
await stream.done('Here is the drafted reply.'); // flush + seal, set final content5. Ask a structured question
Five interaction types — approval, choice, checklist, textInput, and questions (a small multi-question form). Attach one to any push/surface:
interaction: vessels.questions({
prompt: 'A few details to finalise',
questions: [
{ id: 'date', header: 'Date', question: 'Which day?', options: [{ id: 'sat', label: 'Sat' }, { id: 'sun', label: 'Sun' }] },
],
})6. Manage vessels (no message posted)
await vessels.listVessels({ status: 'waiting' }); // inventory
await vessels.updateVessel('booking-123', { labels: ['urgent'], title: 'Renamed' });
await vessels.archiveVessel('booking-123'); // reversible
await vessels.deleteVessel('booking-123'); // permanent — messages cascade7. Clear messages — rewind a feed (no agent context cleared)
await vessels.clearMessages('booking-123', { afterMessageId: msgId }); // "rewind to here" (anchor kept)
await vessels.clearMessages('booking-123', { before: '2026-06-07T00:00:00Z' }); // trim old history
await vessels.clearMessages('booking-123', { afterMessageId: msgId, dryRun: true }); // → { deleted } count, no-op
await vessels.deleteMessage(msgId); // one messagePass one anchor — afterMessageId/after (delete strictly newer) or beforeMessageId/before (older); the anchor row stays. source scopes it: 'agent' (default, = agent+system), 'user', or 'all'. Hard delete — interaction responses cascade. This trims the human-visible feed only; your agent's context lives in your own system.
Then receive the human's responses via webhook (parseWebhookEvent) or poll() — see below.
Vessels.isConfigured()is a static guard (truewhenVESSELS_API_KEYis set) so you can no-op cleanly when not wired up. And don't setvesselStatus— it's deprecated; waiting/active is derived from whether a message carries a live interaction.
API reference
new Vessels(config)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiKey | string | required | Your vsl_ prefixed API key |
| baseUrl | string | https://vessels.app | Override for local dev or self-hosted |
vessels.push(payload)
Push a message to a vessel. Creates the vessel if it doesn't exist yet.
Returns { ok: true, messageId: string, vesselId: string, createdAt: string }.
Full payload:
| Field | Type | Description |
|-------|------|-------------|
| message | string | Required. The message text. |
| vessel | string | Your external ID for this vessel (e.g. booking-123). Creates the vessel on first use. |
| vesselTitle | string | Human-readable name shown in the vessel list. Set on creation; updates on subsequent pushes. |
| vesselStatus | 'active' \| 'waiting' \| 'resolved' | Status badge shown in the vessel list. waiting = amber, resolved = green. |
| labels | string[] | Tags for filtering in the dashboard. Max 10, 50 chars each. Replaces the existing set on every push — send all labels you want, not just new ones. |
| metadata | object | Arbitrary JSON stored on the vessel, passed through in webhook callbacks. |
| card | Card | Structured key-value info attached to this message. { title: string, fields: [{ label, value }] } |
| interaction | Interaction | Interactive prompt for the human (one of 4 types — see helpers below). Max one per message; immutable after the human responds. |
| details | Details \| null | The vessel's persistent reference/identity record — CRM-style facts about who/what the vessel is about (client name, phone, email, links into your own admin). Shown in the open-vessel top bar; values can be copyable. NOT status (status lives in labels). Replaces wholesale on every write. { fields: [{ label, value, url?, tone?, copyable? }] }. Pass null to clear. |
| attachments | Attachment[] | Images or files to show in the message. Max 10. You host the files; Vessels renders them. [{ type: 'image' \| 'file', url: string, filename?: string }] |
| suggestions | string[] | Quick-reply chips shown below the message. Max 5. Tapping fills the text input. Disappear after the user sends any message. |
| previewUrl | string | A single URL rendered as a tappable link card below the message. Presentation only — no response. Compose with any interaction. |
| notify | boolean | Whether to send a push notification. Default true. |
await vessels.push({
vessel: 'booking-123',
vesselTitle: 'Sarah Martinez',
message: 'Ready for review.',
vesselStatus: 'waiting',
labels: ['golf', 'vip'],
metadata: { bookingRef: 'BK-001' },
card: {
title: 'Booking',
fields: [{ label: 'Date', value: 'Saturday 14 June' }],
},
details: {
fields: [
{ label: 'Phone', value: '+61 400 000 000', copyable: true },
{ label: 'Admin', value: 'Open in CRM', url: 'https://crm.example.com/clients/123' },
],
},
attachments: [
{ type: 'image', url: 'https://example.com/photo.jpg' },
{ type: 'file', url: 'https://example.com/report.pdf', filename: 'Report.pdf' },
],
suggestions: ['Looks good', 'Need changes'],
interaction: vessels.approval({ prompt: 'Approve?' }),
});vessels.pushMany(payload)
Push the same message to multiple vessels at once. Max 100 vessels per call. Each vessel gets its own independent copy — interactions are responded to individually.
Returns { ok: true, results: Array<{ vessel, messageId, vesselId, error? }> }.
const { results } = await vessels.pushMany({
vessels: ['booking-101', 'booking-102', 'booking-103'],
message: 'The Windmill Course is closed this Saturday due to maintenance.',
interaction: vessels.approval({
prompt: 'Send cancellation email to this client?',
approveLabel: 'Send',
rejectLabel: 'Skip',
}),
vesselStatus: 'waiting',
});
for (const r of results) {
if (r.error) console.error(`Failed for ${r.vessel}:`, r.error);
else console.log(`Pushed to ${r.vessel} — message ${r.messageId}`);
}pushMany accepts the same fields as push, except vessel and vesselTitle are replaced by vessels (an array of external IDs). Vessel titles are not settable via pushMany — use per-vessel push calls for that.
vessels.validatePush(payload) / vessels.validatePushMany(payload)
Check that a payload complies with the required syntax without sending anything. Useful for confirming an agent's message is well-formed in a test or dry run before it reaches a human. This runs the same schema the server enforces, so a payload that passes here is exactly one the API will accept.
Returns { valid: boolean, errors: string[], details? } — errors is a list of readable field.path: message lines; details is Zod's flatten() output ({ formErrors, fieldErrors }).
const check = vessels.validatePush({
vessel: 'booking-123',
message: 'New booking',
interaction: { type: 'approval', prompt: 'Confirm?' },
});
if (!check.valid) {
console.error(check.errors);
// e.g. ['suggestions: Array must contain at most 5 element(s)']
}You don't have to call this explicitly: push() and pushMany() run the same check internally and throw a VesselsValidationError before the network request, so a malformed payload fails fast without burning an API call. Call validatePush directly when you want a result object instead of an exception.
vessels.editMessage(messageId, patch)
Edit an existing agent-sent message in place. The message re-renders via Supabase Realtime without reloading.
Updatable fields: content, card, attachments, suggestions. Interactions are immutable — create a new message to re-ask.
Only agent-sourced messages can be edited. Returns { ok: true }.
const { messageId } = await vessels.push({
vessel: 'batch-job-1',
message: 'Processing bookings... 0 of 50 complete',
card: { title: 'Progress', fields: [{ label: 'Progress', value: '0 / 50' }] },
});
// Update as work proceeds:
await vessels.editMessage(messageId, {
content: 'Processing bookings... 24 of 50 complete',
card: { title: 'Progress', fields: [{ label: 'Progress', value: '24 / 50' }] },
});
// Final update:
await vessels.editMessage(messageId, {
content: 'All 50 bookings processed.',
card: {
title: 'Batch Complete',
fields: [{ label: 'Processed', value: '50' }, { label: 'Errors', value: '0' }],
},
});vessels.clearMessages(externalId, opts) / vessels.deleteMessage(messageId)
Bulk-trim a vessel's feed back to a point — the durable primitive behind an agent's /rewind. A vessel is the human's live, editable view, so trimming it is a legitimate edit; it does not clear your agent's context (that lives in your own system).
Pass exactly one anchor; the anchor row is always kept (strict compare):
| Anchor | Effect |
|--------|--------|
| afterMessageId / after (ISO) | delete everything strictly newer — "rewind to here" |
| beforeMessageId / before (ISO) | delete everything strictly older — trim history |
source scopes what may go: 'agent' (default, agent + system), 'user' (only the human's), 'all'. dryRun: true returns { deleted } as the would-delete count and deletes nothing. Hard delete — interaction responses cascade; irreversible. Returns { deleted, dryRun }.
// Rewind: drop everything after a known message
const { deleted } = await vessels.clearMessages('booking-123', { afterMessageId: msgId });
// Preview a time-based trim without deleting
await vessels.clearMessages('booking-123', { before: '2026-06-07T00:00:00Z', dryRun: true });
// Delete a single message
await vessels.deleteMessage(msgId);Interaction helpers
All five helpers return a typed interaction object to pass as payload.interaction. There is a fixed set of five types — no custom types.
vessels.approval(opts)
A yes/no decision. Optionally require a reason on rejection.
vessels.approval({
prompt: 'Send the invoice?',
approveLabel: 'Send', // default: 'Approve'
rejectLabel: 'Cancel', // default: 'Reject'
reasonRequired: false, // if true, rejection requires a reason
metadata: { invoiceId: '42' }, // returned in poll event and webhook
})Response shape: { action: 'approved' | 'rejected', reason?: string }
vessels.choice(opts)
Pick one option from a list. Optionally allow a free-text custom value.
vessels.choice({
prompt: 'Which time slot works?',
options: [
{ id: '9am', label: '9:00 AM' },
{ id: '2pm', label: '2:00 PM' },
{ id: '5pm', label: '5:00 PM' },
],
allowCustom: true,
customPlaceholder: 'Type a different time...',
})Response shape: { selected: string, customValue?: string | null }
vessels.checklist(opts)
Pick one or more items from a list.
vessels.checklist({
prompt: 'Which documents are needed?',
options: [
{ id: 'id', label: 'Photo ID', checked: true },
{ id: 'proof', label: 'Proof of address' },
{ id: 'contract', label: 'Signed contract' },
],
minSelections: 1,
submitLabel: 'Confirm selection',
})Response shape: { selected: string[] } — array of selected option IDs.
vessels.textInput(opts)
Free-form text from the human.
vessels.textInput({
prompt: 'Why is the booking being cancelled?',
placeholder: 'Enter reason...',
multiline: true,
submitLabel: 'Submit',
})Response shape: { text: string }
Review-and-decide (the old confirmPreview)
There is no separate preview interaction. To have the human review something then decide,
attach a previewUrl (a link card) to the message and use approval:
await vessels.push({
vessel: 'draft-123',
message: 'Draft email ready for review.',
previewUrl: 'https://your-app.com/drafts/123',
interaction: vessels.approval({ prompt: 'Send this email?', approveLabel: 'Send', rejectLabel: 'Edit' }),
});Response shape: { action: 'approved' | 'rejected', reason?: string }
vessels.poll(options?)
Fetch pending events. Uses a server-side cursor per API key — each call returns only new events since the last acknowledged poll.
Returns { ok: true, events: PollEvent[], hasMore: boolean }.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ack | boolean | true | Advance the cursor so these events aren't returned again |
| limit | number | 50 | Max events per call |
| since | string | — | ISO timestamp — overrides the cursor for this call only |
Poll events are normalised to camelCase by the SDK.
const { events, hasMore } = await vessels.poll({ ack: true });
for (const event of events) {
if (event.type === 'interaction.response') {
// event.interactionType — 'approval' | 'choice' | 'checklist' | 'text_input'
// event.response — response shape depends on interactionType (see above)
// event.interactionMetadata — metadata object you passed when creating the interaction, or null
// event.messageId — UUID of the message containing the interaction
// event.vessel.externalId — your original vessel string ('booking-123')
// event.vessel.id — vessel UUID
// event.vessel.labels — string[] of tags on this vessel
// event.user — { id, email } of the responding user, or null
if (event.interactionType === 'approval' && event.response.action === 'approved') {
await confirmBooking(event.vessel.externalId);
}
}
if (event.type === 'message.user') {
// event.message.content — what the human typed
// event.vessel.externalId — your original vessel string
const reply = await generateReply(event.message.content);
await vessels.push({ vessel: event.vessel.externalId, message: reply });
}
}
// For high volume, page through all pending events
if (hasMore) {
// Call poll() again immediately
}Polling is a complete alternative to webhooks. Use it to get started, or when you can't receive inbound HTTP.
vessels.verifyWebhook(body, signature, webhookSecret)
Verify the X-Vessels-Signature header on incoming webhook requests. Uses constant-time HMAC comparison.
body— raw request body as a string, beforeJSON.parsesignature— theX-Vessels-Signatureheader value (format:sha256=<hex>)webhookSecret— the per-endpoint secret shown when you created the webhook in Settings → Webhooks. Not the same as your API key.
Returns true if the signature is valid, false otherwise.
import express from 'express';
import { Vessels } from 'vessels-sdk';
const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY! });
const app = express();
app.post(
'/webhooks/vessels',
express.raw({ type: 'application/json' }),
async (req, res) => {
const body = req.body.toString('utf8');
const signature = req.headers['x-vessels-signature'] as string;
const valid = vessels.verifyWebhook(body, signature, process.env.VESSELS_WEBHOOK_SECRET!);
if (!valid) return res.status(401).json({ error: 'Invalid signature' });
const payload = JSON.parse(body);
// payload.event — 'interaction.response' | 'message.user'
// Webhook bodies use snake_case (vessel_id, external_id, interaction_type)
if (payload.event === 'interaction.response') {
const { interaction_type, response, vessel, metadata } = payload.data;
// vessel.external_id — your original vessel string
if (interaction_type === 'approval' && response.action === 'approved') {
await confirmBooking(vessel.external_id);
}
}
if (payload.event === 'message.user') {
const { content, vessel, context } = payload.data;
// context — last 10 messages in the vessel, oldest first (convenience, not canonical state)
const reply = await generateReply(content, context);
await vessels.push({ vessel: vessel.external_id, message: reply });
}
res.json({ ok: true });
},
);Webhook payload shapes (snake_case — raw HTTP/JSON layer):
// interaction.response
{
"event": "interaction.response",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"message_id": "uuid",
"interaction_type": "approval",
"response": { "action": "approved" },
"response_id": "uuid",
"metadata": { "invoiceId": "42" },
"vessel": { "id": "uuid", "external_id": "booking-123", "title": "Sarah Martinez", "metadata": {} }
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
// message.user
{
"event": "message.user",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"message_id": "uuid",
"content": "I want to reschedule",
"vessel": { "id": "uuid", "external_id": "booking-123", "title": "Sarah Martinez", "metadata": {} },
"context": [{ "source": "agent", "content": "...", "created_at": "..." }]
},
"timestamp": "2024-01-01T00:00:00.000Z"
}Retries: 3× on failure with backoff (1s, 10s, 60s). All deliveries logged in Settings → Logs.
Error handling
Three typed error classes are exported alongside Vessels:
| Class | HTTP status | Description |
|-------|-------------|-------------|
| VesselsAuthError | 401 | Invalid or revoked API key |
| VesselsValidationError | 400 | Bad request payload. Also thrown locally, before the request by push()/pushMany() when the payload fails client-side validation. Check .details for field-level errors. |
| VesselsRateLimitError | 429 | Rate limit exceeded. Check .retryAfter (seconds) before retrying. |
import { Vessels, VesselsAuthError, VesselsRateLimitError, VesselsValidationError } from 'vessels-sdk';
try {
await vessels.push({ vessel: 'booking-123', message: 'hello' });
} catch (err) {
if (err instanceof VesselsAuthError) {
console.error('Check your API key — it may be revoked');
} else if (err instanceof VesselsRateLimitError) {
console.error(`Rate limited — retry after ${err.retryAfter}s`);
} else if (err instanceof VesselsValidationError) {
console.error('Bad payload:', err.details);
} else {
throw err;
}
}Naming conventions
The SDK and push payload use camelCase (JavaScript convention): vesselTitle, vesselStatus, details, vesselId, messageId.
Webhook POST bodies use snake_case (raw HTTP/JSON layer): vessel_id, external_id, interaction_type.
Poll events are normalised to camelCase by the SDK: event.vessel.externalId, event.interactionType, event.messageId.
Rule of thumb: anything you write (push calls, SDK methods) is camelCase. Anything you read from a raw webhook POST body is snake_case.
TypeScript types
All types used by the SDK are exported directly from vessels-sdk:
import type {
PushPayload,
PushManyPayload,
MessagePatch,
Attachment,
Interaction,
Card,
ApprovalInteraction,
ChoiceInteraction,
ChecklistInteraction,
TextInputInteraction,
} from 'vessels-sdk';Links
- Dashboard: https://vessels.app
- Full integration reference: https://vessels.app/docs
- CLI:
npm install -g vessels - npm: https://www.npmjs.com/package/vessels-sdk
