Documentation Index
Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
Use this file to discover all available pages before exploring further.
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.
import { workflow } from '@vertz/agents';
import { s } from '@vertz/schema';
const pipeline = workflow('content-pipeline', {
input: s.object({
topic: s.string(),
tone: s.string(),
}),
})
.step('research', {
agent: researchAgent,
input: (ctx) => `Research the topic: ${ctx.workflow.input.topic}`,
output: s.object({ findings: s.string(), sources: s.array(s.string()) }),
})
.step('write', {
agent: writerAgent,
input: (ctx) => {
// ctx.prev.research is typed as { findings: string; sources: string[] }
return `Write about this using a ${ctx.workflow.input.tone} tone: ${ctx.prev.research.findings}`;
},
output: s.object({ draft: s.string(), wordCount: s.number() }),
})
.step('edit', {
agent: editorAgent,
input: (ctx) => `Edit this draft: ${ctx.prev.write.draft}`,
output: s.object({ final: s.string() }),
})
.build();
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:
.step('review', {
agent: reviewerAgent,
input: (ctx) => `Review this analysis: ${ctx.prev.analyze.summary}`,
output: s.object({ approved: s.boolean(), feedback: s.string() }),
})
The callback can return a plain string or { message: string }:
// String form
input: (ctx) => `Analyze: ${ctx.workflow.input.topic}`,
// Object form
input: (ctx) => ({ message: `Analyze: ${ctx.workflow.input.topic}` }),
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.
import { runWorkflow, createAdapter } from '@vertz/agents';
const llm = createAdapter({ provider: 'openai' });
const result = await runWorkflow(pipeline, {
input: { topic: 'TypeScript generics', tone: 'conversational' },
llm,
});
if (result.status === 'complete') {
console.log('All steps finished');
console.log(result.stepResults);
} else if (result.status === 'error') {
console.log(`Failed at step: ${result.failedStep}`);
console.log(`Reason: ${result.errorReason}`);
// 'agent-failed' — the agent hit max iterations or errored
// 'invalid-json' — agent response wasn't valid JSON (but output schema expected it)
// 'schema-mismatch' — agent response was valid JSON but didn't match the output schema
} else if (result.status === 'pending') {
console.log(`Waiting for approval at: ${result.pendingStep}`);
console.log(result.approvalMessage);
}
Result shape
type WorkflowErrorReason = 'agent-failed' | 'invalid-json' | 'schema-mismatch';
interface WorkflowResult {
status: 'complete' | 'error' | 'pending';
stepResults: Record<string, StepResult>;
failedStep?: string; // Only set when status is 'error'
errorReason?: WorkflowErrorReason; // Only set when status is 'error'
pendingStep?: string; // Only set when status is 'pending'
approvalMessage?: string; // Only set when status is 'pending'
}
interface StepResult {
status: 'complete' | 'max-iterations' | 'stuck' | 'error';
response: string;
iterations: number;
}
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.
Step 1 "research" completes → ctx.prev = { research: { findings: "...", sources: [...] } }
Step 2 "write" completes → ctx.prev = { research: { ... }, write: { draft: "...", wordCount: 1200 } }
Step 3 "edit" completes → ctx.prev = { research: { ... }, write: { ... }, edit: { final: "..." } }
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'.
const reviewPipeline = workflow('review-pipeline', {
input: s.object({ documentPath: s.string() }),
})
.step('auto-review', {
agent: reviewerAgent,
input: (ctx) => `Review document at: ${ctx.workflow.input.documentPath}`,
output: s.object({ approved: s.boolean(), findings: s.array(s.string()) }),
})
// Approval gate — no agent, just a gate
.step('human-approval', {
approval: {
message: (ctx) =>
`Auto-review found ${ctx.prev['auto-review'].findings.length} findings. Approve to proceed.`,
timeout: '7d',
},
})
.step('publish', {
agent: publisherAgent,
input: (ctx) => `Publish document at: ${ctx.workflow.input.documentPath}`,
output: s.object({ url: s.string() }),
})
.build();
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:
// First run — hits approval gate
const firstRun = await runWorkflow(reviewPipeline, {
input: { documentPath: '/docs/api.md' },
llm,
});
// firstRun.status === 'pending'
// firstRun.pendingStep === 'human-approval'
// firstRun.approvalMessage === 'Auto-review found 3 findings. Approve to proceed.'
// Store the results somewhere (DB, KV, etc.)
const savedResults = firstRun.stepResults;
// ... human approves ...
// Resume — skips all steps up to and including 'human-approval'
const resumed = await runWorkflow(reviewPipeline, {
input: { documentPath: '/docs/api.md' },
llm,
resumeAfter: 'human-approval',
previousResults: savedResults,
});
// resumed.status === 'complete' (if publish step succeeded)
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.
const specialistAgent = agent('specialist', {
state: s.object({}),
initialState: {},
tools: {
/* specialist tools */
},
model: { provider: 'openai', model: 'gpt-4o' },
});
const delegateTool = tool({
description: 'Delegate a task to a specialist agent',
input: s.object({ task: s.string() }),
output: s.object({ result: s.string() }),
async handler(input, ctx) {
const result = await ctx.agents.invoke(specialistAgent, {
message: input.task,
});
return { result: result.response };
},
});
const coordinator = agent('coordinator', {
state: s.object({}),
initialState: {},
tools: { delegate: delegateTool },
model: { provider: 'openai', model: 'gpt-4o' },
});
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.
import { run, memoryStore } from '@vertz/agents';
const store = memoryStore();
// First message — creates a new session
const first = await run(greeter, {
message: 'Hi, my name is Alice',
llm,
store,
});
console.log(first.sessionId); // 'sess_abc123...'
// Second message — resumes the session
const second = await run(greeter, {
message: 'What was my name?',
llm,
store,
sessionId: first.sessionId,
});
// Agent remembers the conversation
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:
import { createAdapter } from '@vertz/agents';
const llm = createAdapter({ provider: 'openai' });
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:
const customLlm: LLMAdapter = {
async chat(messages, tools) {
// Call your LLM and return the response
return { text: '...', toolCalls: [] };
},
};
const result = await run(myAgent, { message: 'Hello', llm: customLlm });
Agent lifecycle
Agents have three lifecycle hooks:
const myAgent = agent('monitored', {
state: s.object({ startedAt: s.string() }),
initialState: { startedAt: '' },
tools: {
/* ... */
},
model: { provider: 'openai', model: 'gpt-4o' },
onStart(ctx) {
ctx.state.startedAt = new Date().toISOString();
console.log(`Agent ${ctx.agent.name} started`);
},
onComplete(ctx) {
console.log(`Agent ${ctx.agent.name} completed`);
},
onStuck(ctx) {
console.log(`Agent ${ctx.agent.name} got stuck`);
},
});
| 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:
agent('careful', {
// ...
loop: {
maxIterations: 50, // Max iterations before stopping (default: 20)
onStuck: 'retry', // 'stop' | 'retry' | 'escalate' (default: 'stop')
stuckThreshold: 3, // Iterations without progress before stuck (default: 3)
checkpointInterval: 5, // Save state every N iterations (default: 5)
},
});