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.app
Getting started
What Vessels is — and is not
Vessels is a channel: the human's view of the conversation between your agent and its operator. Your agent pushes messages and interactions; the human reads and responds; you receive those responses. That is the whole job.
Vessels is not your agent's memory, storage, or context store. Your agent already has those — its own database, its own context window, its own state. Keep them there. A vessel is what the human sees happened, not what your agent knows.
Concretely, that means:
- You own your conversation history and state. Key it by your own
external_id(booking-123). Vessels never becomes the place you read your agent's history back from. - The
contextarray on webhooks is a convenience, not a source of truth. It's the last few messages so a stateless handler has something to work with on turn one — not a substitute for your own record. It is small and fixed by design; if you need full history, you have it on your side. - Read endpoints exist to re-read the channel, not to host your memory.
GET /vessels/:id/messageslets a crashed or stateless worker reconcile against the human-facing record. It is the human's record, exposed for convenience — not your state layer. - Apps cache messages for the human, not the agent. The iOS/web clients store recent messages locally so the person gets an instant view. That cache has nothing to do with agent context.
If a feature request starts with "can Vessels remember / store / track metadata verbatim if you want a copy to ride along on a message or vessel — but it never interprets it, and it is never your canonical store.
Setup
Get an API key
npm install -g vessels
# Step 1: send OTP to your email
vessels init --email me@example.com
# Step 2: verify — creates account + API key, prints copy-ready .env entries
vessels init --email me@example.com --otp 847293
# Output:
# VESSELS_API_KEY=vsl_xxx
# npm install vessels-sdk
# With a webhook (optional — run once your server URL is known):
vessels init --email me@example.com --otp 847293 --webhook-url https://myapp.com/hooks/vessels
# Also outputs: VESSELS_WEBHOOK_SECRET=whsec_xxx
vessels init is designed for Claude Code and AI assistants: fully non-interactive except for one 6-digit OTP.
Creates an account if you don't have one, creates an API key, and optionally creates a webhook endpoint.
Or sign in at https://vessels.app/auth → Settings → API Keys.
API key scopes
Every key carries scopes that gate it by HTTP method. The two scopes are read and push, and a key created the normal way holds both — so by default a key can do everything and you never have to think about this.
| Scope | Grants |
|---|---|
read |
GET requests — polling, reading message history, fetching a vessel/trace |
push |
mutating requests — POST/PATCH/DELETE: pushing messages, interactions, status changes, rewinds |
Scopes matter only when you deliberately narrow a key — e.g. mint a read-only key (read alone) for a dashboard or log shipper that should never be able to write. A request whose method needs a scope the key lacks is rejected with:
HTTP 403
{ "ok": false, "error": "insufficient_scope" }
Tighten the blast radius of a leaked credential by scoping each key to what it actually needs.
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/response to console
// const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY, debug: true });
Vessels.isConfigured() is a static guard — true when VESSELS_API_KEY is present in the environment — so a handler can no-op cleanly when Vessels isn't wired up: if (!Vessels.isConfigured()) return;.
Start from a working agent (vessels init --agent-template)
Don't want to wire the loop yourself? Scaffold a complete, Vessels-native Claude agent and just swap in your tools:
vessels init --agent-template ./my-agent
cd my-agent && npm install
cp .env.example .env # fill in VESSELS_API_KEY, VESSELS_WEBHOOK_SECRET, ANTHROPIC_API_KEY
npm run dev # webhook server on :3000
You edit two files — src/role.ts (who your agent is) and src/tools.ts (your backend tools); everything else is the pre-wired engine. Full walkthrough: The Vessels agent template.
Without the SDK (curl / Python / Go / any HTTP client)
Everything is plain HTTPS. No SDK required. The only convention: push payload is camelCase, webhook body is snake_case.
Send a message:
curl -X POST https://vessels.app/api/v1/push \
-H "Authorization: Bearer $VESSELS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"vessel":"booking-123","message":"New booking received."}'
With an approval interaction:
curl -X POST https://vessels.app/api/v1/push \
-H "Authorization: Bearer $VESSELS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"vessel": "booking-123",
"vesselTitle": "Sarah Martinez",
"message": "New booking — confirm?",
"interaction": {
"type": "approval",
"prompt": "Confirm this booking?",
"approveLabel": "Confirm",
"rejectLabel": "Decline"
}
}'
Poll for responses:
curl "https://vessels.app/api/v1/poll?ack=true" \
-H "Authorization: Bearer $VESSELS_API_KEY"
Webhook signature verification (Python example):
import hmac, hashlib
def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
# In your handler:
raw_body = request.get_data() # bytes, before JSON.parse
sig = request.headers.get("X-Vessels-Signature", "")
if not verify_webhook(raw_body, sig, os.environ["VESSELS_WEBHOOK_SECRET"]):
return 401
payload = json.loads(raw_body)
event_type = payload["event"] # "interaction.response", "message.user", "vessel.created", "vessel.archived", "vessel.deleted", or "message.cancelled"
Go:
import ("crypto/hmac"; "crypto/sha256"; "encoding/hex"; "fmt")
func verifyWebhook(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
Naming conventions
The SDK and push payload use camelCase (JavaScript convention): vesselTitle, vesselStatus, details, 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 messages & content
Send a message
await vessels.push({
vessel: 'booking-123', // string ID — creates vessel if new
vesselTitle: 'Sarah Martinez',
message: 'New booking received.', // required
});
Vessels auto-create. vessel is your own external_id string. Push to one Vessels has never seen and it is created on the spot — it never 404s. Push again with the same string and the message lands in the same vessel (matched on external_id within your workspace). You never pre-create vessels and never store UUIDs.
This is why a stable id works for cross-cutting alerts: push to vessel: 'system-alerts' (or 'monitoring', 'daily-digest') and the first push creates one durable vessel that every later alert appends to — no setup, no bookkeeping.
Bubbles and surfaces
You reach a human in one of two ways — you choose which:
A bubble (vessels.push) is conversational chat. Heads-ups, progress, "done"
confirmations, and quick questions you just want a plain reply to. The conversation
is the interface — the human types back and you get a message.user. No buttons.
await vessels.push({ vessel: 'booking-123', message: 'Offer sent to Priya — CRM set to Awaiting Confirmation.' });
await vessels.push({ vessel: 'booking-123', message: 'Want me to also ask her about catering?' }); // she just replies
A surface (vessels.surface) is a composed, full-width artifact the human
reviews and optionally acts on — an email draft, a quote, an invoice review, a
proposal, a report. It renders as one piece: a title, an optional quick-facts
card, a markdown body (the artifact itself — tables, lists, headings), and an
optional interaction (the action bar). The draft goes in body.
await vessels.surface({
vessel: 'inv-42',
title: 'GreenTurf Invoice #GTL-2025-042',
card: { fields: [
{ label: 'Amount Due', value: '$7,400 (usual: $6,200)' },
{ label: 'Overage', value: '+$1,200 (+19%)' },
] },
body: [
'Invoice #GTL-2025-042 is **$1,200 over** the standard quarterly rate.',
'',
'| Item | Amount |',
'| --- | ---: |',
'| Standard quote | $6,200 |',
'| Invoiced | $7,400 |',
'| Overage | +$1,200 |',
'',
'**Recommendation:** hold and request an itemised breakdown.',
].join('\n'),
interaction: vessels.approval({ prompt: 'Approve the $7,400 payment?', approveLabel: 'Approve & Pay' }),
});
A surface with an interaction is a Review Card (action bar at the foot; decisions are optimistic — the tap registers instantly and settles into a compact "✓ Approved" chip). A surface without one is a read-only document.
You rarely set kind by hand: an interaction or a card on a plain
vessels.push auto-promotes it to a surface, so existing code keeps working.
vessels.surface is just the explicit, self-documenting way to build one (and gives
you the title + body slots).
Markdown
In a bubble — only inline markdown: **bold**, *italic*, ***bold italic***,
and inline code. Underscores are left alone so snake_case renders verbatim.
A stray table or list stays literal.
In a surface body — the inline set plus block elements:
- Links
[text](url)(tappable, http/https only). - Tables — standard pipe tables, great for invoice/quote line items.
- Bullet lists (
- item) and numbered lists (1. item). - Blockquotes (
> quoted text) — e.g. quoting a customer's email. - Headings (
## Section) — rendered bold (no larger type, for now). - Horizontal rules (
---on their own line).
No code fences, images, or raw HTML. Bullet lists use - only (never *) so they
never collide with *italic*.
Suggested responses
The suggestions field on a message — 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.
Preview link
Surface a single URL beautifully — a draft, a dashboard, a document to look at. Renders as a tappable link card below the message. It is presentation, not an interaction: there is no response. Compose it with any interaction when you want the human to look and then decide.
// Just show a link
await vessels.push({
vessel: 'lead-88',
message: 'Draft email ready.',
previewUrl: 'https://your-app.com/preview/88',
});
// Show a link AND ask for a decision (the old "confirm_preview" pattern)
await vessels.push({
vessel: 'lead-88',
message: 'Draft email ready for review.',
previewUrl: 'https://your-app.com/preview/88',
interaction: vessels.approval({ prompt: 'Send this email?', approveLabel: 'Send', rejectLabel: 'Edit' }),
});
- One
previewUrlper message. The human taps to open it in a new tab. - For files or images, use Attachments instead.
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.
Events
A bubble and a surface are both your agent talking. An event (vessels.event)
is your backend talking — a fact from the world the human should see: a booking
landed, a payment cleared, a monitor tripped. It renders as a full-width tinted
banner, a distinct lane that reads as not the agent — so the human can tell at a
glance "this happened" from "here's what the agent is doing about it".
It is the one Vessels write that is purely presentational:
- It fires no webhook and no poll event — your agent never hears about it through Vessels.
- You call it from the same place in your backend that triggers your agent, off
the same fact. The agent learns about the event through its own context (you put it
there), then works and replies in its own voice (
push/surface) as usual. - It still pushes a phone notification — an event is usually exactly what the human wants pinged about.
Contrast a user message, which does round-trip to your agent. An event never does.
// Backend: a monitor trips. Two things fire in parallel, off the same fact —
await Promise.all([
vessels.event({
vessel: 'monitoring',
title: 'Stock Alert — ACME −10.4%',
tone: 'alert', // 'info' | 'alert' | 'success'
card: { fields: [
{ label: 'Ticker', value: 'ACME' },
{ label: 'Drop', value: '−10.4%', tone: 'danger' },
{ label: 'Position', value: '$42,000' },
] },
body: 'Stop-loss threshold (−8%) crossed at 09:31.',
sections: [
{ heading: 'Recent trades', body: '| Time | Px |\n| --- | ---: |\n| 09:31 | 88.10 |', collapsed: true },
],
buttons: [
{ label: 'Open dashboard', url: 'https://app.example.com/acme', tone: 'primary' },
{ label: 'Mute alerts', url: 'https://app.example.com/acme/mute' },
],
}),
runAgent({ trigger: 'stock-alert', ticker: 'ACME' }), // your own agent, your own infra
]);
title(required) — the banner heading and the vessel-list preview line.tone— accent colour:info(neutral),alert(attention),success(good news). Defaults toinfo.body— block markdown, same rich set as a surface body.card— glance-facts, the same shape (and per-fieldurl/tone) as elsewhere.buttons— tappable deep links into your own UI. They open a URL; they are not interactions and never round back to your agent.sections— collapsible{ heading, body }blocks (collapsed: truestarts folded) for tucking detail under the banner.
On the read side (getMessages, the webhook context array) an event message has
source: 'event'.
Ask for decisions
Interactions (5 types)
Attach one interaction per message. Human responds, your agent receives the answer.
approval
A yes/no decision. Approve is one tap. Reject opens an inline reason field — the human types why, sent back as one payload (reason). The reason is optional unless you set reasonRequired: true. Set rejectable: false for a one-way consent/proceed gate — a single full-width Approve button, no reject path (give it a custom approveLabel like 'Proceed').
await vessels.push({
vessel: 'booking-123',
message: 'Sarah wants to book Saturday 10am.',
interaction: vessels.approval({
prompt: 'Confirm this booking?',
approveLabel: 'Confirm',
rejectLabel: 'Decline',
reasonRequired: true, // optional — force a reason on reject (default: optional)
// rejectable: false, // optional — approve-only consent gate (no reject button)
metadata: { out_tray_id: '123', type: 'deposit_invoice' }, // comes back in webhook/poll
}),
});
// Response: { action: 'approved' | 'rejected', reason?: string }
// reason is present only on a rejection where the human added one.
Edit-then-approve. Sometimes the human doesn't want to reject and re-ask — they just want to change one value: "make it 1700, not 1500" — and approve. Declare editables on the approval and bind each to a card field via that field's editableId. "Edit & approve" flips the bound fields to inputs; approving sends only the values that changed in edits, keyed by editable id. You make the values editable — the body markdown stays read-only.
await vessels.surface({
vessel: 'quote-88',
title: 'Quote for the Saturday booking',
card: {
fields: [
{ label: 'Total', value: '$1,500', editableId: 'total' },
{ label: 'Deposit', value: '50%', editableId: 'deposit' },
{ label: 'Date', value: '2026-06-20', editableId: 'date' },
],
},
body: 'Full-day hire, 8 hours on-site, two operators.',
interaction: vessels.approval({
prompt: 'Send this quote?',
approveLabel: 'Send quote',
editables: [
{ id: 'total', type: 'currency', value: 1500 },
{ id: 'deposit', type: 'choice', value: '50', options: [
{ id: '50', label: '50%' }, { id: '25', label: '25%' }, { id: '0', label: 'None' },
] },
{ id: 'date', type: 'date', value: '2026-06-20' },
],
}),
});
// Response: { action: 'approved', edits?: { total: 1700 } }
// edits holds only the values the human changed; nothing changed ⇒ a plain approve.
// Vessels stays the human's view: you receive edits and apply them in YOUR system
// (then may editMessage the surface to reflect the final). Types: text | number |
// currency | date | choice. Each value is validated against its declared editable.
Inline editable values. Instead of (or alongside) a card field, reference an editable straight inside the body markdown with {{id}} — it renders as the value in read mode and becomes an input on "Edit & approve", even inside a table cell. Same editables/edits contract; no editableId needed when a token carries the value.
await vessels.surface({
vessel: 'quote-88',
title: 'Quote',
body: [
'| Item | Amount |',
'| --- | ---: |',
'| Full-day hire | {{base}} |',
'| Deposit ({{deposit}}) | due now |',
'',
'Total **{{total}}**, valid until {{date}}.',
].join('\n'),
interaction: vessels.approval({
prompt: 'Send this quote?',
approveLabel: 'Send quote',
editables: [
{ id: 'base', type: 'currency', value: 1200 },
{ id: 'total', type: 'currency', value: 1500 },
{ id: 'deposit', type: 'choice', value: '50', options: [
{ id: '50', label: '50%' }, { id: '25', label: '25%' },
] },
{ id: 'date', type: 'date', value: '2026-06-20' },
],
}),
});
// Response: { action: 'approved', edits?: { total: 1700, base: 1300 } }
// Put currency symbols/words in the surrounding text ("Total **{{total}}**") — the
// token renders the raw value; you format around it.
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' }
questions
Ask several questions at once — a short form the human fills in and submits together (like "a few details to finalise this"). Each question is a single- or multi-select over 2–4 options, with an optional free-text Other. Use it when a step needs a few answers at once instead of a back-and-forth.
await vessels.push({
vessel: 'booking-123',
title: 'A few details to finalise the booking',
interaction: vessels.questions({
prompt: 'A few details to finalise the booking',
questions: [
{
id: 'date',
question: 'Which date works?',
header: 'Date',
options: [
{ id: 'sat', label: 'Saturday 12th' },
{ id: 'sun', label: 'Sunday 13th' },
],
},
{
id: 'extras',
question: 'Any add-ons?',
header: 'Add-ons',
multiSelect: true,
allowOther: true, // a free-text "Other" (default true)
options: [
{ id: 'cake', label: 'Cake', description: 'Serves 20' },
{ id: 'av', label: 'AV kit' },
],
},
],
submitLabel: 'Submit',
}),
});
// Response: { answers: [{ questionId, selected: string[], other?: string }] }
- 1–4 questions per form; each has 2–4 options.
multiSelectrenders checkboxes (default is single-select radios).allowOther(defaulttrue) adds a free-text field; the typed value comes back in that answer'sother.selectedis the array of chosen option ids for that question (empty if onlyotherwas used).
An interaction isn't a guaranteed response
An interaction stays answerable until the conversation moves past it. Once you send a newer interaction, or the human types a message instead of tapping, the earlier one expires — it greys out in the app and can no longer be answered. This stops the human from scrolling back and approving something the conversation already left behind.
What this means for your agent:
- Don't block waiting for a specific
interaction.response. A human who replies in words instead of tapping sends you amessage.user, not aninteraction.response— and the interaction quietly expires. Treat that message as the answer. - Typing is never an implicit reject. An expired interaction has simply gone unanswered; nothing is recorded. If you still need the decision, push it again.
- Newer asks win. If you re-ask while an earlier interaction is still open, only the latest is live. Send the full question each time rather than relying on an old card still being tappable.
This is pure message-ordering — Vessels never guesses your intent. A plain status update (a message with no interaction) does not expire a pending decision; only a later user message or a later interaction does.
Want the human to review something and decide? That's an
approvalinteraction with a preview link attached — not a separate type. See below.
Show live progress
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 web and 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.
You can go further and give the work a plan — a todo list that ticks off as you go. Steps you emit are filed under whichever task is active, so the plan and the work it produced resolve into one artifact: a checklist where every ticked item carries the steps it took. See Task plans below.
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' },
});
// 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(the card greys out / "done"). After a message is resolved, furtheragentActivityedits on it have no effect. (A paused card — see below — is not sealed and can be resumed.)- You can combine
agentActivity: nullwith any other edit fields (content,card,interaction, etc.) in the same call. - The human can stop a working message. While a message is in the working state, the app shows a Stop button. Tapping it sends you a
message.cancelledwebhook (see Webhooks) — wire that up to abort the task and resolve the activity (agentActivity: null). If you don't handle it, the human is told your agent isn't set up to cancel yet.
Pause for mid-plan input
A multi-step plan often needs the human part-way through — approve a draft before sending, pick a date before booking. Instead of sealing the plan (which greys it out and fragments the flow into a new card each turn), pause it: send agentActivity: { status: 'awaiting_input' } and push your interaction as usual. The working card stays live and on-screen — pending todos intact — but shows it's waiting on the human, not actively working.
// …worked the first steps, now you need a decision before continuing:
await vessels.editMessage(activityId, { agentActivity: { status: 'awaiting_input' } }); // pause, don't seal
await vessels.push({
vessel: 'booking-123',
interaction: vessels.approval({ prompt: 'Send this confirmation to Sarah?' }),
});
// → handle the interaction.response webhook, then RESUME the same card:
await vessels.editMessage(activityId, { agentActivity: { type: 'processing', label: 'Sending confirmation' } });
// …finish the remaining todos, then seal:
await vessels.editMessage(activityId, { agentActivity: null });
- A paused card is not sealed — any later
agentActivityedit (a step or atodosupdate) flips it back toworkingautomatically; you don't need to sendstatus: 'working'explicitly (though you can). - The SDK activity handle exposes this as
act.awaitInput()(pause) alongsideact.done()(seal). - The interaction rides a separate message, so it's answerable while the plan stays paused — the vessel still shows the amber Waiting badge from that interaction.
- Resume by re-attaching to the same message id (
activityId) — that's what keeps it one continuous card instead of spawning a new working card per turn. Persist that id (and your plan) in your own store between the webhook turns; Vessels doesn't track it for you.
Semantic step icons
Each step's label is scanned for keywords and given a matching icon automatically — an envelope for "Sending confirmation email", a calendar for "Checking availability", a dollar sign for "Charging the card". You don't register tools or pick icons; just write a clear label and the right glyph appears (falling back to the activity type, then a generic bolt). Name steps for what they do and the timeline reads itself.
Task plans (todos)
Give a working message a plan and watch it tick off. Declare a list of tasks; mark one active; emit steps; the server files those steps under the active task. While working, the human sees the checklist with the live task spinning and its steps streaming in beneath it; pending tasks wait greyed; finished tasks collapse with a check and their duration. When you resolve, the whole thing becomes one clean artifact: a checklist where each ticked task expands to the steps it took.
This is the same agentActivity channel — todos live inside it, no new endpoint. The activity() handle wraps it so you declare state and Vessels reconciles:
// 1. Open a working message and grab its id
const { messageId } = await vessels.push({
vessel: 'booking-123',
agentActivity: { type: 'thinking' },
});
// 2. Narrate the plan
const act = vessels.activity(messageId);
await act.plan(['Check availability', 'Draft confirmation email', 'Send to customer']);
await act.start('Check availability'); // → in_progress; becomes the step target
await act.step('searching', 'Querying the calendar'); // filed under "Check availability"
await act.step('processing', 'Found 3 open slots');
await act.start('Draft confirmation email'); // auto-finishes the previous task
await act.step('tool_use', 'Sending via SendGrid');
await act.complete(); // finish the current task
await act.done(); // seal — collapses to the final checklist
// 3. Deliver the result (any time after start())
await vessels.editMessage(messageId, { content: 'Booking confirmed and emailed.' });
The activity(messageId) handle
| Method | Effect |
|---|---|
plan(tasks) |
Declare/replace the plan. tasks is an array of strings, or { label, status? } objects. Tasks default to pending. |
start(label) |
Mark that task in_progress (creating it if new); any other in-progress task is finished. New steps file under it. |
step(type, label?) |
Append a step under the active task. Same five types as above. |
complete(label?) |
Finish a task by label, or the current in-progress task if omitted. |
done() |
Seal the activity — finishes the open step and any in-progress task. |
Prefer the raw channel? Send agentActivity: { todos: [{ label, status }] } on any push/editMessage. The list is authoritative and reconciled by label — send the full list each update (TodoWrite-style), and Vessels keeps each task's id and timing across updates. An agent that never sends todos is unchanged: it just renders the flat step timeline.
Live token streaming
When your agent is generating text — an LLM completion, a draft, a chunk of code — you can stream it straight into the vessel as it arrives. The human watches a small monospace "terminal" block fill in real time below the message, then it vanishes the moment you seal it. It pairs naturally with a working card: open the card so the agent reads as alive, stream the raw tokens for texture, then clear the stream and drop in the final message.
This rides a single ephemeral field, tokenStream (plaintext, like agentActivity). It is a live window, not a transcript — send the tail you want shown; only the last 8000 characters are kept.
// Open a working message (no body yet) and start streaming into it
const { messageId } = await vessels.push({
vessel: 'booking-123',
agentActivity: { type: 'thinking' },
});
const out = vessels.stream(messageId);
for await (const token of llm.stream(prompt)) {
out.write(token); // accumulates locally, PATCHes on a ~120ms throttle
}
await out.done('Booked you in for 2pm Thursday — confirmation sent.');
// done() flushes, clears the stream block (it vanishes), and sets the final message text
The stream(messageId) handle
| Method | Effect |
|---|---|
write(text) |
Append to the live buffer; flushed to the server on a throttle (default 120ms). |
set(text) |
Replace the whole buffer and flush immediately. |
done(finalContent?) |
Finish the turn: flush, clear the block, and seal any working card (no-op if none) — optionally setting the final content. |
clear() |
Remove only the block; leave a working card running (you're still working). |
vessels.stream(messageId, { throttleMs, maxChars }) tunes the flush cadence and window size.
- Replace-semantics, self-healing. Each flush sends the current tail window, so a dropped PATCH is corrected by the next one — no gaps, no ordering bugs.
- Prefer the raw channel? Set
tokenStream(a string) on anypush/editMessageto update the window, andtokenStream: null(PATCH only) to clear it. That's exactly what the handle does under the hood. - It's texture, not a transcript. The streamed text is ephemeral and disappears when you seal it — put the real answer in the message
content(e.g. viadone(finalContent)), which is what persists in the channel history.
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).
- Updatable fields:
content,card,attachments,suggestions,agentActivity,tokenStream,kind,title. - Everything else is rejected with HTTP 400 — the
response carries
{ field, hint }and the SDK throwsVesselsFieldNotPatchableError. Most importantlyinteraction: interactions are immutable, so to re-ask,push()a new message with the interaction instead of editing this one. Same for vessel-level fields (details,vesselTitle,labels) — patch the vessel, not the message.
Raw API: PATCH /api/v1/messages/:message_id with Authorization: Bearer vsl_xxx.
Resolve an interaction without a human tap
Sometimes the decision an interaction is asking for gets made somewhere else — the same approval comes through your own admin UI, a parallel channel, or an automated rule. The interaction in Vessels would otherwise sit open forever, its buttons inviting a tap that no longer means anything. resolveInteraction closes it from the agent side: the buttons are replaced by a badge, and the message body / draft stays exactly as the durable record of what was decided.
// You attached an approval to a draft, then approved it in your own admin instead.
await vessels.resolveInteraction(messageId, {
outcome: 'approved', // approved | rejected | cancelled | expired
label: '✅ Approved elsewhere', // optional — badge text; defaults to the outcome name
reason: 'Auto-approved by billing rule', // optional — shown when the receipt expands
});
The badge is tinted by outcome: approved reads green with a check, rejected red with a cross, and cancelled/expired neutral grey (the decision was withdrawn or is no longer relevant, not a verdict).
Rules:
- Idempotent — resolving an already-resolved or already-tapped interaction returns
okwith the existingresponseIdandalreadyResolved: true. A human tap is never overwritten (first writer wins). - No webhook — the agent is closing its own interaction, so there's nothing to deliver back. (Contrast a human tap, which fires
interaction.response.) - Throws
VesselsNotFoundErrorwhen the message doesn't exist,VesselsValidationErrorwhen it carries no interaction oroutcomeis invalid. - To re-ask instead of closing,
push()a new interaction — interactions are immutable (see above).
Raw API: POST /api/v1/messages/:message_id/resolve with Authorization: Bearer vsl_xxx.
The Liveness Scale
How alive your agent feels in Vessels is a ladder. Each rung is additive and worth shipping on its own:
| Level | What you do | What the human feels |
|---|---|---|
| 0 — Silent | Post one final message when done | It works, but feels dead — nothing until the end |
| 1 — Responsive | Push a working message the instant you receive input (agentActivity: { type: 'thinking' }), then editMessage the answer into that same bubble |
Something happens in ~200ms; the agent is clearly alive |
| 2 — Narrated | Emit completed step()s as you go, with clear labels |
The human watches the work happen, icons and all |
| 3 — Transparent | Drive a plan() of todos that tick off, with steps filing under each |
The full "I can see it think and work" experience |
The single biggest win is rung 1: react before you compute. An agent that opens a working card in a quarter-second feels alive even when the real work takes 30s; one that goes dark and then dumps a wall of text feels dead no matter how good the text is.
The agent turn lifecycle
Working cards, steps, todos, Stop — it's all one shape. A turn is the work your agent does in response to a single event (a message.user, an interaction.response, a vessel.created), and a turn moves through three states:
| State | You did | The human can |
|---|---|---|
| Working | Opened a card (agentActivity: { type: … }) |
Watch it, or tap Stop |
| Awaiting input | Paused it (agentActivity: { status: 'awaiting_input' }) + pushed an interaction |
Answer the interaction; the card resumes |
| Resolved | Sealed it (agentActivity: null) |
Read the result, act on any interaction |
| Plain | A message with no activity | Read and reply |
The contract is one line:
Work, then resolve, then ask. Open a card when input arrives, narrate it if you like, and seal it before — or in the same call as — you ask for a decision at the end of a turn. When the decision is mid-plan (more steps follow), pause instead of sealing (see Pause for mid-plan input) and resume the same card after.
An interaction (approval, choice, …) attached to a message whose card is still working is shown but not yet tappable — the app tells the human "Agent is still working." That's deliberate: a decision that lands mid-work reads as the agent doing three things at once. For an end-of-turn ask, resolve first (with the activity() handle that's await act.done() before the push). For a mid-plan ask, pause the card (act.awaitInput()) and put the interaction on its own message — a paused card doesn't gate, so the prompt is live while the plan waits.
A working card is a lease, not a fact
Vessels has no signal that your process is still alive — all it has is the timing of your updates. So a working card is a lease you have to hold:
- Hold it by updating it. Every
step()/editMessageresets the clock. A card that gets no update for ~45 seconds is shown as "No update in a while — the agent may have stopped," and any interaction it was gating unlocks. (A dead agent must never freeze the human's controls.) - Always release it. Seal with
agentActivity: nullwhen you finish — and when you fail. Wrap your turn so the card resolves on the error path too; an orphaned working card ticks until the staleness heuristic gives up on it. The seal is the one step you can't skip.
Concurrency is yours
Vessels delivers every event the moment it happens — including a message.user that arrives while you're still mid-turn. It will not queue, debounce, or lock on your behalf. If a human can reach you mid-work, decide what that means for your agent — interrupt the current turn, queue the new input, or ignore it — and handle message.cancelled (the Stop button) if you want them to be able to call you off. Two overlapping turns on one vessel will both render; how to reconcile them is your design choice, not Vessels'.
Organise vessels
Vessel status
A vessel shows an amber Waiting badge when the ball is in the human's court. This is automatic and system-derived — you don't set it. Attach an interaction to a message and the vessel goes waiting; it clears back to active the moment the human responds or sends a message. (This mirrors the supersession rule, so the badge can never get out of sync with whether there's a live decision.)
The vessel
waitingbadge is a vessel-level signal driven by a live interaction. A working card'sawaiting_input(mid-plan pause, above) is a message-level activity state — they're independent. A paused plan with an interaction beside it shows both; a paused plan with no live interaction shows neither.
// No status field needed — the interaction makes this vessel 'waiting'.
await vessels.surface({
vessel: 'booking-123',
title: 'Approve booking?',
interaction: vessels.approval({ prompt: 'Confirm this booking?' }),
});
To tag a vessel for triage (needs-review, vip, refund), use labels — free-form and filterable — rather than a status.
Vessel details
A vessel's persistent reference record — the CRM-style facts about who or what
this vessel is about: client name, contact info, links into your own admin. It rides
on the vessel (not a message), and lives in the open-vessel top bar behind a caret
— the human taps the vessel name to expand it. Set it once; update it when the
underlying record changes. It replaces the previous details wholesale; pass
null to clear.
await vessels.push({
vessel: 'booking-123',
message: 'Got it — I'll confirm with the venue.',
details: {
fields: [
{ label: 'Client', value: 'Sarah Martinez' },
// copyable → a copy button next to the value (phone, email, ids)
{ label: 'Phone', value: '+61 412 345 678', copyable: true },
{ label: 'Email', value: 'sarah@example.com', copyable: true },
// url → the value becomes a tappable link into your own admin UI
{ label: 'Booking', value: 'Open in CRM', url: 'https://app.example.com/bookings/123' },
],
},
});
Each field is { label, value, url?, tone?, copyable? }. There is no card title —
the vessel name is the heading.
Details is not status. Don't put "Confirmed" / "Pending" here — that's what labels are for (persistent, filterable, shown as chips in the same top bar). Details is stable identity; labels carry state. Re-sending details to announce a state change is the wrong tool — change a label, or post a message.
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. Filter by one or more from the label picker (or tap a badge), and labels are matched by the search box too.
- Available in poll events as
event.vessel.labels.
Naming & renaming vessels
The vessel string is a stable id, never shown to the human. The title is the human-readable name in the vessel list. Name vessels well and rename them as context sharpens — an enquiry that becomes a refund should read "Refund — Sarah M.", not "lead-88".
Set the name with vesselTitle on any push:
await vessels.push({ vessel: 'lead-88', vesselTitle: 'Sarah Martinez', message: 'New enquiry.' });
The name is only ever changed when you explicitly send a new one. A plain push with no vesselTitle leaves the existing title untouched — it is not reset. So you set the name once and every later message keeps it; you don't re-send vesselTitle on every push. A brand-new vessel you never titled shows its external_id as the name until you set one.
Rename without sending a message — PATCH the vessel by your own external id (no UUID needed):
await fetch('https://vessels.app/api/v1/vessels/by-external/lead-88', {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Refund — Sarah M.' }),
});
Either path works at any time; pick vesselTitle when you're already sending a message, PATCH when you only want to rename.
List vessels
Read the workspace's vessels without posting anything — for a triage sweep, a dashboard, or a stateless worker taking stock. listVessels returns camelCase summaries; filter by status and/or archived (non-archived by default).
const waiting = await vessels.listVessels({ status: 'waiting' }); // ball is in the human's court
const all = await vessels.listVessels({ archived: false }); // the default
// each row: { id, externalId, title, status, labels, pinned, archived, metadata, createdAt, updatedAt }
for (const v of waiting) console.log(v.externalId, v.title, v.labels);
status is 'active' | 'waiting' | 'resolved'. Raw API: GET /api/v1/vessels?status=waiting&archived=false with Authorization: Bearer vsl_xxx (needs the read scope); returns { ok: true, vessels: [...] } (snake_case rows). This re-reads the human's channel — it is not your agent's store.
Update vessel without a message
For most updates (labels, details) you can use vessels.push() which handles everything inline. PATCH is for when you want to update vessel state without adding a message to the feed.
The SDK wraps it as updateVessel (keyed by your own external id), with archiveVessel sugar on top — so you rarely hand-roll the fetch:
// Any subset of: title, labels (replace the set), details (null clears), vesselStatus, archived, pinned.
await vessels.updateVessel('booking-123', { labels: ['resolved'], details: null });
await vessels.archiveVessel('booking-123'); // archive (archived: true)
await vessels.archiveVessel('booking-123', false); // unarchive
The raw PATCH is below — by UUID or by your own external id.
By UUID (from a previous push response)
await fetch(`https://vessels.app/api/v1/vessels/${vesselUuid}`, {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ labels: ['resolved'], details: null }),
});
By external ID (your own string — no UUID needed)
await fetch('https://vessels.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({ labels: ['resolved'], details: null }),
});
Both accept: details (null to clear), title, metadata, labels (replace the full set), archived (bool), and pinned (bool — pin to the top of the list).
Delete a vessel
Permanently remove a vessel; its messages and interaction responses cascade with it. Prefer archiveVessel (reversible) over deletion unless you really mean to destroy it. The SDK exposes deleteVessel (keyed by external id); DELETE on the raw paths does the same.
await vessels.deleteVessel('booking-123'); // SDK sugar
// or raw, by external id (or by UUID at /api/v1/vessels/:uuid):
await fetch('https://vessels.app/api/v1/vessels/by-external/booking-123', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}` },
});
Clear messages (rewind)
A vessel is the human's live, editable view — not an append-only log — so trimming it back to a point is a legitimate edit. This is the durable primitive behind an agent's /rewind: the human tells your agent "rewind to yesterday", your agent resolves that to a timestamp (or a message id) and calls this. It trims the human-visible feed only — it does not clear your agent's context, which lives in your own system.
// "Rewind to here" — delete everything strictly AFTER a known message (it stays):
await vessels.clearMessages('booking-123', { afterMessageId: msgId });
// Trim old history — delete everything the agent posted before midnight,
// keeping the human's own replies:
await vessels.clearMessages('booking-123', { before: '2026-06-07T00:00:00Z' });
// Preview first — count without deleting:
const { deleted } = await vessels.clearMessages('booking-123', { afterMessageId: msgId, dryRun: true });
// Delete one message by id:
await vessels.deleteMessage(msgId);
Pass exactly one anchor — afterMessageId/after (delete strictly newer) or beforeMessageId/before (delete strictly older); the anchor row is always kept. source scopes what may go: 'agent' (default) = agent + system messages, 'user' = only the human's, 'all' = everything in range. dryRun: true returns the count and deletes nothing. Hard delete — interaction responses cascade; irreversible.
Raw API: DELETE /api/v1/vessels/:uuid/messages (or /by-external/:externalId/messages) with the anchor + source + dryRun as query params; returns { deleted, dry_run }. Single message: DELETE /api/v1/messages/:message_id.
await fetch('https://vessels.app/api/v1/vessels/by-external/booking-123/messages?afterMessageId=' + msgId, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}` },
});
Acknowledge processing — receipt ticks
When a human sends you a message, the vessel goes silent until you reply. But "silent" is ambiguous: are you working on it, did you deliberately drop it, or did you crash? vessels.ack() makes your processing state visible as receipt ticks on the message, so the human can tell the difference — and so the silent paths (a routed /rewind, a deduped duplicate, a "no match" drop) leave a trace instead of nothing.
It mirrors WhatsApp ticks. messageId is the inbound message's id — the message_id from the message.user (or interaction.response) webhook.
async function handle(event) {
// 1) right after you parse the webhook + verify the signature:
await vessels.ack(event.data.message_id, { stage: 'received' });
// 2) when you acquire your lock / make the first model call:
await vessels.ack(event.data.message_id, { stage: 'processing' });
// 3a) after your final push lands — you're done:
await vessels.ack(event.data.message_id, { stage: 'completed' });
// 3b) …or, if you deliberately short-circuit, say why:
await vessels.ack(event.data.message_id, { stage: 'dropped', reason: 'no_booking_match' });
}
// Sugar for each stage:
await vessels.ackReceived(messageId);
await vessels.ackProcessing(messageId);
await vessels.ackCompleted(messageId);
await vessels.ackDropped(messageId, 'duplicate_event');
How each stage renders on the message:
| Stage | Tick | Means |
|---|---|---|
| (none) | ✓ |
Delivered to Vessels — the human's message reached us (automatic). |
received |
✓✓ |
Your webhook parsed it and the signature checked out; you'll pick it up. |
processing |
✓✓ (pulsing) |
Work started — lock acquired / model running. |
completed |
✓✓✓ |
You finished a pass. |
dropped |
✗ + reason chip |
You deliberately ignored it; the human can tap the chip for the reason. |
Monotonic and idempotent, enforced server-side: a stale lower stage never rolls back a higher one (a late processing can't un-complete), and completed/dropped are terminal. So retries, out-of-order delivery, and a re-running worker are all safe — call it as often as you like.
This is operational signal about your run — it is not the agent's memory or state store. The ticks live on the human's view; your context lives in your own system.
Raw API: POST /api/v1/messages/:message_id/ack with body { stage, reason? }; returns { ok, stage, reason } (the resulting state — a no-op stale call still 200s).
await fetch(`https://vessels.app/api/v1/messages/${messageId}/ack`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ stage: 'dropped', reason: 'no_booking_match' }),
});
User-initiated vessels
Normally your agent starts the conversation — it pushes a message and a vessel appears. User-initiated vessels invert that flow: a human opens a brand-new vessel from the Vessels app, optionally tags it with a type (e.g. Ticket, Enquiry, Phone Call), and types the first message. Vessels delivers that to your agent as a dedicated vessel.created event. Your system reads the type and routes it — book the call, file the ticket, answer the enquiry.
Vessels is a dumb pipe here. It mints the vessel, carries { vessel, type, message } to you, and does nothing else. The type list is yours to define and yours to interpret — Vessels never acts on it.
Enable it
Off by default. Turn on the "New vessel" button and define the optional type list either in the web app (Settings → New Vessel) or via the CLI:
vessels types enable # turn on the "New vessel" button in the app
vessels types add Ticket -d "Support tickets"
vessels types add Enquiry
vessels types list
vessels types remove Ticket
vessels types disable # hide the button again
If you define no types, the human just gets a plain "New vessel" button and type arrives as null.
You can also read and set this configuration over the management API (JWT auth — same Bearer token the CLI uses):
# Read current config
curl https://vessels.app/api/v1/workspace -H "Authorization: Bearer <jwt>"
# Update — enable the button and set the type list
curl -X PATCH https://vessels.app/api/v1/workspace \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{ "userVesselsEnabled": true, "vesselTypes": ["Ticket", "Enquiry", "Phone Call"] }'
The vessel.created event
When the human sends their first message, you receive one vessel.created event (webhook and poll). The first user message is delivered inside this event — it is not sent separately as message.user. So handle vessel.created, or the new vessel goes unanswered.
Webhook payload (snake_case, with header X-Vessels-Event: vessel.created):
{
"event": "vessel.created",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"vessel": { "id": "uuid", "external_id": "web-a1b2c3", "title": "...", "type": "Ticket", "metadata": {} },
"message": { "message_id": "uuid", "content": "first thing the human typed", "created_at": "2024-01-01T00:00:00.000Z" },
"attachments": [{ "type": "image", "filename": "receipt.jpg", "fileId": "uuid", "downloadUrl": "https://...signed" }]
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
type is the chosen vessel type, or null if your workspace defines none. content is
null when the human opened the vessel with attachments only. attachments is present only
when the human attached files to their first message — fetch + store each via its signed
downloadUrl, exactly like inbound files on message.user.
The external_id is minted by Vessels — rename it
Because the human opened this vessel, you never gave it an external_id. Vessels mints one for you with a web- prefix (e.g. web-a1b2c3). Use it as-is, or — once your system has its own canonical ID — rename it so future pushes key on your ID:
// Adopt the new vessel under your own ID
await fetch('https://vessels.app/api/v1/vessels/by-external/web-a1b2c3', {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${process.env.VESSELS_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ externalId: 'ticket-5571', title: 'Sarah — broken link' }),
});
Handling it via the SDK
parseWebhookEvent (webhook) and poll (polling) both return the same normalised camelCase vessel.created event:
const event = await vessels.parseWebhookEvent(body, sig, secret);
if (!event) return res.status(401).send();
if (event.type === 'vessel.created') {
// event.vessel.externalId — minted by Vessels ('web-a1b2c3')
// event.vessel.type — 'Ticket' | 'Enquiry' | ... | null
// event.vessel.title, event.vessel.metadata
// event.message.id, event.message.content — the human's first message
switch (event.vessel.type) {
case 'Ticket': await openTicket(event.vessel.externalId, event.message.content); break;
case 'Enquiry': await answerEnquiry(event.vessel.externalId, event.message.content); break;
default: await triage(event.vessel.externalId, event.message.content);
}
}
The same event also arrives through polling — PollEvent includes a vessel.created variant: { id, type: 'vessel.created', timestamp, vessel: { id, externalId, title, type, metadata }, message: { id, content } }.
vessel.created, vessel.archived, and vessel.deleted are in the default event set for new webhook endpoints, and existing endpoints were backfilled to receive them — so no resubscription is needed. The available webhook events are interaction.response, message.user, vessel.created, vessel.archived, vessel.deleted, and message.cancelled (the last is delivered to every active endpoint regardless of subscription — see above).
Receive responses
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' | 'questions'
// 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
}
if (event.type === 'vessel.created') {
// A human opened a brand-new vessel from the app (see "User-initiated vessels")
// event.vessel.externalId — minted by Vessels, e.g. 'web-a1b2c3'
// event.vessel.type — the chosen type ('Ticket') or null
// event.message.content — the first thing the human typed
}
}
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.
The URL must be a public https endpoint. URLs that resolve to private, loopback, link-local, or cloud-metadata addresses (e.g. https://localhost, https://10.0.0.5, https://169.254.169.254) are rejected — to test locally, expose your dev server through a tunnel (ngrok, Cloudflare Tunnel) with a public https URL.
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" },
"message": {
"id": "uuid",
"content": "New booking — confirm?",
"interaction": { "type": "approval", "prompt": "Confirm this booking?", "approveLabel": "Confirm" },
"metadata": {},
"created_at": "2024-01-01T00:00:00.000Z"
},
"vessel": { "id": "uuid", "external_id": "booking-123", "title": "Sarah Martinez", "type": null, "metadata": {} }
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
data.message is the agent message the interaction was attached to — its text, the original interaction object (so you have the prompt that was approved), and any metadata you attached. You know what was responded to without a second lookup. In the SDK this is event.originMessage.
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", "type": null, "metadata": {} },
"context": [{ "source": "agent", "content": "...", "created_at": "..." }],
"superseded_interaction": { "message_id": "uuid", "interaction_type": "choice", "prompt": "Pick a new time" }
},
"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.
superseded_interaction is present only when this message arrived while one of your interactions was still live (unanswered) — typing past a card expires it. It names the card the human walked past (message_id, interaction_type, prompt) so you get the whole story in one payload: they ignored what you presented and said this instead. It's pure timeline mechanics — Vessels never says they "rejected" it, only that it expired; you decide what their message means. Omitted when nothing was superseded. Mirrored on the poll event; the SDK exposes it as event.supersededInteraction (camelCase, or null).
vessel.created: fired when a human opens a brand-new vessel from the app — see "User-initiated vessels" below for the full picture. The first user message rides inside this event, so handling vessel.created is enough to answer a freshly opened vessel.
{
"event": "vessel.created",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"vessel": { "id": "uuid", "external_id": "web-a1b2c3", "title": "...", "type": "Ticket", "metadata": {} },
"message": { "message_id": "uuid", "content": "first thing the human typed", "created_at": "2024-01-01T00:00:00.000Z" }
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
vessel.archived: fired when a vessel is archived (via PATCH /api/v1/vessels/:id with archived: true). A lifecycle signal your agent can react to — stop polling, close its side of the conversation. data.vessel mirrors the vessel.created vessel object.
{
"event": "vessel.archived",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"vessel": { "id": "uuid", "external_id": "booking-123", "title": "...", "type": "Ticket", "metadata": {} }
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
vessel.deleted: fired when a vessel is permanently deleted (via DELETE /api/v1/vessels/:id or DELETE /api/v1/vessels/by-external/:externalId). The vessel and all its messages are gone — tear down your side. data.vessel mirrors the vessel.created vessel object (it no longer exists in Vessels).
{
"event": "vessel.deleted",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"vessel": { "id": "uuid", "external_id": "booking-123", "title": "...", "type": "Ticket", "metadata": {} }
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
vessel.archived and vessel.deleted are subscribable, in the default event set, and were backfilled onto existing endpoints — no resubscription needed.
message.cancelled: fired when a human taps Stop on an in-progress (working) agent message in the app. Vessels is a dumb pipe — it does not force-stop anything. It delivers this signal and lets your agent decide what to do (abort a task, stop polling, or ignore it). data.message_id is the working agent message the human wants stopped.
{
"event": "message.cancelled",
"vessel_id": "uuid",
"workspace_id": "uuid",
"data": {
"message_id": "uuid",
"vessel": { "id": "uuid", "external_id": "booking-123", "title": "...", "type": null, "metadata": {} }
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
Unlike the other events, message.cancelled is delivered synchronously and to every active webhook endpoint (it ignores the subscribed-events list, since the whole point is to probe whether you handle it). The app reads your HTTP response: return 2xx and the human is told the cancellation was sent; a 404/500, or no webhook at all, tells the human "your agent isn't set up to cancel messages yet." So add a handler that returns 2xx — even one that just acknowledges — to opt in. It is webhook-only (not delivered via poll).
Handler
Use parseWebhookEvent — it verifies the signature and returns a fully typed, camelCase event in one call:
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 event = await vessels.parseWebhookEvent(
req.body.toString(),
req.headers['x-vessels-signature'] as string,
process.env.VESSELS_WEBHOOK_SECRET // the secret from Settings → Webhooks
);
if (!event) return res.status(401).send('Invalid signature');
if (event.type === 'interaction.response') {
// event.vessel.externalId — your original string ('booking-123')
// event.interactionType — 'approval' | 'choice' | 'checklist' | 'text_input' | 'questions'
// event.response — { action: 'approved' | 'rejected', reason?: string } etc.
// event.originMessage — the message that carried the interaction:
// .content, .interaction (incl. the prompt), .metadata — so you know WHAT was approved
if (event.interactionType === 'approval' && (event.response as any).action === 'approved') {
const prompt = (event.originMessage?.interaction as any)?.prompt;
await confirmBooking(event.vessel.externalId, prompt);
}
}
if (event.type === 'message.user') {
// event.message.content — what the human typed
// event.vessel.externalId — your original vessel string
// event.context — last 10 messages (oldest first), convenience only
}
if (event.type === 'vessel.created') {
// A human opened a new vessel — see "User-initiated vessels" below
// event.vessel.externalId — minted by Vessels ('web-a1b2c3')
// event.vessel.type — chosen type ('Ticket') or null
// event.message.content — the first message; there is NO separate message.user for it
await routeNewVessel(event.vessel.type, event.vessel.externalId, event.message.content);
}
if (event.type === 'vessel.archived') {
// The vessel was archived — stop polling / close your side.
await onVesselArchived(event.vessel.externalId);
}
if (event.type === 'vessel.deleted') {
// The vessel was permanently deleted — tear down your side.
await onVesselDeleted(event.vessel.externalId);
}
if (event.type === 'message.cancelled') {
// The human tapped "Stop" on a working message. Abort whatever work you have
// tied to event.messageId. Returning 2xx tells the app cancel is wired up.
await abortWork(event.vessel.externalId, event.messageId);
}
res.json({ ok: true });
});
If you prefer to handle the raw payload directly (e.g. in languages without the SDK), call verifyWebhook first, then read the snake_case fields shown in the payload shapes above.
Retries and idempotency. Each attempt waits up to 10s for a response. Vessels retries on 5xx, timeout, or no response — 3 attempts total, with 500ms then 2s backoff. A 2xx or 4xx is final (no retry). Retries are reliable: delivery runs to completion even after the API response is sent, so a slow first attempt won't drop the remaining retries. Every attempt is logged (Settings → Logs) and can be re-sent with Replay. Because retries can deliver the same event more than once, your handler should be idempotent — use event.id as a deduplication key (stable across retries; it is the response_id for interaction events and the message_id for user message events).
const event = await vessels.parseWebhookEvent(body, sig, secret);
if (!event) return res.status(401).send();
// Idempotency check — skip if already processed
const alreadyProcessed = await redis.exists(`vessels:processed:${event.id}`);
if (alreadyProcessed) return res.json({ ok: true });
await redis.set(`vessels:processed:${event.id}`, 1, 'EX', 86400);
// Safe to process
Deliveries are logged in Settings → Logs, with response status and body. Use Settings → Logs → Replay to re-send a failed delivery to your endpoint.
Read message history
getMessages reads a vessel's history — the human-facing record. Use it to re-read the channel: a stateless or just-restarted worker reconciling against what the human has seen. It is not your agent's memory — keep your canonical history in your own system (see "What Vessels is — and is not"). Pass your own external_id; no UUID needed.
const { messages, hasMore } = await vessels.getMessages({
vessel: 'booking-123', // your external_id (pass { byUuid: true } for a Vessels UUID)
limit: 50, // max 100, newest-last
});
// Paginate older:
const older = await vessels.getMessages({ vessel: 'booking-123', before: messages[0].createdAt });
Each message is { id, source, content, card, interaction, attachments, suggestions, metadata, createdAt } — source is 'agent' | 'user' | 'system' | 'event' (event = a backend banner from vessels.event), and metadata is whatever opaque metadata you attached on push (it rides along verbatim).
Without the SDK:
curl https://vessels.app/api/v1/vessels/by-external/booking-123/messages?limit=50 \
-H "Authorization: Bearer $VESSELS_API_KEY"
# or by UUID: /api/v1/vessels/{uuid}/messages
# response: { "ok": true, "messages": [...], "has_more": false }
Receiving files from the human (inbound)
The section above is outbound — your agent shows the human a file you host. The reverse, a human sending you a photo or document, works as a transient relay — because Vessels is not a file store. We hold the bytes just long enough to hand them to you; you store them.
The round-trip:
- The human attaches a file in the app. Vessels keeps it briefly (≈1h) and delivers a
message.userevent carrying anattachmentsarray. Each entry has a signed, short-liveddownloadUrlplus afileId,type(image|file), andfilename. (A human can also attach files to the first message of a user-initiated vessel — those ride thevessel.createdevent'sattachmentsarray, same shape and same resolve flow.) - Your agent downloads the file and stores it on your own infra (your bucket/DB).
- You return your permanent link with
resolveInboundFile(fileId, yourUrl). Vessels swaps the human's pending attachment for your link (they now see it) and deletes its transient copy. - If you never resolve before the file expires, Vessels deletes it and the human is told it couldn't be delivered. Fetch and store it, or it's gone — we never keep it.
const event = await vessels.parseWebhookEvent(body, sig, secret);
// Same attachments shape on message.user and the first message of vessel.created.
if (event?.type === 'message.user' || event?.type === 'vessel.created') {
for (const att of event.attachments ?? []) {
// att = { type, filename, fileId, downloadUrl }
const bytes = await fetch(att.downloadUrl!).then((r) => r.arrayBuffer());
const url = await myStorage.put(att.fileId, bytes); // YOUR storage — your permanent home
await vessels.resolveInboundFile(att.fileId, url); // we render your link, drop our copy
}
}
The same attachments array (with downloadUrl) also rides the message.user and
vessel.created poll events.
Raw resolve endpoint: POST /api/v1/files/:fileId/resolve with { "url": "https://..." } and
Authorization: Bearer vsl_xxx (idempotent — a repeat returns { ok: true, replayed: true }).
- Images render inline for the human, files as a download link — same as outbound.
- Up to 10 files per message; 25 MB each; images plus common document types.
- The human uploads via
POST /api/v1/files(the app does this) — there is no developer-facing upload endpoint, and no durable file storage. The transient copy is a hand-off, not a home.
Debug a run — conversation trace
getMessages shows what the human ended up seeing. When you need to know why — where your harness diverged from what Vessels rendered — read a conversation trace: a deterministic, machine-readable replay of everything that happened in a vessel, merged onto one timeline, built for an agent to debug its own run.
const trace = await vessels.trace('booking-123'); // your external_id (or { byUuid: true })
console.log(trace.summary.issues); // read these FIRST
The timeline (trace.events[], ordered, each with at + seq) fuses five things you can't reconcile from your own logs:
agent.request— a call your agent made, with the verbatim bytes you sent and Vessels' response. Each carries averdict(ok|degraded|rejected) anddiagnostics— the sent-vs-applied diff: for every field you sent, did it actually land on the stored message? A200that silently droppedinteractionshows up asdegradedwith afield_droppeddiagnostic, not a green tick. This is Vessels' authoritative account of what survived — the thing your logs can't tell you.message— a durable, rendered message artifact (bubble vs surface, title, interaction type, card).activity.step— one agent-activity step with its ownstartedAt/endedAt/durationMs, so "it said planning at X, the dot filled at Y" is recoverable.human.response— the human's immutable answer to an interaction.webhook.delivery— an event Vessels pushed to your endpoint, with its HTTP status.
trace.summary lifts every issue to the top (summary.issues, chronological) so failures are the first thing you read, alongside counts (requests, degraded, rejected, webhookFailures). Issues include both per-request diagnostics and any failed webhook.delivery (code webhook_failed — the developer's endpoint never received the event).
The sent-vs-applied diff needs debug mode.
agent.requestevents — and with them the headline sent-vs-applied diff — are only captured when the workspace has encryption disabled ("debug mode");trace.verbosereports which mode produced the trace. With encryption on, request bodies are never written to storage in plaintext, soverboseisfalseand the trace keeps only the durable messages, steps, human responses and webhook deliveries (still decrypted and complete — just without the per-request diff).Recommended workflow: run with encryption off while building and testing an agent, so traces are fully verbose and you can see exactly which fields landed; turn encryption on before production (required on Pro). Encryption is at-rest with workspace keys held by Vessels — it protects your data in a database compromise, but it is not end-to-end, and it deliberately costs you the verbose trace. Toggle it per workspace in Settings.
From the CLI (writes the full trace to a JSON file; prints a summary + issues):
vessels conversation booking-123 --out trace.json
Without the SDK:
curl https://vessels.app/api/v1/vessels/by-external/booking-123/trace \
-H "Authorization: Bearer $VESSELS_API_KEY"
# or by UUID: /api/v1/vessels/{uuid}/trace
# response: { "ok": true, "trace": { vessel, verbose, summary, events: [...] } }
This is a debugging surface, not your agent's memory or state store.
Reference
Full push payload
await vessels.push({
// One of message, agentActivity, or tokenStream is required
message: 'string',
agentActivity: {
type?: 'thinking' | 'searching' | 'tool_use' | 'browsing' | 'processing', // emit a step
label?: 'string',
todos?: [{ label: 'string', status?: 'pending' | 'in_progress' | 'done' }], // declare a plan
status?: 'working' | 'awaiting_input', // pause the card mid-plan on the human
},
tokenStream: 'string', // live monospace block; replace to grow, PATCH null to clear (vanishes)
// Vessel
vessel: 'string', // external ID, creates if new
vesselTitle: 'string',
labels: ['string'], // up to 10 tags, 50 chars each — visible in web dashboard
metadata: { key: 'value' }, // passed through in webhooks
// Message content
// Card fields take an optional `url` (renders the value as a tappable link,
// deep-linking into your own UI) and `tone` ('success' | 'warning' | 'danger')
// — a colour hint, no behaviour. Shown on full-size surface cards, not the
// compact vessel-list preview.
card: { title: 'string', fields: [{ label: 'string', value: 'string', url: 'https://…', tone: 'warning' }] },
interaction: { ... }, // one of the 5 types
// Vessel reference record (CRM-style identity) shown in the top bar. null to clear.
details: { fields: [{ label: 'string', value: 'string', url: 'https://…', tone: 'warning', copyable: true }] },
previewUrl: 'string', // a link card rendered below the message
attachments: [{ type: 'image' | 'file', url: 'string', filename?: 'string' }],
suggestions: ['string', ...], // up to 5 quick reply chips
kind: 'bubble' | 'surface', // defaults from interaction/card; 'surface' is a full-width artifact
title: 'string', // surface heading (ignored on bubbles)
});
// Returns: { ok: true, messageId: string, vesselId: string, createdAt: string, replayed: boolean }
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',
}),
});
// 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.
Idempotent push
Pass an idempotency key when you push so a client-side retry — your process crashes after the agent ran but before the push succeeded — can't create a duplicate message. Standard Stripe-style semantics: the first request does the work, any retry with the same key replays the original response.
// Reuse the SAME key across retries of the same logical push.
const res = await vessels.push({
vessel: 'booking-123',
message: 'New booking from Sarah Martinez',
idempotencyKey: 'booking-123-confirmation', // sent as the Idempotency-Key header
});
res.replayed; // false on the first call, true if this was a replay
Works the same on pushMany — the whole results array is replayed verbatim.
curl -X POST https://vessels.app/api/v1/push \
-H "Authorization: Bearer vsl_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-123-confirmation" \
-d '{ "vessel": "booking-123", "message": "New booking" }'
Semantics:
- Scope & window — dedup is keyed on (your workspace, the key) for 24 hours. After that the key is fresh again.
- Replay — if the key already completed, you get the original response with HTTP 200 and an
Idempotent-Replayed: trueheader. No second message is created. - In progress (409) — if an identical request is still being processed, you get HTTP 409
{ ok: false, error: "Idempotent request in progress" }. Back off briefly and retry — the SDK throwsVesselsConflictError. - Validation — keys must be 1–255 characters; longer is rejected with HTTP 400.
- Absent — no
Idempotency-Keyheader → behaviour is unchanged.
The key is sent as the Idempotency-Key HTTP header, never in the request body.
The same Idempotency-Key header works on the other side-effecting writes, with identical semantics (replay / 409 in-progress / 24h window): DELETE /api/v1/vessels/:id and DELETE /api/v1/vessels/by-external/:externalId (a retried delete won't double-fire vessel.deleted), POST /api/v1/messages/:id/respond (won't store a duplicate response or double-fire interaction.response), and POST /api/v1/messages/:id/cancel (won't re-probe the agent with message.cancelled).
Validate a payload before sending
Check that a message complies with the required syntax without sending it. This is the same schema the server enforces — a payload that passes here is exactly one the API will accept — so you can confirm an agent's output is well-formed in a test or dry run before it ever reaches a human.
The SDK exposes it two ways. validatePush / validatePushMany check a payload and hand back a result instead of throwing:
const check = vessels.validatePush({
vessel: 'booking-123',
message: 'New booking',
interaction: { type: 'approval', prompt: 'Confirm?' },
});
if (!check.valid) {
console.error(check.errors); // ['interaction.type: Invalid discriminator value. Expected ...', ...]
// check.details = Zod flatten(): { formErrors, fieldErrors }
}
You don't have to call it explicitly — push() and pushMany() run the same check internally and throw a VesselsValidationError (with the same field-level details) before the network call, so a malformed payload fails fast and never burns an API request:
import { VesselsValidationError } from 'vessels-sdk';
try {
await vessels.push({ vessel: 'b1', interaction: { type: 'approval', prompt: 'x' } }); // no message
} catch (e) {
if (e instanceof VesselsValidationError) console.error(e.message, e.details);
}
From the terminal, vessels validate checks a payload file (or piped JSON, or an inline message) and prints exactly what's wrong — no login or API key needed, fully local:
vessels validate payload.json
cat payload.json | vessels validate
vessels validate --message "New booking" --vessel booking-123
It auto-detects push vs push/many (a vessels array means broadcast), exits 0 when valid and 1 with a bulleted list of errors when not — so it drops straight into CI or an agent's self-check.
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.
Integrations & the feature manifest
Dropping Vessels into an agent you already built? The integrations/ directory has
copy-paste adapters (LangGraph, more coming) that map an existing human-in-the-loop step
onto Vessels interactions.
GET /api/v1/manifest returns the live, machine-readable feature manifest — the valid
interaction types, message sources, webhook events, kinds, tones and agent-activity types,
plus the canonical prose for each — composed from the same schemas and docs this page is
built from. Fetch it instead of copying lists out of these docs (no auth required):
curl https://vessels.app/api/v1/manifest
Starter agent template
Don't want to wire the Claude tool loop yourself? vessels init --agent-template scaffolds a complete, Vessels-native agent — the domain-free distillation of the agent Vessels dogfoods itself — and you make it yours by editing two files (src/role.ts = who it is, src/tools.ts = your backend tools):
vessels init --agent-template ./my-agent # default dir: ./vessels-agent
cd my-agent && npm install
cp .env.example .env # VESSELS_API_KEY, VESSELS_WEBHOOK_SECRET, ANTHROPIC_API_KEY
npm run dev # webhook server on :3000
Everything above — the turn lifecycle, the live working card, inbound files + vision, the resolve-before-ask discipline, per-vessel concurrency, idempotency, input sanitisers, and one-card-per-turn — is already wired. Point a workspace webhook at it (vessels webhooks create --url https://<your-url>/) and message a vessel; it answers.
→ Full walkthrough — the harness philosophy, how a turn works, the store seam, receiving files, deploying, and the ways to integrate it into an existing agent: The Vessels agent template.
The web dashboard
Developers and their team can interact with vessels directly at https://vessels.app — no mobile app required. The web dashboard shows all vessels, their status badges, vessel details, 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. The iOS mobile app (search "Vessels" on the App Store, or use TestFlight) adds push notifications so you're notified the moment your agent needs you.
When a push fires a phone notification. So one agent turn (an opening reply, a working card, then the outcome) doesn't buzz the phone three times, Vessels only notifies for pushes worth reading. A push that carries a message (or an interaction) notifies; a pure working-card update — agentActivity or tokenStream with no message — stays silent, since there's nothing new to read. This falls out of the normal split: message is for the human, agentActivity/tokenStream is the live working surface. So lead with your reply and finish with the outcome (both notify), and drive the work in between with agentActivity to keep it quiet.
Data, encryption & privacy
Everything your agent pushes — message content, cards, interactions, attachments — is encrypted at rest (AES-256-GCM) with a key unique to your workspace, and travels over TLS in transit. API keys are stored only as hashes; webhook payloads are HMAC-signed.
A few things worth being precise about, because we'd rather you know exactly what you're getting:
- It is encryption at rest, not end-to-end. Vessels holds the keys (a per-workspace key, itself wrapped by a master key that lives outside the database). This protects you against a database compromise — a stolen dump or backup is ciphertext and useless on its own. It is not zero-knowledge: rendering your vessels on web and mobile, sending notification previews, and building conversation traces all require Vessels to read the data server-side. We never claim we can't read it — we claim we don't share or sell it. See the Privacy Policy.
- Per-workspace keys mean clean deletion. Because your workspace has its own key, deleting the workspace destroys that key — cryptographically shredding its data, not just unlinking rows.
- Debug mode is a deliberate trade-off. A workspace can run with encryption off ("debug mode") so that request/response bodies are captured in full and the conversation trace is fully verbose — ideal while you're building and testing an agent. Turn encryption on before production (required on Pro). The two are mutually exclusive: encryption on ⇒ no plaintext bodies in storage ⇒ no per-request diff in the trace.
- No third-party trackers. The web and mobile apps carry no analytics, advertising, or session-replay SDKs. Our only subprocessors are the infrastructure that runs the service: Supabase (database, auth, realtime, storage), Vercel (hosting), and Firebase Cloud Messaging / Apple APNs (push delivery). The current list is always published in the Privacy Policy.
Who controls what. For the data your agent sends through Vessels — your end-users' bookings, leads, incident details — you are the data controller and Vessels is your processor. If you operate under GDPR or the Australian Privacy Principles, a Data Processing Agreement is available; email privacy@vessels.app. Don't send secrets (API keys, passwords) in metadata or message bodies — Vessels stores and replays those verbatim.
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,vessel.created]
vessels webhooks update <id> [--url https://...] [--events interaction.response,message.user,vessel.created]
vessels webhooks delete <id>
vessels webhooks enable <id>
vessels webhooks disable <id>
# User-initiated vessels — let humans open vessels with a typed first message
vessels types enable # show the "New vessel" button in the app
vessels types add <Name> [-d <desc>] # add a vessel type (e.g. Ticket, Enquiry)
vessels types list
vessels types remove <Name>
vessels types disable
# Validate a push payload against the required syntax — local, no auth needed
vessels validate payload.json # or: cat payload.json | vessels validate
vessels validate --message "<text>" [--vessel <id>]
# Auto-detects push vs push/many. Exit 0 = valid, exit 1 = errors listed.
# Push (agent message, for testing)
vessels push --vessel <id> --message <text> --key vsl_xxx
# Feedback (send a bug report or feature request to the Vessels team — requires login)
vessels feedback "dark mode in the app would be great" --type feature
vessels feedback "push didn't arrive on Android" --type bug
# --type is bug|feature|other (default other); message can be positional or --message
# 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.