The Vessels agent template

A complete, Vessels-native Claude agent you scaffold in one command — then make yours by editing two files.

vessels init --agent-template drops a working agent into a folder. It runs on your box, on your Anthropic key, keeps its state in your store, and talks to Vessels purely over HTTP to show your human operator what's happening and collect their decisions. Swap the two stub tools for your real backend and you have a real agent — a booking manager, a support triager, a contracts analyst, a stock-desk assistant.

It is the domain-free distillation of the agent Vessels dogfoods itself. Everything we learned wiring our own in-house agent — the turn lifecycle, the working card, the resolve-before-ask discipline, the per-vessel concurrency lock, the sanitisers that stop one malformed field from sinking a whole push — is already wired here. You inherit it; you don't rebuild it.

This page is the philosophy and the integration menu. For the API/SDK reference it builds on, see the full integration guide.


Philosophy: a thin harness, not a framework

Three convictions shape the template. If you only remember these, you'll use it well.

1. Vessels is the view layer; your agent owns its data. Vessels never holds your agent's memory, state, or business data. It carries the human-facing messages you send and the answers you get back — nothing more. Your conversation history, your locks, your files all live in your store. This isn't a limitation to work around; it's the boundary that keeps the agent yours. (The long version: "What Vessels is — and is not".)

2. Contacting the human is a structured tool call. The model doesn't print to a person. It raises request_approval / request_choice / request_text / … and the turn pauses until they answer. Human-in-the-loop is native, not bolted on — a decision is a typed interaction with an action bar, not a sentence the human has to parse and reply to in prose.

3. The engine is the boring part, and that's the point. The hard, load-bearing mechanics — ACK-fast then run in the background, idempotency on every push, sealing the working card in a finally, sanitising every model-supplied field, a forced-ending so a turn never trails off as a bare "Done.", one card per turn, the one-ping-per-turn notification rule — are handled for you and you rarely touch them. You shape the agent through what it knows and what it can do, not through plumbing.

The result is a harness, not a framework: a few hundred lines of readable TypeScript you own outright, not a dependency you configure. Read it, change it, throw parts away.


Scaffold it

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

No keys yet? The CLI mints them non-interactively:

npx vessels init --email you@example.com            # emails a 6-digit code
npx vessels init --email you@example.com --otp 123456
# → prints VESSELS_API_KEY
npx vessels webhooks create --url https://<your-public-url>/   # → prints VESSELS_WEBHOOK_SECRET

For local dev, expose :3000 with a tunnel (ngrok, cloudflared) and point the webhook at that public URL. Then open a vessel in the Vessels app and message it — your agent answers.


How a turn works

A turn is the unit of work: one human action in, one complete exchange out.

  1. The human acts in Vessels (opens a vessel, sends a message, answers an interaction) → Vessels POSTs a signed webhook to src/index.ts.
  2. The server verifies the signature, ACKs 200 immediately, and runs the turn in the background. A Claude tool loop far exceeds the ~10s webhook timeout, so it never runs inline — parseWebhookEvent → ACK → runTurn.
  3. The engine runs the loop: it loads the prior conversation from your store, leads with a one-line quick_reply (so the operator is never left on a blank screen), opens a live working card, plans, calls your tools — ticking the plan and narrating steps as it goes — and ends with exactly one finishing tool: a message (finish) or a human decision (request_approval / request_choice / request_checklist / request_text / request_questions).
  4. When the human answers, Vessels sends another webhook and the loop continues — the engine reloads the conversation from your store, so the agent picks up exactly where it left off.

One card per turn. Each turn opens its own working card and always seals it when the turn ends — there's no reattaching to a prior turn's card. This is deliberate: it keeps the human's record in strict chronological order even when a long conversation sits between a plan and its continuation. A mid-plan checkpoint (raise a request_* with keepWorking: true) seals this turn's card as a clean record of what you just did; the next turn continues the remaining steps in a fresh card.

The notification rule rides along. A push carrying a message or interaction buzzes the human's phone; a pure working-card update (agentActivity / tokenStream) stays silent. So the lead reply and the final outcome notify; the work in between is quiet. You get this for free.


The two files you edit

Everything domain-specific lives in two files. The rest is the engine.

File What it is
src/role.ts WHO your agent is and WHAT it does — one short paragraph. The only place the engine learns your domain.
src/tools.ts Your backend tools. Each is { definition, handler, narrate? }. Replace the two stubs.

src/role.ts — one paragraph, layered on top of the generic Vessels protocol prompt:

export const ROLE = `You are Atlas, a support-triage agent for an analytics SaaS. You read
incoming tickets, pull account context, and either resolve them or escalate to a human.`;

src/tools.ts — your backend tools. Each is a tool definition the model sees, a handler that runs against your system, and an optional one-line working-card narration:

