npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vessels-sdk

v0.28.0

Published

Let your agent reach you. Official Vessels SDK.

Readme

vessels-sdk

Node.js SDK for Vessels — let your agent reach you.

Vessels is the communication layer between AI agents and their human operators. Your agent pushes structured messages to a vessel; the human responds via the web or mobile app; your agent receives the answer via polling or webhooks.

Installation

npm install vessels-sdk

Quick start

Get an API key (Claude Code / AI assistants — fully non-interactive):

npm install -g vessels
vessels init --email [email protected]                 # sends a 6-digit OTP
vessels init --email [email protected] --otp 847293    # prints VESSELS_API_KEY=vsl_xxx

Or sign in at vessels.app → Settings → API Keys.

import { Vessels } from 'vessels-sdk';
const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY! });

1. Send a message

The simplest case — a chat bubble in a vessel (created on first push):

await vessels.push({ vessel: 'booking-123', vesselTitle: 'Sarah — Saturday', message: 'New booking request.' });

2. Do real work → the activity harness (the recommended pattern)

For anything beyond a one-liner, open a working card and drive it with a plan + steps. The human watches progress fill in live; the card seals when you finish. This is the SDK's headline feature.

import { Vessels, type AgentActivityType } from 'vessels-sdk';

// Open the card with a plan (todos default to `pending`)
const { messageId } = await vessels.push({
  vessel: 'booking-123',
  message: 'Checking availability…',
  agentActivity: { todos: [
    { label: 'Look up the customer' },
    { label: 'Check the calendar' },
    { label: 'Draft a reply' },
  ]},
});

const act = vessels.activity(messageId);          // a live handle for that card
await act.start('Look up the customer');          // mark a todo in-progress
await act.step('tool_use', 'Queried CRM');        // a step, filed under the active todo
await act.complete('Look up the customer');        // finish it
// …start/step/complete each remaining todo…
await act.done();                                  // seal the card

act.awaitInput() pauses the card on the human mid-plan (push an interaction, then resume with another start/step/complete). step()'s first arg is a typed AgentActivityType'thinking' | 'searching' | 'tool_use' | 'browsing' | 'processing' (or use the exported AgentActivityTypes constant for autocomplete).

3. Surfaces — artifacts vs chat bubbles

surface() is a full-width artifact (a draft, quote, report, diff) — a title, block-markdown body (headings/lists/tables/quotes), an optional glance-card, and an action bar. Use it when the human is reviewing something, not chatting.

await vessels.surface({
  vessel: 'booking-123',
  title: 'Draft reply to Sarah',
  body: '## Hi Sarah\n\nSaturday 2 PM is open — shall I lock it in?\n\n| Slot | Status |\n|---|---|\n| Sat 2 PM | open |',
  interaction: vessels.approval({ prompt: 'Send this reply?' }),
});

4. Live token streaming

Stream model output into a card in real time (a monospace window that clears when sealed):

const { messageId } = await vessels.push({ vessel: 'booking-123', message: 'Drafting…' });
const stream = vessels.stream(messageId);
for await (const chunk of llm) stream.write(chunk);   // throttled PATCHes under the hood
await stream.done('Here is the drafted reply.');       // flush + seal, set final content

5. Ask a structured question

Five interaction types — approval, choice, checklist, textInput, and questions (a small multi-question form). Attach one to any push/surface:

interaction: vessels.questions({
  prompt: 'A few details to finalise',
  questions: [
    { id: 'date', header: 'Date', question: 'Which day?', options: [{ id: 'sat', label: 'Sat' }, { id: 'sun', label: 'Sun' }] },
  ],
})

6. Manage vessels (no message posted)

await vessels.listVessels({ status: 'waiting' });                       // inventory
await vessels.updateVessel('booking-123', { labels: ['urgent'], title: 'Renamed' });
await vessels.archiveVessel('booking-123');                             // reversible
await vessels.deleteVessel('booking-123');                              // permanent — messages cascade

7. Clear messages — rewind a feed (no agent context cleared)

