@economic/agents 2.7.1 → 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,
@@ -83,26 +83,26 @@ const { anthropic, gemini } = createAiGatewayVertexProviders({
83
83
  cloudflareAiGatewayId: env.AI_GATEWAY_ID,
84
84
  cloudflareApiToken: env.CLOUDFLARE_API_TOKEN,
85
85
  googleCloudProjectId: env.GOOGLE_CLOUD_PROJECT_ID,
86
- // Included in cf-aig-metadata on every request, including background calls.
87
- metadata: { agentId: "support" },
88
86
  });
89
87
  ```
90
88
 
91
- Set a stable `agentId` when multiple agents may use the same gateway. AI Gateway
92
- stores this custom metadata with each request, allowing cost queries to filter
93
- by agent without copying cost data into another store.
89
+ When these providers are used inside an SDK agent, the SDK automatically adds
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.
94
92
 
95
- ## Quick start: an agent
93
+ ## Quick start: a chat agent
96
94
 
97
- 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:
98
96
 
99
97
  ```typescript
100
98
  import { openai } from "@ai-sdk/openai";
101
99
  import { callable } from "agents";
102
- import { Agent, tool, type ToolSet } from "@economic/agents";
100
+ import { ChatAgent, tool, type ToolSet } from "@economic/agents";
103
101
  import { z } from "zod";
104
102
 
105
- export class SupportAgent extends Agent {
103
+ export class SupportAgent extends ChatAgent {
104
+ agentName = "customer-support";
105
+
106
106
  getModel() {
107
107
  return openai("gpt-4o");
108
108
  }
@@ -171,6 +171,16 @@ export default {
171
171
  }
172
172
  ```
173
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
+
174
184
  Run `wrangler types` for a typed `Env`.
175
185
 
176
186
  ### Calling it from the client
@@ -189,9 +199,9 @@ await agent.call("checkOrder", ["1234"]);
189
199
 
190
200
  You can also drive turns server-side — call `saveMessages` from a schedule or alarm.
191
201
 
192
- ## A chat agent
202
+ ## Using a chat UI
193
203
 
194
- 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.
195
205
 
196
206
  ```typescript
197
207
  import { openai } from "@ai-sdk/openai";
@@ -199,6 +209,8 @@ import { ChatAgent } from "@economic/agents";
199
209
  import { weatherSkill } from "./skills/weather";
200
210
 
201
211
  export class MyChatAgent extends ChatAgent {
212
+ agentName = "weather-assistant";
213
+
202
214
  getModel() {
203
215
  return openai("gpt-4o");
204
216
  }
@@ -221,15 +233,15 @@ const { chat } = useChat({
221
233
  });
222
234
  ```
223
235
 
224
- What `ChatAgent` adds over `Agent`:
236
+ Built-in chat features:
225
237
 
226
238
  - **Compaction** — past 100,000 tokens, older messages are summarised (with `getModel()`) while recent ones are kept verbatim. Storage keeps the full history.
227
239
  - **Message feedback** — thumbs up/down with an optional comment, in the chat's SQLite (`assistant_messages_feedback`, created automatically):
228
240
 
229
- | Method | Description |
230
- | ---------------------------------------------------- | ------------------------------------------------------------ |
231
- | `submitMessageFeedback(messageId, rating, comment?)` | `rating` is `1` (up) or `-1` (down). Upserts on the message. |
232
- | `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. |
233
245
 
234
246
  Surfaced by the client as `chat.submitMessageFeedback` / `chat.getMessageFeedback`.
235
247
 
@@ -243,6 +255,7 @@ import { Assistant } from "@economic/agents";
243
255
  import { MyChatAgent } from "./chat-agent";
244
256
 
245
257
  export class MyAssistant extends Assistant {
258
+ agentName = "weather-assistant";
246
259
  protected agent = MyChatAgent;
247
260
  protected fastModel = openai("gpt-4o-mini"); // titles and summaries
248
261
  }
@@ -260,27 +273,117 @@ const { status, chats, assistant, chat } = useAssistant({
260
273
 
261
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.
262
275
 
263
- | Method | Returns | Description |
264
- | ---------------- | -------- | ------------------------------------------- |
265
- | `createChat()` | `string` | Creates a chat facet and returns its id. |
266
- | `getChats()` | `Chat[]` | Started chats, most recently updated first. |
267
- | `deleteChat(id)` | `void` | Deletes a chat facet and its record. |
268
- | `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. |
269
282
 
270
283
  - **Titles and summaries** — generated with `fastModel` after each turn (on the first turn, then refreshed as the chat grows).
271
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.
272
285
  - **Retention** — inactive chats are deleted after 90 days, via a Durable Object alarm.
273
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
+
274
377
  ## Bindings
275
378
 
276
- Checked in `Agent.onStart` when a connection opens:
379
+ Validated when the relevant agent starts:
277
380
 
278
- | Binding | Type | Required | Notes |
279
- | ------------------- | ---------------- | -------- | ------------------------------------------------------------------------- |
280
- | `AGENTS_AUDIT_LOGS` | R2 | Yes | Connection rejected if missing. One [audit log](#observability) per turn. |
281
- | `AGENTS_ANALYTICS` | Analytics Engine | No | Per-turn/per-tool [analytics](#observability). Warns if missing. |
282
- | `SKILLS_BUCKET` | R2 | No | Source for [remote skills](#remote-skills). |
283
- | `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. |
284
387
 
285
388
  ## Tools
286
389
 
@@ -302,7 +405,9 @@ getTools(): ToolSet {
302
405
  }
303
406
  ```
304
407
 
305
- `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.
306
411
 
307
412
  ### Tool context
308
413
 
@@ -326,7 +431,7 @@ const call_api = tool({
326
431
  });
327
432
  ```
328
433
 
329
- `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`.
330
435
 
331
436
  ## Skills
332
437
 
@@ -425,7 +530,7 @@ Skill scripts are enabled when a Worker Loader binding named `LOADER` is present
425
530
  }
426
531
  ```
427
532
 
428
- `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.
429
534
 
430
535
  TypeScript scripts use the function-style contract:
431
536
 
@@ -467,7 +572,7 @@ import { skills } from "@cloudflare/think";
467
572
 
468
573
  getSkills() {
469
574
  return [
470
- skills.r2(this.env.SKILLS_BUCKET, {
575
+ skills.r2(this.env.REMOTE_SKILLS, {
471
576
  skills: ["top-customers-by-revenue", "vat-code-review"],
472
577
  }),
473
578
  ];
@@ -485,82 +590,318 @@ skills/top-customers-by-revenue/
485
590
 
486
591
  Source order matters: if two sources define the same skill name, the first source wins.
487
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
+
488
596
  ## Authentication
489
597
 
490
- ### 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.
491
616
 
492
- 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.
493
620
 
494
621
  ```typescript
495
- export class SupportAgent extends Agent {
496
- 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) {
497
681
  return {
498
- allowedIssuers: [env.IDENTITY_ENDPOINT], // strings or RegExp
499
- audience: "my-api",
500
- requiredScopes: ["support.read"],
501
- getClaims: (payload) => ({
502
- userGuid: payload.user_guid as string,
503
- agreementNumber: payload.agreement_number as number,
504
- }),
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
+ },
505
709
  };
506
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
507
722
 
508
- // ...
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));
509
749
  }
510
750
  ```
511
751
 
512
- 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
513
753
 
514
- ### User context
754
+ The SDK emits typed product facts from the lifecycle that produces them:
515
755
 
516
- 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:
517
763
 
518
764
  ```typescript
519
- protected async getUserContext(jwtToken: string) {
520
- const profile = await fetchProfile(jwtToken);
521
- 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
+ };
522
770
  }
523
771
  ```
524
772
 
525
- 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:
526
774
 
527
775
  ```typescript
528
- const error = new Error("No Eva access");
529
- (error as Error & { closeCode: number }).closeCode = 4003;
530
- 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
+ }
531
802
  ```
532
803
 
533
- ### 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.
534
805
 
535
- 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`).
536
807
 