export const BACKEND_TOOLS: BackendTool[] = [
  {
    definition: {
      name: 'lookup_account',
      description: 'Look up an account by id. Returns plan, usage, open tickets.',
      input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
    },
    handler: async ({ id }) => myDb.accounts.get(id),         // your API / DB
    narrate: ({ id }) => ({ type: 'searching', label: `Looked up account ${id}` }),
  },
];

The engine injects a task field into every tool automatically, so the model can tick the plan in the same call it runs your tool — your handler receives the input without task. Keep tools small and composable; the model calls several per turn and runs independent ones together.

Make a tool's failure a value, not an exception. Anything that can fail should return a result that says it failed ({ ok: false, reason: '…' }) rather than throwing — so the model can react and tell the operator, instead of the turn dying.

The same skeleton becomes a booking manager, a contracts analyst, or a stock-desk agent — only these two files change.


What the engine handles for you

The generic Vessels protocol — bubbles vs surfaces, lead-with-a-reply, plan-before-work, "contacting the human is a structured tool call" — is the system prompt; your ROLE rides on top. Out of the box the agent can use the full breadth of Vessels: all five interactions (with metadata, allowCustom, minSelections, reasonRequired, and multi-question forms), edit-then-approve (the operator changes a value — a price, a date — and approves, via editables bound to card fields or inline {{id}} body tokens; only the changes round back) and one-way consent gates (rejectable:false), chat bubbles and full-width surfaces, the live working card with a ticking plan, auto-narrated steps and token streaming, the vessel's persistent details record (CRM-style reference facts in the top bar), labels (triage/status tags), outbound attachments, preview links, suggested replies, vessel naming/renaming, and user-initiated vessel types.

The load-bearing mechanics are handled so you don't have to think about them:

  • ACK-fast + run-in-background + resume-on-next-webhook — the webhook returns 200 in milliseconds; the turn runs after.
  • Idempotency keys on every push — a client-side retry after a crash can't double-post.
  • Seal-in-finally — the working card is always closed out, even if the turn throws midway, so it's never left spinning.
  • Input sanitisers on every model-supplied optional field (card, previewUrl, title, suggestions) — one malformed field can 400 a whole push, so each is cleaned before it's sent.
  • The forced-ending close — if the loop runs out of steps without a finishing tool, a final model call closes the turn cleanly so an intended decision is never downgraded to a bare "Done."
  • One card per turn and the resolve-before-ask discipline.
  • One ping per turn — the notification rule above.

Your agent owns its data

Conversation state, the per-vessel lock, and inbound files all live in your store (src/store.ts) — never in Vessels. The store is one small interface:

export interface AgentStore {
  loadState(vessel: string): Promise<MessageParam[]>;        // the agent's real message history
  saveState(vessel: string, messages: MessageParam[]): Promise<void>;
  acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;  // false if held
  releaseLock(vessel: string): Promise<void>;
  putInboundFile(file: { fileId; bytes; contentType; filename? }): Promise<string>;  // → public URL
  getInboundFile(fileId: string): Promise<StoredFile | null>;
  init?(): Promise<void>;                                    // one-time setup (e.g. create tables)
}