await vessels.clearMessages('booking-123', { afterMessageId: msgId });  // "rewind to here" (anchor kept)
await vessels.clearMessages('booking-123', { before: '2026-06-07T00:00:00Z' }); // trim old history
await vessels.clearMessages('booking-123', { afterMessageId: msgId, dryRun: true }); // → { deleted } count, no-op
await vessels.deleteMessage(msgId);                                      // one message

Pass one anchor — afterMessageId/after (delete strictly newer) or beforeMessageId/before (older); the anchor row stays. source scopes it: 'agent' (default, = agent+system), 'user', or 'all'. Hard delete — interaction responses cascade. This trims the human-visible feed only; your agent's context lives in your own system.

Then receive the human's responses via webhook (parseWebhookEvent) or poll() — see below.

Vessels.isConfigured() is a static guard (true when VESSELS_API_KEY is set) so you can no-op cleanly when not wired up. And don't set vesselStatus — it's deprecated; waiting/active is derived from whether a message carries a live interaction.


API reference

new Vessels(config)

| Option | Type | Default | Description | |--------|------|---------|-------------| | apiKey | string | required | Your vsl_ prefixed API key | | baseUrl | string | https://vessels.app | Override for local dev or self-hosted |


vessels.push(payload)

Push a message to a vessel. Creates the vessel if it doesn't exist yet.

Returns { ok: true, messageId: string, vesselId: string, createdAt: string }.

Full payload:

| Field | Type | Description | |-------|------|-------------| | message | string | Required. The message text. | | vessel | string | Your external ID for this vessel (e.g. booking-123). Creates the vessel on first use. | | vesselTitle | string | Human-readable name shown in the vessel list. Set on creation; updates on subsequent pushes. | | vesselStatus | 'active' \| 'waiting' \| 'resolved' | Status badge shown in the vessel list. waiting = amber, resolved = green. | | labels | string[] | Tags for filtering in the dashboard. Max 10, 50 chars each. Replaces the existing set on every push — send all labels you want, not just new ones. | | metadata | object | Arbitrary JSON stored on the vessel, passed through in webhook callbacks. | | card | Card | Structured key-value info attached to this message. { title: string, fields: [{ label, value }] } | | interaction | Interaction | Interactive prompt for the human (one of 4 types — see helpers below). Max one per message; immutable after the human responds. | | details | Details \| null | The vessel's persistent reference/identity record — CRM-style facts about who/what the vessel is about (client name, phone, email, links into your own admin). Shown in the open-vessel top bar; values can be copyable. NOT status (status lives in labels). Replaces wholesale on every write. { fields: [{ label, value, url?, tone?, copyable? }] }. Pass null to clear. | | attachments | Attachment[] | Images or files to show in the message. Max 10. You host the files; Vessels renders them. [{ type: 'image' \| 'file', url: string, filename?: string }] | | suggestions | string[] | Quick-reply chips shown below the message. Max 5. Tapping fills the text input. Disappear after the user sends any message. | | previewUrl | string | A single URL rendered as a tappable link card below the message. Presentation only — no response. Compose with any interaction. | | notify | boolean | Whether to send a push notification. Default true. |

await vessels.push({
  vessel: 'booking-123',
  vesselTitle: 'Sarah Martinez',
  message: 'Ready for review.',
  vesselStatus: 'waiting',
  labels: ['golf', 'vip'],
  metadata: { bookingRef: 'BK-001' },
  card: {
    title: 'Booking',
    fields: [{ label: 'Date', value: 'Saturday 14 June' }],
  },
  details: {
    fields: [
      { label: 'Phone', value: '+61 400 000 000', copyable: true },
      { label: 'Admin', value: 'Open in CRM', url: 'https://crm.example.com/clients/123' },
    ],
  },
  attachments: [
    { type: 'image', url: 'https://example.com/photo.jpg' },
    { type: 'file', url: 'https://example.com/report.pdf', filename: 'Report.pdf' },
  ],
  suggestions: ['Looks good', 'Need changes'],
  interaction: vessels.approval({ prompt: 'Approve?' }),
});

vessels.pushMany(payload)

Push the same message to multiple vessels at once. Max 100 vessels per call. Each vessel gets its own independent copy — interactions are responded to individually.

Returns { ok: true, results: Array<{ vessel, messageId, vesselId, error? }> }.