537
- ```typescript
538
- export class SupportAgent extends Agent {
539
- static getTokenExchangeConfig(env: Cloudflare.Env) {
540
- return {
541
- tokenUrl: `${env.IDENTITY_ENDPOINT}/connect/token`,
542
- clientId: env.OAUTH_CLIENT_ID,
543
- clientSecret: env.OAUTH_CLIENT_SECRET, // string or Secrets Store binding
544
- scopes: "api.read api.write", // always sent explicitly
545
- tokenHeader: "X-EconomicToken", // default
546
- };
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
547
833
  }
548
834
  }
549
835
  ```
550
836
 
551
- 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
552
848
 
553
- - 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.
554
- - 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).
555
- - 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.
556
- - 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.
557
850
 
558
- ## 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.
559
852
 
560
- 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
+ ```
561
903
 
562
- - **Audit logs** → one JSON object per turn in `AGENTS_AUDIT_LOGS` (model, actor, IP, prompt, response, tool calls).
563
- - **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.
564
905
 
565
906
  ## API reference
566
907
 
@@ -568,36 +909,44 @@ Imported from `@economic/agents` unless noted.
568
909
 
569
910
  ### Classes
570
911
 
571
- | Export | Description |
572
- | ----------- | -------------------------------------------------------- |
573
- | `Agent` | Core agent base. Implement `getModel`/`getSystemPrompt`. |
574
- | `ChatAgent` | `Agent` + compaction and message feedback. |
575
- | `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. |
576
917
 
577
918
  ### Helpers and types
578
919
 
579
- | Export | Description |
580
- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
581
- | `tool(def)` / `Tool` / `ToolSet` | Define a tool with an optional `authorize(ctx)` guard. |
582
- | `skill(def)` / `Skill` | Define a skill (`name`, `description`, `instructions`, `tools?`, `authorize?`). |
583
- | `ToolContext` | `RequestContext & { _userContext?: UserContext }`. |
584
- | `AgentEnv` | The bindings the runtime expects. |
585
- | `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. |
586
932
 
587
933
  ### Members
588
934
 
589
- | Member | On | Description |
590
- | ---------------------------------------------- | ----------- | ------------------------------------------- |
591
- | `getModel(ctx?)` | `Agent` | Required. Model for inference. |
592
- | `getSystemPrompt(ctx?)` | `Agent` | Required. System prompt. |
593
- | `getTools()` | `Agent` | Always-on tools (default `{}`). |
594
- | `getSkills()` | `Agent` | Local skills (default `[]`). |
595
- | `static getJwtAuthConfig(env)` | `Agent` | Optional JWT verification config. |
596
- | `getUserContext(jwtToken)` | `Agent` | Optional per-user context after auth. |
597
- | `submitMessageFeedback` / `getMessageFeedback` | `ChatAgent` | Callable message feedback. |
598
- | `agent` | `Assistant` | Required. Your `ChatAgent` subclass. |
599
- | `fastModel` | `Assistant` | Required. Cheap model for titles/summaries. |
600
- | `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. |
601
950
 
602
951
  ### Package root (`@economic/agents`)
603
952