Two implementations ship; createStore() picks one from the environment:

  • MemoryStore (default) — zero infrastructure. Correct for a single long-lived process; state, files, and the lock live in RAM and reset on restart.
  • PostgresStore — set DATABASE_URL and you get durable conversation history, durable files, and a cross-process per-vessel lock (so two webhooks for the same vessel can't run interleaved turns — the thing you need once you run more than one instance). It self-provisions its tables on boot (CREATE TABLE IF NOT EXISTS) — no migration to run.

Durability is an upgrade, not a prerequisite — you get a working agent with no database at all. Want Redis, Dynamo, or your own DB? Implement the AgentStore interface; nothing else changes.

The per-vessel lock is why the store owns more than memory: "don't run two turns for one vessel at once" is a property of your deployment, so it lives with you. A second webhook that can't take the lock waits for it rather than dropping, so a message that lands mid-turn is never lost.


Receiving files (inbound)

Vessels is not a file store. When the human sends a photo or document, Vessels relays the bytes transiently and hands you a signed, short-lived downloadUrl on the event. The engine does the full handshake in src/inbound.ts, per file:

  1. download — fetch the signed downloadUrl (plain HTTP).
  2. storestore.putInboundFile(...) puts it on your infra and returns a permanent URL.
  3. resolvevessels.resolveInboundFile(fileId, url) so the human sees your hosted copy and Vessels drops its transient one.
  4. view — for supported images (jpeg/png/gif/webp, ≤4MB), the bytes are handed to the model as a vision block so your agent actually sees the image. Non-images are stored and referenced by name.

The handshake is best-effort per file — one file's failure never sinks the others or the turn. The model gets a bracketed note describing what arrived and where it landed, so it never claims it "can't receive files."

The zero-infra default stores files in the same MemoryStore / PostgresStore and serves them from the agent's own GET /files/:id route, so the resolved link points back at you. Set PUBLIC_URL to your externally-reachable base (your tunnel in dev) so that link is fetchable. In production, point putInboundFile at object storage (S3 / R2 / GCS) and return a CDN URL — nothing else changes. You usually don't edit inbound.ts at all.


Backend-only calls

A few Vessels features live on your backend, outside a turn — call them on the vessels SDK directly (none round-trip through the agent's turn loop):

  • vessels.event({ vessel, title, tone, card?, buttons?, sections? }) — paint a backend event on the human's timeline: a fact from your system (a booking landed, an alert fired), not the agent's voice. A full-width tinted banner with deep-link buttons and collapsible sections. Purely presentational — it fires no webhook and no poll event, so the agent never hears it through Vessels; fire it in parallel with triggering the agent off the same fact, and the agent works and replies alongside the banner.
  • vessels.pushMany({ vessels: [...], message, interaction }) — broadcast the same message or decision to many vessels at once (e.g. "course closed Saturday" to every affected booking). Max 100 per call; each interaction is answered independently.
  • vessels.updateVessel(externalId, { title?, labels?, details?, archived?, pinned? }) — change vessel-level state without posting a message; archiveVessel / listVessels / deleteVessel are the lifecycle helpers around it.
  • vessels.clearMessages(vessel, { beforeMessageId? | afterMessageId? | before? | after? }) / vessels.deleteMessage(id) — the durable primitive behind an agent's /rewind: trim the human-visible feed back to a point. It does not touch your agent's own context (that lives in your store) — resolve the operator's "rewind to yesterday" to a timestamp/id, then call this.
  • vessels.getMessages({ vessel }) — re-read a vessel's human-facing history to reconcile a stateless or restarted worker. This is the channel's record exposed for convenience, not your agent's memory — that's your store.
  • vessels.validatePush(payload) — check a payload against the exact server schema without sending. Drops into a test or an agent's self-check; a payload that passes here is one the API will accept.

Deploying

The template is a long-lived Node server (zero-dependency node:http), so background work after the 200 ACK simply finishes. Run it on a VM, a container, Fly, Render, Railway — anywhere a process stays up.

On serverless (Lambda / Vercel / Workers) the process can freeze the moment you respond, which would kill the turn mid-flight. There you must either await the turn before responding, or use the platform's background primitive (e.g. Vercel's waitUntil) so the work survives the response. The parseWebhookEvent → ACK → runTurn shape stays the same — only the server wrapper changes. Express, Hono, and Next all drop in.


Ways to integrate

The template isn't all-or-nothing. Pick the level that fits where your agent already is.

Use it wholesale. Greenfield agent? Scaffold it, fill in role.ts and tools.ts, point object storage at putInboundFile, deploy. You inherit every mechanic above and ship in an afternoon.

Lift the protocol prompt into your own loop. Already have a Claude tool loop? The highest-value piece is src/protocol.ts — the domain-free system prompt that teaches a model how to talk to Vessels well (bubbles vs surfaces, lead with a reply, plan before working, contact the human as a structured tool call). Copy it, append your own role, and register the Vessels control tools from src/vessels-tools.ts alongside your existing tools.

Take the store seam only. Like your own loop but want the durability/locking pattern? src/store.ts is a self-contained AgentStore interface with Memory and Postgres implementations and a self-provisioning schema — lift it as-is and back it with whatever you run.

Take the inbound handshake only. src/inbound.ts is a standalone download → store → resolve → vision helper. If you just need to accept files from a human and feed images to the model, it's ~100 lines you can drop into any handler.

Use just the SDK. Not building a Claude agent at all — maybe your "agent" is a cron job or a workflow engine? Skip the template and call vessels-sdk directly (push, pushMany, poll, the interaction helpers). The template is one way to use the SDK well, not the only way. See the integration guide.

Swap the server shell. Keep the engine, change the front door — drop the node:http server for Express/Hono/Next, or trigger turns from a queue instead of a webhook. As long as you keep parseWebhookEvent → ACK → runTurn, everything downstream is unchanged.


File map

my-agent/
├── src/
│   ├── role.ts          ★ EDIT — who your agent is (one paragraph)
│   ├── tools.ts         ★ EDIT — your backend tools
│   ├── protocol.ts      the domain-free Vessels system prompt (the reusable gold)
│   ├── vessels-tools.ts the built-in Vessels control tools + payload sanitisers
│   ├── agent.ts         one turn: the Claude tool loop, live working card, forced-ending net
│   ├── store.ts         AgentStore — your state, lock, and inbound files (Memory + Postgres)
│   ├── inbound.ts       the download → store → resolve → vision handshake
│   └── index.ts         the webhook server (verify → ACK → run the turn)
├── .env.example
└── README.md

You spend your time in the two starred files. The rest is the engine — read it to understand it, but you rarely need to change it.


Learn more