content-agent 0.7.5 → 0.8.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
@@ -10,81 +10,59 @@ npm install content-agent ai
10
10
 
11
11
  ## Usage
12
12
 
13
- Create a provider instance with your organization ID and token, then use `.agent(threadId)` to get a model for a conversation thread:
13
+ `ContentAgent` extends the AI SDK's [`ToolLoopAgent`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent) — it inherits `.generate()`, `.stream()`, telemetry, and every other capability of the AI SDK's first-class agent abstraction. Construction is the only thing that differs: pass a `provider` and `threadId` instead of a `model`, and `ContentAgent` resolves the model for you.
14
14
 
15
15
  ```typescript
16
16
  import { createContentAgent } from "content-agent";
17
- import { generateText } from "ai";
17
+ import { ContentAgent } from "content-agent/agent";
18
18
 
19
- const contentAgent = createContentAgent({
19
+ const provider = createContentAgent({
20
20
  organizationId: "your-org-id",
21
21
  token: "your-sanity-token",
22
22
  });
23
23
 
24
- const model = contentAgent.agent("my-thread");
24
+ const agent = new ContentAgent({
25
+ provider,
26
+ threadId: "my-thread",
27
+ });
25
28
 
26
- const { text } = await generateText({
27
- model,
29
+ const { text } = await agent.generate({
28
30
  prompt: "What blog posts do I have?",
29
31
  });
30
32
  ```
31
33
 
32
- ## Streaming
34
+ `provider` is reusable — create it once per organization and instantiate as many `ContentAgent`s as you need.
33
35
 
