agents 0.16.1 → 0.17.0
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 +11 -8
- package/dist/{agent-tool-types-NofdbL9X.d.ts → agent-tool-types-Cd1TZPfB.d.ts} +595 -137
- package/dist/agent-tool-types.d.ts +34 -18
- package/dist/agent-tool-types.js +20 -1
- package/dist/agent-tool-types.js.map +1 -0
- package/dist/agent-tools-BXlsuX0d.js +304 -0
- package/dist/agent-tools-BXlsuX0d.js.map +1 -0
- package/dist/agent-tools-_E8wxUIK.d.ts +119 -0
- package/dist/agent-tools.d.ts +24 -18
- package/dist/agent-tools.js.map +1 -1
- package/dist/ai-chat-agent.d.ts +1 -1
- package/dist/ai-chat-agent.js +1 -2
- package/dist/ai-chat-agent.js.map +1 -1
- package/dist/ai-chat-v5-migration.d.ts +1 -1
- package/dist/ai-chat-v5-migration.js +1 -2
- package/dist/ai-chat-v5-migration.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -2
- package/dist/ai-react.js.map +1 -1
- package/dist/ai-types.d.ts +1 -7
- package/dist/ai-types.js +2 -8
- package/dist/ai-types.js.map +1 -1
- package/dist/browser/ai.js +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/chat/index.d.ts +1923 -76
- package/dist/chat/index.js +1784 -278
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/react.d.ts +602 -0
- package/dist/chat/react.js +1506 -0
- package/dist/chat/react.js.map +1 -0
- package/dist/chat-sdk/index.d.ts +4 -4
- package/dist/{classPrivateFieldGet2-CZ7QjTXN.js → classPrivateFieldGet2-DZBYAB34.js} +5 -5
- package/dist/{classPrivateMethodInitSpec-D-0__zd9.js → classPrivateMethodInitSpec-qMjJ6sHQ.js} +2 -2
- package/dist/{client-FUizKzj2.js → client-BZ-B3NhC.js} +19 -3
- package/dist/client-BZ-B3NhC.js.map +1 -0
- package/dist/client-tools-aIBO0Fk7.d.ts +53 -0
- package/dist/client.d.ts +76 -57
- package/dist/client.js +33 -5
- package/dist/client.js.map +1 -1
- package/dist/{compaction-helpers-DVcu5lPN.d.ts → compaction-helpers-wUz6M3us.d.ts} +20 -1
- package/dist/{connector-CrKhowfD.js → connector-CdldGF3h.js} +5 -5
- package/dist/{connector-CrKhowfD.js.map → connector-CdldGF3h.js.map} +1 -1
- package/dist/email.js +1 -1
- package/dist/email.js.map +1 -1
- package/dist/experimental/memory/session/index.d.ts +1 -1
- package/dist/experimental/memory/session/index.js +20 -2
- package/dist/experimental/memory/session/index.js.map +1 -1
- package/dist/experimental/memory/utils/index.d.ts +1 -1
- package/dist/{index-B7IbEeze.d.ts → index-CcbnKkNh.d.ts} +188 -14
- package/dist/index.d.ts +91 -71
- package/dist/index.js +562 -24
- package/dist/index.js.map +1 -1
- package/dist/mcp/client.d.ts +18 -14
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/index.d.ts +30 -30
- package/dist/mcp/index.js +7 -7
- package/dist/mcp/index.js.map +1 -1
- package/dist/{agent-tools-3zLG7MgA.js → message-builder-BymO4N_D.js} +45 -126
- package/dist/message-builder-BymO4N_D.js.map +1 -0
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +4 -2
- package/dist/observability/index.js.map +1 -1
- package/dist/react.d.ts +123 -110
- package/dist/react.js +44 -11
- package/dist/react.js.map +1 -1
- package/dist/serializable.d.ts +1 -1
- package/dist/skills/compile.js +1 -1
- package/dist/skills/compile.js.map +1 -1
- package/dist/skills/index.js +5 -5
- package/dist/skills/index.js.map +1 -1
- package/dist/sub-routing.d.ts +6 -6
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/vite.js +5 -5
- package/dist/vite.js.map +1 -1
- package/dist/wire-types-nflOzNuU.js +240 -0
- package/dist/wire-types-nflOzNuU.js.map +1 -0
- package/dist/{workflow-types-SrZK_o9p.d.ts → workflow-types-Baz_PO5v.d.ts} +42 -22
- package/dist/workflow-types.d.ts +25 -21
- package/dist/workflow-types.js.map +1 -1
- package/dist/workflows.d.ts +22 -21
- package/dist/workflows.js +31 -7
- package/dist/workflows.js.map +1 -1
- package/docs/adding-to-existing-project.md +450 -0
- package/docs/agent-class.md +503 -0
- package/docs/agent-tools.md +552 -0
- package/docs/browse-the-web.md +430 -0
- package/docs/callable-methods.md +627 -0
- package/docs/chat-agents.md +1687 -0
- package/docs/chat-sdk.md +181 -0
- package/docs/client-sdk.md +520 -0
- package/docs/client-tools-continuation.md +177 -0
- package/docs/codemode.md +440 -0
- package/docs/configuration.md +775 -0
- package/docs/cross-domain-authentication.md +171 -0
- package/docs/durable-execution.md +537 -0
- package/docs/email.md +663 -0
- package/docs/get-current-agent.md +204 -0
- package/docs/getting-started.md +305 -0
- package/docs/http-websockets.md +668 -0
- package/docs/human-in-the-loop.md +661 -0
- package/docs/index.md +151 -0
- package/docs/long-running-agents.md +730 -0
- package/docs/mcp-client.md +620 -0
- package/docs/mcp-servers.md +526 -0
- package/docs/mcp-transports.md +308 -0
- package/docs/migration-to-ai-sdk-v5.md +96 -0
- package/docs/migration-to-ai-sdk-v6.md +163 -0
- package/docs/observability.md +261 -0
- package/docs/push-notifications.md +367 -0
- package/docs/queue.md +329 -0
- package/docs/readonly-connections.md +278 -0
- package/docs/resumable-streaming.md +127 -0
- package/docs/retries.md +444 -0
- package/docs/routing.md +749 -0
- package/docs/scheduling.md +898 -0
- package/docs/securing-mcp-servers.md +359 -0
- package/docs/server-driven-messages.md +477 -0
- package/docs/sessions.md +1024 -0
- package/docs/state.md +512 -0
- package/docs/sub-agents.md +389 -0
- package/docs/webhooks.md +604 -0
- package/docs/workflows.md +877 -0
- package/package.json +47 -15
- package/dist/agent-tools-3zLG7MgA.js.map +0 -1
- package/dist/agent-tools-DLquv-dp.d.ts +0 -14
- package/dist/client-FUizKzj2.js.map +0 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Observability
|
|
2
|
+
|
|
3
|
+
Agents emit structured events for every significant operation — RPC calls, state changes, schedule execution, workflow transitions, MCP connections, and more. These events are published to [diagnostics channels](https://developers.cloudflare.com/workers/runtime-apis/nodejs/diagnostics-channel/) and are silent by default (zero overhead when nobody is listening).
|
|
4
|
+
|
|
5
|
+
## Event structure
|
|
6
|
+
|
|
7
|
+
Every event has these fields:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
{
|
|
11
|
+
type: "rpc", // what happened
|
|
12
|
+
agent: "MyAgent", // which agent class emitted it
|
|
13
|
+
name: "user-123", // which agent instance (Durable Object name)
|
|
14
|
+
payload: { method: "getWeather" }, // details
|
|
15
|
+
timestamp: 1758005142787 // when (ms since epoch)
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`agent` and `name` identify the source agent — `agent` is the class name and `name` is the Durable Object instance name.
|
|
20
|
+
|
|
21
|
+
## Channels
|
|
22
|
+
|
|
23
|
+
Events are routed to named channels based on their type:
|
|
24
|
+
|
|
25
|
+
| Channel | Event types | Description |
|
|
26
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
|
27
|
+
| `agents:state` | `state:update` | State sync events |
|
|
28
|
+
| `agents:rpc` | `rpc`, `rpc:error` | RPC method calls and failures |
|
|
29
|
+
| `agents:message` | `message:request`, `message:response`, `message:clear`, `message:cancel`, `message:error`, `tool:result`, `tool:approval` | Chat message and tool lifecycle |
|
|
30
|
+
| `agents:chat` | `chat:request:failed`, `chat:recovery:*`, `chat:stream:stalled`, `chat:context:compacted` | Chat request, recovery, stream-stall, and context-compaction lifecycle |
|
|
31
|
+
| `agents:transcript` | `chat:transcript:repaired` | Transcript repair events |
|
|
32
|
+
| `agents:fiber` | `fiber:run:*`, `fiber:recovery:*` | Durable fiber lifecycle |
|
|
33
|
+
| `agents:agent_tool` | `agent_tool:recovery:*` | Parent/child agent-tool recovery |
|
|
34
|
+
| `agents:schedule` | `schedule:create`, `schedule:execute`, `schedule:cancel`, `schedule:retry`, `schedule:error`, `schedule:duplicate_warning`, `queue:create`, `queue:retry`, `queue:error` | Scheduled and queued task lifecycle |
|
|
35
|
+
| `agents:lifecycle` | `connect`, `disconnect`, `destroy` | Agent connection and teardown |
|
|
36
|
+
| `agents:workflow` | `workflow:start`, `workflow:event`, `workflow:approved`, `workflow:rejected`, `workflow:terminated`, `workflow:paused`, `workflow:resumed`, `workflow:restarted` | Workflow state transitions |
|
|
37
|
+
| `agents:mcp` | `mcp:client:preconnect`, `mcp:client:connect`, `mcp:client:authorize`, `mcp:client:discover`, `mcp:client:close` | MCP client operations |
|
|
38
|
+
| `agents:email` | `email:receive`, `email:reply` | Email processing |
|
|
39
|
+
|
|
40
|
+
## Subscribing to events
|
|
41
|
+
|
|
42
|
+
### Typed subscribe helper
|
|
43
|
+
|
|
44
|
+
The `subscribe()` function from `agents/observability` provides type-safe access to events on a specific channel:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { subscribe } from "agents/observability";
|
|
48
|
+
|
|
49
|
+
const unsub = subscribe("rpc", (event) => {
|
|
50
|
+
if (event.type === "rpc") {
|
|
51
|
+
console.log(`RPC call: ${event.payload.method}`);
|
|
52
|
+
}
|
|
53
|
+
if (event.type === "rpc:error") {
|
|
54
|
+
console.error(
|
|
55
|
+
`RPC failed: ${event.payload.method} — ${event.payload.error}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Clean up when done
|
|
61
|
+
unsub();
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The callback is fully typed — `event` is narrowed to only the event types that flow through that channel.
|
|
65
|
+
|
|
66
|
+
The typed helper uses camelCase keys, so agent-tool recovery is `subscribe("agentTool", ...)`. Raw diagnostics channel subscribers should use the emitted channel name, `agents:agent_tool`.
|
|
67
|
+
|
|
68
|
+
### Raw diagnostics_channel
|
|
69
|
+
|
|
70
|
+
You can also subscribe directly using the Node.js API:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { subscribe } from "node:diagnostics_channel";
|
|
74
|
+
|
|
75
|
+
subscribe("agents:schedule", (event) => {
|
|
76
|
+
console.log(event);
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Tail Workers (production)
|
|
81
|
+
|
|
82
|
+
In production, all diagnostics channel messages are automatically forwarded to [Tail Workers](https://developers.cloudflare.com/workers/observability/tail-workers/). No subscription code is needed in the agent itself — attach a Tail Worker and access events via `event.diagnosticsChannelEvents`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
export default {
|
|
86
|
+
async tail(events) {
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
for (const msg of event.diagnosticsChannelEvents) {
|
|
89
|
+
// msg.channel is "agents:rpc", "agents:workflow", etc.
|
|
90
|
+
// msg.message is the typed event payload
|
|
91
|
+
console.log(msg.timestamp, msg.channel, msg.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This gives you structured, filterable observability in production with zero overhead in the agent hot path.
|
|
99
|
+
|
|
100
|
+
## Custom observability
|
|
101
|
+
|
|
102
|
+
You can override the default implementation by providing your own `Observability` interface:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { Agent } from "agents";
|
|
106
|
+
import type { Observability } from "agents/observability";
|
|
107
|
+
|
|
108
|
+
const myObservability: Observability = {
|
|
109
|
+
emit(event) {
|
|
110
|
+
// Send to your logging service, filter events, etc.
|
|
111
|
+
if (event.type === "rpc:error") {
|
|
112
|
+
myLogger.error(event.payload.method, event.payload.error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
class MyAgent extends Agent {
|
|
118
|
+
override observability = myObservability;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Set `observability` to `undefined` to disable all event emission:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
class MyAgent extends Agent {
|
|
126
|
+
override observability = undefined;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Event reference
|
|
131
|
+
|
|
132
|
+
### RPC events
|
|
133
|
+
|
|
134
|
+
| Type | Payload | When |
|
|
135
|
+
| ----------- | ------------------------ | ------------------------------- |
|
|
136
|
+
| `rpc` | `{ method, streaming? }` | A `@callable` method is invoked |
|
|
137
|
+
| `rpc:error` | `{ method, error }` | A `@callable` method throws |
|
|
138
|
+
|
|
139
|
+
### State events
|
|
140
|
+
|
|
141
|
+
| Type | Payload | When |
|
|
142
|
+
| -------------- | ------- | ---------------------- |
|
|
143
|
+
| `state:update` | `{}` | `setState()` is called |
|
|
144
|
+
|
|
145
|
+
### Message and tool events (`AIChatAgent`)
|
|
146
|
+
|
|
147
|
+
These events are emitted by `AIChatAgent` from `@cloudflare/ai-chat`. They track the chat message lifecycle, including client-side tool interactions.
|
|
148
|
+
|
|
149
|
+
| Type | Payload | When |
|
|
150
|
+
| ------------------ | -------------------------- | ----------------------------------- |
|
|
151
|
+
| `message:request` | `{}` | A chat message is received |
|
|
152
|
+
| `message:response` | `{}` | A chat response stream completes |
|
|
153
|
+
| `message:clear` | `{}` | Chat history is cleared |
|
|
154
|
+
| `message:cancel` | `{ requestId }` | A streaming request is cancelled |
|
|
155
|
+
| `message:error` | `{ error }` | A chat stream fails |
|
|
156
|
+
| `tool:result` | `{ toolCallId, toolName }` | A client tool result is received |
|
|
157
|
+
| `tool:approval` | `{ toolCallId, approved }` | A tool call is approved or rejected |
|
|
158
|
+
|
|
159
|
+
### Chat recovery events
|
|
160
|
+
|
|
161
|
+
| Type | Payload | When |
|
|
162
|
+
| ------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
163
|
+
| `chat:request:failed` | `{ requestId?, stage, messagesPersisted?, error }` | A Think chat request fails while parsing, persisting, running, or streaming |
|
|
164
|
+
| `chat:recovery:detected` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind }` | An interrupted chat fiber is first observed |
|
|
165
|
+
| `chat:recovery:attempt` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind }` | The framework begins a recovery attempt |
|
|
166
|
+
| `chat:recovery:scheduled` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind }` | A retry or continuation callback is scheduled |
|
|
167
|
+
| `chat:recovery:completed` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind }` | Recovery completed successfully |
|
|
168
|
+
| `chat:recovery:skipped` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind, reason? }` | Recovery was skipped because the conversation changed or was no longer recoverable |
|
|
169
|
+
| `chat:recovery:failed` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind, reason? }` | Recovery ran but failed |
|
|
170
|
+
| `chat:recovery:exhausted` | `{ incidentId, requestId, attempt, maxAttempts, recoveryKind, reason }` | Recovery exceeded its configured attempt budget |
|
|
171
|
+
| `chat:stream:stalled` | `{ requestId, timeoutMs }` | The inactivity watchdog fired — no stream chunk arrived within `chatStreamStallTimeoutMs`. With `chatRecovery` on (the default) the turn then routes into bounded recovery (look for `chat:recovery:*`); with recovery off it terminalizes. See [Think configuration](https://github.com/cloudflare/agents/blob/main/docs/think/index.md) |
|
|
172
|
+
|
|
173
|
+
`recoveryKind` is `"retry"` when recovery replays an unanswered user turn and `"continue"` when it continues a partial assistant turn.
|
|
174
|
+
|
|
175
|
+
### Chat context events
|
|
176
|
+
|
|
177
|
+
| Type | Payload | When |
|
|
178
|
+
| ------------------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
179
|
+
| `chat:context:compacted` | `{ reason, shortened, requestId?, attempt? }` | Think compacted the session to handle a context-window overflow. `reason` is `"proactive"` (the `contextOverflow.proactive` guard fired before a step) or `"reactive"` (`contextOverflow.reactive` fired after an overflow). `shortened` is whether compaction actually reduced history — `false` means a retry would overflow again. See [Context-window overflow recovery](https://github.com/cloudflare/agents/blob/main/docs/think/index.md#context-window-overflow-recovery). |
|
|
180
|
+
|
|
181
|
+
### Transcript events
|
|
182
|
+
|
|
183
|
+
| Type | Payload | When |
|
|
184
|
+
| -------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
185
|
+
| `chat:transcript:repaired` | `{ requestId?, removedToolCalls, normalizedInputs, toolCallIds? }` | Think repairs a persisted transcript before sending it to the provider. `removedToolCalls` counts orphaned tool calls healed (preserved as errored results, not deleted); it also fires if an incomplete tool call survives repair and is dropped by the `ignoreIncompleteToolCalls` backstop. `normalizedInputs` counts stringified/missing tool inputs that were repaired. |
|
|
186
|
+
|
|
187
|
+
### Fiber events
|
|
188
|
+
|
|
189
|
+
| Type | Payload | When |
|
|
190
|
+
| ------------------------- | ---------------------------------------------------------------------- | ------------------------------------ |
|
|
191
|
+
| `fiber:run:started` | `{ fiberId, fiberName, managed? }` | A durable fiber starts |
|
|
192
|
+
| `fiber:run:completed` | `{ fiberId, fiberName, managed?, elapsedMs? }` | A durable fiber completes |
|
|
193
|
+
| `fiber:run:failed` | `{ fiberId, fiberName, managed?, error, elapsedMs? }` | A durable fiber throws |
|
|
194
|
+
| `fiber:run:interrupted` | `{ fiberId, fiberName, managed?, recoveryReason, elapsedMs? }` | Startup finds an interrupted fiber |
|
|
195
|
+
| `fiber:recovery:detected` | `{ fiberId, fiberName, managed?, recoveryReason, elapsedMs? }` | Recovery sees an interrupted fiber |
|
|
196
|
+
| `fiber:recovery:attempt` | `{ fiberId, fiberName, managed?, recoveryReason }` | A recovery hook starts |
|
|
197
|
+
| `fiber:recovery:handled` | `{ fiberId, fiberName, managed?, recoveryReason, status, elapsedMs? }` | Recovery handling completes |
|
|
198
|
+
| `fiber:recovery:skipped` | `{ fiberId, fiberName, managed?, reason, elapsedMs? }` | A recovery scan skips remaining work |
|
|
199
|
+
| `fiber:recovery:failed` | `{ fiberId, fiberName, managed?, error, reason?, elapsedMs? }` | A recovery hook fails |
|
|
200
|
+
|
|
201
|
+
### Agent-tool recovery events
|
|
202
|
+
|
|
203
|
+
| Type | Payload | When |
|
|
204
|
+
| ------------------------------ | --------------------------------------------------- | ------------------------------------------------------------ |
|
|
205
|
+
| `agent_tool:recovery:begin` | `{ runCount, totalTimeoutMs? }` | Parent recovery starts scanning stale agent-tool runs |
|
|
206
|
+
| `agent_tool:recovery:row` | `{ runId, agentType, status, reason?, elapsedMs? }` | One stale run is reconciled |
|
|
207
|
+
| `agent_tool:recovery:deadline` | `{ runId, agentType, elapsedMs? }` | Total recovery deadline is exhausted before inspecting a row |
|
|
208
|
+
| `agent_tool:recovery:complete` | `{ runCount, elapsedMs? }` | Parent recovery finishes scanning rows |
|
|
209
|
+
| `agent_tool:recovery:failed` | `{ error }` | Parent recovery fails unexpectedly |
|
|
210
|
+
|
|
211
|
+
### Schedule and queue events
|
|
212
|
+
|
|
213
|
+
| Type | Payload | When |
|
|
214
|
+
| ---------------------------- | ---------------------------------------- | -------------------------------------------- |
|
|
215
|
+
| `schedule:create` | `{ callback, id }` | A schedule is created |
|
|
216
|
+
| `schedule:execute` | `{ callback, id }` | A scheduled callback starts |
|
|
217
|
+
| `schedule:cancel` | `{ callback, id }` | A schedule is cancelled |
|
|
218
|
+
| `schedule:retry` | `{ callback, id, attempt, maxAttempts }` | A scheduled callback is retried |
|
|
219
|
+
| `schedule:error` | `{ callback, id, error, attempts }` | A scheduled callback fails after all retries |
|
|
220
|
+
| `schedule:duplicate_warning` | `{ callback, count, type }` | Duplicate schedules detected for a callback |
|
|
221
|
+
| `queue:create` | `{ callback, id }` | A task is enqueued |
|
|
222
|
+
| `queue:retry` | `{ callback, id, attempt, maxAttempts }` | A queued callback is retried |
|
|
223
|
+
| `queue:error` | `{ callback, id, error, attempts }` | A queued callback fails after all retries |
|
|
224
|
+
|
|
225
|
+
### Lifecycle events
|
|
226
|
+
|
|
227
|
+
| Type | Payload | When |
|
|
228
|
+
| ------------ | -------------------------------- | ------------------------------------- |
|
|
229
|
+
| `connect` | `{ connectionId }` | A WebSocket connection is established |
|
|
230
|
+
| `disconnect` | `{ connectionId, code, reason }` | A WebSocket connection is closed |
|
|
231
|
+
| `destroy` | `{}` | The agent is destroyed |
|
|
232
|
+
|
|
233
|
+
### Workflow events
|
|
234
|
+
|
|
235
|
+
| Type | Payload | When |
|
|
236
|
+
| --------------------- | ------------------------------- | ------------------------------ |
|
|
237
|
+
| `workflow:start` | `{ workflowId, workflowName? }` | A workflow instance is started |
|
|
238
|
+
| `workflow:event` | `{ workflowId, eventType? }` | An event is sent to a workflow |
|
|
239
|
+
| `workflow:approved` | `{ workflowId, reason? }` | A workflow is approved |
|
|
240
|
+
| `workflow:rejected` | `{ workflowId, reason? }` | A workflow is rejected |
|
|
241
|
+
| `workflow:terminated` | `{ workflowId, workflowName? }` | A workflow is terminated |
|
|
242
|
+
| `workflow:paused` | `{ workflowId, workflowName? }` | A workflow is paused |
|
|
243
|
+
| `workflow:resumed` | `{ workflowId, workflowName? }` | A workflow is resumed |
|
|
244
|
+
| `workflow:restarted` | `{ workflowId, workflowName? }` | A workflow is restarted |
|
|
245
|
+
|
|
246
|
+
### MCP events
|
|
247
|
+
|
|
248
|
+
| Type | Payload | When |
|
|
249
|
+
| ----------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
250
|
+
| `mcp:client:preconnect` | `{ serverId }` | Before connecting to an MCP server |
|
|
251
|
+
| `mcp:client:connect` | `{ url, transport, state, error? }` | An MCP connection attempt completes or fails |
|
|
252
|
+
| `mcp:client:authorize` | `{ serverId, authUrl, clientId? }` | An MCP OAuth flow begins |
|
|
253
|
+
| `mcp:client:discover` | `{ url?, state?, error?, capability? }` | MCP capability discovery succeeds or fails |
|
|
254
|
+
| `mcp:client:close` | `{ url, transport?, state, error?, phase? }` | An MCP connection is closed (`phase` is `"terminate-session"` or `"client-close"`) |
|
|
255
|
+
|
|
256
|
+
### Email events
|
|
257
|
+
|
|
258
|
+
| Type | Payload | When |
|
|
259
|
+
| --------------- | ------------------------ | --------------------- |
|
|
260
|
+
| `email:receive` | `{ from, to, subject? }` | An email is received |
|
|
261
|
+
| `email:reply` | `{ from, to, subject? }` | A reply email is sent |
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Push Notifications
|
|
2
|
+
|
|
3
|
+
Send browser push notifications from your agent — even when the user has closed the tab. By combining the agent's persistent state (for storing push subscriptions), scheduling (for timed delivery), and the [Web Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API), you can reach users who are completely offline.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Browser Agent (Durable Object)
|
|
9
|
+
─────── ──────────────────────
|
|
10
|
+
1. Register service worker
|
|
11
|
+
2. Subscribe to push (VAPID key)
|
|
12
|
+
3. Send subscription to agent ──────► Store in this.state
|
|
13
|
+
4. Create reminder ─────────────────► this.schedule(delay, "sendReminder", payload)
|
|
14
|
+
|
|
15
|
+
... user closes tab ...
|
|
16
|
+
|
|
17
|
+
5. Alarm fires → sendReminder()
|
|
18
|
+
web-push sends encrypted payload
|
|
19
|
+
│
|
|
20
|
+
6. Service worker receives push ◄─────────────┘
|
|
21
|
+
7. showNotification()
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The agent stores push subscriptions durably in its state and uses `this.schedule()` to fire notifications at the right time. When the alarm triggers, the agent calls the push service endpoint using the [`web-push`](https://www.npmjs.com/package/web-push) library. The browser's service worker receives the push event and displays a native notification.
|
|
25
|
+
|
|
26
|
+
## Prerequisites
|
|
27
|
+
|
|
28
|
+
### Generate VAPID Keys
|
|
29
|
+
|
|
30
|
+
Web Push requires a VAPID (Voluntary Application Server Identification) key pair. Generate one:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx web-push generate-vapid-keys
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Store the keys in a `.env` file for local development:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
VAPID_PUBLIC_KEY=BGxK...
|
|
40
|
+
VAPID_PRIVATE_KEY=abc1...
|
|
41
|
+
VAPID_SUBJECT=mailto:you@example.com
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For production, use `wrangler secret put`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
wrangler secret put VAPID_PUBLIC_KEY
|
|
48
|
+
wrangler secret put VAPID_PRIVATE_KEY
|
|
49
|
+
wrangler secret put VAPID_SUBJECT
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Create the Agent
|
|
53
|
+
|
|
54
|
+
The agent has three responsibilities: store push subscriptions, schedule reminders, and send notifications when alarms fire.
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { Agent, callable, routeAgentRequest } from "agents";
|
|
58
|
+
import webpush from "web-push";
|
|
59
|
+
|
|
60
|
+
type Subscription = {
|
|
61
|
+
endpoint: string;
|
|
62
|
+
expirationTime: number | null;
|
|
63
|
+
keys: {
|
|
64
|
+
p256dh: string;
|
|
65
|
+
auth: string;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type Reminder = {
|
|
70
|
+
id: string;
|
|
71
|
+
message: string;
|
|
72
|
+
scheduledAt: number;
|
|
73
|
+
sent: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ReminderAgentState = {
|
|
77
|
+
subscriptions: Subscription[];
|
|
78
|
+
reminders: Reminder[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export class ReminderAgent extends Agent<Env, ReminderAgentState> {
|
|
82
|
+
initialState: ReminderAgentState = {
|
|
83
|
+
subscriptions: [],
|
|
84
|
+
reminders: []
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
@callable()
|
|
88
|
+
getVapidPublicKey(): string {
|
|
89
|
+
return this.env.VAPID_PUBLIC_KEY;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@callable()
|
|
93
|
+
async subscribe(subscription: Subscription): Promise<{ ok: boolean }> {
|
|
94
|
+
const exists = this.state.subscriptions.some(
|
|
95
|
+
(s) => s.endpoint === subscription.endpoint
|
|
96
|
+
);
|
|
97
|
+
if (!exists) {
|
|
98
|
+
this.setState({
|
|
99
|
+
...this.state,
|
|
100
|
+
subscriptions: [...this.state.subscriptions, subscription]
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@callable()
|
|
107
|
+
async unsubscribe(endpoint: string): Promise<{ ok: boolean }> {
|
|
108
|
+
this.setState({
|
|
109
|
+
...this.state,
|
|
110
|
+
subscriptions: this.state.subscriptions.filter(
|
|
111
|
+
(s) => s.endpoint !== endpoint
|
|
112
|
+
)
|
|
113
|
+
});
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@callable()
|
|
118
|
+
async createReminder(
|
|
119
|
+
message: string,
|
|
120
|
+
delaySeconds: number
|
|
121
|
+
): Promise<Reminder> {
|
|
122
|
+
const id = crypto.randomUUID();
|
|
123
|
+
const scheduledAt = Date.now() + delaySeconds * 1000;
|
|
124
|
+
const reminder: Reminder = { id, message, scheduledAt, sent: false };
|
|
125
|
+
|
|
126
|
+
this.setState({
|
|
127
|
+
...this.state,
|
|
128
|
+
reminders: [...this.state.reminders, reminder]
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await this.schedule(delaySeconds, "sendReminder", { id, message });
|
|
132
|
+
|
|
133
|
+
return reminder;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
When the scheduled alarm fires, send the push notification to all stored subscriptions:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
async sendReminder(payload: { id: string; message: string }) {
|
|
141
|
+
webpush.setVapidDetails(
|
|
142
|
+
this.env.VAPID_SUBJECT,
|
|
143
|
+
this.env.VAPID_PUBLIC_KEY,
|
|
144
|
+
this.env.VAPID_PRIVATE_KEY
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const deadEndpoints: string[] = [];
|
|
148
|
+
|
|
149
|
+
await Promise.all(
|
|
150
|
+
this.state.subscriptions.map(async (sub) => {
|
|
151
|
+
try {
|
|
152
|
+
await webpush.sendNotification(
|
|
153
|
+
sub,
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
title: "Reminder",
|
|
156
|
+
body: payload.message,
|
|
157
|
+
tag: `reminder-${payload.id}`
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
} catch (err: unknown) {
|
|
161
|
+
const statusCode =
|
|
162
|
+
err instanceof webpush.WebPushError ? err.statusCode : 0;
|
|
163
|
+
if (statusCode === 404 || statusCode === 410) {
|
|
164
|
+
deadEndpoints.push(sub.endpoint);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Clean up expired or revoked subscriptions
|
|
171
|
+
if (deadEndpoints.length > 0) {
|
|
172
|
+
this.setState({
|
|
173
|
+
...this.state,
|
|
174
|
+
subscriptions: this.state.subscriptions.filter(
|
|
175
|
+
(s) => !deadEndpoints.includes(s.endpoint)
|
|
176
|
+
)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Mark reminder as sent
|
|
181
|
+
this.setState({
|
|
182
|
+
...this.state,
|
|
183
|
+
reminders: this.state.reminders.map((r) =>
|
|
184
|
+
r.id === payload.id ? { ...r, sent: true } : r
|
|
185
|
+
)
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Notify any connected clients in real time
|
|
189
|
+
this.broadcast(
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
type: "reminder_sent",
|
|
192
|
+
id: payload.id,
|
|
193
|
+
timestamp: Date.now()
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The `sendReminder` callback handles three things: delivering the push notification via the `web-push` library, cleaning up dead subscriptions (the push service returns 404 or 410 when a subscription is no longer valid), and broadcasting to any connected clients so the UI updates in real time.
|
|
201
|
+
|
|
202
|
+
## Set Up the Service Worker
|
|
203
|
+
|
|
204
|
+
The service worker runs in the browser and receives push events even when no tabs are open. Place this file at `public/sw.js` so it is served from the root of your domain:
|
|
205
|
+
|
|
206
|
+
```javascript
|
|
207
|
+
self.addEventListener("push", (event) => {
|
|
208
|
+
if (!event.data) return;
|
|
209
|
+
|
|
210
|
+
const data = event.data.json();
|
|
211
|
+
|
|
212
|
+
event.waitUntil(
|
|
213
|
+
self.registration.showNotification(data.title || "Notification", {
|
|
214
|
+
body: data.body || "",
|
|
215
|
+
icon: data.icon || "/favicon.ico",
|
|
216
|
+
tag: data.tag,
|
|
217
|
+
data: data.data
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
self.addEventListener("notificationclick", (event) => {
|
|
223
|
+
event.notification.close();
|
|
224
|
+
|
|
225
|
+
event.waitUntil(
|
|
226
|
+
self.clients.matchAll({ type: "window" }).then((windowClients) => {
|
|
227
|
+
for (const client of windowClients) {
|
|
228
|
+
if (client.url.includes(self.location.origin) && "focus" in client) {
|
|
229
|
+
return client.focus();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return self.clients.openWindow("/");
|
|
233
|
+
})
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The `push` event handler parses the JSON payload and displays a native notification. The `notificationclick` handler focuses an existing tab or opens a new one when the user taps the notification.
|
|
239
|
+
|
|
240
|
+
## Build the Client
|
|
241
|
+
|
|
242
|
+
The client needs to: register the service worker, request notification permission, subscribe to push using the VAPID public key, and send the subscription to the agent.
|
|
243
|
+
|
|
244
|
+
### Register the Service Worker
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
249
|
+
return; // Push not supported
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
navigator.serviceWorker.register("/sw.js");
|
|
253
|
+
}, []);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Subscribe to Push
|
|
257
|
+
|
|
258
|
+
Fetch the VAPID public key from the agent, then subscribe through the Push API:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
function base64urlToUint8Array(base64url: string): Uint8Array {
|
|
262
|
+
const padded = base64url + "=".repeat((4 - (base64url.length % 4)) % 4);
|
|
263
|
+
const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
|
264
|
+
const bytes = new Uint8Array(binary.length);
|
|
265
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
266
|
+
return bytes;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function subscribeToPush(agent) {
|
|
270
|
+
const permission = await Notification.requestPermission();
|
|
271
|
+
if (permission !== "granted") return;
|
|
272
|
+
|
|
273
|
+
const vapidPublicKey = await agent.call("getVapidPublicKey");
|
|
274
|
+
const reg = await navigator.serviceWorker.ready;
|
|
275
|
+
const subscription = await reg.pushManager.subscribe({
|
|
276
|
+
userVisibleOnly: true,
|
|
277
|
+
applicationServerKey: base64urlToUint8Array(vapidPublicKey).buffer
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const subJson = subscription.toJSON();
|
|
281
|
+
await agent.call("subscribe", [
|
|
282
|
+
{
|
|
283
|
+
endpoint: subJson.endpoint,
|
|
284
|
+
expirationTime: subJson.expirationTime ?? null,
|
|
285
|
+
keys: subJson.keys
|
|
286
|
+
}
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Create Reminders
|
|
292
|
+
|
|
293
|
+
With the subscription stored, creating a reminder is a single RPC call. The agent handles scheduling and delivery:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
await agent.call("createReminder", ["Check the oven", 300]);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The agent schedules an alarm for 300 seconds (5 minutes). When it fires, the push notification arrives — even if the user closed the tab minutes ago.
|
|
300
|
+
|
|
301
|
+
## Configuration
|
|
302
|
+
|
|
303
|
+
### wrangler.jsonc
|
|
304
|
+
|
|
305
|
+
```jsonc
|
|
306
|
+
{
|
|
307
|
+
"name": "push-notifications",
|
|
308
|
+
"compatibility_date": "2026-01-28",
|
|
309
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
310
|
+
"main": "src/server.ts",
|
|
311
|
+
"durable_objects": {
|
|
312
|
+
"bindings": [{ "name": "ReminderAgent", "class_name": "ReminderAgent" }]
|
|
313
|
+
},
|
|
314
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ReminderAgent"] }],
|
|
315
|
+
"assets": {
|
|
316
|
+
"not_found_handling": "single-page-application"
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The `nodejs_compat` compatibility flag is required for the `web-push` library.
|
|
322
|
+
|
|
323
|
+
### Dependencies
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
npm install agents web-push
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Production Considerations
|
|
330
|
+
|
|
331
|
+
### Subscription Expiry
|
|
332
|
+
|
|
333
|
+
Push subscriptions can expire or be revoked by the user. Always handle 404 and 410 responses from the push service by removing the dead subscription from state, as shown in the `sendReminder` example above.
|
|
334
|
+
|
|
335
|
+
### Per-User vs Shared Agents
|
|
336
|
+
|
|
337
|
+
For most applications, use one agent per user (using the user ID as the agent name). This isolates each user's subscriptions and reminders. For broadcast-style notifications (same message to many users), a shared agent can store all subscriptions, but be aware of the state size as the subscription list grows.
|
|
338
|
+
|
|
339
|
+
### Combining Push with WebSocket Broadcast
|
|
340
|
+
|
|
341
|
+
Use `this.broadcast()` for clients that are currently connected (instant, no push service roundtrip) and Web Push for clients that are offline. The `sendReminder` example above does both — connected clients get a real-time WebSocket message, and offline clients get a push notification.
|
|
342
|
+
|
|
343
|
+
### Multiple Devices
|
|
344
|
+
|
|
345
|
+
A single user may subscribe from multiple browsers or devices. The agent stores each subscription separately, and `sendReminder` iterates over all of them. Each device receives its own push notification.
|
|
346
|
+
|
|
347
|
+
### Retry on Failure
|
|
348
|
+
|
|
349
|
+
If the push service returns a 5xx error (temporary failure), you can retry using `this.schedule()` with a short delay:
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
try {
|
|
353
|
+
await webpush.sendNotification(sub, payload);
|
|
354
|
+
} catch (err: unknown) {
|
|
355
|
+
const statusCode = err instanceof webpush.WebPushError ? err.statusCode : 0;
|
|
356
|
+
if (statusCode >= 500) {
|
|
357
|
+
await this.schedule(60, "retrySendNotification", {
|
|
358
|
+
endpoint: sub.endpoint,
|
|
359
|
+
payload
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Full Example
|
|
366
|
+
|
|
367
|
+
See the complete working example at [`examples/push-notifications/`](https://github.com/cloudflare/agents/tree/main/examples/push-notifications) — includes the agent, service worker, and a React client with subscription management, reminder creation, and real-time state sync.
|