This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
wrkflw is a TypeScript-first workflow engine for Val Town, inspired by Mastra. It enables building strongly-typed, composable workflows that run on Val Town's serverless platform with SQLite-backed durable state.
The engine features full TypeScript type inference across workflow definitions, runtime validation via Zod schemas, and supports all Val Town trigger types (HTTP, Cron, Email).
# Run a simple workflow example deno run --allow-all examples/simple-workflow.ts # Run the API server with canvas visualizer (local development) deno run --allow-all examples/api-server-example-local.ts # Then in another terminal, start the frontend cd frontend npm install npm run dev
The project doesn't have a formal test suite. Test workflows by:
# Format code with Biome deno run -A npm:@biomejs/biome format --write . # Lint code deno run -A npm:@biomejs/biome lint .
The workflow engine consists of these key parts:
backend/step.ts): Individual workflow steps with typed inputs/outputsbackend/workflow.ts): Fluent API for composing steps with type inferencebackend/engine.ts): Sequential step execution with context managementbackend/storage.ts): SQLite persistence for workflow statebackend/run.ts): Workflow execution instance lifecycleThe workflow builder threads types through the .then() chain:
outputSchema must match the next step's inputSchemaExample flow:
// Step 1: string -> number
const step1 = createStep({
inputSchema: z.object({ a: z.string() }),
outputSchema: z.object({ b: z.number() }),
execute: async ({ inputData }) => ({ b: 42 })
});
// Step 2: number -> boolean (MUST accept step1's output type)
const step2 = createStep({
inputSchema: z.object({ b: z.number() }),
outputSchema: z.object({ c: z.boolean() }),
execute: async ({ inputData }) => ({ c: true })
});
// Workflow builder enforces type compatibility
const workflow = createWorkflow({
inputSchema: z.object({ a: z.string() }),
outputSchema: z.object({ c: z.boolean() })
})
.then(step1) // OK: accepts { a: string }
.then(step2) // OK: accepts { b: number } from step1
.commit(); // Final type: { c: boolean }
Workflows persist state to SQLite after each step execution:
runId (UUID)backend/migrations.ts handle schema evolutionSteps receive a rich execution context:
inputData: Validated input for this stepgetStepResult(step): Access outputs from previous stepsgetInitData(): Get workflow's initial inputstate / setState: Workflow-level state (optional)runId, workflowId: Execution identifierswrkflw/
├── backend/ # Core workflow engine
│ ├── index.ts # Main exports
│ ├── types.ts # TypeScript type definitions
│ ├── step.ts # Step creation and execution
│ ├── workflow.ts # Workflow builder with type inference
│ ├── engine.ts # Sequential execution engine
│ ├── storage.ts # SQLite persistence layer
│ ├── run.ts # Run lifecycle management
│ ├── migrations.ts # Database schema migrations
│ ├── prebuilt-steps.ts # Reusable steps (httpGet, template, etc.)
│ ├── visualize.ts # Mermaid diagram generation
│ └── api-server.ts # REST API for canvas visualizer
├── frontend/ # React canvas visualizer
│ ├── src/
│ │ ├── App.tsx # Main app container
│ │ ├── components/
│ │ │ ├── WorkflowVisualizer.tsx # React Flow canvas
│ │ │ └── StepNode.tsx # Custom step nodes
│ │ └── types.ts # Frontend types
│ └── package.json
├── examples/ # Usage examples
│ ├── simple-workflow.ts
│ ├── http-trigger.ts
│ ├── cron-trigger.ts
│ ├── prebuilt-steps-simple.ts
│ ├── workflow-visualization.ts
│ └── api-server-example-local.ts
├── deno.json # Deno configuration
├── biome.json # Biome formatter/linter config
└── PLAN.md # Original implementation plan
Steps should have clear, focused responsibilities:
import { createStep } from "./backend/index.ts";
import { z } from "npm:zod@^3.23";
const myStep = createStep({
id: 'my-step', // Unique identifier
description: 'What this step does', // Optional description
inputSchema: z.object({ /* ... */ }), // Input validation
outputSchema: z.object({ /* ... */ }), // Output validation
execute: async ({ inputData, getStepResult }) => {
// Step logic here
return { /* matches outputSchema */ };
}
});
When adding reusable steps to backend/prebuilt-steps.ts:
backend/index.tsWhen modifying workflow state schema:
backend/migrations.tsWorkflowStorage.init() to create new columns/tablesThe API server (backend/api-server.ts) follows REST conventions:
GET /api/workflows - List workflowsGET /api/workflows/:id - Get workflow detailsPOST /api/workflows/:id/runs - Start new runGET /api/runs/:runId - Get run statusWhen adding endpoints:
handleRequest()The codebase uses dynamic imports for Val Town compatibility:
// Storage imports sqlite dynamically
const { sqlite } = await import("https://esm.town/v/std/sqlite");
This allows the code to work both:
Workflows are designed to be called from any Val Town trigger:
HTTP Trigger (*.http.ts):
export default async function(req: Request) {
const run = await workflow.createRun();
const result = await run.start({ inputData: { /* ... */ } });
return Response.json(result);
}
Cron Trigger (*.cron.ts):
export default async function(interval: Interval) {
const run = await workflow.createRun();
await run.start({ inputData: { /* ... */ } });
}
Email Trigger (*.email.ts):
export default async function(email: Email) {
const run = await workflow.createRun();
await run.start({
inputData: {
from: email.from,
subject: email.subject
}
});
}
wrkflw_ prefix to avoid naming conflictsworkflow_id and status for performanceGenerate workflow structure diagrams:
const diagram = workflow.visualize("mermaid");
console.log(diagram); // Paste into GitHub, mermaid.live, etc.
Generate execution state diagrams:
const run = await workflow.createRun();
await run.start({ inputData: { /* ... */ } });
const executionDiagram = await run.visualize("mermaid");
The frontend provides an interactive React Flow canvas:
deno run --allow-all examples/api-server-example-local.tscd frontend && npm run devErrors bubble up from steps to the engine:
StepResult with status: 'failed'Workflows auto-initialize on first run:
const run = await workflow.createRun(); // Calls init() internally
This creates database tables if they don't exist. For explicit control:
await workflow.init();
Within a step's execute function:
execute: async ({ getStepResult, getInitData }) => {
// Get output from a specific previous step
const userData = getStepResult(fetchUser);
// Get the workflow's initial input
const initialInput = getInitData();
return { /* ... */ };
}
Define workflow-level state that persists across steps:
const workflow = createWorkflow({
id: 'stateful-workflow',
inputSchema: z.object({ /* ... */ }),
outputSchema: z.object({ /* ... */ }),
stateSchema: z.object({ counter: z.number() }) // Workflow state
});
const step = createStep({
execute: async ({ state, setState }) => {
const count = (state?.counter || 0) + 1;
setState({ counter: count });
return { /* ... */ };
}
});
The roadmap is defined in PLAN.md:
When implementing new features, follow the type-safe pattern established in Phase 1.
To quickly understand the codebase architecture:
Reading these four files provides 80% of the architectural understanding needed to work effectively in this codebase.