Workflows
Workflows are Cloudflare's durable-execution primitive — each step persists its return value, retries on failure with backoff, and resumes from the last completed step after a worker restart. ayjnt detects workflow.ts files automatically and wires the workflow binding, the typed RPC stub, and the GeneratedEnv field.
Why pair workflows with agents
Agents own state. Workflows own multi-step durable execution. Pair them when you have a long-running task — order processing, ETL, a multi-step API call chain — that should survive worker restarts and retry with backoff. The agent stores the "what" (the order, the job record); the workflow runs the "how" (mark processing → reserve → charge → mark complete).
The file convention
agents/
└── orders/
├── agent.ts ← OrdersAgent (Durable Object)
└── workflow.ts ← OrdersProcessing (the trigger)
Drop a file named workflow.ts next to your agent.ts. The default-exported class must extend AgentWorkflow from agents/workflows (or WorkflowEntrypoint from cloudflare:workers for workflows that don't need
agent RPC). ayjnt scans the tree, derives the binding name and
workflow name from the class name, and emits the
wrangler.jsonc entry.
A minimal workflow
import { AgentWorkflow } from "agents/workflows";
import type { AgentWorkflowEvent } from "agents/workflows";
import type { WorkflowStep } from "cloudflare:workers";
import type OrdersAgent from "./agent.ts";
type Params = {
orderId: string;
sku: string;
qty: number;
customerId: string;
};
export default class OrdersProcessing extends AgentWorkflow<OrdersAgent, Params> {
async run(
event: Readonly<AgentWorkflowEvent<Params>>,
step: WorkflowStep,
): Promise<{ orderId: string; charged: boolean }> {
const { orderId, sku, qty, customerId } = event.payload;
// Each step.do is a durable boundary. Re-runs from here on failure.
await step.do("mark-processing", async () => {
await this.agent.markStatus(orderId, "processing");
});
const reserved = await step.do("reserve-inventory", async () => {
if (qty <= 0) throw new Error("qty must be positive");
return { sku, qty, reservation: crypto.randomUUID() };
});
// step.sleep survives restarts.
await step.sleep("payment-clearance-delay", "2 seconds");
const charged = await step.do("charge-customer", async () => {
return { customerId, amount: qty * 9.99, txn: crypto.randomUUID() };
});
await step.do("mark-complete", async () => {
await this.agent.markStatus(orderId, "complete");
});
return { orderId, charged: !!charged };
}
}
The <OrdersAgent, Params> generics give you
fully typed RPC on this.agent — calling this.agent.markStatus(...) autocompletes and is
checked against the Agent's signature.
Triggering a workflow from an agent
import { Agent } from "agents";
import { withWorkflow } from "ayjnt/workflow";
import type { GeneratedEnv } from "@ayjnt/env";
import type OrdersProcessing from "./workflow.ts";
export default class OrdersAgent
extends withWorkflow<typeof OrdersProcessing>()(Agent)<GeneratedEnv, State>
{
override async onRequest(req: Request): Promise<Response> {
const { sku, qty, customerId } = await req.json();
const orderId = crypto.randomUUID();
this.setState({
orders: [...this.state.orders, { id: orderId, sku, qty, status: "queued" }],
});
// No magic binding string — params are typed against
// OrdersProcessing's Params generic. IntelliSense knows the shape.
const workflowId = await this.workflow({ orderId, sku, qty, customerId });
return Response.json({ orderId, workflowId });
}
// The workflow calls this back via this.agent.markStatus(...)
async markStatus(orderId: string, status: Status): Promise<void> {
this.setState({ /* update DO state */ });
}
} withWorkflow<typeof OrdersProcessing>()(Agent)
is the mixin that adds a typed this.workflow(params)
method. The framework derived the binding name (ORDERS_PROCESSING)
from the co-located workflow.ts at build time and
patched it onto the agent's prototype — so user code never has
to spell the binding string. Params are checked against the
workflow's Params generic; rename a field in workflow.ts and every call site lights up red.
If you need to trigger a workflow that isn't co-located with
the agent (e.g. a fire-and-forget batch under a separate workflows/ tree), use the SDK's this.runWorkflow("BINDING_NAME", params) directly — withWorkflow is purely an ergonomic shortcut for
the co-located case.
What ayjnt wires up
One workflow.ts file produces three pieces of
generated code:
"workflows": [
{
"name": "orders-processing",
"binding": "ORDERS_PROCESSING",
"class_name": "OrdersProcessing"
}
]import OrdersProcessing from "../../agents/orders/workflow.ts";
export { OrdersProcessing }; // Cloudflare runtime registers the classexport type GeneratedEnv = {
ORDERS_AGENT: DurableObjectNamespace<OrdersAgent>;
ORDERS_PROCESSING: Workflow; // ← workflow binding, typed
};Name derivation
The class name drives everything else:
-
OrdersProcessing→binding: "ORDERS_PROCESSING"(UPPER_SNAKE) -
OrdersProcessing→name: "orders-processing"(kebab) -
OrdersProcessing→class_name: "OrdersProcessing"(verbatim)
Bindings must be unique across agents and workflows; the scanner
throws a clear error if two classes would collide (e.g. an
OrdersAgent and an OrdersAgent
workflow class would both want ORDERS_AGENT).
Workflows are not Durable Objects
Workflows have NO entries in the migrations[]
array — they're ephemeral execution containers, not Durable
Objects. State lives in the paired Agent's DO. The framework
reflects this: workflow classes only appear under workflows[] in wrangler.jsonc, never
in durable_objects.bindings or migrations[].
Step naming matters
step.do("name", fn) keys the persisted result by
name. Renaming a step in a deployed workflow makes the runtime
think it's a new step and re-execute it on resume. Keep step
names stable across deploys — treat them like database column
names.
The agent ⇄ workflow loop
The recommended pattern:
-
Agent receives a request, inserts a record at
queued, callsthis.runWorkflow(...). -
Workflow runs durably, calls back into the agent via
this.agent.<method>(...)(typed RPC) to update milestones. -
Agent's
setStatebroadcasts to connected WebSocket clients — the UI watches state change in real time.
WorkflowEntrypoint vs AgentWorkflow
ayjnt detects both. Use AgentWorkflow when you want
typed RPC back to the originating agent (most cases). Use plain WorkflowEntrypoint (from cloudflare:workers) when the workflow doesn't need
to talk back to an agent — e.g. a fire-and-forget batch job
triggered from a cron.
Detection rules
// ✓ detected
export default class OrdersProcessing extends AgentWorkflow<…> {}
// ✓ detected
export default class Cleanup extends WorkflowEntrypoint<…> {}
// ✗ NOT detected — base class is "AW", not "AgentWorkflow"
import { AgentWorkflow as AW } from "agents/workflows";
export default class X extends AW<…> {}
// ✗ NOT detected — not the default export
export class OrdersProcessing extends AgentWorkflow<…> {}The detection is source-level regex; keep the import plain and the class default-exported.
Looking up a running workflow
// Inside the agent
const instance = await this.env.ORDERS_PROCESSING.get(workflowId);
const status = await instance.status();
// → { status: "running" | "complete" | "errored" | ..., output?: ... }
The Workflow binding type comes from @cloudflare/workers-types and is automatically
added to GeneratedEnv by ayjnt.
Co-located UI
Workflows pair nicely with a live status board. The example's agents/orders/app.tsx uses useAgent() from @ayjnt/orders to watch
the order list, calls the agent's @callable placeOrder(...) on form submit, and
renders each row's status flipping through queued → processing → complete in real time. The
workflow's step.do(...) blocks RPC back into the
agent's markStatus method, and setState broadcasts each update over the
WebSocket.
Reference
- Cloudflare's Workflow + Agents docs
-
examples/workflows— the OrdersAgent + OrdersProcessing pair plus the React status board this guide is based on.