const { results } = await vessels.pushMany({
  vessels: ['booking-101', 'booking-102', 'booking-103'],
  message: 'The Windmill Course is closed this Saturday due to maintenance.',
  interaction: vessels.approval({
    prompt: 'Send cancellation email to this client?',
    approveLabel: 'Send',
    rejectLabel: 'Skip',
  }),
  vesselStatus: 'waiting',
});

for (const r of results) {
  if (r.error) console.error(`Failed for ${r.vessel}:`, r.error);
  else console.log(`Pushed to ${r.vessel} — message ${r.messageId}`);
}

pushMany accepts the same fields as push, except vessel and vesselTitle are replaced by vessels (an array of external IDs). Vessel titles are not settable via pushMany — use per-vessel push calls for that.


vessels.validatePush(payload) / vessels.validatePushMany(payload)

Check that a payload complies with the required syntax without sending anything. Useful for confirming an agent's message is well-formed in a test or dry run before it reaches a human. This runs the same schema the server enforces, so a payload that passes here is exactly one the API will accept.

Returns { valid: boolean, errors: string[], details? }errors is a list of readable field.path: message lines; details is Zod's flatten() output ({ formErrors, fieldErrors }).

const check = vessels.validatePush({
  vessel: 'booking-123',
  message: 'New booking',
  interaction: { type: 'approval', prompt: 'Confirm?' },
});

if (!check.valid) {
  console.error(check.errors);
  // e.g. ['suggestions: Array must contain at most 5 element(s)']
}

You don't have to call this explicitly: push() and pushMany() run the same check internally and throw a VesselsValidationError before the network request, so a malformed payload fails fast without burning an API call. Call validatePush directly when you want a result object instead of an exception.


vessels.editMessage(messageId, patch)

Edit an existing agent-sent message in place. The message re-renders via Supabase Realtime without reloading.

Updatable fields: content, card, attachments, suggestions. Interactions are immutable — create a new message to re-ask.

Only agent-sourced messages can be edited. Returns { ok: true }.

const { messageId } = await vessels.push({
  vessel: 'batch-job-1',
  message: 'Processing bookings... 0 of 50 complete',
  card: { title: 'Progress', fields: [{ label: 'Progress', value: '0 / 50' }] },
});

// Update as work proceeds:
await vessels.editMessage(messageId, {
  content: 'Processing bookings... 24 of 50 complete',
  card: { title: 'Progress', fields: [{ label: 'Progress', value: '24 / 50' }] },
});

// Final update:
await vessels.editMessage(messageId, {
  content: 'All 50 bookings processed.',
  card: {
    title: 'Batch Complete',
    fields: [{ label: 'Processed', value: '50' }, { label: 'Errors', value: '0' }],
  },
});

vessels.clearMessages(externalId, opts) / vessels.deleteMessage(messageId)

Bulk-trim a vessel's feed back to a point — the durable primitive behind an agent's /rewind. A vessel is the human's live, editable view, so trimming it is a legitimate edit; it does not clear your agent's context (that lives in your own system).

Pass exactly one anchor; the anchor row is always kept (strict compare):

| Anchor | Effect | |--------|--------| | afterMessageId / after (ISO) | delete everything strictly newer — "rewind to here" | | beforeMessageId / before (ISO) | delete everything strictly older — trim history |

