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 workflowconstonboardUser=defineWorkflow<{userId: string}>({id: "user/onboard",retries: 3,backoff: {type: "exponential",delay: 1000},},async(ctx)=>{constuser=awaitctx.step("fetch-user",async()=>{returndb.users.findById(ctx.data.userId);});awaitctx.step("send-welcome-email",async()=>{awaitemail.send({to: user.email,template: "welcome"});});awaitctx.step("provision-account",async()=>{awaitbilling.createAccount(user.id);});return{onboarded: true};});// 2. Create and start the engineconstengine=createEngine({connection: {host: "localhost",port: 6379},});engine.register(onboardUser);awaitengine.start();// 3. Trigger a runconst{ runId }=awaitengine.trigger(onboardUser,{userId: "usr_123"});Run steps concurrently with ctx.parallel(). All steps finish before any error is thrown.
constprocessOrder=defineWorkflow<{orderId: string}>({id: "order/process"},async(ctx)=>{const[inventory,payment]=awaitctx.parallel([ctx.step("check-inventory",()=>inventory.check(ctx.data.orderId)),ctx.step("authorize-payment",()=>payments.authorize(ctx.data.orderId)),]);awaitctx.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.
awaitctx.step("send-reminder",async()=>{awaitemail.sendReminder(userId);});awaitctx.sleep("24h");// workflow pauses, worker is freedawaitctx.step("check-response",async()=>{// resumes here after 24 hours});constparent=defineWorkflow({id: "parent"},async(ctx)=>{const{ runId }=awaitctx.trigger(childWorkflow,{key: "value"});});Each step can override retry and timeout settings:
awaitctx.step("call-external-api",async()=>{returnfetch("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";constworkflow=defineWorkflow({id: "validated",input: z.object({email: z.string().email()}),},async(ctx)=>{// ctx.data is typed and validated});import{FatalError}from"chisel-engine";awaitctx.step("check-permissions",async()=>{if(!hasAccess){// Immediately fails the workflow — no retriesthrownewFatalError("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.
construn=awaitengine.getRun(runId);// { id, workflowId, status, data, result, steps, progress: { completed, total, percentage }}awaitengine.cancelRun(runId);awaitengine.retryRun(runId);import{Hono}from"hono";import{chiselHono}from"chisel-engine/hono";constapp=newHono();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";conststudio=createStudio(engine,{port: 4040});awaitstudio.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: 4040host: "localhost",// default: "localhost"open: true,// auto-open in browser (default: false)});constengine=createEngine({// Redis connectionconnection: {host: "localhost",port: 6379},// or: connection: { url: "redis://..." },// Defaults applied to all workflowsdefaults: {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 middlewaremiddleware: {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 retriesbackoff: {type: "exponential",delay: 1000},// backoff strategytimeout: 120_000,// workflow timeout (ms)priority: 1,// lower = higher priorityconcurrency: {limit: 1,key: (data)=>data.tenantId,// keyed concurrency},input: zodSchema,// optional input validation});MIT