agent-ticketing 0.1.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.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # agent-ticketing
2
+
3
+ Agent SDK for **Agent Mailbox** — claim tickets, heartbeat, ask the requester, complete. Zero dependencies, Node 18+.
4
+
5
+ ```bash
6
+ npm install agent-ticketing # (or use it from this repo via workspaces)
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```ts
12
+ import { MailboxAgent } from "agent-ticketing";
13
+
14
+ const mailbox = new MailboxAgent({
15
+ baseUrl: "https://agent-mailbox.<you>.workers.dev",
16
+ apiKey: process.env.MAILBOX_API_KEY, // mbx_live_… from Settings → Agents
17
+ });
18
+
19
+ await mailbox.poll(async (ticket, ctx) => {
20
+ await ctx.progress("reading the request"); // heartbeat + visible note
21
+ const env = await ctx.ask("staging or prod?"); // → pending, waits for the human
22
+ // ...do the work...
23
+ const id = await ctx.attach("./report.pdf"); // staged upload
24
+ await ctx.complete(`Deployed to ${env}`, { attachmentIds: [id] }); // → done
25
+ });
26
+ ```
27
+
28
+ ## Polling modes
29
+
30
+ ```ts
31
+ await mailbox.poll(handler); // continuous: pickup ≤ ~3s
32
+ await mailbox.poll(handler, { mode: "interval", every: "5m" }); // lazy: near-free, pickup ≤ 5m
33
+ ```
34
+
35
+ The server holds each poll cheaply (facade claim gate) — continuous polling does
36
+ NOT keep the Durable Object hot, so pick the mode by latency preference, not cost.
37
+
38
+ ## Semantics you get for free
39
+
40
+ - **Auto-heartbeat** every 45s while your handler runs (server expiry: 3 min of silence).
41
+ - **Crash recovery**: the instance id defaults to `host:<hostname>` (stable), so if
42
+ your process dies mid-ticket, the next `poll()` re-delivers the same ticket to you.
43
+ - **Claim-loss detection**: if the human resolves/cancels the ticket while you work,
44
+ `ctx.signal` aborts and the next `ctx.*` call throws `ClaimLostError` — stop quietly.
45
+ - **Handler errors → release**: the ticket goes back to human triage (unassigned) with
46
+ the error in the thread. Use `onError: "abandon"` to let the claim expire and retry
47
+ yourself later instead.
48
+ - **Returning a string** from the handler is shorthand for `ctx.complete(thatString)`.
49
+ - **Retries**: 429s honor `Retry-After`; 5xx/network errors back off exponentially.
50
+ Message posts are idempotent (client-generated ULIDs), so retries never duplicate.
51
+ - **Graceful shutdown**: SIGINT/SIGTERM stop the loop; in-flight claims are re-delivered
52
+ to this instance on restart.
53
+
54
+ ## Examples
55
+
56
+ - [`examples/simple-agent.mjs`](examples/simple-agent.mjs) — the full context API.
57
+ - [`examples/runner-claude.mjs`](examples/runner-claude.mjs) — session-based agents
58
+ (Claude Code, Codex): the runner owns the loop and heartbeats, `claude -p` owns
59
+ the thinking, the ticket is completed with its output.
60
+
61
+ ```bash
62
+ MAILBOX_URL=https://… MAILBOX_API_KEY=mbx_live_… node examples/runner-claude.mjs --interval 5m
63
+ ```
64
+
65
+ ## Low-level client
66
+
67
+ Every REST endpoint is also exposed directly if you don't want the loop:
68
+ `claimNext`, `getTicket`, `heartbeat`, `postMessage`, `complete`, `release`,
69
+ `uploadAttachment`.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @mailbox/agent — the ~300-line integration an agent needs (docs/06).
3
+ *
4
+ * const mailbox = new MailboxAgent({ baseUrl, apiKey });
5
+ * await mailbox.poll(async (ticket, ctx) => {
6
+ * await ctx.progress("reading the request");
7
+ * const answer = await ctx.ask("prod or staging?");
8
+ * await ctx.complete(`done — deployed to ${answer}`);
9
+ * });
10
+ *
11
+ * Handles: long-poll loop (continuous or interval), auto-heartbeat, retry with
12
+ * backoff (respects Retry-After), idempotent message IDs, crash recovery
13
+ * (stable instance id ⇒ re-claiming your in-flight ticket), claim-loss
14
+ * detection (human resolved/cancelled ⇒ ctx.signal aborts), graceful SIGTERM.
15
+ */
16
+ export type TicketStatus = "new" | "open" | "pending" | "done" | "resolved" | "cancelled";
17
+ export type Ticket = {
18
+ id: string;
19
+ subject: string;
20
+ status: TicketStatus;
21
+ requesterId: string;
22
+ assigneeAgentId: string | null;
23
+ claim: {
24
+ agentId: string;
25
+ agentInstanceId: string;
26
+ claimedAt: number;
27
+ lastHeartbeatAt: number;
28
+ } | null;
29
+ createdAt: number;
30
+ updatedAt: number;
31
+ doneAt: number | null;
32
+ metadata: Record<string, string>;
33
+ };
34
+ export type Message = {
35
+ id: string;
36
+ ticketId: string;
37
+ author: {
38
+ type: "human" | "agent" | "system";
39
+ id: string;
40
+ };
41
+ kind: "message" | "progress" | "status_change";
42
+ body: string;
43
+ bodyFormat: "markdown" | "text";
44
+ replyToMessageId: string | null;
45
+ attachments: {
46
+ id: string;
47
+ filename: string;
48
+ contentType: string;
49
+ size: number;
50
+ }[];
51
+ createdAt: number;
52
+ };
53
+ export type Claimed = {
54
+ ticket: Ticket;
55
+ messages: Message[];
56
+ };
57
+ export type MailboxConfig = {
58
+ baseUrl: string;
59
+ apiKey: string;
60
+ /** Stable id per running instance. Default: "host:<hostname>" — stable across
61
+ * restarts so an interrupted ticket is re-delivered to you (crash recovery). */
62
+ agentInstanceId?: string;
63
+ };
64
+ export type MailboxEvent = {
65
+ ts: string;
66
+ event: "poll_start" | "claimed" | "completed" | "released" | "claim_lost" | "handler_error" | "poll_error";
67
+ instanceId: string;
68
+ ticketId?: string;
69
+ subject?: string;
70
+ /** For errors: message, and HTTP status/code when the server rejected a call. */
71
+ detail?: {
72
+ message?: string;
73
+ status?: number;
74
+ code?: string;
75
+ };
76
+ };
77
+ export type PollOptions = {
78
+ /** "continuous": re-poll immediately (pickup ≤ ~3s). "interval": sleep between
79
+ * empty polls — near-free, pickup within the interval. */
80
+ mode?: "continuous" | "interval";
81
+ /** Interval-mode sleep between empty polls: ms or "30s" | "5m" | "1h". Default "5m". */
82
+ every?: number | string;
83
+ /** Long-poll hold per request, 1–30s. Default 25. */
84
+ timeoutSec?: number;
85
+ /** Auto-heartbeat cadence while a handler runs. Default 45s (server expiry 3min). */
86
+ heartbeatSeconds?: number;
87
+ /** Handler threw: "release" hands the ticket back to human triage (default);
88
+ * "abandon" leaves the claim to expire so THIS agent retries it later. */
89
+ onError?: "release" | "abandon";
90
+ /** Stop polling (also wired to SIGINT/SIGTERM unless handleSignals: false). */
91
+ signal?: AbortSignal;
92
+ handleSignals?: boolean;
93
+ /** Called for lifecycle logs. Default: console.error. */
94
+ log?: (msg: string) => void;
95
+ /** Structured lifecycle events (NDJSON-friendly) — every event carries the
96
+ * ticket ULID so client logs cross-reference the server-side thread history. */
97
+ onEvent?: (e: MailboxEvent) => void;
98
+ };
99
+ export type TicketContext = {
100
+ ticket: Ticket;
101
+ messages: Message[];
102
+ /** Aborts when the claim is lost (expired, or human resolved/cancelled/withdrew). */
103
+ signal: AbortSignal;
104
+ /** Heartbeat with a visible progress note (collapsed in the UI timeline). */
105
+ progress(note: string): Promise<void>;
106
+ /** Post a markdown message to the thread. */
107
+ post(body: string, opts?: {
108
+ attachmentIds?: string[];
109
+ }): Promise<void>;
110
+ /** Ask the human (ticket → pending) and WAIT for their reply. Returns the reply body. */
111
+ ask(question: string, opts?: {
112
+ pollSeconds?: number;
113
+ }): Promise<string>;
114
+ /** Upload a file; returns a staged attachmentId to pass to post()/complete(). */
115
+ attach(filePath: string, contentType?: string): Promise<string>;
116
+ /** Mark the work done (ticket → done, awaiting human review). Ends the handler's claim. */
117
+ complete(summary?: string, opts?: {
118
+ attachmentIds?: string[];
119
+ }): Promise<void>;
120
+ /** Give the ticket back to human triage (unassigns it). Ends the handler's claim. */
121
+ release(reason?: string): Promise<void>;
122
+ };
123
+ export declare class ClaimLostError extends Error {
124
+ constructor(message?: string);
125
+ }
126
+ export declare class MailboxApiError extends Error {
127
+ status: number;
128
+ code: string;
129
+ constructor(status: number, code: string, message: string);
130
+ }
131
+ export declare class MailboxAgent {
132
+ #private;
133
+ readonly baseUrl: string;
134
+ readonly instanceId: string;
135
+ constructor(cfg: MailboxConfig);
136
+ claimNext(timeoutSec?: number): Promise<Claimed | null>;
137
+ getTicket(ticketId: string): Promise<Claimed>;
138
+ heartbeat(ticketId: string, note?: string): Promise<void>;
139
+ postMessage(ticketId: string, body: string, opts?: {
140
+ kind?: "message" | "progress";
141
+ askRequester?: boolean;
142
+ attachmentIds?: string[];
143
+ }): Promise<Message>;
144
+ complete(ticketId: string, summary?: string): Promise<Ticket>;
145
+ release(ticketId: string, reason?: string): Promise<Ticket>;
146
+ uploadAttachment(ticketId: string, file: {
147
+ filename: string;
148
+ contentType?: string;
149
+ data: Uint8Array | string;
150
+ }): Promise<string>;
151
+ poll(handler: (ticket: Ticket, ctx: TicketContext) => Promise<void | string>, opts?: PollOptions): Promise<void>;
152
+ }
153
+ export declare function ulid(now?: number): string;
package/dist/index.js ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * @mailbox/agent — the ~300-line integration an agent needs (docs/06).
3
+ *
4
+ * const mailbox = new MailboxAgent({ baseUrl, apiKey });
5
+ * await mailbox.poll(async (ticket, ctx) => {
6
+ * await ctx.progress("reading the request");
7
+ * const answer = await ctx.ask("prod or staging?");
8
+ * await ctx.complete(`done — deployed to ${answer}`);
9
+ * });
10
+ *
11
+ * Handles: long-poll loop (continuous or interval), auto-heartbeat, retry with
12
+ * backoff (respects Retry-After), idempotent message IDs, crash recovery
13
+ * (stable instance id ⇒ re-claiming your in-flight ticket), claim-loss
14
+ * detection (human resolved/cancelled ⇒ ctx.signal aborts), graceful SIGTERM.
15
+ */
16
+ import { hostname } from "node:os";
17
+ import { readFile } from "node:fs/promises";
18
+ import { basename } from "node:path";
19
+ export class ClaimLostError extends Error {
20
+ constructor(message = "claim lost (ticket resolved, cancelled, or expired)") {
21
+ super(message);
22
+ this.name = "ClaimLostError";
23
+ }
24
+ }
25
+ export class MailboxApiError extends Error {
26
+ status;
27
+ code;
28
+ constructor(status, code, message) {
29
+ super(message);
30
+ this.status = status;
31
+ this.code = code;
32
+ this.name = "MailboxApiError";
33
+ }
34
+ }
35
+ // ── client ────────────────────────────────────────────────────────────────────
36
+ export class MailboxAgent {
37
+ baseUrl;
38
+ instanceId;
39
+ #apiKey;
40
+ constructor(cfg) {
41
+ this.baseUrl = cfg.baseUrl.replace(/\/$/, "");
42
+ this.#apiKey = cfg.apiKey;
43
+ this.instanceId = cfg.agentInstanceId ?? `host:${hostname()}`;
44
+ }
45
+ // ── low-level API (1:1 with docs/03-api.md) ────────────────────────────────
46
+ async claimNext(timeoutSec = 25) {
47
+ const res = await this.#request("POST", "/v1/tickets/claim", {
48
+ agentInstanceId: this.instanceId,
49
+ timeoutSec,
50
+ });
51
+ if (res.status === 204)
52
+ return null;
53
+ return (await res.json());
54
+ }
55
+ async getTicket(ticketId) {
56
+ const res = await this.#request("GET", `/v1/tickets/${ticketId}`);
57
+ return (await res.json());
58
+ }
59
+ async heartbeat(ticketId, note) {
60
+ await this.#request("POST", `/v1/tickets/${ticketId}/heartbeat`, {
61
+ agentInstanceId: this.instanceId,
62
+ note,
63
+ });
64
+ }
65
+ async postMessage(ticketId, body, opts = {}) {
66
+ const res = await this.#request("POST", `/v1/tickets/${ticketId}/messages`, {
67
+ id: ulid(),
68
+ agentInstanceId: this.instanceId,
69
+ body,
70
+ kind: opts.kind,
71
+ askRequester: opts.askRequester,
72
+ attachmentIds: opts.attachmentIds,
73
+ });
74
+ return (await res.json()).message;
75
+ }
76
+ async complete(ticketId, summary) {
77
+ const res = await this.#request("POST", `/v1/tickets/${ticketId}/complete`, {
78
+ agentInstanceId: this.instanceId,
79
+ summary,
80
+ });
81
+ return (await res.json()).ticket;
82
+ }
83
+ async release(ticketId, reason) {
84
+ const res = await this.#request("POST", `/v1/tickets/${ticketId}/release`, {
85
+ agentInstanceId: this.instanceId,
86
+ reason,
87
+ });
88
+ return (await res.json()).ticket;
89
+ }
90
+ async uploadAttachment(ticketId, file) {
91
+ const params = new URLSearchParams({
92
+ filename: file.filename,
93
+ contentType: file.contentType ?? "application/octet-stream",
94
+ });
95
+ const body = typeof file.data === "string" ? new TextEncoder().encode(file.data) : file.data;
96
+ const res = await fetch(`${this.baseUrl}/v1/tickets/${ticketId}/attachments?${params}`, {
97
+ method: "POST",
98
+ headers: {
99
+ Authorization: `Bearer ${this.#apiKey}`,
100
+ "Content-Length": String(body.byteLength),
101
+ },
102
+ body,
103
+ });
104
+ if (!res.ok)
105
+ throw await toApiError(res);
106
+ return (await res.json()).attachment.id;
107
+ }
108
+ // ── the poll loop (docs/06 Layer 1) ────────────────────────────────────────
109
+ async poll(handler, opts = {}) {
110
+ const mode = opts.mode ?? "continuous";
111
+ const everyMs = parseDuration(opts.every ?? "5m");
112
+ const timeoutSec = Math.min(Math.max(opts.timeoutSec ?? 25, 1), 30);
113
+ const heartbeatMs = (opts.heartbeatSeconds ?? 45) * 1000;
114
+ const onError = opts.onError ?? "release";
115
+ const log = opts.log ?? ((m) => console.error(`[mailbox] ${m}`));
116
+ const emit = (e) => opts.onEvent?.({ ts: new Date().toISOString(), instanceId: this.instanceId, ...e });
117
+ const stop = new AbortController();
118
+ if (opts.signal)
119
+ opts.signal.addEventListener("abort", () => stop.abort(), { once: true });
120
+ if (opts.handleSignals !== false) {
121
+ const onSignal = () => {
122
+ log("shutting down (claim will be re-delivered to this instance on restart)");
123
+ stop.abort();
124
+ };
125
+ process.once("SIGINT", onSignal);
126
+ process.once("SIGTERM", onSignal);
127
+ }
128
+ log(`polling ${this.baseUrl} as instance "${this.instanceId}" (${mode})`);
129
+ emit({ event: "poll_start" });
130
+ while (!stop.signal.aborted) {
131
+ let claimed = null;
132
+ try {
133
+ claimed = await this.claimNext(timeoutSec);
134
+ }
135
+ catch (e) {
136
+ log(`claim failed: ${String(e)} — backing off 10s`);
137
+ emit({ event: "poll_error", detail: errDetail(e) });
138
+ await sleep(10_000, stop.signal);
139
+ continue;
140
+ }
141
+ if (!claimed) {
142
+ if (mode === "interval")
143
+ await sleep(everyMs, stop.signal);
144
+ continue;
145
+ }
146
+ await this.#handleTicket(claimed, handler, { heartbeatMs, onError, log, emit, stop: stop.signal });
147
+ // after finishing a ticket, poll again immediately in both modes —
148
+ // there may be more queued work.
149
+ }
150
+ }
151
+ async #handleTicket(claimed, handler, o) {
152
+ const { ticket } = claimed;
153
+ const claimLost = new AbortController();
154
+ let finished = false;
155
+ o.log(`claimed ${ticket.id}: ${ticket.subject}`);
156
+ o.emit({ event: "claimed", ticketId: ticket.id, subject: ticket.subject });
157
+ const heartbeatTimer = setInterval(() => {
158
+ this.heartbeat(ticket.id).catch((e) => {
159
+ if (e instanceof MailboxApiError && (e.status === 409 || e.status === 404)) {
160
+ o.log(`claim lost on ${ticket.id}: ${e.message}`);
161
+ o.emit({ event: "claim_lost", ticketId: ticket.id, detail: errDetail(e) });
162
+ claimLost.abort();
163
+ clearInterval(heartbeatTimer);
164
+ }
165
+ // transient errors: next heartbeat retries; server allows 3min of silence
166
+ });
167
+ }, o.heartbeatMs);
168
+ const self = this;
169
+ const ctx = {
170
+ ticket,
171
+ messages: claimed.messages,
172
+ signal: claimLost.signal,
173
+ async progress(note) {
174
+ assertLive(claimLost.signal);
175
+ await self.heartbeat(ticket.id, note);
176
+ },
177
+ async post(body, opts) {
178
+ assertLive(claimLost.signal);
179
+ await self.postMessage(ticket.id, body, { attachmentIds: opts?.attachmentIds });
180
+ },
181
+ async ask(question, opts) {
182
+ assertLive(claimLost.signal);
183
+ const asked = await self.postMessage(ticket.id, question, { askRequester: true });
184
+ const pollMs = (opts?.pollSeconds ?? 5) * 1000;
185
+ for (;;) {
186
+ await sleep(pollMs, claimLost.signal);
187
+ assertLive(claimLost.signal);
188
+ const { ticket: t, messages } = await self.getTicket(ticket.id);
189
+ if (t.status === "resolved" || t.status === "cancelled")
190
+ throw new ClaimLostError(`ticket ${t.status}`);
191
+ const reply = messages.find((m) => m.author.type === "human" && m.id > asked.id);
192
+ if (reply)
193
+ return reply.body;
194
+ }
195
+ },
196
+ async attach(filePath, contentType) {
197
+ assertLive(claimLost.signal);
198
+ const data = new Uint8Array(await readFile(filePath));
199
+ return self.uploadAttachment(ticket.id, { filename: basename(filePath), contentType, data });
200
+ },
201
+ async complete(summary, opts) {
202
+ assertLive(claimLost.signal);
203
+ if (opts?.attachmentIds?.length) {
204
+ await self.postMessage(ticket.id, summary ?? "attached results", { attachmentIds: opts.attachmentIds });
205
+ await self.complete(ticket.id);
206
+ }
207
+ else {
208
+ await self.complete(ticket.id, summary);
209
+ }
210
+ finished = true;
211
+ o.emit({ event: "completed", ticketId: ticket.id });
212
+ },
213
+ async release(reason) {
214
+ assertLive(claimLost.signal);
215
+ await self.release(ticket.id, reason);
216
+ finished = true;
217
+ o.emit({ event: "released", ticketId: ticket.id, detail: { message: reason } });
218
+ },
219
+ };
220
+ try {
221
+ const returned = await handler(ticket, ctx);
222
+ if (!finished && !claimLost.signal.aborted) {
223
+ if (typeof returned === "string") {
224
+ await self.complete(ticket.id, returned); // returning a string = the summary
225
+ o.emit({ event: "completed", ticketId: ticket.id });
226
+ }
227
+ else {
228
+ o.log(`handler returned without complete/release — releasing ${ticket.id} to triage`);
229
+ await self.release(ticket.id, "agent handler finished without completing");
230
+ o.emit({ event: "released", ticketId: ticket.id, detail: { message: "handler finished without completing" } });
231
+ }
232
+ }
233
+ o.log(`finished ${ticket.id}`);
234
+ }
235
+ catch (e) {
236
+ if (claimLost.signal.aborted || e instanceof ClaimLostError) {
237
+ o.log(`stopped working ${ticket.id}: claim lost`);
238
+ o.emit({ event: "claim_lost", ticketId: ticket.id, detail: errDetail(e) });
239
+ }
240
+ else {
241
+ o.log(`handler error on ${ticket.id}: ${String(e)}`);
242
+ o.emit({ event: "handler_error", ticketId: ticket.id, detail: errDetail(e) });
243
+ if (o.onError === "release" && !finished) {
244
+ await this.release(ticket.id, `agent error: ${truncate(String(e), 500)}`).catch(() => undefined);
245
+ o.emit({ event: "released", ticketId: ticket.id, detail: { message: "released after handler error" } });
246
+ }
247
+ // "abandon": say nothing; the visibility timeout reverts the claim and
248
+ // this same instance will pick it up again later.
249
+ }
250
+ }
251
+ finally {
252
+ clearInterval(heartbeatTimer);
253
+ }
254
+ }
255
+ // ── internals ──────────────────────────────────────────────────────────────
256
+ async #request(method, path, body) {
257
+ let attempt = 0;
258
+ for (;;) {
259
+ attempt++;
260
+ let res;
261
+ try {
262
+ res = await fetch(`${this.baseUrl}${path}`, {
263
+ method,
264
+ headers: { Authorization: `Bearer ${this.#apiKey}`, "Content-Type": "application/json" },
265
+ body: body === undefined ? undefined : JSON.stringify(body),
266
+ });
267
+ }
268
+ catch (e) {
269
+ if (attempt >= 5)
270
+ throw e;
271
+ await sleep(backoff(attempt)); // network blip
272
+ continue;
273
+ }
274
+ if (res.ok || res.status === 204)
275
+ return res;
276
+ if (res.status === 429) {
277
+ const retryAfter = Number(res.headers.get("Retry-After") ?? "30");
278
+ await sleep(retryAfter * 1000);
279
+ continue;
280
+ }
281
+ if (res.status >= 500 && attempt < 5) {
282
+ await sleep(backoff(attempt));
283
+ continue;
284
+ }
285
+ throw await toApiError(res); // 4xx: caller's problem (message ids make retries idempotent anyway)
286
+ }
287
+ }
288
+ }
289
+ function errDetail(e) {
290
+ if (e instanceof MailboxApiError)
291
+ return { message: e.message, status: e.status, code: e.code };
292
+ return { message: e instanceof Error ? e.message : String(e) };
293
+ }
294
+ function assertLive(signal) {
295
+ if (signal.aborted)
296
+ throw new ClaimLostError();
297
+ }
298
+ async function toApiError(res) {
299
+ let code = "error";
300
+ let message = res.statusText;
301
+ try {
302
+ const body = (await res.json());
303
+ if (body.error)
304
+ ({ code, message } = body.error);
305
+ }
306
+ catch {
307
+ // non-JSON body
308
+ }
309
+ return new MailboxApiError(res.status, code, message);
310
+ }
311
+ function backoff(attempt) {
312
+ return Math.min(1000 * 2 ** (attempt - 1), 30_000) * (0.5 + Math.random() * 0.5);
313
+ }
314
+ function sleep(ms, signal) {
315
+ return new Promise((resolve) => {
316
+ const t = setTimeout(done, ms);
317
+ function done() {
318
+ signal?.removeEventListener("abort", done);
319
+ clearTimeout(t);
320
+ resolve();
321
+ }
322
+ signal?.addEventListener("abort", done, { once: true });
323
+ });
324
+ }
325
+ function parseDuration(v) {
326
+ if (typeof v === "number")
327
+ return v;
328
+ const m = /^(\d+)(ms|s|m|h)$/.exec(v.trim());
329
+ if (!m)
330
+ throw new Error(`invalid duration: "${v}" (use e.g. 30000, "30s", "5m", "1h")`);
331
+ const n = Number(m[1]);
332
+ return { ms: n, s: n * 1000, m: n * 60_000, h: n * 3_600_000 }[m[2]];
333
+ }
334
+ function truncate(s, n) {
335
+ return s.length > n ? `${s.slice(0, n)}…` : s;
336
+ }
337
+ // ULID (Crockford base32, time-sortable) — message idempotency keys.
338
+ const ENC = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
339
+ export function ulid(now = Date.now()) {
340
+ let time = "";
341
+ let t = now;
342
+ for (let i = 0; i < 10; i++) {
343
+ time = ENC[t % 32] + time;
344
+ t = Math.floor(t / 32);
345
+ }
346
+ const bytes = crypto.getRandomValues(new Uint8Array(10));
347
+ let rand = "";
348
+ let bits = 0;
349
+ let acc = 0;
350
+ for (const b of bytes) {
351
+ acc = (acc << 8) | b;
352
+ bits += 8;
353
+ while (bits >= 5) {
354
+ rand += ENC[(acc >>> (bits - 5)) & 31];
355
+ bits -= 5;
356
+ acc &= (1 << bits) - 1;
357
+ }
358
+ }
359
+ return time + rand;
360
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/runner.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mailbox-claude-runner — autonomous pickup for session-based agents.
4
+ * Polls the mailbox; per ticket, launches a headless Claude Code session with
5
+ * the thread as prompt and completes the ticket with its output.
6
+ *
7
+ * MAILBOX_URL=https://…workers.dev MAILBOX_API_KEY=mbx_live_… \
8
+ * npx @mailbox/agent # or: mailbox-claude-runner [--interval 5m] [--once]
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import { appendFileSync } from "node:fs";
12
+ import { promisify } from "node:util";
13
+ import { MailboxAgent } from "./index.js";
14
+ const exec = promisify(execFile);
15
+ const baseUrl = process.env.MAILBOX_URL;
16
+ const apiKey = process.env.MAILBOX_API_KEY;
17
+ if (!baseUrl || !apiKey) {
18
+ console.error("Set MAILBOX_URL and MAILBOX_API_KEY (Settings → Agents in the mailbox UI)");
19
+ process.exit(1);
20
+ }
21
+ const args = process.argv.slice(2);
22
+ const interval = args.includes("--interval") ? args[args.indexOf("--interval") + 1] : null;
23
+ const once = args.includes("--once");
24
+ const claudeCmd = process.env.CLAUDE_CMD ?? "claude";
25
+ // MAILBOX_LOG_FILE=agent.ndjson → one JSON event per line (ts, event, ticketId…)
26
+ // for grep-able history that cross-references the server-side ticket threads.
27
+ const logFile = process.env.MAILBOX_LOG_FILE;
28
+ const onEvent = logFile
29
+ ? (e) => {
30
+ try {
31
+ appendFileSync(logFile, JSON.stringify(e) + "\n");
32
+ }
33
+ catch (err) {
34
+ console.error(`[mailbox] could not write ${logFile}: ${String(err)}`);
35
+ }
36
+ }
37
+ : undefined;
38
+ const mailbox = new MailboxAgent({ baseUrl, apiKey });
39
+ function promptFor(ticket, messages) {
40
+ const thread = messages
41
+ .filter((m) => m.kind === "message")
42
+ .map((m) => `${m.author.type === "human" ? "REQUESTER" : m.author.type.toUpperCase()}: ${m.body}`)
43
+ .join("\n\n");
44
+ return [
45
+ `You are working a ticket from a ticketing system. Do the task and reply with your final result.`,
46
+ `Your reply is posted back to the requester as the ticket resolution — be complete but concise.`,
47
+ ``,
48
+ `TICKET: ${ticket.subject}`,
49
+ ``,
50
+ thread,
51
+ ].join("\n");
52
+ }
53
+ const controller = new AbortController();
54
+ await mailbox.poll(async (ticket, ctx) => {
55
+ await ctx.progress("launching headless Claude Code session");
56
+ const { stdout } = await exec(claudeCmd, ["-p", promptFor(ticket, ctx.messages), "--output-format", "text"], {
57
+ timeout: 10 * 60 * 1000,
58
+ maxBuffer: 10 * 1024 * 1024,
59
+ signal: ctx.signal,
60
+ });
61
+ const result = stdout.trim();
62
+ if (!result)
63
+ throw new Error("claude produced no output");
64
+ await ctx.complete(result.slice(0, 60_000));
65
+ if (once)
66
+ controller.abort();
67
+ }, {
68
+ mode: interval ? "interval" : "continuous",
69
+ every: interval ?? "5m",
70
+ onError: "release",
71
+ signal: controller.signal,
72
+ onEvent,
73
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "agent-ticketing",
3
+ "version": "0.1.0",
4
+ "description": "Agent SDK for Agent Mailbox — poll tickets, heartbeat, ask the requester, complete. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "typescript": "^5.8.0"
28
+ },
29
+ "bin": {
30
+ "mailbox-claude-runner": "./dist/runner.js"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/terryds/agent-mailbox"
35
+ },
36
+ "license": "MIT",
37
+ "keywords": [
38
+ "ai-agents",
39
+ "ticketing",
40
+ "mcp",
41
+ "claude-code"
42
+ ]
43
+ }