workflow 5.0.0-beta.1 → 5.0.0-beta.2
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.js +1 -1
- 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/custom-serialization.mdx +168 -0
- package/docs/cookbook/advanced/durable-objects.mdx +148 -0
- package/docs/cookbook/advanced/isomorphic-packages.mdx +145 -0
- package/docs/cookbook/advanced/meta.json +10 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +279 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +135 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +191 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +278 -0
- package/docs/cookbook/agent-patterns/meta.json +10 -0
- package/docs/cookbook/agent-patterns/stop-workflow.mdx +216 -0
- package/docs/cookbook/agent-patterns/tool-orchestration.mdx +255 -0
- package/docs/cookbook/agent-patterns/tool-streaming.mdx +181 -0
- package/docs/cookbook/common-patterns/batching.mdx +179 -0
- package/docs/cookbook/common-patterns/child-workflows.mdx +372 -0
- package/docs/cookbook/common-patterns/content-router.mdx +207 -0
- package/docs/cookbook/common-patterns/fan-out.mdx +208 -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 +152 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +249 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/index.mdx +41 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +204 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +203 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +128 -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/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 +311 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +282 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +284 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +296 -0
- package/package.json +13 -13
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Serializable Steps
|
|
3
|
+
description: Wrap non-serializable objects (like AI model providers) inside step functions so they can cross the workflow boundary.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Return a callback from a step to defer provider initialization, making non-serializable AI SDK models work inside durable workflows.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<Callout>
|
|
9
|
+
This is an advanced guide. It dives into workflow internals and is not required reading to use workflow.
|
|
10
|
+
</Callout>
|
|
11
|
+
|
|
12
|
+
## The Problem
|
|
13
|
+
|
|
14
|
+
Workflow functions run inside a sandboxed VM where every value that crosses a function boundary must be serializable (JSON-safe). AI SDK model providers — `openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc. — return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error.
|
|
15
|
+
|
|
16
|
+
```typescript lineNumbers
|
|
17
|
+
import { openai } from "@ai-sdk/openai";
|
|
18
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
19
|
+
import { getWritable } from "workflow";
|
|
20
|
+
import type { UIMessageChunk } from "ai";
|
|
21
|
+
|
|
22
|
+
export async function brokenAgent(prompt: string) {
|
|
23
|
+
"use workflow";
|
|
24
|
+
|
|
25
|
+
const writable = getWritable<UIMessageChunk>();
|
|
26
|
+
const agent = new DurableAgent({
|
|
27
|
+
// This fails — the model object is not serializable
|
|
28
|
+
model: openai("gpt-4o"),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await agent.stream({ messages: [{ role: "user", content: prompt }], writable });
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The Solution: Step-as-Factory
|
|
36
|
+
|
|
37
|
+
Instead of passing the model object, pass a **callback function** that returns the model. Marking that callback with `"use step"` tells the compiler to serialize the *function reference* (which is just a string identifier) rather than its return value. The provider is only instantiated at execution time, inside the step's full Node.js runtime.
|
|
38
|
+
|
|
39
|
+
```typescript lineNumbers
|
|
40
|
+
import { openai as openaiProvider } from "@ai-sdk/openai";
|
|
41
|
+
|
|
42
|
+
// Returns a step function, not a model object
|
|
43
|
+
export function openai(...args: Parameters<typeof openaiProvider>) {
|
|
44
|
+
return async () => {
|
|
45
|
+
"use step";
|
|
46
|
+
return openaiProvider(...args); // [!code highlight]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `DurableAgent` receives a function (`() => Promise<LanguageModel>`) instead of a model object. When the agent needs to call the LLM, it invokes the factory inside a step where the real provider can be constructed with full Node.js access.
|
|
52
|
+
|
|
53
|
+
## How `@workflow/ai` Uses This
|
|
54
|
+
|
|
55
|
+
The `@workflow/ai` package ships pre-wrapped providers for all major AI SDK backends. Each one follows the same pattern:
|
|
56
|
+
|
|
57
|
+
```typescript lineNumbers
|
|
58
|
+
// packages/ai/src/providers/anthropic.ts
|
|
59
|
+
import { anthropic as anthropicProvider } from "@ai-sdk/anthropic";
|
|
60
|
+
|
|
61
|
+
export function anthropic(...args: Parameters<typeof anthropicProvider>) {
|
|
62
|
+
return async () => {
|
|
63
|
+
"use step";
|
|
64
|
+
return anthropicProvider(...args); // [!code highlight]
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This means you import from `@workflow/ai` instead of `@ai-sdk/*` directly:
|
|
70
|
+
|
|
71
|
+
```typescript lineNumbers
|
|
72
|
+
import { anthropic } from "@workflow/ai/anthropic";
|
|
73
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
74
|
+
import { getWritable } from "workflow";
|
|
75
|
+
import type { UIMessageChunk } from "ai";
|
|
76
|
+
|
|
77
|
+
export async function chatAgent(prompt: string) {
|
|
78
|
+
"use workflow";
|
|
79
|
+
|
|
80
|
+
const writable = getWritable<UIMessageChunk>();
|
|
81
|
+
const agent = new DurableAgent({
|
|
82
|
+
model: anthropic("claude-sonnet-4-20250514"), // [!code highlight]
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await agent.stream({ messages: [{ role: "user", content: prompt }], writable });
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Writing Your Own Serializable Wrapper
|
|
90
|
+
|
|
91
|
+
Apply the same pattern to any non-serializable dependency. The key rule: **the outer function captures serializable arguments, and the inner `"use step"` function constructs the real object at runtime**.
|
|
92
|
+
|
|
93
|
+
```typescript lineNumbers
|
|
94
|
+
import type { S3Client as S3ClientType } from "@aws-sdk/client-s3";
|
|
95
|
+
|
|
96
|
+
// The arguments (region, bucket) are plain strings — serializable
|
|
97
|
+
export function createS3Client(region: string) {
|
|
98
|
+
return async (): Promise<S3ClientType> => {
|
|
99
|
+
"use step";
|
|
100
|
+
const { S3Client } = await import("@aws-sdk/client-s3");
|
|
101
|
+
return new S3Client({ region });
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Usage in a workflow
|
|
106
|
+
export async function processUpload(region: string, key: string) {
|
|
107
|
+
"use workflow";
|
|
108
|
+
|
|
109
|
+
const getClient = createS3Client(region); // [!code highlight]
|
|
110
|
+
// getClient is a serializable step reference, not an S3Client
|
|
111
|
+
await uploadFile(getClient, key);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function uploadFile(
|
|
115
|
+
getClient: () => Promise<S3ClientType>,
|
|
116
|
+
key: string
|
|
117
|
+
) {
|
|
118
|
+
"use step";
|
|
119
|
+
const client = await getClient(); // [!code highlight]
|
|
120
|
+
// Now you have a real S3Client with full Node.js access
|
|
121
|
+
await client.send(/* ... */);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Why This Works
|
|
126
|
+
|
|
127
|
+
1. **Compiler transformation**: `"use step"` tells the SWC plugin to extract the function into a separate bundle. The workflow VM only sees a serializable reference (function ID + captured arguments).
|
|
128
|
+
2. **Closure tracking**: The compiler tracks which variables the step function closes over. Only serializable values (strings, numbers, plain objects) can be captured.
|
|
129
|
+
3. **Deferred construction**: The actual provider/client is only constructed when the step executes in the Node.js runtime — never in the sandboxed workflow VM.
|
|
130
|
+
|
|
131
|
+
## Key APIs
|
|
132
|
+
|
|
133
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) — marks a function for extraction and serialization
|
|
134
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
|
|
135
|
+
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — accepts a model factory for durable AI agent streaming
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
## Pattern
|
|
11
|
+
|
|
12
|
+
Replace `Agent` with `DurableAgent`, wrap the function in `"use workflow"`, mark each tool with `"use step"`, and stream output through `getWritable()`.
|
|
13
|
+
|
|
14
|
+
### Simplified
|
|
15
|
+
|
|
16
|
+
```typescript lineNumbers
|
|
17
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
18
|
+
import { getWritable } from "workflow";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
21
|
+
|
|
22
|
+
declare function searchFlights(args: { from: string; to: string; date: string }): Promise<{ flights: { id: string; price: number }[] }>; // @setup
|
|
23
|
+
declare function bookFlight(args: { flightId: string; passenger: string }): Promise<{ confirmationId: string }>; // @setup
|
|
24
|
+
|
|
25
|
+
export async function flightAgent(messages: ModelMessage[]) {
|
|
26
|
+
"use workflow";
|
|
27
|
+
|
|
28
|
+
const agent = new DurableAgent({ // [!code highlight]
|
|
29
|
+
model: "anthropic/claude-haiku-4.5",
|
|
30
|
+
instructions: "You are a helpful flight booking assistant.",
|
|
31
|
+
tools: {
|
|
32
|
+
searchFlights: {
|
|
33
|
+
description: "Search for available flights",
|
|
34
|
+
inputSchema: z.object({
|
|
35
|
+
from: z.string(),
|
|
36
|
+
to: z.string(),
|
|
37
|
+
date: z.string(),
|
|
38
|
+
}),
|
|
39
|
+
execute: searchFlights,
|
|
40
|
+
},
|
|
41
|
+
bookFlight: {
|
|
42
|
+
description: "Book a specific flight",
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
flightId: z.string(),
|
|
45
|
+
passenger: z.string(),
|
|
46
|
+
}),
|
|
47
|
+
execute: bookFlight,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await agent.stream({ // [!code highlight]
|
|
53
|
+
messages,
|
|
54
|
+
writable: getWritable<UIMessageChunk>(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Full Implementation
|
|
60
|
+
|
|
61
|
+
```typescript lineNumbers
|
|
62
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
63
|
+
import { getWritable } from "workflow";
|
|
64
|
+
import { z } from "zod";
|
|
65
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
66
|
+
|
|
67
|
+
// Step: Search flights with full Node.js access and automatic retries
|
|
68
|
+
async function searchFlights({
|
|
69
|
+
from,
|
|
70
|
+
to,
|
|
71
|
+
date,
|
|
72
|
+
}: {
|
|
73
|
+
from: string;
|
|
74
|
+
to: string;
|
|
75
|
+
date: string;
|
|
76
|
+
}) {
|
|
77
|
+
"use step";
|
|
78
|
+
|
|
79
|
+
const response = await fetch(
|
|
80
|
+
`https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
|
|
81
|
+
);
|
|
82
|
+
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
|
|
83
|
+
return response.json();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step: Book a flight — retries on transient failures
|
|
87
|
+
async function bookFlight({
|
|
88
|
+
flightId,
|
|
89
|
+
passenger,
|
|
90
|
+
}: {
|
|
91
|
+
flightId: string;
|
|
92
|
+
passenger: string;
|
|
93
|
+
}) {
|
|
94
|
+
"use step";
|
|
95
|
+
|
|
96
|
+
const response = await fetch("https://api.example.com/bookings", {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ flightId, passenger }),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) throw new Error(`Booking failed: ${response.status}`);
|
|
102
|
+
return response.json();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Step: Check flight status
|
|
106
|
+
async function checkStatus({ flightId }: { flightId: string }) {
|
|
107
|
+
"use step";
|
|
108
|
+
|
|
109
|
+
const response = await fetch(
|
|
110
|
+
`https://api.example.com/flights/${flightId}/status`
|
|
111
|
+
);
|
|
112
|
+
return response.json();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function flightAgent(messages: ModelMessage[]) {
|
|
116
|
+
"use workflow";
|
|
117
|
+
|
|
118
|
+
const writable = getWritable<UIMessageChunk>(); // [!code highlight]
|
|
119
|
+
|
|
120
|
+
const agent = new DurableAgent({ // [!code highlight]
|
|
121
|
+
model: "anthropic/claude-haiku-4.5",
|
|
122
|
+
instructions: "You are a helpful flight booking assistant.",
|
|
123
|
+
tools: {
|
|
124
|
+
searchFlights: {
|
|
125
|
+
description: "Search for available flights between two airports",
|
|
126
|
+
inputSchema: z.object({
|
|
127
|
+
from: z.string().describe("Departure airport code"),
|
|
128
|
+
to: z.string().describe("Arrival airport code"),
|
|
129
|
+
date: z.string().describe("Travel date (YYYY-MM-DD)"),
|
|
130
|
+
}),
|
|
131
|
+
execute: searchFlights,
|
|
132
|
+
},
|
|
133
|
+
bookFlight: {
|
|
134
|
+
description: "Book a specific flight for a passenger",
|
|
135
|
+
inputSchema: z.object({
|
|
136
|
+
flightId: z.string().describe("Flight ID from search results"),
|
|
137
|
+
passenger: z.string().describe("Passenger full name"),
|
|
138
|
+
}),
|
|
139
|
+
execute: bookFlight,
|
|
140
|
+
},
|
|
141
|
+
checkStatus: {
|
|
142
|
+
description: "Check the current status of a flight",
|
|
143
|
+
inputSchema: z.object({
|
|
144
|
+
flightId: z.string().describe("Flight ID to check"),
|
|
145
|
+
}),
|
|
146
|
+
execute: checkStatus,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = await agent.stream({ // [!code highlight]
|
|
152
|
+
messages,
|
|
153
|
+
writable,
|
|
154
|
+
maxSteps: 10,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return { messages: result.messages };
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### API Route
|
|
162
|
+
|
|
163
|
+
```typescript lineNumbers
|
|
164
|
+
import { createUIMessageStreamResponse } from "ai";
|
|
165
|
+
import { start } from "workflow/api";
|
|
166
|
+
import { flightAgent } from "@/workflows/flight-agent";
|
|
167
|
+
import type { UIMessage } from "ai";
|
|
168
|
+
import { convertToModelMessages } from "ai";
|
|
169
|
+
|
|
170
|
+
export async function POST(req: Request) {
|
|
171
|
+
const { messages }: { messages: UIMessage[] } = await req.json();
|
|
172
|
+
const modelMessages = await convertToModelMessages(messages);
|
|
173
|
+
|
|
174
|
+
const run = await start(flightAgent, [modelMessages]); // [!code highlight]
|
|
175
|
+
|
|
176
|
+
return createUIMessageStreamResponse({
|
|
177
|
+
stream: run.readable,
|
|
178
|
+
headers: {
|
|
179
|
+
"x-workflow-run-id": run.runId,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Key APIs
|
|
186
|
+
|
|
187
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
|
|
188
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries and full Node.js access
|
|
189
|
+
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — durable wrapper around AI SDK's Agent
|
|
190
|
+
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — streams agent output to the client
|
|
191
|
+
- [`start()`](/docs/api-reference/workflow-api/start) — starts a workflow run from an API route
|
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
## Pattern
|
|
11
|
+
|
|
12
|
+
Create a typed hook using `defineHook()`. When the agent calls the approval tool, the tool creates a hook instance using the tool call ID as the token, then awaits it. The UI renders approval controls, and an API route resumes the hook with the decision.
|
|
13
|
+
|
|
14
|
+
### Simplified
|
|
15
|
+
|
|
16
|
+
```typescript lineNumbers
|
|
17
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
18
|
+
import { defineHook, sleep, getWritable } from "workflow";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
21
|
+
|
|
22
|
+
export const bookingApprovalHook = defineHook({
|
|
23
|
+
schema: z.object({
|
|
24
|
+
approved: z.boolean(),
|
|
25
|
+
comment: z.string().optional(),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
declare function confirmBooking(args: { flightId: string; passenger: string }): Promise<{ confirmationId: string }>; // @setup
|
|
30
|
+
|
|
31
|
+
// This tool runs at the workflow level (no "use step") because hooks are workflow primitives
|
|
32
|
+
async function requestBookingApproval(
|
|
33
|
+
{ flightId, passenger, price }: { flightId: string; passenger: string; price: number },
|
|
34
|
+
{ toolCallId }: { toolCallId: string }
|
|
35
|
+
) {
|
|
36
|
+
const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight]
|
|
37
|
+
|
|
38
|
+
const result = await Promise.race([ // [!code highlight]
|
|
39
|
+
hook.then((payload) => ({ type: "decision" as const, ...payload })),
|
|
40
|
+
sleep("24h").then(() => ({ type: "timeout" as const, approved: false })),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
if (result.type === "timeout") return "Booking request expired after 24 hours.";
|
|
44
|
+
if (!result.approved) return `Booking rejected: ${result.comment || "No reason given"}`;
|
|
45
|
+
|
|
46
|
+
const booking = await confirmBooking({ flightId, passenger });
|
|
47
|
+
return `Booked! Confirmation: ${booking.confirmationId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function bookingAgent(messages: ModelMessage[]) {
|
|
51
|
+
"use workflow";
|
|
52
|
+
|
|
53
|
+
const agent = new DurableAgent({
|
|
54
|
+
model: "anthropic/claude-haiku-4.5",
|
|
55
|
+
instructions: "You help book flights. Always request approval before booking.",
|
|
56
|
+
tools: {
|
|
57
|
+
requestBookingApproval: {
|
|
58
|
+
description: "Request human approval before booking a flight",
|
|
59
|
+
inputSchema: z.object({
|
|
60
|
+
flightId: z.string(),
|
|
61
|
+
passenger: z.string(),
|
|
62
|
+
price: z.number(),
|
|
63
|
+
}),
|
|
64
|
+
execute: requestBookingApproval,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await agent.stream({ // [!code highlight]
|
|
70
|
+
messages,
|
|
71
|
+
writable: getWritable<UIMessageChunk>(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Full Implementation
|
|
77
|
+
|
|
78
|
+
```typescript lineNumbers
|
|
79
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
80
|
+
import { defineHook, sleep, getWritable } from "workflow";
|
|
81
|
+
import { z } from "zod";
|
|
82
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
83
|
+
|
|
84
|
+
// Define the approval hook with schema validation
|
|
85
|
+
export const bookingApprovalHook = defineHook({
|
|
86
|
+
schema: z.object({
|
|
87
|
+
approved: z.boolean(),
|
|
88
|
+
comment: z.string().optional(),
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Step: Search for flights (full Node.js access, automatic retries)
|
|
93
|
+
async function searchFlights({
|
|
94
|
+
from,
|
|
95
|
+
to,
|
|
96
|
+
date,
|
|
97
|
+
}: {
|
|
98
|
+
from: string;
|
|
99
|
+
to: string;
|
|
100
|
+
date: string;
|
|
101
|
+
}) {
|
|
102
|
+
"use step";
|
|
103
|
+
|
|
104
|
+
// Your real flight search API call here
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
106
|
+
return {
|
|
107
|
+
flights: [
|
|
108
|
+
{ id: "FL-100", airline: "Example Air", price: 299, from, to, date },
|
|
109
|
+
{ id: "FL-200", airline: "Demo Airlines", price: 349, from, to, date },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step: Confirm the booking after approval
|
|
115
|
+
async function confirmBooking({
|
|
116
|
+
flightId,
|
|
117
|
+
passenger,
|
|
118
|
+
}: {
|
|
119
|
+
flightId: string;
|
|
120
|
+
passenger: string;
|
|
121
|
+
}) {
|
|
122
|
+
"use step";
|
|
123
|
+
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
125
|
+
return { confirmationId: `CONF-${flightId}-${Date.now().toString(36)}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Workflow-level tool: hooks must be created in workflow context, not inside steps
|
|
129
|
+
async function requestBookingApproval(
|
|
130
|
+
{
|
|
131
|
+
flightId,
|
|
132
|
+
passenger,
|
|
133
|
+
price,
|
|
134
|
+
}: { flightId: string; passenger: string; price: number },
|
|
135
|
+
{ toolCallId }: { toolCallId: string }
|
|
136
|
+
) {
|
|
137
|
+
// No "use step" — hooks are workflow-level primitives
|
|
138
|
+
|
|
139
|
+
const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight]
|
|
140
|
+
|
|
141
|
+
// Race: human approval vs. 24-hour timeout
|
|
142
|
+
const result = await Promise.race([ // [!code highlight]
|
|
143
|
+
hook.then((payload) => ({ type: "decision" as const, ...payload })),
|
|
144
|
+
sleep("24h").then(() => ({ type: "timeout" as const, approved: false })),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
if (result.type === "timeout") {
|
|
148
|
+
return "Booking request expired after 24 hours.";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!result.approved) {
|
|
152
|
+
return `Booking rejected: ${result.comment || "No reason given"}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Approved — proceed with booking
|
|
156
|
+
const booking = await confirmBooking({ flightId, passenger });
|
|
157
|
+
return `Flight ${flightId} booked for ${passenger}. Confirmation: ${booking.confirmationId}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function bookingAgent(messages: ModelMessage[]) {
|
|
161
|
+
"use workflow";
|
|
162
|
+
|
|
163
|
+
const writable = getWritable<UIMessageChunk>();
|
|
164
|
+
|
|
165
|
+
const agent = new DurableAgent({
|
|
166
|
+
model: "anthropic/claude-haiku-4.5",
|
|
167
|
+
instructions:
|
|
168
|
+
"You are a flight booking assistant. Search for flights, then request approval before booking.",
|
|
169
|
+
tools: {
|
|
170
|
+
searchFlights: {
|
|
171
|
+
description: "Search for available flights",
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
from: z.string().describe("Departure airport code"),
|
|
174
|
+
to: z.string().describe("Arrival airport code"),
|
|
175
|
+
date: z.string().describe("Travel date (YYYY-MM-DD)"),
|
|
176
|
+
}),
|
|
177
|
+
execute: searchFlights,
|
|
178
|
+
},
|
|
179
|
+
requestBookingApproval: {
|
|
180
|
+
description: "Request human approval before booking a flight",
|
|
181
|
+
inputSchema: z.object({
|
|
182
|
+
flightId: z.string().describe("Flight ID to book"),
|
|
183
|
+
passenger: z.string().describe("Passenger name"),
|
|
184
|
+
price: z.number().describe("Total price"),
|
|
185
|
+
}),
|
|
186
|
+
execute: requestBookingApproval,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await agent.stream({ messages, writable }); // [!code highlight]
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### API Route for Approvals
|
|
196
|
+
|
|
197
|
+
```typescript lineNumbers
|
|
198
|
+
import { bookingApprovalHook } from "@/workflows/booking-agent";
|
|
199
|
+
|
|
200
|
+
export async function POST(request: Request) {
|
|
201
|
+
const { toolCallId, approved, comment } = await request.json();
|
|
202
|
+
|
|
203
|
+
// Schema validation happens automatically via defineHook
|
|
204
|
+
await bookingApprovalHook.resume(toolCallId, { approved, comment }); // [!code highlight]
|
|
205
|
+
|
|
206
|
+
return Response.json({ success: true });
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Approval Component
|
|
211
|
+
|
|
212
|
+
```tsx lineNumbers
|
|
213
|
+
"use client";
|
|
214
|
+
|
|
215
|
+
import { useState } from "react";
|
|
216
|
+
|
|
217
|
+
export function BookingApproval({
|
|
218
|
+
toolCallId,
|
|
219
|
+
input,
|
|
220
|
+
output,
|
|
221
|
+
}: {
|
|
222
|
+
toolCallId: string;
|
|
223
|
+
input?: { flightId: string; passenger: string; price: number };
|
|
224
|
+
output?: string;
|
|
225
|
+
}) {
|
|
226
|
+
const [comment, setComment] = useState("");
|
|
227
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
228
|
+
|
|
229
|
+
if (output) {
|
|
230
|
+
return <p className="text-sm text-muted-foreground">{output}</p>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const handleSubmit = async (approved: boolean) => {
|
|
234
|
+
setIsSubmitting(true);
|
|
235
|
+
await fetch("/api/hooks/approval", {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: { "Content-Type": "application/json" },
|
|
238
|
+
body: JSON.stringify({ toolCallId, approved, comment }),
|
|
239
|
+
});
|
|
240
|
+
setIsSubmitting(false);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className="border rounded-lg p-4 space-y-3">
|
|
245
|
+
{input && (
|
|
246
|
+
<div className="text-sm space-y-1">
|
|
247
|
+
<div>Flight: {input.flightId}</div>
|
|
248
|
+
<div>Passenger: {input.passenger}</div>
|
|
249
|
+
<div>Price: ${input.price}</div>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
<textarea
|
|
253
|
+
value={comment}
|
|
254
|
+
onChange={(e) => setComment(e.target.value)}
|
|
255
|
+
placeholder="Add a comment (optional)..."
|
|
256
|
+
className="w-full border rounded p-2 text-sm"
|
|
257
|
+
rows={2}
|
|
258
|
+
/>
|
|
259
|
+
<div className="flex gap-2">
|
|
260
|
+
<button type="button" onClick={() => handleSubmit(true)} disabled={isSubmitting}>
|
|
261
|
+
Approve
|
|
262
|
+
</button>
|
|
263
|
+
<button type="button" onClick={() => handleSubmit(false)} disabled={isSubmitting}>
|
|
264
|
+
Reject
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Key APIs
|
|
273
|
+
|
|
274
|
+
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
|
|
275
|
+
- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries
|
|
276
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation
|
|
277
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry
|
|
278
|
+
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — durable agent with tool definitions
|