@steadlake/task
v0.8.2
Published
Durable task execution SDK for Steadlake — define tasks with steps, retries, scheduling, and event-driven workflows.
Downloads
837
Readme
Steadlake Task SDK
Durable task execution for your backend. Write task() + step.* in your app; the hosted control plane handles retries, queues, sleeps, events, approvals, and run history.
Your code stays in your process — Next.js, Hono, Node, or Cloudflare Workers. The platform does not host a separate task runtime.
Full API: docs/USAGE.md.
Install
npm install @steadlake/taskSTEADLAKE_API_KEY=sk_...
STEADLAKE_PROJECT_ID=proj_...
STEADLAKE_ENV=dev # local laptop → dashboard "dev"
# STEADLAKE_ENV=prod # default for a deployed appQuick start
1. Define a task
// tasks/send-email.ts
import { task } from "@steadlake/task";
import { z } from "zod";
export const sendEmail = task({
id: "send-email",
schema: z.object({ to: z.string().email(), subject: z.string() }),
run: async ({ payload }) => {
await emailService.send(payload.to, payload.subject);
return { sent: true };
},
});2. Connect your app (no public URL)
The app pulls work from the hosted API. Local next dev needs no tunnel and no extra port.
// instrumentation.ts or your server boot — do not await connect()
import { createConnectHandler } from "@steadlake/task/adapters/connect";
import { sendEmail } from "@/tasks/send-email";
const worker = createConnectHandler({ tasks: [sendEmail] });
void worker.connect();connect() resolves only after its AbortSignal aborts. Awaiting it in instrumentation.ts or another startup hook prevents that path from completing. Use a detached loop in the app process; await worker.connect() only in a dedicated worker-process entry point.
next dev # only thisRuns show up on the hosted dashboard under STEADLAKE_ENV (dev on a laptop, prod in the deployed app). Same repo, same process, two keys/environments.
HTTP inbound adapters (createNextHandler, createHandler, createWorkerHandler) still work when DEW can POST a public baseUrl — see USAGE.
3. Trigger
await sendEmail.trigger({ to: "[email protected]", subject: "Welcome!" });What you get
| You write | Platform guarantee |
|---|---|
| step.run | Durable by default. Connect/inbound adapters persist each completed step (persistSteps). Without that, concurrent step.run calls share one checkpoint. { durable: false } for cheap compute. Per-step retry and timeout (pass { signal } into fn so external work can stop). |
| step.activity | Single-permit side effect. A lost result is unknown, not a blind retry. |
| step.sleep / sleepUntil | Durable timer. No compute while waiting. |
| step.waitForEvent | Broadcast wait + CEL match (data.amount > 100 && data.status == "paid"). |
| step.waitForSignal | Targeted resume by run id. |
| step.approve | Human gate. UI can resume with a capability token — no API key in the browser. |
| step.invoke / all / map | Engine-side child runs and joins. parentClosePolicy: "abandon" leaves children running. |
| step.parallel | Concurrent local functions, one combined checkpoint. Prefer Promise.all + step.run when each side should persist on its own. |
| step.set | Live query fields for UIs (read via GET /v1/runs/:id/query). |
| step.search | Indexed search attributes. List with listRuns({ search }). |
| step.write | Outbound stream chunks (tokens, progress) on the run timeline. |
| step.continueAsNew | Finish this run ({ continuedAs, payload }) and start a successor with a fresh history. |
| run.stream / useRunEvents | Cursor-resumable SSE. React hook in @steadlake/task/react. |
| queue | Concurrency, keyed concurrency, rate limit, throttle, singleton. Pause / override at runtime. |
| event + debounce / batch | Event-subscribed tasks. |
| version | In-flight runs refuse a new handler unless replayOnLatest. |
| hooks.onCancel | Cancel re-dispatches so cleanup actually runs. |
| ttl | Drop queued runs that sat too long. |
RetryableError("rate limited", { retryAfter: "45s" }) sets Retry-After for the next attempt. FatalError fails permanently.
Steps
export const importUsers = task({
id: "import-users",
run: async ({ payload, step }) => {
const users = await step.run("fetch", () => api.getUsers(payload.source));
const valid = await step.run("validate", () => users.filter(isValid));
await step.run("insert", () => db.users.bulkInsert(valid));
return { imported: valid.length };
},
});Step IDs must be stable. Use send-${i} in loops, not dynamic IDs.
await step.run("charge", () => stripe.charges.create({ amount }), {
retry: { maxAttempts: 3, backoff: "exponential" },
timeout: "20s",
});
await step.sleep("wait", "3d");
await step.set({ stage: "waiting-warehouse" });
await step.write("progress", { pct: 40 });Waits, signals, approval
const shipment = await step.waitForEvent<{ trackingId: string }>("shipped", {
event: "order.shipped",
match: "data.amount>100",
timeout: "7d",
});
const { approved } = await step.approve("review", {
title: "Approve $10,000 payout?",
timeout: "24h",
});A Slack/chat button can finish the approval without a project key:
import { resolveToken } from "@steadlake/task";
await resolveToken(token, { approved: true, approvedBy: "[email protected]" }, process.env.STEADLAKE_API_URL!);The token is on the suspended timeline event (metadata.token).
Composition
const [tax, ship] = Object.values(
await step.parallel("quotes", {
tax: () => taxApi.quote(payload),
ship: () => shipping.reserve(payload),
}),
);
const child = await step.invoke("charge", {
task: chargeCustomer,
payload: { orderId: payload.orderId },
parentClosePolicy: "cancel", // or "abandon"
});
await step.map("line-items", items, {
task: processItem,
payload: (item) => ({ id: item.id }),
concurrency: 5,
failurePolicy: "fail-fast",
});Queues, cron, events
export const processVideo = task({
id: "process-video",
ttl: "10m",
search: { kind: "video" },
queue: {
name: "video",
concurrencyLimit: 3,
key: "data.userId",
rateLimit: { max: 100, duration: "1h" },
throttle: { max: 10, duration: "1m" }, // drop excess
singleton: false, // true = cancel any in-flight run with the same key, including sleep/wait
},
cron: { pattern: "0 9 * * 1-5", timezone: "America/New_York" },
event: "video.uploaded",
debounce: { period: "30s", key: "userId" },
run: async ({ payload }) => { /* ... */ },
});Runtime controls (no deploy):
const client = getDefaultClient();
await client.pauseQueue("video");
await client.overrideQueueConcurrency("video", 20);
await client.replayRuns({ triggerId: "process-video", status: "failed", since: "2026-08-01T00:00:00Z" });Version pin and cancel
A deploy that changes the task body will not replay in-flight runs (the pin is a hash, or set version yourself). Opt in with replayOnLatest: true.
run.cancel() asks the platform to dispatch again so hooks.onCancel can release holds, then the run is marked cancelled. A 202 suspend on that dispatch is re-queued until onCancel runs.
export const fulfill = task({
id: "fulfill",
version: "2026-08-16",
hooks: {
onCancel: async ({ payload }) => {
await warehouse.release(payload.orderId);
},
},
run: async ({ payload, step }) => { /* ... */ },
});Frontend
import { useRunEvents } from "@steadlake/task/react";
const { events } = useRunEvents(runId, {
apiUrl: process.env.NEXT_PUBLIC_STEADLAKE_API_URL,
apiKey: process.env.STEADLAKE_API_KEY, // server-only; use a proxy in the browser
});Or for await (const event of run.stream({ cursor })) on the server.
Environment variables
| Variable | Description |
|---|---|
| STEADLAKE_API_KEY | Project API key from the hosted dashboard |
| STEADLAKE_PROJECT_ID | Project id |
| STEADLAKE_ENV | dev (laptop) or prod (deploy). Default prod. Local Connect uses dev. |
| STEADLAKE_API_URL | Override platform URL (self-hosted) |
On Cloudflare Workers, read bindings with configureFromWorkerEnv(env).
See USAGE.md for adapters, channels, activities, and the type reference.
