workflow 5.0.0-beta.1 → 5.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -2
- package/dist/api.js +1 -1
- package/dist/astro.js +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/builtins.js +1 -1
- package/dist/internal/class-serialization.js +1 -1
- package/dist/internal/errors.js +1 -1
- package/dist/nest.js +1 -1
- package/dist/next.cjs +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/stdlib.js +1 -1
- package/dist/sveltekit.js +1 -1
- package/dist/typescript-plugin.cjs +1 -1
- package/dist/vite.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/ai/resumable-streams.mdx +1 -1
- package/docs/api-reference/workflow/create-webhook.mdx +37 -18
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
- package/docs/api-reference/workflow-ai/index.mdx +0 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
- package/docs/cookbook/advanced/child-workflows.mdx +372 -0
- package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
- package/docs/cookbook/advanced/meta.json +9 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +336 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +147 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +150 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +255 -0
- package/docs/cookbook/agent-patterns/meta.json +4 -0
- package/docs/cookbook/common-patterns/batching.mdx +105 -0
- package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
- package/docs/cookbook/common-patterns/meta.json +15 -0
- package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
- package/docs/cookbook/common-patterns/saga.mdx +247 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +125 -0
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
- package/docs/cookbook/index.mdx +38 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +360 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +303 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +516 -0
- package/docs/cookbook/meta.json +5 -0
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +1 -1
- package/docs/deploying/world/vercel-world.mdx +1 -1
- package/docs/errors/start-invalid-workflow-function.mdx +1 -1
- package/docs/foundations/index.mdx +0 -3
- package/docs/foundations/meta.json +0 -1
- package/docs/foundations/serialization.mdx +1 -1
- package/docs/foundations/starting-workflows.mdx +1 -1
- package/docs/getting-started/index.mdx +8 -1
- package/docs/getting-started/meta.json +2 -1
- package/docs/getting-started/python.mdx +165 -0
- package/docs/meta.json +1 -0
- package/docs/migration-guides/index.mdx +34 -0
- package/docs/migration-guides/meta.json +9 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +363 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +314 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +318 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +337 -0
- package/package.json +13 -13
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Agent Cancellation
|
|
3
|
+
description: Cancel a running agent from the outside — either immediately via run.cancel() or gracefully via a stop signal hook.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Two patterns for cancelling a running agent — Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook + Promise.race for a clean exit with cleanup and final stream notification.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback. Two patterns are available depending on whether you need the agent to exit cleanly or just need the run to stop: **Hard Cancellation** via `getRun(runId).cancel()` for immediate forced termination, or **Stop Signal** via a hook + `Promise.race` for a graceful exit that runs cleanup and notifies streaming clients before returning.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
* **Chat stop buttons** — let users cancel a long-running agent from the browser
|
|
13
|
+
* **Admin cancellation** — stop an agent from a different process or API
|
|
14
|
+
* **Timeout fallback** — combine with `sleep()` to auto-stop after a deadline
|
|
15
|
+
|
|
16
|
+
## Choosing an approach
|
|
17
|
+
|
|
18
|
+
Pick the option that matches what your endpoint needs to deliver to the caller:
|
|
19
|
+
|
|
20
|
+
* **Hard Cancellation** — terminates the run immediately with no opportunity for cleanup or client notification. A single line of code, but the workflow throws `WorkflowRunCancelledError` and any streaming clients see an abrupt connection close.
|
|
21
|
+
* **Stop Signal** — the workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a real result.
|
|
22
|
+
|
|
23
|
+
The trade-offs at a glance:
|
|
24
|
+
|
|
25
|
+
| | Hard Cancellation | Stop Signal |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| Mechanism | `getRun(runId).cancel()` | Hook + `Promise.race` |
|
|
28
|
+
| Speed to terminate | Immediate | At the next `await` boundary in the workflow |
|
|
29
|
+
| Runs `finally` / cleanup | No | Yes |
|
|
30
|
+
| Final stream notification | No (abrupt close) | Yes (`data-stopped` part) |
|
|
31
|
+
| `run.returnValue` | Throws `WorkflowRunCancelledError` | Returns the workflow's result |
|
|
32
|
+
| Code complexity | One line | Hook + race + signal step |
|
|
33
|
+
| Best for | Stuck or unresponsive runs, forced termination | User-facing stop, admin cancel, timeouts |
|
|
34
|
+
|
|
35
|
+
## Hard Cancellation
|
|
36
|
+
|
|
37
|
+
Call `.cancel()` on a run to terminate it immediately:
|
|
38
|
+
|
|
39
|
+
```typescript lineNumbers
|
|
40
|
+
import { getRun } from "workflow/api";
|
|
41
|
+
|
|
42
|
+
export async function POST(
|
|
43
|
+
_request: Request,
|
|
44
|
+
{ params }: { params: Promise<{ runId: string }> }
|
|
45
|
+
) {
|
|
46
|
+
const { runId } = await params;
|
|
47
|
+
await getRun(runId).cancel(); // [!code highlight]
|
|
48
|
+
return Response.json({ success: true });
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This is an abrupt termination — the run is stopped mid-step with no opportunity to exit cleanly:
|
|
53
|
+
|
|
54
|
+
* **No cleanup runs** — `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped
|
|
55
|
+
* **No final notification to the client** — the writable closes abruptly, so a streaming UI just sees the connection drop with no `data-stopped` part to render a clean ending
|
|
56
|
+
* **`run.returnValue` throws** — anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload
|
|
57
|
+
* **Underlying step keeps running** — same caveat as the Stop Signal pattern below: the model stream or HTTP call inside the current step continues to completion in the background
|
|
58
|
+
|
|
59
|
+
Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else — chat stop buttons, admin "stop" actions, timeout fallbacks — you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result.
|
|
60
|
+
|
|
61
|
+
## Stop Signal
|
|
62
|
+
|
|
63
|
+
<Callout type="warn">
|
|
64
|
+
**Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes — tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
|
|
65
|
+
</Callout>
|
|
66
|
+
|
|
67
|
+
### Example
|
|
68
|
+
|
|
69
|
+
```typescript lineNumbers
|
|
70
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
71
|
+
import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
|
|
72
|
+
import { z } from "zod";
|
|
73
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
74
|
+
|
|
75
|
+
export const stopHook = defineHook({
|
|
76
|
+
schema: z.object({ reason: z.string().optional() }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function searchWeb({ query }: { query: string }) {
|
|
80
|
+
"use step";
|
|
81
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
82
|
+
return { results: [{ title: `${query} - Wikipedia`, snippet: `Overview of ${query}...` }] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function analyzeData({ topic }: { topic: string }) {
|
|
86
|
+
"use step";
|
|
87
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
88
|
+
return { summary: `Analysis of ${topic}: significant developments found.`, confidence: 0.85 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function emitStopSignal(details: { reason?: string }) { // [!code highlight]
|
|
92
|
+
"use step";
|
|
93
|
+
const writer = getWritable<UIMessageChunk>().getWriter();
|
|
94
|
+
try {
|
|
95
|
+
await writer.write({ type: "data-stopped", id: "stop-signal", data: details } as UIMessageChunk);
|
|
96
|
+
} finally {
|
|
97
|
+
writer.releaseLock();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function stoppableAgent(messages: ModelMessage[]) {
|
|
102
|
+
"use workflow";
|
|
103
|
+
|
|
104
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
105
|
+
const hook = stopHook.create({ token: `stop:${workflowRunId}` }); // [!code highlight]
|
|
106
|
+
|
|
107
|
+
const agent = new DurableAgent({
|
|
108
|
+
model: "anthropic/claude-haiku-4.5",
|
|
109
|
+
instructions: "You are a research assistant. Search and analyze data as needed.",
|
|
110
|
+
tools: {
|
|
111
|
+
searchWeb: {
|
|
112
|
+
description: "Search the web for information",
|
|
113
|
+
inputSchema: z.object({ query: z.string() }),
|
|
114
|
+
execute: searchWeb,
|
|
115
|
+
},
|
|
116
|
+
analyzeData: {
|
|
117
|
+
description: "Analyze a piece of data",
|
|
118
|
+
inputSchema: z.object({ topic: z.string() }),
|
|
119
|
+
execute: analyzeData,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const result = await Promise.race([ // [!code highlight]
|
|
125
|
+
agent
|
|
126
|
+
.stream({ messages, writable: getWritable<UIMessageChunk>(), maxSteps: 15 })
|
|
127
|
+
.then((r) => ({ type: "complete" as const, messages: r.messages })),
|
|
128
|
+
hook.then(({ reason }) => ({ type: "stopped" as const, reason })), // [!code highlight]
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
if (result.type === "stopped") {
|
|
132
|
+
await emitStopSignal({ reason: result.reason }); // [!code highlight]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### API Route to Trigger Stop
|
|
140
|
+
|
|
141
|
+
```typescript lineNumbers
|
|
142
|
+
import { stopHook } from "@/workflows/stoppable-agent";
|
|
143
|
+
|
|
144
|
+
export async function POST(
|
|
145
|
+
request: Request,
|
|
146
|
+
{ params }: { params: Promise<{ runId: string }> }
|
|
147
|
+
) {
|
|
148
|
+
const { runId } = await params;
|
|
149
|
+
const { reason } = await request.json();
|
|
150
|
+
|
|
151
|
+
await stopHook.resume(`stop:${runId}`, { // [!code highlight]
|
|
152
|
+
reason: reason || "User requested stop",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return Response.json({ success: true });
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Client Stop Button
|
|
160
|
+
|
|
161
|
+
```tsx lineNumbers
|
|
162
|
+
"use client";
|
|
163
|
+
|
|
164
|
+
export function StopButton({ runId }: { runId: string }) {
|
|
165
|
+
const handleStop = async () => {
|
|
166
|
+
await fetch(`/api/chat/${runId}/stop`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ reason: "User clicked stop" }),
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<button type="button" onClick={handleStop}>
|
|
175
|
+
Stop Agent
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## How it works
|
|
182
|
+
|
|
183
|
+
1. A hook is created with token `stop:${workflowRunId}` when the workflow starts
|
|
184
|
+
2. `Promise.race` runs the agent stream and the stop hook concurrently
|
|
185
|
+
3. When the stop API resumes the hook, the race resolves immediately — the workflow exits
|
|
186
|
+
4. Before returning, `emitStopSignal` writes a `data-stopped` part to the stream so the client knows the agent was stopped (not just disconnected)
|
|
187
|
+
5. The client detects `data-stopped` and updates the UI accordingly
|
|
188
|
+
|
|
189
|
+
This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — race a long-running operation against a hook signal.
|
|
190
|
+
|
|
191
|
+
## Adapting this
|
|
192
|
+
|
|
193
|
+
* **Add a timeout** — race a third `sleep()` promise to auto-stop after a deadline
|
|
194
|
+
* **Audit logging** — include a `reason` field in the stop schema to record who stopped and why
|
|
195
|
+
* **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID
|
|
196
|
+
* **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop
|
|
197
|
+
* **Hard Cancellation as a fallback** — wire your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume errors with `not found` / `expired` (for example, the hook was already consumed). This guarantees the run is terminated even when the Stop Signal path is unavailable.
|
|
198
|
+
|
|
199
|
+
## Key APIs
|
|
200
|
+
|
|
201
|
+
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal
|
|
202
|
+
* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
|
|
203
|
+
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream a stop notification to the client
|
|
204
|
+
* [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — the agent that gets raced against the stop hook
|
|
205
|
+
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — entry point for Hard Cancellation: `getRun(runId).cancel()`
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Durable Agent
|
|
3
|
+
description: Replace a stateless AI agent with a durable one that survives crashes, retries tool calls, and streams output.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Convert an AI SDK Agent into a DurableAgent backed by a workflow, with tools as retryable steps.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use this pattern to make any AI SDK agent durable. The agent becomes a workflow, tools become steps, and the framework handles retries, streaming, and state persistence automatically.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Any AI agent with tool calls that should survive crashes and restarts
|
|
13
|
+
- Agents where tool calls hit external APIs that need automatic retries
|
|
14
|
+
- Long-running agent sessions where losing progress is unacceptable
|
|
15
|
+
- Agents that need per-step observability in the workflow event log
|
|
16
|
+
|
|
17
|
+
## Pattern
|
|
18
|
+
|
|
19
|
+
Replace `Agent` with `DurableAgent`, wrap the function in `"use workflow"`, mark each tool with `"use step"`, and stream output through `getWritable()`.
|
|
20
|
+
|
|
21
|
+
### Workflow
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
25
|
+
import { getWritable } from "workflow";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
28
|
+
|
|
29
|
+
async function searchFlights({ from, to, date }: {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
date: string;
|
|
33
|
+
}) {
|
|
34
|
+
"use step"; // [!code highlight]
|
|
35
|
+
const res = await fetch(
|
|
36
|
+
`https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
|
|
37
|
+
);
|
|
38
|
+
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
|
|
39
|
+
return res.json();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function bookFlight({ flightId, passenger }: {
|
|
43
|
+
flightId: string;
|
|
44
|
+
passenger: string;
|
|
45
|
+
}) {
|
|
46
|
+
"use step"; // [!code highlight]
|
|
47
|
+
const res = await fetch("https://api.example.com/bookings", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify({ flightId, passenger }),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) throw new Error(`Booking failed: ${res.status}`);
|
|
53
|
+
return res.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function checkWeather({ city }: { city: string }) {
|
|
57
|
+
"use step"; // [!code highlight]
|
|
58
|
+
const res = await fetch(`https://api.weather.com/forecast?city=${city}`);
|
|
59
|
+
return res.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function flightAgent(messages: ModelMessage[]) {
|
|
63
|
+
"use workflow";
|
|
64
|
+
|
|
65
|
+
const agent = new DurableAgent({ // [!code highlight]
|
|
66
|
+
model: "anthropic/claude-haiku-4.5",
|
|
67
|
+
instructions: "You are a helpful flight booking assistant.",
|
|
68
|
+
tools: {
|
|
69
|
+
searchFlights: {
|
|
70
|
+
description: "Search for available flights between two airports",
|
|
71
|
+
inputSchema: z.object({
|
|
72
|
+
from: z.string().describe("Departure airport code"),
|
|
73
|
+
to: z.string().describe("Arrival airport code"),
|
|
74
|
+
date: z.string().describe("Travel date (YYYY-MM-DD)"),
|
|
75
|
+
}),
|
|
76
|
+
execute: searchFlights,
|
|
77
|
+
},
|
|
78
|
+
bookFlight: {
|
|
79
|
+
description: "Book a specific flight for a passenger",
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
flightId: z.string().describe("Flight ID from search results"),
|
|
82
|
+
passenger: z.string().describe("Passenger full name"),
|
|
83
|
+
}),
|
|
84
|
+
execute: bookFlight,
|
|
85
|
+
},
|
|
86
|
+
checkWeather: {
|
|
87
|
+
description: "Check the weather forecast for a city",
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
city: z.string().describe("City name"),
|
|
90
|
+
}),
|
|
91
|
+
execute: checkWeather,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await agent.stream({ // [!code highlight]
|
|
97
|
+
messages,
|
|
98
|
+
writable: getWritable<UIMessageChunk>(), // [!code highlight]
|
|
99
|
+
maxSteps: 10,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { messages: result.messages };
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### API route
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import type { UIMessage } from "ai";
|
|
110
|
+
import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
|
|
111
|
+
import { start } from "workflow/api";
|
|
112
|
+
import { flightAgent } from "@/app/workflows/flight-agent";
|
|
113
|
+
|
|
114
|
+
export async function POST(req: Request) {
|
|
115
|
+
const { messages }: { messages: UIMessage[] } = await req.json();
|
|
116
|
+
const modelMessages = await convertToModelMessages(messages); // [!code highlight]
|
|
117
|
+
|
|
118
|
+
const run = await start(flightAgent, [modelMessages]); // [!code highlight]
|
|
119
|
+
|
|
120
|
+
return createUIMessageStreamResponse({ // [!code highlight]
|
|
121
|
+
stream: run.readable,
|
|
122
|
+
headers: {
|
|
123
|
+
"x-workflow-run-id": run.runId,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## How it works
|
|
130
|
+
|
|
131
|
+
1. **DurableAgent wraps Agent** — same API as AI SDK's `Agent`, but backed by a workflow. If the process crashes, the agent resumes from the last completed step on replay.
|
|
132
|
+
2. **Tools as steps** — each tool's `execute` function uses `"use step"`, giving it automatic retries, full Node.js access, and an entry in the workflow event log.
|
|
133
|
+
3. **Streaming** — `getWritable<UIMessageChunk>()` streams the agent's output (text chunks, tool calls, tool results) to the client in real time via `createUIMessageStreamResponse`.
|
|
134
|
+
4. **maxSteps** — limits the total number of LLM calls the agent can make, preventing runaway tool loops.
|
|
135
|
+
|
|
136
|
+
## Adapting to your use case
|
|
137
|
+
|
|
138
|
+
- **Change the model** — replace `"anthropic/claude-haiku-4.5"` with any AI Gateway model string (e.g. `"openai/gpt-4o"`, `"anthropic/claude-sonnet-4-5"`).
|
|
139
|
+
- **Add tools** — define a new `"use step"` function with a Zod schema. Each tool automatically gets retries and persistence.
|
|
140
|
+
- **Workflow-level tools** — if a tool needs workflow primitives like `sleep()` or `createHook()`, omit `"use step"` so it runs in the workflow context instead.
|
|
141
|
+
- **Multi-turn** — pass `result.messages` plus new user messages to subsequent `agent.stream()` calls for multi-turn conversations.
|
|
142
|
+
- **Client integration** — use `useChat()` from `@ai-sdk/react` with `WorkflowChatTransport` from `@workflow/ai` for a full chat UI with reconnection support.
|
|
143
|
+
|
|
144
|
+
## Key APIs
|
|
145
|
+
|
|
146
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
|
|
147
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries and full Node.js access
|
|
148
|
+
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — durable wrapper around AI SDK's Agent
|
|
149
|
+
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — streams agent output to the client
|
|
150
|
+
- [`start()`](/docs/api-reference/workflow-api/start) — starts a workflow run from an API route
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Human-in-the-Loop
|
|
3
|
+
description: Pause an AI agent to wait for human approval, then resume based on the decision.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Use defineHook with the tool call ID to suspend an agent for human approval, with an optional timeout.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- Booking confirmations where users must approve before charges are made
|
|
13
|
+
- Content publishing gates where an editor must sign off
|
|
14
|
+
- Any agent action where the cost of getting it wrong justifies a human check
|
|
15
|
+
- Actions with side effects that can't be easily undone
|
|
16
|
+
|
|
17
|
+
## Pattern
|
|
18
|
+
|
|
19
|
+
Create a typed hook using `defineHook()`. When the agent calls the approval tool, the tool emits a custom data part to the stream so the client can render approval controls, then creates a hook and suspends. An API route resumes the hook with the decision.
|
|
20
|
+
|
|
21
|
+
### Workflow
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
25
|
+
import { defineHook, sleep, getWritable } from "workflow";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
28
|
+
|
|
29
|
+
// Exported so the approval API route can call .resume()
|
|
30
|
+
export const bookingApprovalHook = defineHook({ // [!code highlight]
|
|
31
|
+
schema: z.object({
|
|
32
|
+
approved: z.boolean(),
|
|
33
|
+
comment: z.string().optional(),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function searchFlights({ from, to, date }: {
|
|
38
|
+
from: string;
|
|
39
|
+
to: string;
|
|
40
|
+
date: string;
|
|
41
|
+
}) {
|
|
42
|
+
"use step";
|
|
43
|
+
const res = await fetch(
|
|
44
|
+
`https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
|
|
45
|
+
);
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function confirmBooking({ flightId, passenger }: {
|
|
50
|
+
flightId: string;
|
|
51
|
+
passenger: string;
|
|
52
|
+
}) {
|
|
53
|
+
"use step";
|
|
54
|
+
const res = await fetch("https://api.example.com/bookings", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: JSON.stringify({ flightId, passenger }),
|
|
57
|
+
});
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Stream a custom data part so the client can render the approval UI.
|
|
62
|
+
// This MUST run before the hook suspends the workflow — otherwise
|
|
63
|
+
// the tool-invocation won't appear in the stream until the tool returns,
|
|
64
|
+
// and the client would have no way to show approval buttons.
|
|
65
|
+
async function emitApprovalRequest(details: {
|
|
66
|
+
flightId: string;
|
|
67
|
+
passenger: string;
|
|
68
|
+
price: number;
|
|
69
|
+
toolCallId: string;
|
|
70
|
+
}) {
|
|
71
|
+
"use step";
|
|
72
|
+
const writer = getWritable<UIMessageChunk>().getWriter();
|
|
73
|
+
try {
|
|
74
|
+
await writer.write({
|
|
75
|
+
type: "data-approval-needed", // [!code highlight]
|
|
76
|
+
id: details.toolCallId,
|
|
77
|
+
data: details,
|
|
78
|
+
} as UIMessageChunk);
|
|
79
|
+
} finally {
|
|
80
|
+
writer.releaseLock();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Stream the resolution so the client can update the approval card.
|
|
85
|
+
async function emitApprovalResolved(details: {
|
|
86
|
+
toolCallId: string;
|
|
87
|
+
result: string;
|
|
88
|
+
}) {
|
|
89
|
+
"use step";
|
|
90
|
+
const writer = getWritable<UIMessageChunk>().getWriter();
|
|
91
|
+
try {
|
|
92
|
+
await writer.write({
|
|
93
|
+
type: "data-approval-resolved", // [!code highlight]
|
|
94
|
+
id: details.toolCallId,
|
|
95
|
+
data: details,
|
|
96
|
+
} as UIMessageChunk);
|
|
97
|
+
} finally {
|
|
98
|
+
writer.releaseLock();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// No "use step" — hooks are workflow-level primitives
|
|
103
|
+
async function requestBookingApproval(
|
|
104
|
+
{ flightId, passenger, price }: {
|
|
105
|
+
flightId: string;
|
|
106
|
+
passenger: string;
|
|
107
|
+
price: number;
|
|
108
|
+
},
|
|
109
|
+
{ toolCallId }: { toolCallId: string }
|
|
110
|
+
) {
|
|
111
|
+
// Emit to the stream before suspending so the UI can show buttons
|
|
112
|
+
await emitApprovalRequest({ flightId, passenger, price, toolCallId }); // [!code highlight]
|
|
113
|
+
|
|
114
|
+
const hook = bookingApprovalHook.create({ token: toolCallId });
|
|
115
|
+
|
|
116
|
+
// Race: human decision vs. timeout
|
|
117
|
+
const result = await Promise.race([
|
|
118
|
+
hook.then((payload) => ({ type: "decision" as const, ...payload })),
|
|
119
|
+
sleep("24h").then(() => ({ type: "timeout" as const, approved: false as const })),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
if (result.type === "timeout") {
|
|
123
|
+
const msg = "Booking request expired.";
|
|
124
|
+
await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
|
|
125
|
+
return msg;
|
|
126
|
+
}
|
|
127
|
+
if (!result.approved) {
|
|
128
|
+
const msg = `Rejected: ${result.comment || "No reason given"}`;
|
|
129
|
+
await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
|
|
130
|
+
return msg;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const booking = await confirmBooking({ flightId, passenger });
|
|
134
|
+
const msg = `Booked! Confirmation: ${booking.confirmationId}`;
|
|
135
|
+
await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
|
|
136
|
+
return msg;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function bookingAgent(messages: ModelMessage[]) {
|
|
140
|
+
"use workflow";
|
|
141
|
+
|
|
142
|
+
const agent = new DurableAgent({
|
|
143
|
+
model: "anthropic/claude-haiku-4.5",
|
|
144
|
+
instructions: "You help book flights. Always request approval before booking.",
|
|
145
|
+
tools: {
|
|
146
|
+
searchFlights: {
|
|
147
|
+
description: "Search for available flights",
|
|
148
|
+
inputSchema: z.object({
|
|
149
|
+
from: z.string().describe("Departure airport code"),
|
|
150
|
+
to: z.string().describe("Arrival airport code"),
|
|
151
|
+
date: z.string().describe("Travel date (YYYY-MM-DD)"),
|
|
152
|
+
}),
|
|
153
|
+
execute: searchFlights,
|
|
154
|
+
},
|
|
155
|
+
requestBookingApproval: {
|
|
156
|
+
description: "Request human approval before booking a flight",
|
|
157
|
+
inputSchema: z.object({
|
|
158
|
+
flightId: z.string().describe("Flight ID to book"),
|
|
159
|
+
passenger: z.string().describe("Passenger name"),
|
|
160
|
+
price: z.number().describe("Total price"),
|
|
161
|
+
}),
|
|
162
|
+
execute: requestBookingApproval,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await agent.stream({
|
|
168
|
+
messages,
|
|
169
|
+
writable: getWritable<UIMessageChunk>(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Approval API route
|
|
175
|
+
|
|
176
|
+
The approval route imports the hook definition and calls `.resume()` with the tool call ID as the token:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { bookingApprovalHook } from "@/app/workflows/booking-agent";
|
|
180
|
+
|
|
181
|
+
export async function POST(req: Request) {
|
|
182
|
+
const { toolCallId, approved, comment } = await req.json();
|
|
183
|
+
|
|
184
|
+
await bookingApprovalHook.resume(toolCallId, { approved, comment }); // [!code highlight]
|
|
185
|
+
|
|
186
|
+
return Response.json({ success: true });
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Client rendering
|
|
191
|
+
|
|
192
|
+
Listen for `data-approval-needed` and `data-approval-resolved` custom data parts in the message stream. The approval tool invocation itself won't appear until the tool returns, so the custom data parts are the mechanism for showing and updating the approval UI.
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
// Scan all messages for the resolution
|
|
196
|
+
const approvalResult = messages
|
|
197
|
+
.flatMap((m) => m.parts)
|
|
198
|
+
.find((p) => p.type === "data-approval-resolved")
|
|
199
|
+
?.data?.result;
|
|
200
|
+
|
|
201
|
+
// In your message parts loop:
|
|
202
|
+
{message.parts.map((part, i) => {
|
|
203
|
+
if (part.type === "data-approval-needed") { // [!code highlight]
|
|
204
|
+
const { flightId, passenger, price, toolCallId } = part.data;
|
|
205
|
+
if (approvalResult) {
|
|
206
|
+
return <div key={i}>Result: {approvalResult}</div>;
|
|
207
|
+
}
|
|
208
|
+
return (
|
|
209
|
+
<div key={i} className="rounded-lg border p-4 space-y-3">
|
|
210
|
+
<div className="text-sm">
|
|
211
|
+
<div>Flight: {flightId}</div>
|
|
212
|
+
<div>Passenger: {passenger}</div>
|
|
213
|
+
<div>Price: ${price}</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="flex gap-2">
|
|
216
|
+
<button onClick={() => approve(toolCallId)}>Approve</button> {/* [!code highlight] */}
|
|
217
|
+
<button onClick={() => reject(toolCallId)}>Reject</button> {/* [!code highlight] */}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
// Hide the requestBookingApproval tool-invocation part
|
|
223
|
+
if (part.type === "tool-invocation" &&
|
|
224
|
+
part.toolInvocation.toolName === "requestBookingApproval") {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
// ... other part types
|
|
228
|
+
})}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## How it works
|
|
232
|
+
|
|
233
|
+
1. **`defineHook()` with schema** — creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it.
|
|
234
|
+
2. **`toolCallId` as token** — the approval tool uses the tool call ID as the hook token, naturally linking the hook to the specific tool invocation.
|
|
235
|
+
3. **`emitApprovalRequest` step** — writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this, the client would never see the approval controls because tool invocations don't stream until the tool returns.
|
|
236
|
+
4. **No `"use step"` on the approval tool** — the tool runs at the workflow level because `defineHook().create()` is a workflow primitive. It calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, `confirmBooking`) for I/O.
|
|
237
|
+
5. **`Promise.race` with sleep** — the approval races against a durable timeout. If nobody responds, the workflow continues with an expiration message.
|
|
238
|
+
6. **`emitApprovalResolved` step** — writes the outcome to the stream so the client can update the card immediately, without waiting for the tool-invocation result.
|
|
239
|
+
|
|
240
|
+
## Adapting to your use case
|
|
241
|
+
|
|
242
|
+
- **Change the approval schema** — add fields like `reason`, `amount`, `reviewerEmail` to match your domain.
|
|
243
|
+
- **Multiple approval gates** — the pattern works for any number of tools. Each tool creates its own hook with its own `toolCallId`.
|
|
244
|
+
- **Escalation** — if the first approver doesn't respond, use `sleep()` + another hook to escalate to a backup reviewer.
|
|
245
|
+
- **Adjust timeout** — use `"24h"` for production, shorter durations for demos.
|
|
246
|
+
- **Workflow-level vs step tools** — tools that use `sleep()`, `defineHook()`, or other workflow primitives must NOT use `"use step"`. Tools with only I/O (API calls, DB queries) should use `"use step"` for retries.
|
|
247
|
+
|
|
248
|
+
## Key APIs
|
|
249
|
+
|
|
250
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
|
|
251
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries
|
|
252
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation
|
|
253
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry
|
|
254
|
+
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps
|
|
255
|
+
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — durable agent with tool definitions
|