34
- Use `streamText` from the AI SDK for streaming responses:
36
+ > **Why a class instead of `streamText({ model, ... })`?**
37
+ >
38
+ > The AI SDK 6 explicitly invites SDK authors to ship their own `Agent` implementations: ["The `Agent` is now an interface instead of a class, which allows developers to implement their own agent abstractions for specific needs."](https://ai-sdk.dev/blog/ai-sdk-6) `ContentAgent` is that implementation for the Sanity Content Agent — it preconfigures `stopWhen`, `prepareStep`, and `toolChoice` for the agent's tool-response mode so you don't have to.
35
39
 
36
- ```typescript
37
- import { streamText } from "ai";
40
+ > **Migrating from `streamText({ model: provider.agent(threadId) })`?** Nothing breaks — the lower-level API is still supported and works exactly as before. But if you're using tool-response mode (`config.responseMode: 'tool'`) you'll want to switch to `ContentAgent` to pick up the safe defaults that prevent empty responses when the model burns its step budget on client tool calls. See [Tool-response mode](#tool-response-mode) below.
41
+
42
+ ## Streaming
38
43
 
39
- const { textStream } = streamText({
40
- model: contentAgent.agent("my-thread"),
44
+ ```typescript
45
+ const result = await agent.stream({
41
46
  prompt: "Summarize my latest content",
42
47
  });
43
48
 
44
- for await (const text of textStream) {
49
+ for await (const text of result.textStream) {
45
50
  process.stdout.write(text);
46
51
  }
47
52
  ```
48
53
 
49
- ## Applications
50
-
51
- Each application key uniquely identifies a deployed Sanity Studio workspace. Since multiple studios can share the same project ID and dataset, the application key disambiguates which one to target.
52
-
53
- List available applications for your organization, then target one by its key:
54
-
55
- ```typescript
56
- const apps = await contentAgent.applications();
57
- const app = apps.find((app) => app.title === "My Studio");
58
-
59
- const model = contentAgent.agent("my-thread", {
60
- application: { key: app.key },
61
- });
62
- ```
63
-
64
- You can also set a default application on the provider so every `.agent()` and `.prompt()` call inherits it:
65
-
66
- ```typescript
67
- const contentAgent = createContentAgent({
68
- organizationId: "your-org-id",
69
- token: "your-token",
70
- application: resolvedApp,
71
- });
72
-
73
- // No need to pass application per-call
74
- const model = contentAgent.agent("my-thread");
75
- ```
76
-
77
54
  ## Tools
78
55
 
79
- Pass AI SDK tools to `streamText` or `generateText` — tool schemas are forwarded to the agent, and execution happens locally on the client:
56
+ Pass AI SDK tools to the constructor — schemas are forwarded to the agent, and execution happens locally on the client:
80
57
 
81
58
  ```typescript
82
- import { streamText, tool } from "ai";
59
+ import { ContentAgent } from "content-agent/agent";
60
+ import { tool } from "ai";
83
61
  import { z } from "zod";
84
62
 
85
- const result = streamText({
86
- model: contentAgent.agent("my-thread"),
87
- prompt: "Find all draft blog posts and submit them for review.",
63
+ const agent = new ContentAgent({
64
+ provider,
65
+ threadId: "my-thread",
88
66
  tools: {
89
67
  submit_for_review: tool({
90
68
  description: "Submit a document for editorial review.",
@@ -103,14 +81,70 @@ const result = streamText({
103
81
  }),
104
82
  },
105
83
  });
84
+
85
+ const result = await agent.stream({
86
+ prompt: "Find all draft blog posts and submit them for review.",
87
+ });
88
+ ```
89
+
90
+ ## Tool-response mode
91
+
92
+ Set `config.responseMode: 'tool'` to deliver responses through a server-side `final_answer` tool. This is what the Linear, Slack, and MS Teams integrations use — the agent suppresses raw model text on the wire and emits all output via `final_answer.message`, with structured suggestions and a guaranteed delivery contract.
93
+
94
+ ```typescript
95
+ const agent = new ContentAgent({
96
+ provider,
97
+ threadId: "my-thread",
98
+ tools: { update_issue_status, get_settings },
99
+ config: {
100
+ responseMode: "tool",
101
+ enableReplySuggestions: true,
102
+ },
103
+ });
104
+ ```
105
+
106
+ In this mode `ContentAgent` adjusts the [loop control](https://ai-sdk.dev/docs/agents/loop-control) primitives to make the agent safe by default:
107
+
108
+ 1. **[`stopWhen`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent#stopwhen)** defaults to "stop only when the last step's tool calls are exclusively `final_answer`," plus a [`stepCountIs(20)`](https://ai-sdk.dev/docs/reference/ai-sdk-core/step-count-is) safety cap. A `final_answer` accompanied by other tool calls in the same step is treated as a preamble (the model is signalling more work to do) and the loop continues; a `final_answer` alone ends it.
109
+ 2. **[`prepareStep`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent#preparestep)** is composed with a safety net that, on the cap step, forces [`toolChoice`](https://ai-sdk.dev/docs/agents/building-agents#tool-choice) `{ type: 'tool', toolName: 'final_answer' }` if the model has been calling tools without ever converging on `final_answer`. This guarantees text on the wire even in pathological cases where the model would otherwise burn its step budget on client tool calls.
110
+
111
+ If you pass your own `stopWhen` or `prepareStep`, they take precedence — `ContentAgent` doesn't reach into them. Outside tool-response mode the agent is a plain `ToolLoopAgent` with no overrides.
112
+
113
+ ## Applications
114
+
115
+ Each application key uniquely identifies a deployed Sanity Studio workspace. Since multiple studios can share the same project ID and dataset, the application key disambiguates which one to target.
116
+
117
+ List available applications for your organization, then target one by its key:
118
+
119
+ ```typescript
120
+ const apps = await provider.applications();
121
+ const app = apps.find((app) => app.title === "My Studio");
122
+
123
+ const agent = new ContentAgent({
124
+ provider,
125
+ threadId: "my-thread",
126
+ application: { key: app.key },
127
+ });
128
+ ```
129
+
130
+ You can also set a default application on the provider so every agent and `.prompt()` call inherits it:
131
+
132
+ ```typescript
133
+ const provider = createContentAgent({
134
+ organizationId: "your-org-id",
135
+ token: "your-token",
136
+ application: resolvedApp,
137
+ });
106
138
  ```
107
139
 
108
140
  ## Configuration
109
141
 
110
- Pass a `config` object to `.agent()` to control behavior:
142
+ Pass a `config` object to control agent behavior:
111
143
 
112
144
  ```typescript
113
- const model = contentAgent.agent("my-thread", {
145
+ const agent = new ContentAgent({
146
+ provider,
147
+ threadId: "my-thread",
114
148
  config: {
115
149
  instruction: "You are a helpful content assistant for our marketing team.",
116
150
  userMessageContext: {
@@ -131,13 +165,15 @@ const model = contentAgent.agent("my-thread", {
131
165
 
132
166
  ### Config fields
133
167
 
134
- | Field | Type | Description |
135
- | -------------------- | ------------------------ | ---------------------------------------------------------------- |
136
- | `instruction` | `string` | Custom instruction included in the system prompt |
137
- | `userMessageContext` | `Record<string, string>` | Key-value pairs appended to each user message as XML tags |
138
- | `capabilities` | `object` | Controls what operations the agent can perform (`read`, `write`) |
139
- | `filter` | `object` | GROQ boolean expressions for document access control |
140
- | `perspectives` | `object` | Perspective locking for read and write operations |
168
+ | Field | Type | Description |
169
+ | ------------------------ | ------------------------ | ----------------------------------------------------------------------- |
170
+ | `instruction` | `string` | Custom instruction included in the system prompt |
171
+ | `userMessageContext` | `Record<string, string>` | Key-value pairs appended to each user message as XML tags |
172
+ | `capabilities` | `object` | Controls what operations the agent can perform (`read`, `write`) |
173
+ | `filter` | `object` | GROQ boolean expressions for document access control |
174
+ | `perspectives` | `object` | Perspective locking for read and write operations |
175
+ | `responseMode` | `'stream' \| 'tool'` | Set to `'tool'` to deliver responses through the `final_answer` tool |
176
+ | `enableReplySuggestions` | `boolean` | When `responseMode: 'tool'`, include reply-suggestion buttons on output |
141
177
 
142
178
  ### Capabilities
143
179
 
@@ -151,6 +187,21 @@ config: { capabilities: { read: true, write: false } }
151
187
  config: { capabilities: { read: true, write: { preset: "minimal" } } }
152
188
  ```
153
189
 
190
+ ## One-shot prompts
191
+
192
+ For stateless single-turn requests, use the provider's `.prompt()` method directly — no thread, no agent class:
193
+
194
+ ```typescript
195
+ const { text } = await provider.prompt(
196
+ {
197
+ application: { key: "projectId.datasetName" },
198
+ config: { capabilities: { read: true, write: false } },
199
+ instructions: "Be concise",
200
+ },
201
+ { message: "List my 5 most recent posts" },
202
+ );
203
+ ```
204
+
154
205
  ## Studio usage
155
206
 
156
207
  `createStudioAgent` makes the provider work inside Sanity Studio with no token or manual configuration. It derives the API host, organization ID, and application from the Studio context:
@@ -158,35 +209,42 @@ config: { capabilities: { read: true, write: { preset: "minimal" } } }
158
209
  ```typescript
159
210
  import { useMemo } from "react";
160
211
  import { createStudioAgent } from "content-agent";
212
+ import { ContentAgent } from "content-agent/agent";
161
213
  import { useClient, useWorkspace } from "sanity";
162
214
 
163
215
  function MyComponent() {
164
216
  const client = useClient({ apiVersion: "2024-01-01" });
165
217
  const { name: workspace } = useWorkspace();
166
218
 
167
- const contentAgent = useMemo(
219
+ const provider = useMemo(
168
220
  () => createStudioAgent(client, workspace),
169
221
  [client, workspace],
170
222
  );
171
223
 
172
- // Use contentAgent.agent(threadId) with useChat, generateText, etc.
224
+ const agent = useMemo(
225
+ () => new ContentAgent({ provider, threadId: "my-thread" }),
226
+ [provider],
227
+ );
228
+
229
+ // Use agent.stream() / agent.generate() with useChat, etc.
173
230
  }
174
231
  ```
175
232
 
176
- The factory call is synchronous — async setup (org ID fetch, application resolution) runs lazily on the first API call and is cached at the module level. Wrap in `useMemo` so the provider keeps a stable identity across renders.
233
+ The factory call is synchronous — async setup (org ID fetch, application resolution) runs lazily on the first API call and is cached at the module level. Wrap in `useMemo` so the provider and agent keep stable identities across renders.
177
234
 
178
235
  Since there is no token in a Studio context, `credentials: 'include'` is set on all fetch calls automatically so the browser sends session cookies. The base URL is derived from the client's project-scoped `getUrl` method.
179
236
 
180
237
  ### Using with `useChat`
181
238
 
182
- For a streaming chat UI, pair with `DirectChatTransport` from the AI SDK:
239
+ For a streaming chat UI, pair with [`DirectChatTransport`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/direct-chat-transport) and [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) from the AI SDK:
183
240
 
184
241
  ```tsx
185
242
  import { useMemo, useState } from "react";
186
243
  import { useClient, useWorkspace } from "sanity";
187
244
  import { useChat } from "@ai-sdk/react";
188
- import { DirectChatTransport, ToolLoopAgent } from "ai";
245
+ import { DirectChatTransport } from "ai";
189
246
  import { createStudioAgent } from "content-agent";
247
+ import { ContentAgent } from "content-agent/agent";
190
248
 
191
249
  function ChatTool() {
192
250
  const client = useClient({ apiVersion: "2024-01-01" });
@@ -194,10 +252,9 @@ function ChatTool() {
194
252
  const [threadId] = useState(() => `thread-${crypto.randomUUID()}`);
195
253
 
196
254
  const transport = useMemo(() => {
197
- const contentAgent = createStudioAgent(client, workspace);
198
- return new DirectChatTransport({
199
- agent: new ToolLoopAgent({ model: contentAgent.agent(threadId) }),
200
- });
255
+ const provider = createStudioAgent(client, workspace);
256
+ const agent = new ContentAgent({ provider, threadId });
257
+ return new DirectChatTransport({ agent });
201
258
  }, [client, workspace, threadId]);
202
259
 
203
260
  const { messages, sendMessage, status } = useChat({ transport });
@@ -208,10 +265,11 @@ function ChatTool() {
208
265
 
209
266
  ### Lower-level helpers
210
267
 
211
- If you already know the organization ID and need more control:
268
+ If you need more control than `ContentAgent` provides, the underlying primitives are still exported:
212
269
 
270
+ - `provider.agent(threadId, settings)` — returns a raw `LanguageModelV3` you can pass to [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text)/[`generateText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text)/[`ToolLoopAgent`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent) directly
213
271
  - `fromClient(client, workspace)` — derives `baseURL` and `application` from a Sanity client
214
- - `contentAgent.resolveApplication(client, workspace)` — fetches applications and matches by resource ID + workspace name
272
+ - `provider.resolveApplication(client, workspace)` — fetches applications and matches by resource ID + workspace name
215
273
 
216
274
  ## License
217
275