source scopes what may go: 'agent' (default, agent + system), 'user' (only the human's), 'all'. dryRun: true returns { deleted } as the would-delete count and deletes nothing. Hard delete — interaction responses cascade; irreversible. Returns { deleted, dryRun }.

// Rewind: drop everything after a known message
const { deleted } = await vessels.clearMessages('booking-123', { afterMessageId: msgId });

// Preview a time-based trim without deleting
await vessels.clearMessages('booking-123', { before: '2026-06-07T00:00:00Z', dryRun: true });

// Delete a single message
await vessels.deleteMessage(msgId);

Interaction helpers

All five helpers return a typed interaction object to pass as payload.interaction. There is a fixed set of five types — no custom types.

vessels.approval(opts)

A yes/no decision. Optionally require a reason on rejection.

vessels.approval({
  prompt: 'Send the invoice?',
  approveLabel: 'Send',          // default: 'Approve'
  rejectLabel: 'Cancel',         // default: 'Reject'
  reasonRequired: false,         // if true, rejection requires a reason
  metadata: { invoiceId: '42' }, // returned in poll event and webhook
})

Response shape: { action: 'approved' | 'rejected', reason?: string }

vessels.choice(opts)

Pick one option from a list. Optionally allow a free-text custom value.

vessels.choice({
  prompt: 'Which time slot works?',
  options: [
    { id: '9am',  label: '9:00 AM' },
    { id: '2pm',  label: '2:00 PM' },
    { id: '5pm',  label: '5:00 PM' },
  ],
  allowCustom: true,
  customPlaceholder: 'Type a different time...',
})

Response shape: { selected: string, customValue?: string | null }

vessels.checklist(opts)

Pick one or more items from a list.

vessels.checklist({
  prompt: 'Which documents are needed?',
  options: [
    { id: 'id',       label: 'Photo ID',         checked: true },
    { id: 'proof',    label: 'Proof of address' },
    { id: 'contract', label: 'Signed contract' },
  ],
  minSelections: 1,
  submitLabel: 'Confirm selection',
})

Response shape: { selected: string[] } — array of selected option IDs.

vessels.textInput(opts)

Free-form text from the human.

vessels.textInput({
  prompt: 'Why is the booking being cancelled?',
  placeholder: 'Enter reason...',
  multiline: true,
  submitLabel: 'Submit',
})

Response shape: { text: string }

Review-and-decide (the old confirmPreview)

There is no separate preview interaction. To have the human review something then decide, attach a previewUrl (a link card) to the message and use approval:

await vessels.push({
  vessel: 'draft-123',
  message: 'Draft email ready for review.',
  previewUrl: 'https://your-app.com/drafts/123',
  interaction: vessels.approval({ prompt: 'Send this email?', approveLabel: 'Send', rejectLabel: 'Edit' }),
});

Response shape: { action: 'approved' | 'rejected', reason?: string }


vessels.poll(options?)

Fetch pending events. Uses a server-side cursor per API key — each call returns only new events since the last acknowledged poll.

Returns { ok: true, events: PollEvent[], hasMore: boolean }.

| Option | Type | Default | Description | |--------|------|---------|-------------| | ack | boolean | true | Advance the cursor so these events aren't returned again | | limit | number | 50 | Max events per call | | since | string | — | ISO timestamp — overrides the cursor for this call only |

Poll events are normalised to camelCase by the SDK.

const { events, hasMore } = await vessels.poll({ ack: true });

for (const event of events) {
  if (event.type === 'interaction.response') {
    // event.interactionType      — 'approval' | 'choice' | 'checklist' | 'text_input'
    // event.response             — response shape depends on interactionType (see above)
    // event.interactionMetadata  — metadata object you passed when creating the interaction, or null
    // event.messageId            — UUID of the message containing the interaction
    // event.vessel.externalId    — your original vessel string ('booking-123')
    // event.vessel.id            — vessel UUID
    // event.vessel.labels        — string[] of tags on this vessel
    // event.user                 — { id, email } of the responding user, or null

    if (event.interactionType === 'approval' && event.response.action === 'approved') {
      await confirmBooking(event.vessel.externalId);
    }
  }

  if (event.type === 'message.user') {
    // event.message.content      — what the human typed
    // event.vessel.externalId    — your original vessel string
    const reply = await generateReply(event.message.content);
    await vessels.push({ vessel: event.vessel.externalId, message: reply });
  }
}

// For high volume, page through all pending events
if (hasMore) {
  // Call poll() again immediately
}

Polling is a complete alternative to webhooks. Use it to get started, or when you can't receive inbound HTTP.


vessels.verifyWebhook(body, signature, webhookSecret)

Verify the X-Vessels-Signature header on incoming webhook requests. Uses constant-time HMAC comparison.

  • body — raw request body as a string, before JSON.parse
  • signature — the X-Vessels-Signature header value (format: sha256=<hex>)
  • webhookSecret — the per-endpoint secret shown when you created the webhook in Settings → Webhooks. Not the same as your API key.

Returns true if the signature is valid, false otherwise.

import express from 'express';
import { Vessels } from 'vessels-sdk';

const vessels = new Vessels({ apiKey: process.env.VESSELS_API_KEY! });
const app = express();

app.post(
  '/webhooks/vessels',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const body = req.body.toString('utf8');
    const signature = req.headers['x-vessels-signature'] as string;

    const valid = vessels.verifyWebhook(body, signature, process.env.VESSELS_WEBHOOK_SECRET!);
    if (!valid) return res.status(401).json({ error: 'Invalid signature' });

    const payload = JSON.parse(body);
    // payload.event — 'interaction.response' | 'message.user'
    // Webhook bodies use snake_case (vessel_id, external_id, interaction_type)

    if (payload.event === 'interaction.response') {
      const { interaction_type, response, vessel, metadata } = payload.data;
      // vessel.external_id — your original vessel string
      if (interaction_type === 'approval' && response.action === 'approved') {
        await confirmBooking(vessel.external_id);
      }
    }

    if (payload.event === 'message.user') {
      const { content, vessel, context } = payload.data;
      // context — last 10 messages in the vessel, oldest first (convenience, not canonical state)
      const reply = await generateReply(content, context);
      await vessels.push({ vessel: vessel.external_id, message: reply });
    }

    res.json({ ok: true });
  },
);

