Vessels — Full Integration Reference
Let your agent reach you.
Vessels is the communication layer between AI agents and their human operators. Production URL: https://vessels-two.vercel.app
Setup
Get an API key
npm install -g vessels
# Step 1: send OTP to your email
vessels login --email me@example.com
# Step 2: verify with the code from the email
vessels login --email me@example.com --otp 847293
# Create an API key — printed once, output includes VESSELS_API_KEY=vsl_xxx
vessels keys create --name my-project
Or sign in at https://vessels-two.vercel.app/auth → Settings → API Keys.
Install the SDK
npm install vessels-sdk
Initialise
import { Vessels } from 'vessels-sdk';
const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY });
// Debug mode — logs every request and response to console
const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY, debug: true });
Naming conventions
The SDK and push payload use camelCase (JavaScript convention): vesselTitle, vesselStatus, pinCard, previewUrl, externalId.
Webhook payloads use snake_case (JSON/HTTP convention): 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, SDK calls) is camelCase. Anything you read from a raw webhook POST body is snake_case.
Send a message
await vessels.push({
vessel: 'booking-123', // string ID — creates vessel if new
vesselTitle: 'Sarah Martinez',
message: 'New booking received.', // required
});
Interactions (5 types)
Attach one interaction per message. Human responds, your agent receives the answer.
approval
await vessels.push({
vessel: 'booking-123',
message: 'Sarah wants to book Saturday 10am.',
interaction: vessels.approval({
prompt: 'Confirm this booking?',
approveLabel: 'Confirm',
rejectLabel: 'Decline',
metadata: { out_tray_id: '123', type: 'deposit_invoice' }, // comes back in webhook/poll
}),
vesselStatus: 'waiting',
});
// Response: { action: 'approved' | 'rejected', reason?: string }
choice
Options must be { id, label } objects.
await vessels.push({
vessel: 'booking-123',
message: 'Which time should I offer?',
interaction: vessels.choice({
prompt: 'Choose a time slot',
options: [
{ id: 'sat-10am', label: '10am Saturday' },
{ id: 'sat-2pm', label: '2pm Saturday' },
],
allowCustom: true,
}),
});
// Response: { selected: 'sat-10am', customValue: string | null }
checklist
Options must be { id, label } objects.
await vessels.push({
vessel: 'invoice-42',
message: 'Which items to include?',
interaction: vessels.checklist({
prompt: 'Select line items',
options: [
{ id: 'session', label: 'Session fee $120', checked: true },
{ id: 'travel', label: 'Travel $30' },
],
}),
});
// Response: { selected: ['session', 'travel'] } // array of IDs
text_input
await vessels.push({
vessel: 'lead-88',
message: 'Prospect asked about pricing.',
interaction: vessels.textInput({
prompt: 'How should I respond?',
placeholder: 'Type your reply...',
multiline: true,
}),
});
// Response: { text: 'string' }
confirm_preview
await vessels.push({
vessel: 'lead-88',
message: 'Draft email ready.',
interaction: vessels.confirmPreview({
prompt: 'Send this email?',
previewUrl: 'https://your-app.com/preview/88',
approveLabel: 'Send',
rejectLabel: 'Edit',
}),
});
// Response: { action: 'approved' | 'rejected', reason?: string }
Vessel status
await vessels.push({
vessel: 'booking-123',
message: 'Booking confirmed.',
vesselStatus: 'resolved', // 'active' | 'waiting' | 'resolved'
});
- waiting — amber badge, agent needs human input
- active — no badge, normal state
- resolved — green badge, entity is done
Pinned card
Stays above the message stream, always visible. Replaces previous pinned card.
await vessels.push({
vessel: 'booking-123',
message: 'Status updated.',
pinCard: {
title: 'Booking Status',
fields: [
{ label: 'Client', value: 'Sarah Martinez' },
{ label: 'Status', value: 'Confirmed' },
],
},
});
Labels
Tag vessels for filtering in the dashboard. Set them when pushing or via PATCH.
await vessels.push({
vessel: 'booking-123',
message: 'New booking.',
labels: ['golf', 'saturday', 'vip'],
});
- Max 10 labels per vessel, 50 characters each.
- Labels replace the existing set on every push — send all labels you want, not just new ones.
- Visible as colour-coded badges in the vessel list. Click to filter by label.
- Available in poll events as
event.vessel.labels.
Get responses — Polling
const { events } = await vessels.poll({ ack: true });
for (const event of events) {
if (event.type === 'interaction.response') {
// event.interactionType — 'approval' | 'choice' | 'checklist' | 'text_input' | 'confirm_preview'
// event.response — see shapes above
// event.interactionMetadata — metadata you passed when pushing the interaction (or null)
// event.vessel.externalId — your original vessel string ('booking-123')
// event.vessel.id — vessel UUID
// event.vessel.labels — string[] of tags on this vessel
// event.messageId — UUID of the message with the interaction
if (event.interactionType === 'approval' && event.response.action === 'approved') {
await confirmBooking(event.vessel.externalId);
}
}
if (event.type === 'message.user') {
// Human sent a message in the vessel
// event.message.content
// event.vessel.externalId
}
}
Poll events are camelCase — the SDK normalises the raw API response for you.
Get responses — Webhooks
Register via CLI or Settings → Webhooks. You'll receive a webhook secret — store it as VESSELS_WEBHOOK_SECRET. Vessels POSTs signed JSON on each event.
vessels webhooks create --url https://myapp.com/hooks/vessels
# output includes: VESSELS_WEBHOOK_SECRET=<secret>
Note: webhook payloads use snake_case (external_id, interaction_type) — this is the raw HTTP/JSON layer, not the SDK. See the naming conventions section above.
Webhook payload shape
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": { "out_tray_id": "123" },
"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"
}
The context field contains the last 10 messages in the vessel (oldest first) as a convenience — so your agent has recent history without a separate API call. It is not configurable. This is not the agent's canonical state — treat it as a conversation shortcut, not a source of truth.
Handler
import { Vessels } from 'vessels-sdk';
import express from 'express';
const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY });
const app = express();
app.post('/vessels/webhook', express.raw({ type: '*/*' }), async (req, res) => {
const rawBody = req.body.toString();
const valid = await vessels.verifyWebhook(
rawBody,
req.headers['x-vessels-signature'],
process.env.VESSELS_WEBHOOK_SECRET // the secret from Settings → Webhooks
);
if (!valid) return res.status(401).send('Invalid signature');
const payload = JSON.parse(rawBody);
// payload.event — 'interaction.response' | 'message.user'
// payload.vessel_id — vessel UUID
// payload.timestamp — ISO timestamp
if (payload.event === 'interaction.response') {
const { interaction_type, response, vessel } = payload.data;
// vessel.external_id — your original vessel string ('booking-123') [snake_case in webhook body]
if (interaction_type === 'approval' && response.action === 'approved') {
await confirmBooking(vessel.external_id);
}
}
if (payload.event === 'message.user') {
const { content, vessel, context } = payload.data;
// content — what the human typed
// vessel.external_id — your original vessel string
// context — last 10 messages, convenience only
}
res.json({ ok: true });
});
Retries: 3× on failure (500ms, 2s backoff). All deliveries logged in Settings → Logs.
Update vessel without a message
For most updates (status, pinned card) you can use vessels.push() which handles everything inline. PATCH is for when you want to update state without adding a message to the feed.
By UUID (from a previous push response)
await fetch(`https://vessels-two.vercel.app/api/v1/vessels/${vesselUuid}`, {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ vesselStatus: 'resolved', pinCard: null }),
});
By external ID (your own string — no UUID needed)
await fetch('https://vessels-two.vercel.app/api/v1/vessels/by-external/booking-123', {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ vesselStatus: 'resolved', pinCard: null }),
});
Both accept: pinCard (null to clear), vesselStatus, title, metadata.
Attachments
The developer hosts files. Vessels renders URLs.
await vessels.push({
vessel: 'booking-123',
message: 'Receipt processed.',
attachments: [
{ type: 'image', url: 'https://your-bucket.s3.amazonaws.com/receipt.jpg' },
{ type: 'file', url: 'https://your-app.com/reports/q1.pdf', filename: 'Q1 Report.pdf' },
],
});
- Images render inline in the message. Tap to open full size.
- Files render as a labelled download link. Tap opens in browser.
- Max 10 attachments per message.
- No upload endpoint. No file storage. Vessels is just an
<img>or<a>tag pointing at your URL.
Suggested responses
Quick reply chips the human can tap to pre-fill their reply. Disappear after any message is sent.
await vessels.push({
vessel: 'booking-123',
message: 'Client asked about availability next week.',
suggestions: [
'We have slots on Tuesday and Thursday',
"Let me check and get back to you",
"We're fully booked next week",
],
});
- Tapping fills the text input. The human can edit before sending.
- Max 5 suggestions per message.
- Not a replacement for interaction cards — suggestions are for free-form replies.
Edit a message (live updates)
Update a message's content, card, attachments, or suggestions after it's been sent. The message re-renders in place via Supabase Realtime. Use this for progress bars, live status, or corrections.
const { messageId } = await vessels.push({
vessel: 'batch-job-1',
message: 'Processing bookings... 0 of 50 complete',
card: { title: 'Batch Progress', fields: [{ label: 'Progress', value: '0 / 50' }] },
});
// Later, as work proceeds:
await vessels.editMessage(messageId, {
content: 'Processing bookings... 24 of 50 complete',
card: { title: 'Batch Progress', fields: [{ label: 'Progress', value: '24 / 50' }] },
});
// When done:
await vessels.editMessage(messageId, {
content: 'All 50 bookings processed.',
card: { title: 'Batch Complete', fields: [{ label: 'Processed', value: '50' }, { label: 'Errors', value: '0' }] },
});
Rules:
- Only agent-sourced messages can be edited (not user or system messages).
- Interaction cards are immutable after creation and cannot be changed via edit.
- Updatable fields:
content,card,attachments,suggestions,agentActivity.
Raw API: PATCH /api/v1/messages/:message_id with Authorization: Bearer vsl_xxx.
Agent activity (working state + step history)
When your agent works through multiple steps before delivering a result, you can stream those steps into the vessel in real time. On mobile, this renders as an animated working card. When the agent resolves, the card collapses into a small tappable chip — "Agent worked for 23s" — and the human can tap to see every step the agent took.
This requires no extra infrastructure. You signal the current phase by setting agentActivity on a push or edit. The server accumulates the step history automatically.
Workflow
// 1. Push to open a working card — no message text needed yet
const { messageId } = await vessels.push({
vessel: 'booking-123',
agentActivity: { type: 'thinking' },
vesselStatus: 'waiting',
});
// 2. Switch phases as your agent progresses — server closes the previous step
await vessels.editMessage(messageId, {
agentActivity: { type: 'searching', label: 'Checking FlightAware...' },
});
await vessels.editMessage(messageId, {
agentActivity: { type: 'tool_use', label: 'Reading your calendar' },
});
// 3. Resolve — null collapses the working card into a chip, message content appears
await vessels.editMessage(messageId, {
agentActivity: null,
content: 'Found 3 flights under $400.',
interaction: vessels.choice({ prompt: 'Which works?', options: [...] }),
});
Activity types
Five fixed types — use the one that matches what your agent is doing:
| Type | When to use |
|---|---|
thinking |
LLM reasoning, planning, deciding |
searching |
Web search, vector lookup, database query |
tool_use |
Calling an API, running a function, reading a file |
browsing |
Reading a webpage, scraping content |
processing |
Data transform, calculation, batch operation |
The optional label field adds a plain-text description shown during that step: { type: 'tool_use', label: 'Querying your CRM' }. If omitted, the type name is shown.
What the human sees
While working: An animated card appears in the vessel — live BlobSpinner animation, current step label, elapsed timer ticking up. As you switch phases, the animation morphs to match the new activity type.
After resolving: The working card collapses into a subtle chip above the final message — "Agent worked for 23s" with step-type icons in sequence. Tapping it opens a timeline showing every step, its label, and how long it took.
The step history is stored permanently on the message — it doesn't disappear and is visible to anyone who opens the vessel later.
SDK constants
import { AgentActivityTypes } from 'vessels-sdk';
await vessels.editMessage(messageId, {
agentActivity: { type: AgentActivityTypes.toolUse, label: 'Calling Stripe' },
});
AgentActivityTypes exports: thinking, searching, toolUse (maps to 'tool_use'), browsing, processing.
Notes
agentActivityis optional onpush(). When set,messagemay be omitted — the push opens a working card with no text content yet.- Each
editMessagewith a newagentActivityobject closes the previous step (recordsended_atandduration_msserver-side) and starts a new one. agentActivity: nullseals the history withresolved_at. After this, any furtheragentActivityedits on the same message have no effect.- You can combine
agentActivity: nullwith any other edit fields (content,card,interaction, etc.) in the same call.
Broadcast push (pushMany)
Push the same message to multiple vessels at once. Each vessel gets its own independent copy — interactions are responded to individually.
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',
});
// results: [{ vessel: 'booking-101', messageId: '...', vesselId: '...' }, ...]
- Max 100 vessels per call.
- Each vessel is upserted as normal (created if it doesn't exist).
- Each interaction is independent — the human approves/rejects per vessel.
Raw API: POST /api/v1/push/many with Authorization: Bearer vsl_xxx.
Full push payload
await vessels.push({
// One of message or agentActivity is required
message: 'string',
agentActivity: { type: 'thinking' | 'searching' | 'tool_use' | 'browsing' | 'processing', label?: 'string' },
// Vessel
vessel: 'string', // external ID, creates if new
vesselTitle: 'string',
vesselStatus: 'active' | 'waiting' | 'resolved',
labels: ['string'], // up to 10 tags, 50 chars each — visible in web dashboard
metadata: { key: 'value' }, // passed through in webhooks
// Message content
card: { title: 'string', fields: [{ label: 'string', value: 'string' }] },
interaction: { ... }, // one of the 5 types
pinCard: { title: 'string', fields: [...] }, // null to clear
previewUrl: 'string',
attachments: [{ type: 'image' | 'file', url: 'string', filename?: 'string' }],
suggestions: ['string', ...], // up to 5 quick reply chips
});
// Returns: { ok: true, messageId: string, vesselId: string, createdAt: string }
Commands & Mentions
Define shorthand your team uses when messaging vessels. Configure them once in Settings → Commands — they apply to all vessels in your workspace.
Commands (/) are actions your agent understands. When a team member types / in the message input, an autocomplete dropdown shows all registered commands.
Mentions (@) are routing targets — departments, queues, or anything on your end that receives the forwarded message. Typing @ shows the registered mentions.
Both render with syntax highlighting in the message input and in message history — commands in blue, mentions in purple — so they feel intentional rather than free text.
Vessels sends the raw message string to your webhook unchanged. It is your agent's responsibility to parse /commands and route @mentions:
Human types: /accept @billing confirm this one
Webhook data.content: "/accept @billing confirm this one"
Register commands and mentions at Settings → Commands. Shape: name (e.g. /accept, @billing) plus an optional description shown in the autocomplete dropdown.
The web dashboard
Developers and their team can interact with vessels directly at https://vessels-two.vercel.app — no mobile app required. The web dashboard shows all vessels, their status badges, pinned cards, and full message history. Humans can reply to agent messages and respond to all 5 interaction types directly in the browser. The dashboard updates in real time via Supabase Realtime.
This means the web UI is a complete interaction surface on its own — the agent sends, the human responds in the dashboard, the agent polls or receives a webhook. Mobile (Phase B) adds push notifications but is not required for the core loop.
Environment variables
VESSELS_API_KEY=vsl_your_key_here
VESSELS_WEBHOOK_SECRET=your_webhook_secret # from Settings → Webhooks
CLI reference
All commands are non-interactive — safe to run from agents, Claude Code, or CI.
# Auth (two-step OTP — works non-interactively)
vessels login --email me@example.com # sends OTP
vessels login --email me@example.com --otp <code> # verifies, saves session
vessels logout
vessels whoami
# API keys
vessels keys list
vessels keys create [--name <name>] # prints key once, output includes VESSELS_API_KEY=vsl_xxx
vessels keys revoke <id>
# Webhooks
vessels webhooks list
vessels webhooks create --url https://myapp.com/hooks/vessels [--events interaction.response,message.user]
vessels webhooks delete <id>
vessels webhooks enable <id>
vessels webhooks disable <id>
# Push (agent message, for testing)
vessels push --vessel <id> --message <text> --key vsl_xxx
# Message (user message — fires webhooks, prints delivery log)
vessels message --vessel <id> --message <text>
# Accepts vessel UUID or external_id (e.g. booking-123).
# Sends a message as the logged-in user, then immediately prints each
# webhook delivery that fired: status code, endpoint URL, and response body.
# Useful for testing your webhook handler end-to-end from the terminal.
Example output
$ vessels message --vessel booking-123 --message "Rescheduled to 3pm"
Sent.
vessel_id 4f2a1c…
message_id d8b93e…
Webhook deliveries (1):
✓ 200 https://myapp.com/hooks/vessels
message.user · 2:34:07 PM
{"ok":true}
If the delivery fails you see the exact status code and response body your endpoint returned — no need to dig through server logs.