Lightweight workflow engine built on BullMQ and Redis.
Chisel gives you an Inngest-shaped developer experience — defineWorkflow, ctx.step(), checkpoint-and-resume — with zero magic. No build plugins, no code transforms, no directives. Works anywhere JavaScript runs.
- Durable steps — each step checkpoints its result to Redis. On failure, the workflow resumes from the last successful step.
- Step & workflow retries — configurable retry counts and backoff (exponential or fixed) at both the step and workflow level.
- Parallel execution — run steps concurrently with
ctx.parallel(). All steps complete before errors propagate (no zombie steps). - Sleep & delays —
ctx.sleep("5m")pauses the workflow using BullMQ delayed jobs for long durations. - Workflow triggers — trigger child workflows from within a workflow via
ctx.trigger(). - Input validation — optional Zod schema validation on workflow input (Zod is not a dependency).
- Keyed concurrency — per-key concurrency limits via Redis locks.
- Deduplication — prevent duplicate triggers with configurable TTL.
- Bounded Redis retention — terminal run state is pruned by age/count defaults so checkpoints do not grow without bound.
- Lifecycle events — subscribe to
workflow:start,workflow:complete,step:fail, etc. - Middleware —
beforeStep/afterStep/beforeWorkflowhooks. - Hono adapter — optional REST API adapter for trigger, status, cancel, retry, and health.
- Typed end-to-end — full TypeScript generics from
defineWorkflow<TInput>toctx.data.
npm install chisel-engine
# or
pnpm add chisel-engine
# or
bun add chisel-engineRedis must be available. BullMQ and ioredis are bundled dependencies.
import { createEngine, defineWorkflow } from "chisel-engine";
// 1. Define a workflow
const onboardUser = defineWorkflow<{ userId: string }>(
{
id: "user/onboard",
retries: 3,
backoff: { type: "exponential", delay: 1000 },
},
async (ctx) => {
const user = await ctx.step("fetch-user", async () => {
return db.users.findById(ctx.data.userId);
});
await ctx.step("send-welcome-email", async () => {
await email.send({ to: user.email, template: "welcome" });
});
await ctx.step("provision-account", async () => {
await billing.createAccount(user.id);
});
return { onboarded: true };
}
);
// 2. Create and start the engine
const engine = createEngine({
connection: { host: "localhost", port: 6379 },
});
engine.register(onboardUser);
await engine.start();
// 3. Trigger a run
const { runId } = await engine.trigger(onboardUser, { userId: "usr_123" });Run steps concurrently with ctx.parallel(). All steps finish before any error is thrown.
const processOrder = defineWorkflow<{ orderId: string }>(
{ id: "order/process" },
async (ctx) => {
const [inventory, payment] = await ctx.parallel([
ctx.step("check-inventory", () => inventory.check(ctx.data.orderId)),
ctx.step("authorize-payment", () => payments.authorize(ctx.data.orderId)),
]);
await ctx.step("fulfill", () => fulfillment.ship(ctx.data.orderId));
}
);Pause a workflow. Short sleeps use an in-process timer; long sleeps (>5s) use BullMQ delayed jobs so no worker is blocked.
await ctx.step("send-reminder", async () => {
await email.sendReminder(userId);
});
await ctx.sleep("24h"); // workflow pauses, worker is freed
await ctx.step("check-response", async () => {
// resumes here after 24 hours
});const parent = defineWorkflow({ id: "parent" }, async (ctx) => {
const { runId } = await ctx.trigger(childWorkflow, { key: "value" });
});Each step can override retry and timeout settings:
await ctx.step(
"call-external-api",
async () => {
return fetch("https://api.example.com/data").then((r) => r.json());
},
{
retries: 5,
backoff: { type: "exponential", delay: 2000 },
timeout: 30_000,
}
);Pass a Zod schema (or any object with .parse()) to validate input at trigger time:
import { z } from "zod";
const workflow = defineWorkflow(
{
id: "validated",
input: z.object({ email: z.string().email() }),
},
async (ctx) => {
// ctx.data is typed and validated
}
);import { FatalError } from "chisel-engine";
await ctx.step("check-permissions", async () => {
if (!hasAccess) {
// Immediately fails the workflow — no retries
throw new FatalError("User lacks required permissions");
}
});engine.on("workflow:complete", ({ workflowId, runId, result, duration }) => {
console.log(`${workflowId} completed in ${duration}ms`);
});
engine.on("step:fail", ({ workflowId, stepName, error, attempt }) => {
metrics.increment("step.failure", { workflowId, stepName });
});Available events: workflow:start, workflow:complete, workflow:fail, step:start, step:complete, step:fail, step:retry.
const run = await engine.getRun(runId);
// { id, workflowId, status, data, result, steps, progress: { completed, total, percentage } }
await engine.cancelRun(runId);
await engine.retryRun(runId);import { Hono } from "hono";
import { chiselHono } from "chisel-engine/hono";
const app = new Hono();
app.route("/workflows", chiselHono(engine));Endpoints:
POST /:workflowId— trigger a workflowGET /runs/:runId— get run statusGET /runs/:runId/steps— get step detailsPOST /runs/:runId/cancel— cancel a runPOST /runs/:runId/retry— retry a failed runGET /health— health check
A real-time dashboard for monitoring and managing your workflows.
npm install chisel-studioimport { createStudio } from "chisel-studio";
const studio = createStudio(engine, { port: 4040 });
await studio.start();
// → Chisel Studio running at http://localhost:4040Features:
- Real-time activity feed — live SSE stream of workflow and step events
- Step trace visualization — waterfall timeline showing step durations and status
- Workflow management — trigger, retry, and cancel runs from the UI
- Light & dark mode — system preference detection with manual toggle
Options:
createStudio(engine, {
port: 4040, // default: 4040
host: "localhost", // default: "localhost"
open: true, // auto-open in browser (default: false)
});const engine = createEngine({
// Redis connection
connection: { host: "localhost", port: 6379 },
// or: connection: { url: "redis://..." },
// Defaults applied to all workflows
defaults: {
retries: 3,
backoff: { type: "exponential", delay: 2000 },
timeout: 60_000,
},
// Redis key prefix (default: "chisel")
prefix: "myapp",
// Terminal run retention in Redis (defaults shown)
retention: {
completed: { age: 7 * 24 * 60 * 60, count: 10_000 },
failed: { age: 30 * 24 * 60 * 60, count: 10_000 },
cancelled: { age: 7 * 24 * 60 * 60, count: 10_000 },
},
// Custom logger (default: console)
logger: pino(),
// Global middleware
middleware: {
beforeStep: ({ workflowId, stepName }) => { /* ... */ },
afterStep: ({ stepName, result, duration }) => { /* ... */ },
},
});Set retention: false to disable pruning entirely, or set a status to false to keep that terminal state indefinitely.
defineWorkflow({
id: "my/workflow",
retries: 5, // workflow-level retries
backoff: { type: "exponential", delay: 1000 }, // backoff strategy
timeout: 120_000, // workflow timeout (ms)
priority: 1, // lower = higher priority
concurrency: {
limit: 1,
key: (data) => data.tenantId, // keyed concurrency
},
input: zodSchema, // optional input validation
});MIT