Workflows coordinate multiple agents into a sequential pipeline. Each step runs an agent, validates the output, and passes it to the next step via ctx.prev. Approval gates suspend the workflow until a human approves.
Defining workflows
Use the builder pattern: workflow() returns a builder, chain .step() calls, then .build() to finalize.
Each step’s ctx.prev is strongly typed based on preceding steps’ output schemas. No casts needed.
Step options
| Option | Type | Description |
|---|
agent | AgentDefinition | The agent to execute. Omit for approval-only steps. |
input | (ctx: StepContext) => string | { message } | Transform workflow context into the agent’s message. |
output | Schema | Schema for validating the agent’s response (parsed as JSON). |
approval | StepApprovalConfig | Turns the step into a human approval gate. |
The input callback receives a StepContext with access to the workflow input and all previous step outputs:
The callback can return a plain string or { message: string }:
If no input callback is provided, the agent receives a default message: "Execute step \"step-name\"".
Workflow options
| Option | Type | Description |
|---|
input | Schema | Schema for validating workflow input. |
access | { start?, approve? } | Access control for starting or approving the workflow. |
Validation rules
- Workflow names must match
/^[a-z][a-z0-9-]*$/
- Step names must match the same pattern
- At least one step is required
- Duplicate step names within a workflow throw an error
- Both workflow and step definitions are deeply frozen after creation
Running workflows
Use runWorkflow() to execute a workflow. It runs each step sequentially, passing outputs forward via ctx.prev.
Result shape
How output accumulation works
Each step’s output is stored in ctx.prev keyed by step name. If a step has an output schema, the agent’s response is parsed as JSON and validated against it. Validated data is stored in prev. Steps without an output schema store { response: string }.
If output validation fails (invalid JSON or schema mismatch), the workflow returns an error — it does not silently fall back.
ctx.prev is strongly typed — each step sees the output types of all preceding steps based on their output schemas. No casts needed.
Approval gates
An approval gate suspends the workflow until a human approves. When runWorkflow() hits an approval step, it returns immediately with status: 'pending'.
Approval config
| Option | Type | Description |
|---|
message | string | (ctx: StepContext) => string | Message shown to the human approver. |
timeout | string | How long to wait (e.g., '7d'). |
Resuming after approval
When the workflow returns pending, store the step results. After the human approves, call runWorkflow() again with resumeAfter pointing to the approval step:
The resumeAfter step name must match an existing step in the workflow. An invalid name throws an
error.
Building the approval UX
The approval primitive is transport-agnostic — runWorkflow() doesn’t dictate how approvals are delivered or collected. Common patterns:
- HTTP endpoint — Store pending state in a database, expose
POST /workflows/:id/approve, render an approval button in a dashboard
- Webhook — Send the approval message to Slack/Discord, listen for a reaction or command
- Durable Object — On Cloudflare, hold workflow state in a Durable Object that wakes when an approval event arrives
- CLI prompt — For dev tooling, prompt in the terminal and resume immediately
Agent-to-agent invocation
Tools can invoke other agents using ctx.agents.invoke(). This enables delegation patterns where a coordinator agent dispatches work to specialized agents.
Invoke options
| Option | Type | Description |
|---|
message | string | The message to send to the target agent. Required |
instanceId | string | Optional instance ID for the invoked agent. |
The invoked agent runs a full ReAct loop with the same LLM adapter as the calling agent. It returns { response: string }.
Session persistence
Agents support persistent sessions via an AgentStore. Pass a store to run() to enable conversation history across multiple calls.
Available stores
| Store | Import | Description |
|---|
memoryStore | @vertz/agents | In-memory, for testing and dev. |
sqliteStore | @vertz/agents | SQLite-backed via vertz:sqlite. |
d1Store | @vertz/agents | Cloudflare D1 — pass { binding: env.DB }. Tables are created on first call. |
Session options
| Option | Type | Description |
|---|
store | AgentStore | The persistence backend. Required for sessions |
sessionId | string | Resume an existing session. Omit to create new. |
maxStoredMessages | number | Cap messages per session (default: 200). |
userId | string | Session ownership — enforced on resume. |
tenantId | string | Tenant scoping — enforced on resume. |
LLM adapters
Agents communicate with LLMs through adapters. Use createAdapter() to create one:
Available providers
| Provider | Value | Env variable |
|---|
| OpenAI | 'openai' | OPENAI_API_KEY |
| Anthropic | 'anthropic' | ANTHROPIC_API_KEY |
| Cloudflare AI | 'cloudflare' | CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN |
| MiniMax | 'minimax' | MINIMAX_API_KEY |
Custom adapters
You can provide a custom LLMAdapter directly — any object with a chat() method:
Agent lifecycle
Agents have three lifecycle hooks:
| Hook | Called when |
|---|
onStart | Before the ReAct loop begins. |
onComplete | After the loop completes successfully. |
onStuck | When the agent hits max-iterations or stuck state. |
Loop configuration
Control the ReAct loop behavior: