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;
});