vessels 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Your agent's webhook server.
3
+ *
4
+ * Vessels POSTs an event here when the human acts (opens a vessel, sends a message,
5
+ * answers an interaction). We verify the signature, ACK 200 immediately (a Claude tool
6
+ * loop far exceeds the ~10s webhook timeout — never run it inline), and run the turn in
7
+ * the background. The engine pushes everything the human sees back through the SDK.
8
+ *
9
+ * This is a zero-dependency node:http server so it runs anywhere with no framework.
10
+ * Prefer Express/Hono/Next? Keep `parseWebhookEvent → ACK → runTurn` and swap the shell.
11
+ *
12
+ * ┌── deployment note ─────────────────────────────────────────────────────────┐
13
+ * │ This long-lived process keeps running after the 200, so background work just │
14
+ * │ finishes. On serverless (Lambda/Vercel/Workers) the process may freeze after │
15
+ * │ the response — there you must await the turn or use the platform's background │
16
+ * │ primitive (e.g. waitUntil) so it isn't killed mid-turn. │
17
+ * └────────────────────────────────────────────────────────────────────────────┘
18
+ */
19
+ import 'dotenv/config';
20
+ import http from 'node:http';
21
+ import { Vessels } from 'vessels-sdk';
22
+ import { createStore, type AgentStore } from './store.js';
23
+ import { runTurn } from './agent.js';
24
+ import { renderInteractionResponse } from './vessels-tools.js';
25
+
26
+ const API_KEY = process.env.VESSELS_API_KEY;
27
+ const WEBHOOK_SECRET = process.env.VESSELS_WEBHOOK_SECRET;
28
+ const PORT = Number(process.env.PORT || 3000);
29
+
30
+ if (!API_KEY || !WEBHOOK_SECRET || !process.env.ANTHROPIC_API_KEY) {
31
+ console.error('Missing env. Set VESSELS_API_KEY, VESSELS_WEBHOOK_SECRET, ANTHROPIC_API_KEY (copy .env.example → .env).');
32
+ process.exit(1);
33
+ }
34
+
35
+ const vessels = new Vessels({ apiKey: API_KEY, baseUrl: process.env.VESSELS_BASE_URL });
36
+ let store: AgentStore;
37
+
38
+ /** Read the raw request body (we need the exact bytes to verify the HMAC signature). */
39
+ function readBody(req: http.IncomingMessage): Promise<string> {
40
+ return new Promise((resolve, reject) => {
41
+ let data = '';
42
+ req.on('data', (c) => (data += c));
43
+ req.on('end', () => resolve(data));
44
+ req.on('error', reject);
45
+ });
46
+ }
47
+
48
+ /** Fire-and-forget: this process is long-lived, so the turn just finishes after we ACK. */
49
+ function runInBackground(work: Promise<unknown>): void {
50
+ void Promise.resolve(work).catch((err) => console.error('[agent] background error', err));
51
+ }
52
+
53
+ const server = http.createServer(async (req, res) => {
54
+ if (req.method === 'GET') {
55
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
56
+ res.end('Vessels agent is alive. POST webhook events here.');
57
+ return;
58
+ }
59
+ if (req.method !== 'POST') {
60
+ res.writeHead(405).end();
61
+ return;
62
+ }
63
+
64
+ const raw = await readBody(req);
65
+ const signature = req.headers['x-vessels-signature'];
66
+ const event = await vessels.parseWebhookEvent(raw, Array.isArray(signature) ? signature[0] : signature ?? '', WEBHOOK_SECRET);
67
+
68
+ if (!event) {
69
+ res.writeHead(401, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ ok: false, error: 'Invalid signature or unknown event' }));
71
+ return;
72
+ }
73
+
74
+ const vessel = event.vessel.externalId ?? event.vessel.id;
75
+
76
+ // Build the turn for this event. ACK first, then run it in the background.
77
+ let work: Promise<void> | null = null;
78
+
79
+ if (event.type === 'vessel.created') {
80
+ // The human opened a new vessel from the app — it has a placeholder title; the agent
81
+ // names it on its first plan(). `message.content` is the first thing they typed.
82
+ // `vessel.type` is the type they chose (Ticket / Enquiry / …) — yours to interpret; pass
83
+ // it in so the agent can route on it. Enable types with `vessels types enable/add`.
84
+ const first = (event.message.content ?? '').trim();
85
+ const typeNote = event.vessel.type ? `[New ${event.vessel.type} vessel] ` : '';
86
+ work = runTurn({
87
+ vessels,
88
+ store,
89
+ vessel,
90
+ humanInput: `${typeNote}${first || '(the operator opened a new vessel)'}`,
91
+ nameVessel: true,
92
+ idempotencyKeyBase: `vc:${event.message.id}`,
93
+ });
94
+ } else if (event.type === 'message.user') {
95
+ const content = (event.message.content ?? '').trim();
96
+ if (content) {
97
+ // If this message expired a live interaction, tell the agent so it reacts to what they
98
+ // actually said instead of waiting on the now-dead card.
99
+ const sup = event.supersededInteraction;
100
+ const humanInput = sup
101
+ ? `[The operator did not answer your ${sup.interactionType}${sup.prompt ? ` "${sup.prompt}"` : ''} — that card expired because they sent a message instead. Respond to what they actually said; re-offer or adjust only if it still makes sense.]\n${content}`
102
+ : content;
103
+ work = runTurn({ vessels, store, vessel, humanInput, idempotencyKeyBase: `mu:${event.message.id}` });
104
+ }
105
+ } else if (event.type === 'interaction.response') {
106
+ const originInteraction = event.originMessage?.interaction ?? null;
107
+ const prompt = (originInteraction?.prompt as string | undefined) ?? undefined;
108
+ work = runTurn({
109
+ vessels,
110
+ store,
111
+ vessel,
112
+ humanInput: renderInteractionResponse(event.interactionType, event.response, prompt, originInteraction),
113
+ idempotencyKeyBase: `ir:${event.id}`,
114
+ });
115
+ }
116
+ // event.type === 'message.cancelled' → nothing long-running to abort here; just ACK.
117
+
118
+ res.writeHead(200, { 'Content-Type': 'application/json' });
119
+ res.end(JSON.stringify({ ok: true }));
120
+ if (work) runInBackground(work);
121
+ });
122
+
123
+ createStore()
124
+ .then((s) => {
125
+ store = s;
126
+ server.listen(PORT, () => console.log(`Vessels agent listening on :${PORT}`));
127
+ })
128
+ .catch((err) => {
129
+ console.error('[agent] failed to start', err);
130
+ process.exit(1);
131
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * THE VESSELS PROTOCOL — how your agent talks to its human operator.
3
+ *
4
+ * This prompt is DOMAIN-FREE on purpose. It teaches the model the *mechanics* of
5
+ * Vessels — the message kinds, the control tools, the payload shape Vessels reads,
6
+ * and the interaction principles (lead with a reply, plan before working, contact
7
+ * the human as a structured tool call). It says NOTHING about WHAT your agent does.
8
+ *
9
+ * Your agent's job lives in two other places you own:
10
+ * • `role.ts` — WHO the agent is and WHAT it does (one short paragraph).
11
+ * • `tools.ts` — your real backend tools.
12
+ *
13
+ * At runtime the system prompt is: ROLE + VESSELS_PROTOCOL (+ NAME_RULE on a
14
+ * freshly-opened vessel). Keep this file as-is unless the Vessels product changes;
15
+ * shape the agent through role.ts and tools.ts instead.
16
+ */
17
+
18
+ export const VESSELS_PROTOCOL = `You reach your human operator through Vessels. They are not at a terminal watching logs — they see a clean feed of messages from you and answer when you ask. Contacting them is a deliberate, structured act: you do not "print" to them, you call a tool. Everything below is HOW to use that channel well.
19
+
20
+ BUBBLES vs SURFACES — every message you send is one of two kinds:
21
+ - BUBBLE (chat): quick_reply, send_update, finish. A conversational line — inline markdown
22
+ only (**bold**, *italic*, \`code\`, [links](url)). No card, no interaction. ONE short
23
+ sentence; the reply IS the interaction (the human just types back). This is most of what
24
+ you send: heads-ups, progress, completion notes, quick questions.
25
+ - SURFACE (artifact): a full-width composed thing the human reviews — something to approve,
26
+ a report or proposal to read. You compose it as ONE piece: a \`title\` heading, an optional
27
+ \`card\` of glance-facts, and a block-markdown \`body\` (the artifact itself — tables,
28
+ bullet/numbered lists, blockquotes, bold headings, links). Two ways to make one:
29
+ • request_approval/choice/checklist/text → a surface WITH a decision (the action bar).
30
+ • show_document(title, body) → a read-only surface (no decision, doesn't end the turn).
31
+ Put the REAL artifact in the body — never a "draft is ready" summary. The body is the one
32
+ place length is welcome; everything else stays terse.
33
+
34
+ KEEP CHAT OUTPUT TERSE (writing it is the slow part) — this is about bubbles, NOT surface bodies:
35
+ - A chat MESSAGE (send_update, or a finishing tool's message): ONE short sentence. No preamble, no 🎉.
36
+ - plan() task labels: ≤ 5 words each.
37
+ - Cards: ≤ 4 fields, short values.
38
+ - The plan + auto-narrated steps + card carry the detail.
39
+ Your private reasoning already streams live into the card as you work and then vanishes
40
+ (it is NOT saved) — so think naturally and the operator sees the work happen. You do NOT
41
+ need to narrate in chat messages; keep saved messages terse and let the live thinking show it.
42
+
43
+ ALWAYS LEAD WITH quick_reply — never leave the operator on a blank screen while you think.
44
+ Your FIRST action every turn is quick_reply: one short conversational line, pushed instantly.
45
+ The turn ends ONLY when you SAY so — via the quick_reply "done" flag. Nothing is inferred:
46
+ - quick_reply({ done: true }) → this line FULLY resolves the turn and you're handing back to
47
+ the operator now. The turn ends. Only two things qualify: a clarifying question back (you're
48
+ missing something you need), or an answer you can give from what you ALREADY know — no
49
+ lookup, no backend work.
50
+ - quick_reply (done false/omitted) → this is your "on it" reply; you're about to keep working.
51
+ Call plan([...]) and work the steps, then end with the right finishing tool (request_* / finish).
52
+ LITMUS TEST before you set done: true — does the line PROMISE something will follow? Anything
53
+ like "on it", "pulling that now", "let me check", "looking into it", "I'll get you…", "one
54
+ sec" — or any answer you can't give without a lookup — is a promise. NEVER mark a promise
55
+ done: true; that ends the turn and the work never happens. When in doubt, leave done false
56
+ and keep working — an unfinished promise is the one thing you must never leave.
57
+ So: quick_reply first, every turn; then either mark it done (you've settled it) or keep
58
+ working. Don't manufacture a 4-step plan for what's really one quick question back; and
59
+ whenever you'd otherwise GUESS a key detail, ask it as a done:true question instead.
60
+
61
+ Tools:
62
+
63
+ 1. Plan + triage — drive the live ticking checklist AND surface the vessel's state:
64
+ - plan(todos) — declare 3–4 tasks up front. On a vessel's FIRST plan(), ALSO set:
65
+ • labels: 1–3 triage tags in your own vocabulary — how this vessel shows up in the
66
+ operator's list. Replace-semantics (send the full set).
67
+ • pinCard: a compact {title, fields} pinning the entity's state at a glance — it stays
68
+ in the header as the chat scrolls.
69
+ Re-send pinCard (on a later plan/finish/request_*) to UPDATE it as state changes. Set
70
+ labels once unless they change.
71
+ - Advance the plan by passing task:"<exact label>" ON the work tool itself (your backend
72
+ tools and send_update take it) — that ticks the plan in the SAME call. Do NOT waste a
73
+ whole turn on a lone step(); only use step() when you advance with no tool to run. You do
74
+ NOT write step narration — the backend tools auto-narrate under the task.
75
+
76
+ 2. Backend tools — YOUR tools (defined in tools.ts). Each can take task:"<label>" to tick the
77
+ plan as it runs. Call independent tools TOGETHER in one response to move fast — each
78
+ round-trip is slow.
79
+ - When the operator must review TEXT YOU WROTE before approving (a message, a reply, a
80
+ note), put the FULL text in the request_approval message field — it renders as the review
81
+ body, so they read the actual words inline. Do NOT bury it behind a previewUrl or
82
+ summarise it as "draft is ready"; show the real text.
83
+ - Reserve a previewUrl for something too long or rich to inline (a multi-page document, a
84
+ PDF) — a link to open, not a substitute for showing short text.
85
+
86
+ 3. Finishing tools — pick EXACTLY ONE as the FINAL action of your turn:
87
+ - request_approval — yes/no sign-off (optionally with a previewUrl to review)
88
+ - request_choice — pick one option (with options[])
89
+ - request_checklist — pick several options (with options[])
90
+ - request_text — free-text answer
91
+ - request_questions — SEVERAL questions at once, answered together (a short form)
92
+ - finish — wrap up; no further human action needed
93
+
94
+ MID-PLAN CHECKPOINTS — keepWorking: when a multi-step plan needs the operator's input
95
+ PART-WAY THROUGH (e.g. plan = Draft → Get approval → Send), raise the request_* with
96
+ keepWorking:true. The plan card stays LIVE and paused on them — pending tasks intact, not
97
+ greyed out — and when they answer you pick the SAME plan back up and finish the remaining
98
+ steps, one continuous card. Omit keepWorking on the FINAL question/decision, which seals
99
+ the plan and ends the turn.
100
+
101
+ 0. quick_reply(message, done?) — ALWAYS your first action (see the lead-with-a-reply rule
102
+ above): one conversational line, pushed instantly. done:true → it's the whole answer and
103
+ the turn ends. done false/omitted → it's your "on it" line; the working card opens right
104
+ after and you MUST plan() and work. NEVER mark a promise ("on it", "pulling that now")
105
+ done:true — leave done false and work.
106
+
107
+ Flow:
108
+ - A [SYSTEM EVENT] means you are PROACTIVELY reaching the operator. They ALREADY see your
109
+ opening line and a live ticking plan — so DO NOT re-announce the event or restate its
110
+ details in a chat message. Get straight to work.
111
+ - plan(todos, labels, pinCard) → call each task's backend tool WITH task:"<label>" (it ticks
112
+ the plan + auto-narrates) → then ONE finishing tool. END the turn with a single, complete
113
+ result: the finishing message (one sentence) plus, for approvals, a compact card. Never drop
114
+ a bare card and trail off.
115
+ - Across the conversation, spread the interaction types (a choice, then an approval, then a
116
+ checklist, then a text question) rather than leaning on only one.
117
+ - You CAN rename the vessel any time — set vesselTitle on plan(), quick_reply or finish. If the
118
+ operator asks to rename it, just do it and confirm; never claim titles are fixed.
119
+ - After the human answers, do what it implies, then finish or ask the next question.
120
+ - ONE closing line per turn. The finishing tool's message IS the wrap-up — do NOT also send a
121
+ near-duplicate finish/send_update saying the same thing.
122
+
123
+ More you can attach (use when they genuinely help — don't decorate):
124
+ - ATTACHMENTS: images render inline, files as a download link. Pass {type, url, filename?} on
125
+ send_update or show_document — only URLs you already host (e.g. one a backend tool returned).
126
+ - PREVIEW LINK: a single tappable link card under a message (previewUrl) — a draft/dashboard to
127
+ open. Presentation only, no response. Pair it with a request_* when they should look THEN decide.
128
+ - INTERACTION METADATA: attach metadata to any request_* and it rides back to you verbatim in the
129
+ response — use it to correlate the answer with your own record (an id, a type) instead of guessing.
130
+ - The operator's messages may contain /commands or @mentions your workspace defined — they arrive
131
+ as plain text; interpret them per your role.
132
+
133
+ Be efficient — every assistant turn is a slow round-trip, so do MORE per turn:
134
+ - BUNDLE: advance the plan via task:"<label>" ON the work tool, and call independent work tools
135
+ TOGETHER in one response. A lone step() burns a whole round-trip.
136
+ - You MUST end with an ending tool (request_* or finish). When you reach the task that needs the
137
+ human, call its work tools AND the request_* tool in the SAME response — do not tick that task
138
+ and stop. If you trail off without an ending tool the turn dies as a bare "Done."
139
+ - Never repeat a tool call with identical arguments — reuse the result you already have.
140
+ - In task:"…" use the EXACT task label from your plan() — never invent a new name.`;
141
+
142
+ // Appended on a freshly-opened vessel (a vessel.created event), which arrives with a
143
+ // placeholder title. The agent names it from the task on its first plan(). Domain-free.
144
+ export const NAME_RULE = `
145
+
146
+ NAME THIS VESSEL — it was just opened with a placeholder title. In your FIRST plan() this turn,
147
+ set vesselTitle to a short, specific name drawn from the task: who or what it's about, ≤6 words,
148
+ no generic words like "New" or "Request". Set it once; omit vesselTitle on later plan() calls.`;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * ★ EDIT THIS FILE ★
3
+ *
4
+ * This is the ONE place the engine learns your domain. Everything else in this
5
+ * template is Vessels-generic — the tool loop, the message protocol, the store.
6
+ * Describe WHO your agent is and WHAT it does, in a few plain sentences. Do not
7
+ * re-explain how to talk to Vessels here (that's `protocol.ts`, appended for you).
8
+ *
9
+ * Whatever you write becomes the top of the system prompt, ahead of the Vessels
10
+ * protocol. Keep it tight — a paragraph, not a manual. The same template that runs
11
+ * a booking manager runs a legal analyst or a stock-desk agent; only this string
12
+ * and `tools.ts` change.
13
+ *
14
+ * Examples of the SHAPE (replace entirely — these are not your agent):
15
+ * "You are Atlas, a support-triage agent for an analytics SaaS. You read incoming
16
+ * tickets, pull account context, and either resolve them or escalate to a human."
17
+ * "You are a contracts analyst. You review redline requests, check them against
18
+ * our standard terms, and surface anything that needs a lawyer's sign-off."
19
+ */
20
+
21
+ export const ROLE = `TODO: Describe your agent. You are <name>, a <role> that <does what> on your operator's behalf. Add any standing rules it should always follow (tone, what it may decide alone vs. must hand back for approval, hard constraints).`;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * THE STORE SEAM — your agent's runtime state, on YOUR infrastructure.
3
+ *
4
+ * Two things live here, and both are facets of "the agent owns its own runtime":
5
+ * 1. Conversation state — the real Anthropic message history per vessel (including
6
+ * tool_use / tool_result blocks). This is your agent's memory. Vessels is NOT
7
+ * your memory; it only shows the human what happened.
8
+ * 2. A per-vessel lock — "don't run two turns for one vessel at once." That's a
9
+ * property of YOUR deployment, so it lives here too, never in Vessels.
10
+ *
11
+ * Durability is an UPGRADE, not a prerequisite:
12
+ * • MemoryStore (default) — zero infra. Correct for a single long-lived process.
13
+ * State lives in RAM and resets on restart; the lock is an in-process mutex.
14
+ * • PostgresStore — set DATABASE_URL and you get durable state + a cross-process
15
+ * lock. It self-provisions (CREATE TABLE IF NOT EXISTS on init) — no migration
16
+ * to run. Horizontally scaled? This is the lock that keeps turns serialised.
17
+ *
18
+ * Swap in Redis/Dynamo/your-DB by implementing the same `AgentStore` interface.
19
+ */
20
+ import type { MessageParam } from '@anthropic-ai/sdk/resources/messages';
21
+ import type { AgentTodoStatus } from 'vessels-sdk';
22
+
23
+ /**
24
+ * A paused plan's handle, saved when a turn ends on a MID-PLAN checkpoint
25
+ * (a request_* with keepWorking). The next turn re-attaches to the SAME working
26
+ * card (`activityId`) and its `todos` instead of opening a new one — so the
27
+ * operator sees one continuous card across a multi-step flow. Like everything in
28
+ * the store, this is the agent's own runtime state, never Vessels'.
29
+ */
30
+ export type ResumeMarker = { activityId: string; todos: { label: string; status: AgentTodoStatus }[] };
31
+
32
+ export interface AgentStore {
33
+ /** The agent's conversation history for this vessel (empty array if new). */
34
+ loadState(vessel: string): Promise<MessageParam[]>;
35
+ /** Persist the full conversation history for this vessel. */
36
+ saveState(vessel: string, messages: MessageParam[]): Promise<void>;
37
+ /** The paused-plan handle for this vessel, or null when no plan is paused. */
38
+ loadResume(vessel: string): Promise<ResumeMarker | null>;
39
+ /** Save the paused-plan handle (or null to clear it once the plan resumes/ends). */
40
+ saveResume(vessel: string, marker: ResumeMarker | null): Promise<void>;
41
+ /** Try to take the per-vessel lock. Returns false if someone else holds it. TTL bounds a crash. */
42
+ acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;
43
+ /** Release the per-vessel lock. */
44
+ releaseLock(vessel: string): Promise<void>;
45
+ /** Optional one-time setup (e.g. create tables). Called once at boot. */
46
+ init?(): Promise<void>;
47
+ }
48
+
49
+ // ─── MemoryStore — the zero-infra default ──────────────────────────────────────
50
+
51
+ export class MemoryStore implements AgentStore {
52
+ private state = new Map<string, MessageParam[]>();
53
+ private resume = new Map<string, ResumeMarker>();
54
+ private locks = new Map<string, number>(); // vessel → expiry (ms epoch)
55
+
56
+ async loadState(vessel: string): Promise<MessageParam[]> {
57
+ return this.state.get(vessel) ?? [];
58
+ }
59
+
60
+ async saveState(vessel: string, messages: MessageParam[]): Promise<void> {
61
+ this.state.set(vessel, messages);
62
+ }
63
+
64
+ async loadResume(vessel: string): Promise<ResumeMarker | null> {
65
+ return this.resume.get(vessel) ?? null;
66
+ }
67
+
68
+ async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
69
+ if (marker) this.resume.set(vessel, marker);
70
+ else this.resume.delete(vessel);
71
+ }
72
+
73
+ async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
74
+ const now = Date.now();
75
+ const until = this.locks.get(vessel);
76
+ if (until && until > now) return false; // still held
77
+ this.locks.set(vessel, now + ttlSeconds * 1000);
78
+ return true;
79
+ }
80
+
81
+ async releaseLock(vessel: string): Promise<void> {
82
+ this.locks.delete(vessel);
83
+ }
84
+ }
85
+
86
+ // ─── PostgresStore — durable, self-provisioning ─────────────────────────────────
87
+
88
+ export class PostgresStore implements AgentStore {
89
+ // `pg` is imported lazily so MemoryStore users don't need a database driver loaded.
90
+ private pool: import('pg').Pool | null = null;
91
+ constructor(private readonly connectionString: string) {}
92
+
93
+ async init(): Promise<void> {
94
+ const { default: pg } = await import('pg');
95
+ this.pool = new pg.Pool({ connectionString: this.connectionString });
96
+ await this.pool.query(`
97
+ CREATE TABLE IF NOT EXISTS agent_state (
98
+ vessel TEXT PRIMARY KEY,
99
+ messages JSONB NOT NULL,
100
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
101
+ );
102
+ CREATE TABLE IF NOT EXISTS agent_locks (
103
+ vessel TEXT PRIMARY KEY,
104
+ expires_at TIMESTAMPTZ NOT NULL
105
+ );
106
+ CREATE TABLE IF NOT EXISTS agent_resume (
107
+ vessel TEXT PRIMARY KEY,
108
+ marker JSONB NOT NULL,
109
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
110
+ );
111
+ `);
112
+ }
113
+
114
+ private get db(): import('pg').Pool {
115
+ if (!this.pool) throw new Error('PostgresStore not initialised — call init() at boot');
116
+ return this.pool;
117
+ }
118
+
119
+ async loadState(vessel: string): Promise<MessageParam[]> {
120
+ const { rows } = await this.db.query<{ messages: MessageParam[] }>(
121
+ 'SELECT messages FROM agent_state WHERE vessel = $1',
122
+ [vessel]
123
+ );
124
+ return rows[0]?.messages ?? [];
125
+ }
126
+
127
+ async saveState(vessel: string, messages: MessageParam[]): Promise<void> {
128
+ await this.db.query(
129
+ `INSERT INTO agent_state (vessel, messages, updated_at)
130
+ VALUES ($1, $2, now())
131
+ ON CONFLICT (vessel) DO UPDATE SET messages = EXCLUDED.messages, updated_at = now()`,
132
+ [vessel, JSON.stringify(messages)]
133
+ );
134
+ }
135
+
136
+ async loadResume(vessel: string): Promise<ResumeMarker | null> {
137
+ const { rows } = await this.db.query<{ marker: ResumeMarker }>(
138
+ 'SELECT marker FROM agent_resume WHERE vessel = $1',
139
+ [vessel]
140
+ );
141
+ return rows[0]?.marker ?? null;
142
+ }
143
+
144
+ async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
145
+ if (!marker) {
146
+ await this.db.query('DELETE FROM agent_resume WHERE vessel = $1', [vessel]);
147
+ return;
148
+ }
149
+ await this.db.query(
150
+ `INSERT INTO agent_resume (vessel, marker, updated_at)
151
+ VALUES ($1, $2, now())
152
+ ON CONFLICT (vessel) DO UPDATE SET marker = EXCLUDED.marker, updated_at = now()`,
153
+ [vessel, JSON.stringify(marker)]
154
+ );
155
+ }
156
+
157
+ async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
158
+ // Atomic: take the row if free, OR steal it if the prior holder's TTL has lapsed.
159
+ const { rows } = await this.db.query(
160
+ `INSERT INTO agent_locks (vessel, expires_at)
161
+ VALUES ($1, now() + make_interval(secs => $2))
162
+ ON CONFLICT (vessel) DO UPDATE
163
+ SET expires_at = EXCLUDED.expires_at
164
+ WHERE agent_locks.expires_at < now()
165
+ RETURNING vessel`,
166
+ [vessel, ttlSeconds]
167
+ );
168
+ return rows.length > 0;
169
+ }
170
+
171
+ async releaseLock(vessel: string): Promise<void> {
172
+ await this.db.query('DELETE FROM agent_locks WHERE vessel = $1', [vessel]);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Pick a store from the environment: PostgresStore when DATABASE_URL is set, else the
178
+ * in-memory default. Calls init() once. Replace this with your own wiring as you grow.
179
+ */
180
+ export async function createStore(): Promise<AgentStore> {
181
+ const url = process.env.DATABASE_URL;
182
+ const store: AgentStore = url ? new PostgresStore(url) : new MemoryStore();
183
+ await store.init?.();
184
+ return store;
185
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ★ EDIT THIS FILE ★
3
+ *
4
+ * Your agent's BACKEND tools — the things it actually does in YOUR system (look
5
+ * something up, charge a card, file a ticket, run a query). The engine wires these
6
+ * into the Claude tool loop next to the built-in Vessels control tools (quick_reply,
7
+ * plan, request_approval, …). When the model calls one, the engine runs your
8
+ * `handler`, feeds the result back to the model, and (optionally) ticks the live
9
+ * working card via `narrate`.
10
+ *
11
+ * THE STATE BOUNDARY: these handlers run against YOUR backend and YOUR data. Vessels
12
+ * never sees your business data and is never your agent's memory — it only carries
13
+ * the human-facing messages the engine sends. A handler returns plain data; the model
14
+ * decides what (if anything) to surface to the operator.
15
+ *
16
+ * You do NOT need to add a `task` field to your schema — the engine injects it into
17
+ * every tool automatically so the model can tick the plan in the same call. Your
18
+ * handler receives the model's input WITHOUT `task`.
19
+ */
20
+ import type { AgentActivityType } from 'vessels-sdk';
21
+ import type { Tool } from '@anthropic-ai/sdk/resources/messages';
22
+
23
+ export interface BackendTool {
24
+ /** The Anthropic tool definition the model sees (name, description, input_schema). */
25
+ definition: Tool;
26
+ /** Run the tool against your backend. Return any JSON-serialisable result. */
27
+ handler: (input: Record<string, unknown>) => Promise<unknown> | unknown;
28
+ /**
29
+ * Optional: turn a call into a one-line working-card step (an icon + label the
30
+ * operator sees tick by under the current task). Omit and the engine derives a
31
+ * label from the tool name. `type` drives the icon (searching, processing, …).
32
+ */
33
+ narrate?: (
34
+ input: Record<string, unknown>,
35
+ result: unknown
36
+ ) => { type: AgentActivityType; label: string };
37
+ }
38
+
39
+ /**
40
+ * Your tools. Replace these two stubs with real ones. Keep them small and composable —
41
+ * the model calls several per turn. Anything that can fail should return a result that
42
+ * SAYS it failed (e.g. `{ ok: false, reason: '…' }`) rather than throwing, so the model
43
+ * can react and tell the operator.
44
+ */
45
+ export const BACKEND_TOOLS: BackendTool[] = [
46
+ {
47
+ // ── TODO: replace with a real read from your system ───────────────────────
48
+ definition: {
49
+ name: 'lookup_record',
50
+ description:
51
+ 'TODO: Look something up in your backend by id/name. Describe exactly what it returns so the model uses it well.',
52
+ input_schema: {
53
+ type: 'object',
54
+ properties: {
55
+ query: { type: 'string', description: 'What to look up' },
56
+ },
57
+ required: ['query'],
58
+ },
59
+ },
60
+ handler: async (input) => {
61
+ // TODO: call your API / DB here and return the real record.
62
+ throw new Error(
63
+ `lookup_record is a stub — implement it in tools.ts (query=${String(input.query)})`
64
+ );
65
+ },
66
+ narrate: (input) => ({ type: 'searching', label: `Looked up ${String(input.query ?? '')}`.trim() }),
67
+ },
68
+ {
69
+ // ── TODO: replace with a real action in your system ───────────────────────
70
+ definition: {
71
+ name: 'perform_action',
72
+ description:
73
+ 'TODO: Do something in your backend (create/update/send). Describe its effect and what it returns.',
74
+ input_schema: {
75
+ type: 'object',
76
+ properties: {
77
+ action: { type: 'string', description: 'What to do' },
78
+ detail: { type: 'string', description: 'Any relevant detail / args' },
79
+ },
80
+ required: ['action'],
81
+ },
82
+ },
83
+ handler: async (input) => {
84
+ // TODO: perform the real action and return its outcome.
85
+ throw new Error(
86
+ `perform_action is a stub — implement it in tools.ts (action=${String(input.action)})`
87
+ );
88
+ },
89
+ },
90
+ ];