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

  • agentActivity is optional on push(). When set, message may be omitted — the push opens a working card with no text content yet.
  • Each editMessage with a new agentActivity object closes the previous step (records ended_at and duration_ms server-side) and starts a new one.
  • agentActivity: null seals the history with resolved_at. After this, any further agentActivity edits on the same message have no effect.
  • You can combine agentActivity: null with 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.