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:
- Running example files directly with Deno
- Using the API server + frontend visualizer for interactive testing
- Checking SQLite state in the database after execution
# 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:
- Step System (
backend/step.ts): Individual workflow steps with typed inputs/outputs - Workflow Builder (
backend/workflow.ts): Fluent API for composing steps with type inference - Execution Engine (
backend/engine.ts): Sequential step execution with context management - Storage Layer (
backend/storage.ts): SQLite persistence for workflow state - Run Management (
backend/run.ts): Workflow execution instance lifecycle
The workflow builder threads types through the .then() chain:
- Each step's
outputSchemamust match the next step'sinputSchema - TypeScript enforces this at compile time
- The workflow's final output type is inferred from the last step
Example 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:
- Each run gets a unique
runId(UUID) - Snapshots include: status, execution path, step results, state, errors
- Storage uses UPSERT pattern to handle interruptions
- Migrations in
backend/migrations.tshandle schema evolution
Steps 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 identifiers
wrkflw/
├── 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:
- Follow the existing pattern (httpGet, template, etc.)
- Use generic Zod schemas for flexibility
- Add clear JSDoc comments with examples
- Export from
backend/index.ts
When modifying workflow state schema:
- Add migration to
backend/migrations.ts - Update
WorkflowStorage.init()to create new columns/tables - Test migration with existing data
- Consider backward compatibility
The 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 status
When adding endpoints:
- Add route matching in
handleRequest() - Include CORS headers
- Serialize data appropriately
- Handle errors gracefully
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:
- In Val Town (where sqlite is available)
- Locally with Deno (where it may not be)
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
}
});
}
- SQLite is the only available database in Val Town
- No blob storage in Phase 1 (use JSON serialization)
- Tables use
wrkflw_prefix to avoid naming conflicts - Indexes on
workflow_idandstatusfor performance
Generate 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:
- Start API server:
deno run --allow-all examples/api-server-example-local.ts - Start frontend:
cd frontend && npm run dev - Open http://localhost:3000
- Select workflows, create runs, watch execution in real-time
Errors bubble up from steps to the engine:
- Step execution wrapped in try/catch
- Errors stored in
StepResultwithstatus: 'failed' - Workflow snapshot saved with error details
- Error propagates to caller (no automatic retries in Phase 1)
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:
- Phase 2: Parallel execution, branching, data transformations
- Phase 3: Durability (sleep, suspend/resume)
- Phase 4: Advanced features (foreach, nested workflows, retries)
- Phase 5: Observability (streaming, debugging UI, time-travel)
When implementing new features, follow the type-safe pattern established in Phase 1.
To quickly understand the codebase architecture:
- types.ts - Core type definitions and interfaces
- workflow.ts - Type inference through builder pattern
- engine.ts - Sequential execution logic
- examples/simple-workflow.ts - Complete end-to-end example
Reading these four files provides 80% of the architectural understanding needed to work effectively in this codebase.