Example client (TypeScript)
A complete, dependency-free integration for Node.js 18 or later. It estimates, creates and follows a task, decides approvals, and runs a webhook receiver that verifies signatures and drops duplicate deliveries.
Save it as quickstart.ts and run it with tsx:
bash
export THREADPOOL_URL="https://threadpool.example.com"
export THREADPOOL_API_KEY="tpk_..."
npx tsx quickstart.ts run "Compare the top 5 European e-scooter operators on pricing, fleet size and funding." 120
npx tsx quickstart.ts decide <approvalId> approve "Within budget"
THREADPOOL_WEBHOOK_SECRET="whsec_..." npx tsx quickstart.ts listen 8787
The walkthrough is in Getting started; the endpoints are described in the API reference and the receiver's checks in Webhooks.
Source
ts
// ThreadPool API quickstart: a minimal end-to-end integration with no
// dependencies (Node 18+ for global fetch). Run it with tsx:
//
// export THREADPOOL_URL=https://threadpool.example.com # or http://localhost:3000
// export THREADPOOL_API_KEY=tpk_... # scopes: tasks:create tasks:view tasks:estimate (+ approvals:decide)
//
// npx tsx docs/api/examples/quickstart.ts run "Compare the top 5 European e-scooter operators" 120
// npx tsx docs/api/examples/quickstart.ts decide <approvalId> approve "Looks good"
// THREADPOOL_WEBHOOK_SECRET=whsec_... npx tsx docs/api/examples/quickstart.ts listen 8787
//
// See docs/api/getting-started.md for the walkthrough.
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const BASE_URL = (process.env.THREADPOOL_URL ?? "http://localhost:3000").replace(/\/$/, "");
const API_KEY = process.env.THREADPOOL_API_KEY ?? "";
// ── Minimal client ──────────────────────────────────────────────────────
type TaskStatus =
| "CREATED"
| "PLANNING"
| "IN_PROGRESS"
| "AWAITING_APPROVAL"
| "AWAITING_QA"
| "COMPLETED"
| "PARTIAL"
| "FAILED"
| "CANCELLING"
| "CANCELLED";
interface Approval {
id: string;
kind: "PLAN" | "HIRE";
status: "PENDING" | "APPROVED" | "REJECTED" | "EXPIRED";
amount: number | null;
reasons: string[];
expiresAt: string | null;
}
interface Task {
id: string;
status: TaskStatus;
budget: number;
spent: number;
remainingBudget: number;
steps: Array<{ sequence: number; capability: string; status: string; qaScore: number | null; agent: { name: string } | null }>;
approvals: Approval[];
result: { content: string | null; failureReason: string | null; averageQuality: number | null } | null;
}
interface Estimate {
planSource: string;
steps: Array<{ sequence: number; label: string; agent: { name: string; price: number } | null }>;
cost: { minFeasible: number; recommended: number } | null;
prediction: { expectedCost: number; expectedQuality: number; meetThresholdProbability: number } | null;
recommendations: Array<{ code: string; message: string; setBudget: number | null }>;
}
class ThreadPoolError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly requestId: string | null,
readonly details?: unknown,
) {
super(`${status} ${code}: ${message}${requestId ? ` (requestId ${requestId})` : ""}`);
}
}
const FINAL: TaskStatus[] = ["COMPLETED", "PARTIAL", "FAILED", "CANCELLED"];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function api<T>(method: "GET" | "POST", path: string, body?: unknown, headers: Record<string, string> = {}, retries = 3): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${API_KEY}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
// Rate limited: wait as instructed and retry (both per-key and per-IP 429s carry Retry-After).
if (res.status === 429 && retries > 0) {
const wait = Number(res.headers.get("retry-after") ?? "5");
console.warn(`rate limited; retrying in ${wait}s`);
await sleep(wait * 1000);
return api<T>(method, path, body, headers, retries - 1);
}
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) {
const err = typeof data.error === "object" && data.error ? data.error : { code: data.code ?? "HTTP_ERROR", message: String(data.error ?? res.statusText) };
throw new ThreadPoolError(res.status, err.code, err.message, data.requestId ?? res.headers.get("x-request-id"), err.details);
}
return data as T;
}
const estimateTask = (input: { prompt: string; budget: number; qualityThreshold?: number }) =>
api<{ estimate: Estimate }>("POST", "/api/v1/tasks/estimate", input).then((r) => r.estimate);
// The Idempotency-Key makes the create safe to retry after a timeout.
const createTask = (input: { prompt: string; budget: number; qualityThreshold?: number }, idempotencyKey: string) =>
api<{ task: Task }>("POST", "/api/v1/tasks", input, { "Idempotency-Key": idempotencyKey }).then((r) => r.task);
const getTask = (id: string) => api<{ task: Task }>("GET", `/api/v1/tasks/${encodeURIComponent(id)}`).then((r) => r.task);
const decideApproval = (id: string, decision: "approve" | "reject", reason?: string) =>
api<{ approval: Approval; taskResumed: boolean; taskCancelled: boolean }>("POST", `/api/v1/approvals/${encodeURIComponent(id)}`, { decision, ...(reason ? { reason } : {}) });
// ── Commands ────────────────────────────────────────────────────────────
async function run(prompt: string, budget: number) {
const input = { prompt, budget, qualityThreshold: 70 };
try {
const est = await estimateTask(input);
console.log(`Estimate (${est.planSource}): ${est.steps.length} steps, expected cost ${est.prediction?.expectedCost ?? "?"}, expected quality ${est.prediction?.expectedQuality ?? "?"}`);
for (const r of est.recommendations) console.log(` - ${r.message}`);
} catch (err) {
// An estimate is advisory; the task can still be created.
if (err instanceof ThreadPoolError && err.code === "ESTIMATE_UNAVAILABLE") console.warn("Estimate unavailable right now; continuing.");
else throw err;
}
const task = await createTask(input, randomUUID());
console.log(`Created task ${task.id} (${task.status})`);
let announced = new Set<string>();
for (;;) {
await sleep(5000);
const t = await getTask(task.id);
const done = t.steps.filter((s) => s.status === "DONE").length;
console.log(` ${t.status} - ${done}/${t.steps.length} steps done, spent ${t.spent}/${t.budget}`);
for (const a of t.approvals.filter((x) => x.status === "PENDING" && !announced.has(x.id))) {
console.log(` Waiting for approval ${a.id} (${a.kind}${a.amount !== null ? `, ${a.amount} tokens` : ""}, reasons: ${a.reasons.join(", ")}).`);
console.log(` Decide with: npx tsx docs/api/examples/quickstart.ts decide ${a.id} approve`);
announced = new Set([...announced, a.id]);
}
if (FINAL.includes(t.status)) {
console.log(`\nFinal status: ${t.status}`);
if (t.result?.failureReason) console.log(`Reason: ${t.result.failureReason}`);
if (t.result?.content) console.log(`\n${t.result.content}`);
return;
}
}
}
async function decide(approvalId: string, decision: string, reason?: string) {
if (decision !== "approve" && decision !== "reject") throw new Error('decision must be "approve" or "reject"');
try {
const r = await decideApproval(approvalId, decision, reason);
console.log(`Approval ${r.approval.id}: ${r.approval.status}. Task resumed: ${r.taskResumed}. Task cancelled: ${r.taskCancelled}.`);
} catch (err) {
if (err instanceof ThreadPoolError && err.code === "CONFLICT") console.log(`Already decided or expired: ${err.message}`);
else throw err;
}
}
// ── Webhook receiver ────────────────────────────────────────────────────
const TOLERANCE_SECONDS = 300;
function verifySignature(secret: string, rawBody: string, timestamp: string | undefined, signatureHeader: string | undefined): boolean {
if (!timestamp || !signatureHeader || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false; // replay protection
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatureHeader.split(",").some((part) => {
const [version, hex] = part.trim().split("=");
if (version !== "v1" || !hex || !/^[0-9a-f]{64}$/.test(hex)) return false;
return timingSafeEqual(Buffer.from(hex, "hex"), expected);
});
}
function listen(port: number) {
const secret = process.env.THREADPOOL_WEBHOOK_SECRET;
if (!secret) throw new Error("Set THREADPOOL_WEBHOOK_SECRET to the endpoint's whsec_ secret.");
// Use a database with a unique constraint in production; a Set is enough here.
const seen = new Set<string>();
createServer((req, res) => {
if (req.method !== "POST") return void res.writeHead(405).end();
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8"); // verify the RAW body, before parsing
const ok = verifySignature(secret, raw, req.headers["threadpool-webhook-timestamp"] as string | undefined, req.headers["threadpool-webhook-signature"] as string | undefined);
if (!ok) return void res.writeHead(400).end("invalid signature");
const event = JSON.parse(raw) as { id: string; type: string; data: { task: { id: string; status: string }; approval?: { id: string; kind: string } } };
res.writeHead(204).end(); // acknowledge first, then process
if (seen.has(event.id)) return; // duplicate delivery (a retry): already handled
seen.add(event.id);
console.log(`${event.type} for task ${event.data.task.id} (${event.data.task.status})`);
if (event.type === "APPROVAL_REQUESTED" && event.data.approval) {
console.log(` approval ${event.data.approval.id} (${event.data.approval.kind}) is waiting`);
}
if (["TASK_COMPLETED", "TASK_PARTIAL", "TASK_FAILED"].includes(event.type) && API_KEY) {
getTask(event.data.task.id)
.then((t) => console.log(` fetched result: ${t.result?.content?.slice(0, 120) ?? t.result?.failureReason ?? "(none)"}`))
.catch((err) => console.error(` could not fetch the task: ${err}`));
}
});
}).listen(port, () => console.log(`Listening for ThreadPool webhooks on http://localhost:${port}/`));
}
// ── Entry point ─────────────────────────────────────────────────────────
async function main() {
const [command, ...args] = process.argv.slice(2);
if (command !== "listen" && !API_KEY) throw new Error("Set THREADPOOL_API_KEY.");
switch (command) {
case "run":
return run(args[0] ?? "Summarise the main competitors in the European e-scooter market", Number(args[1] ?? 100));
case "decide":
return decide(args[0], args[1], args[2]);
case "listen":
return listen(Number(args[0] ?? 8787));
default:
console.log('Usage: quickstart.ts run "<prompt>" <budget> | decide <approvalId> approve|reject [reason] | listen [port]');
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});