@heatloop/sdk
v0.1.1
Published
Typed Node.js client for the Heatloop API. Wraps auth, polling, webhook verification, SSE, and the rating loop with end-to-end response typing.
Maintainers
Readme
@heatloop/sdk
Typed Node.js client for the Heatloop API. Wraps auth,
adaptive polling, webhook verification, SSE, and the mandatory rating loop —
with end-to-end response typing keyed on your own response_schema.
npm install @heatloop/sdkRequires Node 20+.
Contents
- Quickstart
- Schemas
- Polling
- Rating loop
- Multi-respondent consensus
- Templates
- Server-Sent Events
- Webhooks
- Errors
- Configuration
Quickstart
import { Heatloop, defineSchema, score, verdict } from "@heatloop/sdk";
const heatloop = new Heatloop({ apiKey: process.env.HEATLOOP_API_KEY! });
const submission = await heatloop.tasks.submit({
type: "ui-review",
prompt: "Review this landing page.",
responseSchema: defineSchema({
visual_hierarchy: score(1, 10),
typography: score(1, 10),
verdict: verdict(),
}),
skillTags: ["ui-review"],
budgetPerResponse: 1.0,
});
const result = await submission.waitForResult();
console.log(result.responses[0].scores.visual_hierarchy); // typed: number
console.log(result.responses[0].verdict); // typed: "pass" | "fail" | "conditional"
await result.responses[0].rate("useful");Schemas
Schema builders generate the response_schema wire format and thread the
result type all the way through to result.responses[i] — no manual generics.
import {
boolean, defineSchema, flags, multiselect,
number, score, select, text, verdict,
} from "@heatloop/sdk";
const schema = defineSchema({
visual_hierarchy: score(1, 10),
typography: score(1, 10),
contrast_pass: boolean(),
primary_cta: select(["click", "scroll", "ignore"] as const),
flags_seen: flags(["broken-link", "low-contrast", "missing-alt"] as const),
verdict: verdict(),
notes: text({ maxLength: 280 }),
});Each builder returns a strongly-typed SchemaField; defineSchema collects
them into a Schema whose InferResponse<T> you can use anywhere.
Polling
submission.waitForResult() polls GET /v1/tasks/:id with the API's
retry_after_seconds hint, falling back to an adaptive cadence that gets
patient as the SLA approaches. Cancel with an AbortSignal:
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 60_000);
const result = await submission.waitForResult({ signal: ctrl.signal });If the task expires, is cancelled, or hits a terminal API error, the promise
rejects with a typed error (TaskExpired, TaskCancelled, RateLimited, …).
Rating loop
Every response must be rated within 24 hours — the rating drives Looper
reputation. unusable requires a reason both at the type layer and at runtime:
await response.rate("useful");
await response.rate("partial", { reason: "Missed the brand voice." });
await response.rate("unusable", { reason: "Wrong domain." });
// await response.rate("unusable"); // ← fails to compile
// After 24h, rate() throws RatingWindowExpired.Multi-respondent consensus
Tasks with maxRespondents > 1 get an aggregation payload on the terminal
result that lets you act on the cohort without inspecting every response:
const result = await submission.waitForResult();
if (result.aggregation) {
const agg = result.aggregation;
console.log(agg.verdict_consensus); // "pass" | "fail" | "conditional"
console.log(agg.verdict_agreement); // 0–1, share matching the consensus
console.log(agg.consensus_confidence); // "high" | "medium" | "low"
console.log(agg.consensus_score); // 0–1, composite of agreement + score coherence
// Per-dimension stats
console.log(agg.score_stats.quality); // { n, mean, median, min, max, stddev }
// Which flags multiple loopers raised
for (const [flag, { count, share }] of Object.entries(agg.flags_raised)) {
if (share >= 0.5) console.log(`${flag} flagged by majority (${count})`);
}
}The composite score weights verdict agreement at 60% and per-dimension score
coherence at 40%. Treat low confidence as "needs human review of the raw
responses" rather than as a weak signal — it specifically catches genuinely
split cohorts.
Templates
For repeated task shapes, sponsors save a template once and agents reference it by id:
const submission = await heatloop.tasks.submit({
templateId: "tpl_ui_review",
context: "SaaS landing page",
attachments: [{ type: "image/png", url: "https://...", label: "shot", size_bytes: 240_000 }],
});For programmatic management (CI scripts):
const templates = await heatloop.sponsor.templates.list();
const tpl = await heatloop.sponsor.templates.create({
name: "UI Review",
type: "ui-review",
prompt: "Review this design.",
responseSchema: schema, // a defineSchema(...) result, or raw wire shape
defaultSkillTags: ["ui-review"],
defaultBudgetPerResponse: 1.0,
});
await heatloop.sponsor.templates.delete(tpl.id);Auth caveat:
/v1/sponsor/templatesendpoints currently authenticate against a sponsor session (cookie or, in dev, theX-Sponsor-Idheader), not the agent API key. Pass session credentials viarequestOptions.headersuntil the API hardens to accept Bearer keys on sponsor routes. The agent-facing path (tasks.submit({ templateId })) uses the API key directly and is unaffected.
Server-Sent Events
For callback_mode: "sse" tasks (or alongside polling), tasks.events()
returns an async iterable over typed events:
const ctrl = new AbortController();
for await (const event of heatloop.tasks.events(taskId, { signal: ctrl.signal })) {
switch (event.type) {
case "task.status":
console.log(`Initial status: ${event.payload.status}`);
break;
case "task.response_received":
console.log(`Response ${event.payload.response_id} received`);
break;
case "task.completed":
console.log("Done.");
break;
}
}
// Iteration ends on a terminal event (completed / cancelled / expired)
// or when ctrl.abort() fires.Auto-reconnects with exponential backoff (default 5 attempts) on transient
drops; 4xx errors propagate immediately. Pass { reconnect: { attempts: 1 } }
to disable.
Webhooks
callback_mode: "webhook" deliveries are signed with HMAC-SHA256 over the raw
JSON body, keyed on the per-task webhook_secret returned at submission time
(submission.webhookSecret). Use constructEvent to verify and parse in one
call:
import express from "express";
import { Heatloop, WebhookSignatureError } from "@heatloop/sdk";
const heatloop = new Heatloop({ apiKey: process.env.HEATLOOP_API_KEY! });
const app = express();
app.post(
"/heatloop",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = heatloop.webhooks.constructEvent({
rawBody: req.body,
signature: req.header("x-heatloop-signature") ?? "",
eventType: req.header("x-heatloop-event") ?? "",
secret: process.env.HEATLOOP_WEBHOOK_SECRET!,
});
if (event.type === "task.completed") {
// event.payload is typed via the discriminated union
console.log(`Task ${event.payload.task_id} completed.`);
}
res.status(200).end();
} catch (err) {
if (err instanceof WebhookSignatureError) {
return res.status(400).json({ reason: err.reason });
}
throw err;
}
},
);The same constructEvent works for Fastify (@fastify/raw-body), bare Node
(req stream → buffer), Hono, etc. — anywhere you can give it the raw bytes
and the two headers.
For a boolean-only check without parsing, use
heatloop.webhooks.verify({ rawBody, signature, secret }).
Errors
All thrown errors inherit from HeatloopError. Catch the base for blanket
handling, or narrow on a specific class:
| Class | When |
| ----------------------- | ---------------------------------------------------- |
| ValidationError | 400 — request shape rejected |
| Unauthorized | 401 — missing/invalid API key |
| Forbidden | 403 — key lacks permission |
| BudgetExhausted | 402 — sponsor's monthly budget cap hit |
| NotFound | 404 — task / template id unknown |
| Conflict | 409 — concurrent state change |
| RateLimited | 429 — surfaces retryAfterSeconds |
| ServerError | 5xx — retryable; SDK retries automatically |
| TaskExpired | terminal — task hit its expiry without responses |
| TaskCancelled | terminal — task was cancelled |
| RatingWindowExpired | response.rate() called past the 24h window |
| WebhookSignatureError | signature mismatch / missing secret / replay |
import { BudgetExhausted, RateLimited } from "@heatloop/sdk";
try {
await heatloop.tasks.submit({ ... });
} catch (err) {
if (err instanceof BudgetExhausted) return alertSponsor();
if (err instanceof RateLimited) return setTimeout(retry, err.retryAfterSeconds * 1000);
throw err;
}Configuration
const heatloop = new Heatloop({
apiKey: process.env.HEATLOOP_API_KEY!,
baseUrl: "https://api.heatloop.ai", // default
timeout: 30_000, // per-request ms, default 30s
retry: {
attempts: 4, // total tries; { attempts: 1 } disables
baseDelayMs: 1_000, // first retry wait, doubles per attempt with jitter
maxDelayMs: 30_000, // cap on per-attempt wait
},
fetch: customFetch, // override (e.g. undici, polyfill)
});429 responses honor Retry-After; 5xx uses exponential backoff with full
jitter. 400-class errors short-circuit retries.
Why an SDK
The Heatloop REST API is straightforward, but every TypeScript agent ends up
rebuilding the same plumbing: adaptive polling, HMAC-SHA256 webhook
verification, the mandatory rating loop, and end-to-end response typing keyed
on the agent's own response_schema. This package ships those once for the
whole ecosystem.
License
MIT
