> ## 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

> Multi-step sequential pipelines with approval gates, agent coordination, and output accumulation

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.

```ts theme={null}
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.                   |

### Input callbacks

The `input` callback receives a `StepContext` with access to the workflow input and all previous step outputs:

```ts theme={null}
.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 }`:

```ts theme={null}
// 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`.

```ts theme={null}
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

```ts theme={null}
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'`.

```ts theme={null}
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:

```ts theme={null}
// 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)
```

<Warning>
  The `resumeAfter` step name must match an existing step in the workflow. An invalid name throws an
  error.
</Warning>

### 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.

```ts theme={null}
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.

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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)
  },
});
```
