@economic/agents 2.7.2 → 3.0.0-alpha.32271766282.1.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 CHANGED
@@ -1,21 +1,21 @@
1
1
  # @economic/agents
2
2
 
3
- Our agents SDK for building AI agents on Cloudflare Workers. Each agent is a Durable Object running an LLM loop — model, system prompt, tools, skills, auth, telemetry — on [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think) and the [`ai`](https://www.npmjs.com/package/ai) SDK.
3
+ Our agents SDK for building AI agents on Cloudflare Workers. Each agent is a Durable Object running an LLM loop — model, system prompt, tools, skills, auth, analytics, and audit logging — on [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think) and the [`ai`](https://www.npmjs.com/package/ai) SDK.
4
4
 
5
5
  React client: [`@economic/agents-react`](../react/README.md).
6
6
 
7
- > The v1 (`@economic/agents/v1`) API (`ChatAgentHarness`, `buildLLMParams`) is deprecated and will be removed in v3.
7
+ Upgrading from version 2: see the [v3 migration guide](./MIGRATION.md).
8
8
 
9
9
  ## The three classes
10
10
 
11
- - **`Agent`** — the core. Runs the agent loop and keeps message history. Drive it over a WebSocket or programmatically (schedule, alarm, RPC). Most agents stop here.
12
- - **`ChatAgent`** — `Agent` plus chat features: compaction and message feedback.
13
- - **`Assistant`** — per-user shell over `ChatAgent`: create/list/delete chats, titles, summaries, retention.
11
+ - **`ChatAgent`** — persistent conversational agent built on `Think`, with tools, skills, authentication, compaction, and message feedback. Drive it over a WebSocket or programmatically.
12
+ - **`TaskAgent`** — one bounded, at-most-once input-to-output run without chat history.
13
+ - **`Assistant`** — per-user shell over `ChatAgent`: create/list/delete chats, titles, summaries, and retention.
14
14
 
15
15
  ```
16
- Agent ← LLM + tools + skills (most agents stop here)
17
- └─ ChatAgent ← one persistent chat
18
- └─ Assistant ← one user, many chats
16
+ ChatAgent ← one persistent conversation
17
+ Assistant → ChatAgent ← one user, many conversations
18
+ TaskAgent ← one bounded run, no conversation
19
19
  ```
20
20
 
21
21
  ## Install
@@ -36,9 +36,9 @@ npm install -D wrangler vite @cloudflare/vite-plugin @cloudflare/worker-bundler
36
36
  Routes Claude models through Cloudflare AI Gateway → Google Vertex AI.
37
37
 
38
38
  ```typescript
39
- import { createAnthropicVertexProvider } from "@economic/agents/providers";
39
+ import { createAnthropicProvider } from "@economic/agents/providers";
40
40
 
41
- const anthropic = createAnthropicVertexProvider({
41
+ const anthropic = createAnthropicProvider({
42
42
  cloudflareAccountId: env.CLOUDFLARE_ACCOUNT_ID,
43
43
  cloudflareAiGatewayId: env.AI_GATEWAY_ID,
44
44
  cloudflareApiToken: env.CLOUDFLARE_API_TOKEN,
@@ -87,21 +87,22 @@ const { anthropic, gemini } = createAiGatewayVertexProviders({
87
87
  ```
88
88
 
89
89
  When these providers are used inside an SDK agent, the SDK automatically adds
90
- the entry-point agent class as `agentId` in `cf-aig-metadata`. This includes
91
- requests made by child chat/facet agents and allows a shared gateway's usage to
92
- be grouped by agent without application configuration or duplicated analytics.
90
+ the agent's explicit `agentName` as `agentId` in `cf-aig-metadata`. This allows
91
+ usage from a shared gateway and shared Worker to be grouped by logical agent.
93
92
 
94
- ## Quick start: an agent
93
+ ## Quick start: a chat agent
95
94
 
96
- Subclass `Agent` and implement `getModel` and `getSystemPrompt`. Add tools with `getTools`, skills with `getSkills`. Expose a `@callable` method to run a turn — `saveMessages` injects a message, runs the turn, and persists the result:
95
+ Subclass `ChatAgent`, set `agentName` to the stable logical agent name, and implement `getModel` and `getSystemPrompt`. `agentName` identifies the configured agent across audit logs, analytics, observability, and AI Gateway metadata; it is deliberately not inferred from the implementation class or Durable Object name. A config-driven harness can implement it as a getter. Add tools with `getTools`, skills with `getSkills`. Expose a `@callable` method to run a turn — `saveMessages` injects a message, runs the turn, and persists the result:
97
96
 
98
97
  ```typescript
99
98
  import { openai } from "@ai-sdk/openai";
100
99
  import { callable } from "agents";
101
- import { Agent, tool, type ToolSet } from "@economic/agents";
100
+ import { ChatAgent, tool, type ToolSet } from "@economic/agents";
102
101
  import { z } from "zod";
103
102
 
104
- export class SupportAgent extends Agent {
103
+ export class SupportAgent extends ChatAgent {
104
+ agentName = "customer-support";
105
+
105
106
  getModel() {
106
107
  return openai("gpt-4o");
107
108
  }
@@ -170,6 +171,16 @@ export default {
170
171
  }
171
172
  ```
172
173
 
174
+ Analytics is opt-in. To send the SDK's typed product events to that binding:
175
+
176
+ ```typescript
177
+ import { createAnalyticsEngineHandler } from "@economic/agents";
178
+
179
+ protected getAnalyticsHandlers() {
180
+ return [createAnalyticsEngineHandler(this.env.AGENTS_ANALYTICS)];
181
+ }
182
+ ```
183
+
173
184
  Run `wrangler types` for a typed `Env`.
174
185
 
175
186
  ### Calling it from the client
@@ -188,9 +199,9 @@ await agent.call("checkOrder", ["1234"]);
188
199
 
189
200
  You can also drive turns server-side — call `saveMessages` from a schedule or alarm.
190
201
 
191
- ## A chat agent
202
+ ## Using a chat UI
192
203
 
193
- For a chat UI, extend `ChatAgent` instead of `Agent`. It adds compaction and message feedback, and connects to a browser with [`useChat`](../react/README.md#usechat) — no `Assistant` required. The DO name is the chat; address one per user, ticket, or whatever fits.
204
+ `ChatAgent` connects to a browser with [`useChat`](../react/README.md#usechat) — no `Assistant` required. The DO name is the chat; address one per user, ticket, or whatever fits.
194
205
 
195
206
  ```typescript
196
207
  import { openai } from "@ai-sdk/openai";
@@ -198,6 +209,8 @@ import { ChatAgent } from "@economic/agents";
198
209
  import { weatherSkill } from "./skills/weather";
199
210
 
200
211
  export class MyChatAgent extends ChatAgent {
212
+ agentName = "weather-assistant";
213
+
201
214
  getModel() {
202
215
  return openai("gpt-4o");
203
216
  }
@@ -220,15 +233,15 @@ const { chat } = useChat({
220
233
  });
221
234
  ```
222
235
 
223
- What `ChatAgent` adds over `Agent`:
236
+ Built-in chat features:
224
237
 
225
238
  - **Compaction** — past 100,000 tokens, older messages are summarised (with `getModel()`) while recent ones are kept verbatim. Storage keeps the full history.
226
239
  - **Message feedback** — thumbs up/down with an optional comment, in the chat's SQLite (`assistant_messages_feedback`, created automatically):
227
240
 
228
- | Method | Description |
229
- | ---------------------------------------------------- | ------------------------------------------------------------ |
230
- | `submitMessageFeedback(messageId, rating, comment?)` | `rating` is `1` (up) or `-1` (down). Upserts on the message. |
231
- | `getMessageFeedback()` | All feedback for the chat, keyed by message id. |
241
+ | Method | Description |
242
+ | ---------------------------------------------------- | ---------------------------------------------------------- |
243
+ | `submitMessageFeedback(messageId, rating, comment?)` | `1` is up, `-1` is down, and `0` clears existing feedback. |
244
+ | `getMessageFeedback()` | All feedback for the chat, keyed by message id. |
232
245
 
233
246
  Surfaced by the client as `chat.submitMessageFeedback` / `chat.getMessageFeedback`.
234
247
 
@@ -242,6 +255,7 @@ import { Assistant } from "@economic/agents";
242
255
  import { MyChatAgent } from "./chat-agent";
243
256
 
244
257
  export class MyAssistant extends Assistant {
258
+ agentName = "weather-assistant";
245
259
  protected agent = MyChatAgent;
246
260
  protected fastModel = openai("gpt-4o-mini"); // titles and summaries
247
261
  }
@@ -259,27 +273,117 @@ const { status, chats, assistant, chat } = useAssistant({
259
273
 
260
274
  The `Assistant` keeps the chat list in its own SQLite (a `chats` table, created automatically) and exposes four callable methods. A newly created chat facet is added to this table when its first turn starts.
261
275
 
262
- | Method | Returns | Description |
263
- | ---------------- | -------- | ------------------------------------------- |
264
- | `createChat()` | `string` | Creates a chat facet and returns its id. |
265
- | `getChats()` | `Chat[]` | Started chats, most recently updated first. |
266
- | `deleteChat(id)` | `void` | Deletes a chat facet and its record. |
267
- | `renameChat(id)` | `void` | Renames a registered chat. |
276
+ | Method | Returns | Description |
277
+ | ----------------------- | -------- | ------------------------------------------- |
278
+ | `createChat()` | `string` | Creates a chat facet and returns its id. |
279
+ | `getChats()` | `Chat[]` | Started chats, most recently updated first. |
280
+ | `deleteChat(id)` | `void` | Deletes a chat facet and its record. |
281
+ | `renameChat(id, title)` | `void` | Renames a registered chat. |
268
282
 
269
283
  - **Titles and summaries** — generated with `fastModel` after each turn (on the first turn, then refreshed as the chat grows).
270
284
  - **Empty chat cleanup** — a newly created facet is scheduled for deletion after one day. Its first completed turn replaces that schedule with the normal sliding retention period.
271
285
  - **Retention** — inactive chats are deleted after 90 days, via a Durable Object alarm.
272
286
 
287
+ ## TaskAgent
288
+
289
+ Use `TaskAgent` for one input-to-output run without conversation history. Implement the model,
290
+ system prompt, and an HTTP transport that calls `runTask`:
291
+
292
+ ```typescript
293
+ import { openai } from "@ai-sdk/openai";
294
+ import { TaskAgent } from "@economic/agents";
295
+
296
+ type SummariseContext = {
297
+ tenantId: string;
298
+ };
299
+
300
+ type SummariseRequest = {
301
+ input: string;
302
+ context: SummariseContext;
303
+ };
304
+
305
+ export class SummariseTask extends TaskAgent<SummariseContext> {
306
+ agentName = "summarise-task";
307
+
308
+ getModel() {
309
+ return openai("gpt-4o-mini");
310
+ }
311
+
312
+ getSystemPrompt() {
313
+ return "Summarise the supplied text concisely.";
314
+ }
315
+
316
+ override async onRequest(request: Request): Promise<Response> {
317
+ const authentication = await this.authenticateRequest(request);
318
+ if (authentication instanceof Response) return authentication;
319
+
320
+ const { input, context } = (await request.json()) as SummariseRequest;
321
+ return Response.json(await this.runTask(input, context, authentication));
322
+ }
323
+ }
324
+ ```
325
+
326
+ Export and bind the class as a normal SQLite-backed Cloudflare Durable Object, then route requests
327
+ with `routeAgentRequest` as shown in the [chat-agent quick start](#quick-start-a-chat-agent).
328
+ `TaskAgent` requires the `AGENTS_AUDIT_LOGS` binding and refuses to start without it.
329
+ `TaskAgent.onRequest` returns `501` until the subclass defines its transport.
330
+ `runTask` trusts the authentication value passed by that transport; do not expose
331
+ an RPC endpoint that lets callers construct the `authentication` argument themselves.
332
+
333
+ The Durable Object name is the run id. The first call to `runTask` claims it before inference
334
+ starts, so another call using the same name returns `{ status: "duplicate" }` rather than running
335
+ and billing twice. `retentionDays` controls how long that claim is retained and defaults to one day.
336
+ Use a fresh, stable id for each intended run.
337
+
338
+ `runTask(input, context?, authentication?)` accepts either a string or `UIMessage[]` and returns:
339
+
340
+ | Status | Meaning |
341
+ | ----------- | ----------------------------------------------------------------------- |
342
+ | `completed` | Contains `output` and an `outputType` of `"text"` or `"json"`. |
343
+ | `error` | The run failed and contains a combined error description. |
344
+ | `duplicate` | That Durable Object name has already been claimed within its retention. |
345
+
346
+ The task may use tools and skills through `getTools()` and `getSkills()` just like `ChatAgent`.
347
+ Tool and skill authorization receives the request context plus `_token` and `_userContext` from
348
+ request-scoped authentication. `maxSteps` limits the model/tool loop and defaults to 10.
349
+
350
+ For structured output, return a JSON Schema from `getOutputSchema`. The completed `output` remains
351
+ a JSON string and `outputType` is `"json"`. `validateOutput` can reject values that pass the schema
352
+ but fail application-specific checks:
353
+
354
+ ```typescript
355
+ protected getOutputSchema() {
356
+ return {
357
+ type: "object",
358
+ properties: {
359
+ summary: { type: "string" },
360
+ confidence: { type: "number", minimum: 0, maximum: 1 },
361
+ },
362
+ required: ["summary", "confidence"],
363
+ additionalProperties: false,
364
+ };
365
+ }
366
+
367
+ protected validateOutput(value: unknown) {
368
+ const result = value as { summary?: string };
369
+ return result.summary?.trim() ? null : "Summary must not be empty";
370
+ }
371
+ ```
372
+
373
+ When a structured task also has tools or skills, the SDK adds a terminal `submit_result` tool and
374
+ instructs the model to call it with the final value. Without tools, it uses the model's native JSON
375
+ output mode.
376
+
273
377
  ## Bindings
274
378
 
275
- Checked in `Agent.onStart` when a connection opens:
379
+ Validated when the relevant agent starts:
276
380
 
277
- | Binding | Type | Required | Notes |
278
- | ------------------- | ---------------- | -------- | ------------------------------------------------------------------------- |
279
- | `AGENTS_AUDIT_LOGS` | R2 | Yes | Connection rejected if missing. One [audit log](#observability) per turn. |
280
- | `AGENTS_ANALYTICS` | Analytics Engine | No | Per-turn/per-tool [analytics](#observability). Warns if missing. |
281
- | `SKILLS_BUCKET` | R2 | No | Source for [remote skills](#remote-skills). |
282
- | `LOADER` | Worker Loader | No | Enables [native skill scripts](#skill-scripts) when bound. |
381
+ | Binding | Type | Required | Notes |
382
+ | ------------------- | ---------------- | --------- | ------------------------------------------------------------------ |
383
+ | `AGENTS_DB` | D1 | Chat | Global registry for top-level agent instances. |
384
+ | `AGENTS_AUDIT_LOGS` | R2 | Chat/task | Startup fails if missing. One [audit log](#audit-logging) per run. |
385
+ | `AGENTS_ANALYTICS` | Analytics Engine | No | Optional destination for [product analytics](#analytics). |
386
+ | `LOADER` | Worker Loader | No | Enables [native skill scripts](#skill-scripts) when bound. |
283
387
 
284
388
  ## Tools
285
389
 
@@ -301,7 +405,9 @@ getTools(): ToolSet {
301
405
  }
302
406
  ```
303
407
 
304
- `authorize` returning `false` hides the tool for that request.
408
+ `authorize` returning `false` keeps the tool visible to the model but replaces its execution
409
+ with an explicit permission refusal. This lets the model explain the missing access instead of
410
+ silently trying another tool. Unauthorized skills cannot be activated.
305
411
 
306
412
  ### Tool context
307
413
 
@@ -325,7 +431,7 @@ const call_api = tool({
325
431
  });
326
432
  ```
327
433
 
328
- `ToolContext<RequestContext, UserContext>` is `RequestContext & { _userContext?: UserContext }`. Request context comes from the client (`toolContext` in the hooks); `_userContext` comes from [`getUserContext`](#user-context) when using JWT Authentication (via `getJwtAuthConfig`).
434
+ `ToolContext<RequestContext, UserContext>` adds `_token` and `_userContext` to the request context. Request context comes from the client (`toolContext` in the hooks); authenticated values come from the agent's configured `Authenticator`.
329
435
 
330
436
  ## Skills
331
437
 
@@ -424,7 +530,7 @@ Skill scripts are enabled when a Worker Loader binding named `LOADER` is present
424
530
  }
425
531
  ```
426
532
 
427
- `Agent` automatically creates a script runner when `env.LOADER` is bound. Without the binding, script execution is unavailable.
533
+ `ChatAgent` automatically creates a script runner when `env.LOADER` is bound. Without the binding, script execution is unavailable.
428
534
 
429
535
  TypeScript scripts use the function-style contract:
430
536
 
@@ -466,7 +572,7 @@ import { skills } from "@cloudflare/think";
466
572
 
467
573
  getSkills() {
468
574
  return [
469
- skills.r2(this.env.SKILLS_BUCKET, {
575
+ skills.r2(this.env.REMOTE_SKILLS, {
470
576
  skills: ["top-customers-by-revenue", "vat-code-review"],
471
577
  }),
472
578
  ];
@@ -484,82 +590,318 @@ skills/top-customers-by-revenue/
484
590
 
485
591
  Source order matters: if two sources define the same skill name, the first source wins.
486
592
 
593
+ Remote skill storage is owned by the application. `REMOTE_SKILLS` above is an example
594
+ consumer binding, not a binding reserved by this SDK or included in `AgentEnv`.
595
+
487
596
  ## Authentication
488
597
 
489
- ### JWT
598
+ Authentication is generic. An agent can implement `getAuthenticator(env)` to return an `Authenticator`. The SDK does not know what kind of credentials the authenticator accepts. If the method is absent, the agent remains accessible without SDK-level authentication.
599
+
600
+ Each `ChatAgent`, `Assistant`, or `TaskAgent` class owns its authentication hook. An `Assistant` does not inherit authentication from its child `ChatAgent`; configure the two boundaries independently. For example, an assistant can verify access to the chat list while each chat separately verifies and exchanges its token for tool calls.
601
+
602
+ For `ChatAgent` and `Assistant`, an authenticator runs once when a WebSocket connects. `TaskAgent` runs it once per request. In either case it returns a denial or the authenticated values the agent needs:
603
+
604
+ ```typescript
605
+ return {
606
+ authenticated: true,
607
+ token,
608
+ userContext: { userId, role },
609
+ };
610
+ ```
611
+
612
+ - `token` is an optional downstream credential. When returned, it is available as `ctx._token`.
613
+ - `userContext` becomes `ctx._userContext` in prompts, models, tool authorization, and skill authorization.
614
+ - A denial uses `{ authenticated: false, status: 401 | 403, message }`.
615
+ - Unexpected operational failures should throw. They produce an `"error"` status and close code `1011` rather than being reported as bad user credentials.
490
616
 
491
- Override the static `getJwtAuthConfig(env)` to verify a JWT on connect (static so an `Assistant` can check before routing). Return `undefined` to skip.
617
+ ### JWT verification and token exchange
618
+
619
+ `createJwtAuthenticator` supplies the normal bearer-JWT implementation. It reads the token from `Authorization: Bearer …` or `Sec-WebSocket-Protocol: bearer, <token>`, verifies it once, and optionally calls the consumer's `getExchangedToken` once.
492
620
 
493
621
  ```typescript
494
- export class SupportAgent extends Agent {
495
- static getJwtAuthConfig(env: Cloudflare.Env) {
622
+ import { ChatAgent, createJwtAuthenticator } from "@economic/agents";
623
+
624
+ export class SupportAgent extends ChatAgent {
625
+ agentName = "customer-support";
626
+
627
+ protected getAuthenticator(env: Cloudflare.Env) {
628
+ return createJwtAuthenticator({
629
+ verificationConfig: {
630
+ allowedIssuers: [env.IDENTITY_ENDPOINT],
631
+ audience: "eva",
632
+ requiredScopes: ["eva.read"],
633
+ getClaims: (payload) => ({
634
+ userGuid: payload.user_guid as string,
635
+ agreementNumber: payload.agreement_number as number,
636
+ }),
637
+ },
638
+
639
+ async getExchangedToken({ token, claims }) {
640
+ // The consumer owns the exchange protocol and HTTP request.
641
+ return exchangeForBackendToken({
642
+ token,
643
+ userGuid: claims.userGuid,
644
+ env,
645
+ });
646
+ },
647
+
648
+ async getUserContext({ token, claims }) {
649
+ // `token` is now the value returned by getExchangedToken.
650
+ const profile = await fetchProfile(token);
651
+ return { ...profile, agreementNumber: claims.agreementNumber };
652
+ },
653
+ });
654
+ }
655
+ }
656
+ ```
657
+
658
+ The resulting flow is:
659
+
660
+ ```text
661
+ bearer token → verify once → getExchangedToken once → authenticated result
662
+ ├─ token / ctx._token
663
+ └─ userContext
664
+ ```
665
+
666
+ `verificationConfig` is consumed by the SDK's built-in JWT verification. `getExchangedToken` is entirely consumer-defined: it receives `{ token, claims }` and returns the exchanged token string. The SDK does not prescribe an identity provider, HTTP endpoint, grant type, or exchange protocol.
667
+
668
+ The final token is exposed as `ToolContext._token`. Without `getExchangedToken`, it is the verified source JWT. Tools decide how that value maps to an HTTP header or another downstream transport; the authentication API contains no header names.
669
+
670
+ Errors thrown by `getExchangedToken` are operational failures and close the socket with `1011`. A consumer can deliberately reject credentials by throwing an error with `closeCode: 4001` or `4003`.
671
+
672
+ ### Custom authentication
673
+
674
+ Any authenticator can validate arbitrary credentials. This example validates an app-secret/agreement-grant pair:
675
+
676
+ ```typescript
677
+ export class IntegrationAgent extends ChatAgent {
678
+ agentName = "accounting-integration";
679
+
680
+ protected getAuthenticator(env: Cloudflare.Env) {
496
681
  return {
497
- allowedIssuers: [env.IDENTITY_ENDPOINT], // strings or RegExp
498
- audience: "my-api",
499
- requiredScopes: ["support.read"],
500
- getClaims: (payload) => ({
501
- userGuid: payload.user_guid as string,
502
- agreementNumber: payload.agreement_number as number,
503
- }),
682
+ async authenticate(request: Request) {
683
+ const appSecret = request.headers.get("X-AppSecretToken");
684
+ const agreementGrant = request.headers.get("X-AgreementGrantToken");
685
+
686
+ if (!appSecret || !agreementGrant) {
687
+ return {
688
+ authenticated: false as const,
689
+ status: 401 as const,
690
+ message: "Unauthorized",
691
+ };
692
+ }
693
+
694
+ const identity = await validateCredentials({ appSecret, agreementGrant }, env);
695
+
696
+ if (!identity) {
697
+ return {
698
+ authenticated: false as const,
699
+ status: 401 as const,
700
+ message: "Unauthorized",
701
+ };
702
+ }
703
+
704
+ return {
705
+ authenticated: true as const,
706
+ userContext: identity,
707
+ };
708
+ },
504
709
  };
505
710
  }
711
+ }
712
+ ```
713
+
714
+ The agent does not distinguish this from JWT authentication. Tool credentials supplied in the request context remain a consumer concern and pass through unchanged.
715
+
716
+ A custom authenticator may omit `token`, as the example above does. In that case
717
+ `ctx._token` is `undefined`; tools that require a downstream credential must check
718
+ for it and fail safely. The built-in JWT authenticator always returns the verified
719
+ source JWT, or the exchanged token when `getExchangedToken` is configured.
720
+
721
+ ### Tool context and credential lifetime
506
722
 
507
- // ...
723
+ After authentication succeeds, the result is stored in the server-side state attached to that WebSocket connection. Connection state survives Durable Object hibernation and is discarded when the connection closes. Authentication is not written to Durable Object KV, SQLite, or the agent's persisted `state`.
724
+
725
+ Before every turn, the SDK builds the tool context as follows:
726
+
727
+ ```typescript
728
+ toolContext._token = authentication.token; // string | undefined
729
+ toolContext._userContext = authentication.userContext;
730
+ ```
731
+
732
+ When present, `_token` is the normalized verified or exchanged token. It is `undefined` when a custom authenticator does not provide a downstream credential. Ordinary request-context fields, including any consumer-defined `tokens`, pass through unchanged. The completed context is used by `getModel`, `getSystemPrompt`, tool `authorize`, skill `authorize`, normal tool execution, and approved-tool continuations through `beforeTurn`, `beforeStep`, and `beforeToolCall`.
733
+
734
+ `beforeTurn` does not authenticate, check expiry, or manage connections. It only reads the authenticated result and copies it into the tool context. JWT expiry is validated by `verifyJwt` when the connection is established. Updating `authToken` in the React hooks replaces the WebSocket, so the new token is verified and exchanged on the new connection.
735
+
736
+ Authentication is scoped to the individual WebSocket connection. Turns use the state belonging to the connection that initiated them, including after the Durable Object hibernates and wakes again. Think's automatic chat recovery runs without a current connection, so the SDK restores authentication from the most recently authenticated live WebSocket for that recovery only. Other connectionless calls, including RPC calls, do not inherit WebSocket authentication and must provide and validate their own credentials.
737
+
738
+ ### TaskAgent authentication
739
+
740
+ `TaskAgent` uses the same `getAuthenticator` contract, but authentication is request-scoped rather than connection-scoped. A custom `onRequest` transport calls `authenticateRequest(request)`, rejects a returned `Response`, and passes a successful result to `runTask`:
741
+
742
+ ```typescript
743
+ override async onRequest(request: Request) {
744
+ const authentication = await this.authenticateRequest(request);
745
+ if (authentication instanceof Response) return authentication;
746
+
747
+ const { input, context } = await request.json();
748
+ return Response.json(await this.runTask(input, context, authentication));
508
749
  }
509
750
  ```
510
751
 
511
- On failure the socket closes (`4001` unauthorized, `4003` forbidden) and status becomes `"unauthorized"`. The token is read from `Authorization: Bearer …`, then the `Sec-WebSocket-Protocol: bearer, <token>` header.
752
+ ## Analytics
512
753
 
513
- ### User context
754
+ The SDK emits typed product facts from the lifecycle that produces them:
514
755
 
515
- Implement `getUserContext(jwtToken)` to load per-user data after auth. It's exposed as `ctx._userContext` in `getModel`, `getSystemPrompt`, tools, and skill `authorize`. Runs only when JWT auth is configured.
756
+ - `prompt.received`
757
+ - `turn.completed`
758
+ - `llm.completed` (model, finish reason, and token usage from the model response)
759
+ - `tool.completed` (tool, active skill, duration, success, and payload sizes)
760
+ - `feedback.submitted`
761
+
762
+ These events are inputs for analytics questions such as which tools or skills are used most. The SDK does not encode particular reports or business questions. Override `getAnalyticsAttributes()` to attach query dimensions that only the consumer knows, such as a tenant or user category:
516
763
 
517
764
  ```typescript
518
- protected async getUserContext(jwtToken: string) {
519
- const profile = await fetchProfile(jwtToken);
520
- return { role: profile.role, tokens: profile.tokens };
765
+ protected getAnalyticsAttributes() {
766
+ return {
767
+ tenantId: this.userContext?.tenantId ?? "unknown",
768
+ userCategory: this.userContext?.category ?? "unknown",
769
+ };
521
770
  }
522
771
  ```
523
772
 
524
- If `getUserContext` throws, the socket closes with `1011` — or, when the thrown `Error` carries a numeric `closeCode` in the `4000`–`4999` range, with that code and an `"unauthorized"` status frame:
773
+ Nothing is sent anywhere unless the consumer configures a handler. Multiple handlers can receive the same event, and a failing handler cannot fail or delay the agent turn:
525
774
 
526
775
  ```typescript
527
- const error = new Error("No Eva access");
528
- (error as Error & { closeCode: number }).closeCode = 4003;
529
- throw error;
776
+ import {
777
+ createAnalyticsEngineHandler,
778
+ type AnalyticsHandler,
779
+ } from "@economic/agents";
780
+
781
+ protected getAnalyticsHandlers() {
782
+ const classificationHandler: AnalyticsHandler = {
783
+ name: "conversation classifier",
784
+ handle: (event) => {
785
+ if (event.type !== "prompt.received") return;
786
+
787
+ // Forward to an application-owned Queue or classifier agent. That service
788
+ // owns the semantic buckets and sends its result to Snowplow or Analytics Engine.
789
+ return this.env.CLASSIFICATION_QUEUE.send({
790
+ eventId: event.eventId,
791
+ messages: event.messages,
792
+ attributes: event.attributes,
793
+ });
794
+ },
795
+ };
796
+
797
+ return [
798
+ createAnalyticsEngineHandler(this.env.AGENTS_ANALYTICS),
799
+ classificationHandler,
800
+ ];
801
+ }
530
802
  ```
531
803
 
532
- ### Token exchange
804
+ The built-in Analytics Engine handler stores metrics and dimensions, but never prompt content. A custom handler receives the typed event, including prompt content on `prompt.received`, so sending that content requires an explicit consumer decision. For reliable asynchronous processing, a handler should enqueue or forward the event to a Durable Object; handlers themselves are intentionally non-blocking.
533
805
 
534
- For agents whose clients connect with a _down-scoped_ token — one only valid for the client ↔ agent hop — override the static `getTokenExchangeConfig(env)` to exchange it (RFC 8693) at the identity server for a token issued to the agent's own OAuth client. Requires `getJwtAuthConfig`.
806
+ The SDK emits these events from `beforeTurn`, `onStepFinish`, `afterToolCall`, and `onChatResponse`. Handlers run in the background under `keepAliveWhile`, so their work is protected from idle eviction without delaying the turn response. If an agent overrides one of those lifecycle hooks, it must call the corresponding `super` method to retain SDK analytics (the same rule already applies to the SDK's turn setup in `beforeTurn`).
535
807
 
536
- ```typescript
537
- export class SupportAgent extends Agent {
538
- static getTokenExchangeConfig(env: Cloudflare.Env) {
539
- return {
540
- tokenUrl: `${env.IDENTITY_ENDPOINT}/connect/token`,
541
- clientId: env.OAUTH_CLIENT_ID,
542
- clientSecret: env.OAUTH_CLIENT_SECRET, // string or Secrets Store binding
543
- scopes: "api.read api.write", // always sent explicitly
544
- tokenHeader: "X-EconomicToken", // default
545
- };
808
+ ## Operational observability
809
+
810
+ `ChatAgent` and `TaskAgent` preserve Cloudflare Agents' diagnostics-channel events and mirror failures and degraded conditions into structured Worker logs. Successful internal lifecycle events are not logged. The exception is a completed chat turn, whose duration and status are needed for latency distributions and error-rate denominators.
811
+
812
+ The existing AI SDK telemetry stream also produces one operational record for each model call and executed tool:
813
+
814
+ - `agent.llm.completed` / `agent.llm.failed`: provider, model, finish reason, duration, time to first chunk, and token usage.
815
+ - `agent.tool.completed` / `agent.tool.failed`: tool name, duration, and error details when applicable.
816
+
817
+ Prompts, responses, tool arguments, and tool results are deliberately excluded. The same underlying model and tool facts may also be sent through product-analytics handlers, but observability records contain no analytics attributes or prompt content and are intended for service-level metrics and alerts.
818
+
819
+ Each record is a single JSON log with a stable source, event name, severity, agent identity, and the event-specific attributes. This lets a Workers Trace Events Logpush job create Datadog log-based counts, error rates, latency distributions, and alerts without a Datadog client in the agent:
820
+
821
+ ```json
822
+ {
823
+ "source": "@economic/agents",
824
+ "event": "chat:turn:finish",
825
+ "level": "info",
826
+ "timestamp": 1787086800000,
827
+ "agentName": "customer-support",
828
+ "agentClass": "ChatHarness",
829
+ "durableObjectName": "user-123:support",
830
+ "attributes": {
831
+ "status": "completed",
832
+ "durationMs": 825
546
833
  }
547
834
  }
548
835
  ```
549
836
 
550
- When configured:
837
+ The account-level Logpush destination is configured outside the SDK. Opt the deployed Worker into an existing job in `wrangler.jsonc`:
838
+
839
+ ```jsonc
840
+ {
841
+ "logpush": true,
842
+ }
843
+ ```
844
+
845
+ Cloudflare runtime signals such as invocation outcome, CPU time, wall time, uncaught exceptions, and resource-limit failures are added by the platform rather than the SDK.
846
+
847
+ ## Audit logging
551
848
 
552
- - The verified connect token is exchanged on every connect; the exchanged token is what `getUserContext` receives and what tools see as `ctx.tokens[tokenHeader]` each turn. **Client-sent request-body tokens for that key are ignored** — tool credentials always derive from the verified connection identity, so clients should stop sending them.
553
- - Renewal is connection-based. The client token rides in the WebSocket protocols, so presenting a renewed token always means a reconnect, which re-verifies and re-exchanges. Keep the `authToken` passed to the hooks current (refresh it before expiry) and the socket rotates silently — the `unauthorized` close (`4001`) then only happens when the session is genuinely dead (logout, revocation).
554
- - The exchanged token expires on the identity server's clock (with a 30s margin). A turn arriving after expiry closes the connection `4001` to force a reconnect; a client that refreshes its token on time never hits this.
555
- - Exchange failures at connect: `invalid_grant` (dead session token) → status `"unauthorized"` + close `4001`; anything else (identity server down, misconfigured client) → status `"error"` + close `1011`. Partial configuration is the app's concern — throw from `getTokenExchangeConfig` if you consider it a deployment fault, or return `undefined` to disable the exchange.
849
+ The SDK writes one versioned audit-log object per chat turn to `AGENTS_AUDIT_LOGS`. Each object contains the logical agent and harness identity, model, actor and IP, the rendered system prompt, the user message that initiated that turn, the final response, and the tool calls made during that turn. Turn content and tool activity carry their actual span timestamps.
556
850
 
557
- ## Observability
851
+ Tool inputs are retained so the attempted action can be established. Tool outputs are deliberately excluded because they may contain credentials, large API responses, or unrelated sensitive data. A tool failure retains its timestamp, error type, and message.
558
852
 
559
- Emitted per turn from the `ai` SDK's OpenTelemetry spans, no setup beyond bindings:
853
+ Completed records are written directly to R2 inside the Agent SDK's `keepAliveWhile`, which prevents the Durable Object from being evicted while the write is pending. Tool-span collection is scoped to the individual turn tracer, so simultaneous agents cannot share pending tool calls.
854
+
855
+ ```json
856
+ {
857
+ "schemaVersion": 2,
858
+ "id": "accounts-payable/user-123_chat-456/2026-08-18T20-14-30.000Z-ab12cd34.json",
859
+ "startedAt": "2026-08-18T20:14:25.000Z",
860
+ "completedAt": "2026-08-18T20:14:30.000Z",
861
+ "durableObjectName": "user-123:chat-456",
862
+ "agent": {
863
+ "name": "accounts-payable",
864
+ "class": "ChatHarness",
865
+ "llm": {
866
+ "model": "claude-sonnet-4-6",
867
+ "provider": "anthropic"
868
+ }
869
+ },
870
+ "actor": {
871
+ "id": "user-123",
872
+ "ip": {
873
+ "client": "192.0.2.1",
874
+ "forwardedFor": ["192.0.2.1"]
875
+ }
876
+ },
877
+ "systemPrompt": {
878
+ "timestamp": "2026-08-18T20:14:25.000Z",
879
+ "content": "You are the accounts payable assistant."
880
+ },
881
+ "prompt": {
882
+ "timestamp": "2026-08-18T20:14:25.000Z",
883
+ "content": "Email the first overdue invoice."
884
+ },
885
+ "response": {
886
+ "timestamp": "2026-08-18T20:14:30.000Z",
887
+ "content": "Email drafted.",
888
+ "finishReason": "stop"
889
+ },
890
+ "status": "success",
891
+ "tools": [
892
+ {
893
+ "id": "call-789",
894
+ "name": "create_email",
895
+ "input": { "invoiceId": "invoice-123" },
896
+ "status": "success",
897
+ "startedAt": "2026-08-18T20:14:27.000Z",
898
+ "completedAt": "2026-08-18T20:14:28.000Z"
899
+ }
900
+ ]
901
+ }
902
+ ```
560
903
 
561
- - **Audit logs** → one JSON object per turn in `AGENTS_AUDIT_LOGS` (model, actor, IP, prompt, response, tool calls).
562
- - **Analytics** → per-turn and per-tool data points in `AGENTS_ANALYTICS`.
904
+ Operational observability is separate from both audit logging and product analytics. Cloudflare diagnostics, logs, traces, and any future Datadog export are not analytics handlers.
563
905
 
564
906
  ## API reference
565
907
 
@@ -567,36 +909,44 @@ Imported from `@economic/agents` unless noted.
567
909
 
568
910
  ### Classes
569
911
 
570
- | Export | Description |
571
- | ----------- | -------------------------------------------------------- |
572
- | `Agent` | Core agent base. Implement `getModel`/`getSystemPrompt`. |
573
- | `ChatAgent` | `Agent` + compaction and message feedback. |
574
- | `Assistant` | Per-user manager of `ChatAgent` chats. |
912
+ | Export | Description |
913
+ | ----------- | -------------------------------------------------------------------- |
914
+ | `ChatAgent` | Persistent Think agent with tools, skills, compaction, and feedback. |
915
+ | `TaskAgent` | Bounded, at-most-once task without conversational state. |
916
+ | `Assistant` | Per-user manager of `ChatAgent` chats. |
575
917
 
576
918
  ### Helpers and types
577
919
 
578
- | Export | Description |
579
- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
580
- | `tool(def)` / `Tool` / `ToolSet` | Define a tool with an optional `authorize(ctx)` guard. |
581
- | `skill(def)` / `Skill` | Define a skill (`name`, `description`, `instructions`, `tools?`, `authorize?`). |
582
- | `ToolContext` | `RequestContext & { _userContext?: UserContext }`. |
583
- | `AgentEnv` | The bindings the runtime expects. |
584
- | `AgentConnectionState` / `AgentConnectionStatus` / `AgentConnectionType` | Connection state shared with the client. |
920
+ | Export | Description |
921
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
922
+ | `tool(def)` / `Tool` / `ToolSet` | Define a tool with an optional `authorize(ctx)` guard. |
923
+ | `skill(def)` / `Skill` | Define a skill (`name`, `description`, `instructions`, `tools?`, `authorize?`). |
924
+ | `ToolContext` | Request context plus `_token` and `_userContext`. |
925
+ | `TaskRunInput` / `TaskRunResult` / `TaskAuthentication` | Task input, result states, and request-scoped authenticated values. |
926
+ | `createJwtAuthenticator(config)` | JWT verification with an optional consumer-defined token exchange. |
927
+ | `Authenticator` / `AuthenticationResult` / `Authenticated` | Generic authentication contract and normalized results. |
928
+ | `AnalyticsHandler` / `AnalyticsEvent` | Typed, consumer-configured product-analytics pipeline. |
929
+ | `createAnalyticsEngineHandler(dataset)` | Optional Cloudflare Analytics Engine destination; excludes prompt content. |
930
+ | `AgentEnv` | The bindings the runtime expects. |
931
+ | `AgentConnectionStatus` / `AgentConnectionType` | Chat/assistant connection status and type shared with the client. |
585
932
 
586
933
  ### Members
587
934
 
588
- | Member | On | Description |
589
- | ---------------------------------------------- | ----------- | ------------------------------------------- |
590
- | `getModel(ctx?)` | `Agent` | Required. Model for inference. |
591
- | `getSystemPrompt(ctx?)` | `Agent` | Required. System prompt. |
592
- | `getTools()` | `Agent` | Always-on tools (default `{}`). |
593
- | `getSkills()` | `Agent` | Local skills (default `[]`). |
594
- | `static getJwtAuthConfig(env)` | `Agent` | Optional JWT verification config. |
595
- | `getUserContext(jwtToken)` | `Agent` | Optional per-user context after auth. |
596
- | `submitMessageFeedback` / `getMessageFeedback` | `ChatAgent` | Callable message feedback. |
597
- | `agent` | `Assistant` | Required. Your `ChatAgent` subclass. |
598
- | `fastModel` | `Assistant` | Required. Cheap model for titles/summaries. |
599
- | `createChat` / `getChats` / `deleteChat` | `Assistant` | Callable chat management. |
935
+ | Member | On | Description |
936
+ | ------------------------------------------------------- | ---------------- | ------------------------------------------------ |
937
+ | `agentName` | All agents | Required stable identity for shared deployments. |
938
+ | `getModel(ctx?)` | Chat/task agents | Required. Model for inference. |
939
+ | `getSystemPrompt(ctx?)` | Chat/task agents | Required. System prompt. |
940
+ | `getTools()` | Chat/task agents | Always-on tools (default `{}`). |
941
+ | `getSkills()` | Chat/task agents | Skills (default `[]`). |
942
+ | `getAuthenticator(env)` | All agents | Optional generic authenticator. |
943
+ | `runTask(input, context?, authentication?)` | `TaskAgent` | Executes the instance's at-most-once task. |
944
+ | `getOutputSchema()` / `validateOutput(value)` | `TaskAgent` | Optional structured-output contract. |
945
+ | `maxSteps` / `retentionDays` | `TaskAgent` | Task loop and run-id retention limits. |
946
+ | `submitMessageFeedback` / `getMessageFeedback` | `ChatAgent` | Callable message feedback. |
947
+ | `agent` | `Assistant` | Required. Your `ChatAgent` subclass. |
948
+ | `fastModel` | `Assistant` | Required. Cheap model for titles/summaries. |
949
+ | `createChat` / `getChats` / `deleteChat` / `renameChat` | `Assistant` | Callable chat management. |
600
950
 
601
951
  ### Package root (`@economic/agents`)
602
952