Webhook payload shapes (snake_case — raw HTTP/JSON layer):

// interaction.response
{
  "event": "interaction.response",
  "vessel_id": "uuid",
  "workspace_id": "uuid",
  "data": {
    "message_id": "uuid",
    "interaction_type": "approval",
    "response": { "action": "approved" },
    "response_id": "uuid",
    "metadata": { "invoiceId": "42" },
    "vessel": { "id": "uuid", "external_id": "booking-123", "title": "Sarah Martinez", "metadata": {} }
  },
  "timestamp": "2024-01-01T00:00:00.000Z"
}

// message.user
{
  "event": "message.user",
  "vessel_id": "uuid",
  "workspace_id": "uuid",
  "data": {
    "message_id": "uuid",
    "content": "I want to reschedule",
    "vessel": { "id": "uuid", "external_id": "booking-123", "title": "Sarah Martinez", "metadata": {} },
    "context": [{ "source": "agent", "content": "...", "created_at": "..." }]
  },
  "timestamp": "2024-01-01T00:00:00.000Z"
}

Retries: 3× on failure with backoff (1s, 10s, 60s). All deliveries logged in Settings → Logs.


Error handling

Three typed error classes are exported alongside Vessels:

| Class | HTTP status | Description | |-------|-------------|-------------| | VesselsAuthError | 401 | Invalid or revoked API key | | VesselsValidationError | 400 | Bad request payload. Also thrown locally, before the request by push()/pushMany() when the payload fails client-side validation. Check .details for field-level errors. | | VesselsRateLimitError | 429 | Rate limit exceeded. Check .retryAfter (seconds) before retrying. |

import { Vessels, VesselsAuthError, VesselsRateLimitError, VesselsValidationError } from 'vessels-sdk';

try {
  await vessels.push({ vessel: 'booking-123', message: 'hello' });
} catch (err) {
  if (err instanceof VesselsAuthError) {
    console.error('Check your API key — it may be revoked');
  } else if (err instanceof VesselsRateLimitError) {
    console.error(`Rate limited — retry after ${err.retryAfter}s`);
  } else if (err instanceof VesselsValidationError) {
    console.error('Bad payload:', err.details);
  } else {
    throw err;
  }
}

Naming conventions

The SDK and push payload use camelCase (JavaScript convention): vesselTitle, vesselStatus, details, vesselId, messageId.

Webhook POST bodies use snake_case (raw HTTP/JSON layer): vessel_id, external_id, interaction_type.

Poll events are normalised to camelCase by the SDK: event.vessel.externalId, event.interactionType, event.messageId.

Rule of thumb: anything you write (push calls, SDK methods) is camelCase. Anything you read from a raw webhook POST body is snake_case.


TypeScript types

All types used by the SDK are exported directly from vessels-sdk:

import type {
  PushPayload,
  PushManyPayload,
  MessagePatch,
  Attachment,
  Interaction,
  Card,
  ApprovalInteraction,
  ChoiceInteraction,
  ChecklistInteraction,
  TextInputInteraction,
} from 'vessels-sdk';

Links