@agent-native/core 0.121.0 → 0.121.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,13 +10,18 @@ share the same [actions](/docs/actions), SQL data, and application state. Start
10
10
  with chat so users can talk to the agent immediately, then add the app surfaces
11
11
  your workflow earns.
12
12
 
13
+ The quickest way in is the **Chat template**: a minimal app that gives you a
14
+ working AI chat interface, durable threads, auth, and an `actions/` directory
15
+ ready to extend. It's the foundation most Agent-Native apps grow from.
16
+
13
17
  The first useful path is:
14
18
 
15
19
  1. Create a chat app.
16
- 2. Add one action.
17
- 3. Render the action result inline in chat.
18
- 4. Persist data in SQL.
19
- 5. Add a page the agent can open when visual inspection is better than another
20
+ 2. Connect an AI engine so the agent can respond.
21
+ 3. Add one action.
22
+ 4. Render the action result inline in chat.
23
+ 5. Persist data in SQL.
24
+ 6. Add a page the agent can open when visual inspection is better than another
20
25
  paragraph in the transcript.
21
26
 
22
27
  Want a complete domain app instead? Clone a rich template such as
@@ -29,7 +34,7 @@ Want a complete domain app instead? Clone a rich template such as
29
34
 
30
35
  You'll need [Node.js 22+](https://nodejs.org) and [pnpm](https://pnpm.io).
31
36
 
32
- Create the minimal chat-first app:
37
+ Open a terminal, go to the directory where you want your Agent Native app, and run:
33
38
 
34
39
  ```bash
35
40
  npx @agent-native/core@latest create my-app --template chat
@@ -42,103 +47,212 @@ This gives you durable chat threads, auth, live sync, an `actions/` directory,
42
47
  standard `view-screen` and `navigate` actions, and a small React app you can
43
48
  extend.
44
49
 
45
- Run `create` with no flags if you want the CLI picker for domain templates and
46
- advanced app shapes:
50
+ The dev server starts and opens `http://localhost:8080` in your browser. You may
51
+ notice a few startup log lines like `NitroViteError: Vite environment "nitro" is
52
+ unavailable`. These are a normal race condition during the initial boot and
53
+ resolve on their own. The app is ready once **VITE ready** displays in the
54
+ terminal output.
55
+
56
+ When the app starts, you might find yourself on the login screen rather than in the app. In this case, just sign up with a made up login to satisfy the login screen.
57
+ For local development, no email verification is actually required.
58
+
59
+ ### If you want a different type of app
60
+
61
+ This guide uses the Chat template because it is the shortest path
62
+ to an agentic application: the agent can act on day one, and you have a real app
63
+ surface to grow from. However, if you want to create a different kind of app, run `create` with no flags for the CLI picker for domain templates and advanced app shapes:
47
64
 
48
65
  ```bash
49
66
  npx @agent-native/core@latest create my-app
50
67
  ```
51
68
 
52
- The rest of this guide assumes the Chat template because it is the shortest path
53
- to an agentic application: the agent can act on day one, and you have a real app
54
- surface to grow from.
69
+ ## 2. Connect an AI engine {#connect-ai}
70
+
71
+ The agent chat can't respond until you connect an AI engine. After you're logged in, click the **Connect AI** button.
72
+
73
+ You have two options:
74
+
75
+ **Option A: Connect Builder.** Click **Connect Builder.io**, choose the Builder Space, and click the **Authorize** button. You don't have to manually provide any keys.
76
+
77
+ **Option B: Add your own keys.** Enter your Anthropic or OpenAI API key. You can get an
78
+ Anthropic key at [console.anthropic.com](https://console.anthropic.com).
79
+
80
+ Alternatively, create a `.env` file in the `my-app/` directory (the same
81
+ directory that contains `package.json`) before running `pnpm dev`:
82
+
83
+ ```bash
84
+ echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env
85
+ ```
86
+
87
+ Restart the dev server. Once an AI engine is connected, the Setup panel
88
+ hides itself and the agent is ready to chat.
89
+
90
+ > **Blank screen?** Create a `.env` file in your `my-app/` directory with
91
+ > `ANTHROPIC_API_KEY=sk-ant-...`, then restart `pnpm dev`. The in-app Setup
92
+ > panel only appears once the app has loaded, so a missing key that prevents
93
+ > the app from rendering needs to be fixed via the environment variable rather than
94
+ > the UI.
95
+
96
+ ## 3. Add an action {#add-an-action}
55
97
 
56
- ## 2. Add an action {#add-an-action}
98
+ An action is a typed operation that both your agent and your UI can call. It's
99
+ how the agent does things in your app. Actions live in the `actions/` directory
100
+ and can be triggered from chat, from React components, from the CLI, or on a
101
+ schedule. You define them once and call them from anywhere.
57
102
 
58
- An action is one typed operation your agent and UI can both call. Replace the
59
- starter `hello` action with the first real operation in your domain. This example
60
- analyzes form responses and returns a validated shape for a custom chat chart:
103
+ ### Try the starter action
61
104
 
62
- ```ts filename="actions/analyze-responses.ts"
105
+ The Chat template includes a `hello` action at `actions/hello.ts`:
106
+
107
+ ```ts filename="actions/hello.ts"
63
108
  import { defineAction } from "@agent-native/core/action";
64
109
  import { z } from "zod";
65
110
 
66
- const responseChartResultSchema = z.object({
111
+ export default defineAction({
112
+ description: "Return a friendly greeting.",
113
+ schema: z.object({
114
+ name: z.string().default("world").describe("Name to greet"),
115
+ }),
116
+ http: { method: "GET" },
117
+ run: async ({ name }) => {
118
+ return { message: `Hello, ${name}!` };
119
+ },
120
+ });
121
+ ```
122
+
123
+ Run it from the terminal (inside your `my-app/` directory):
124
+
125
+ ```bash
126
+ pnpm action hello --name Alice
127
+ ```
128
+
129
+ Or open your app at `http://localhost:8080` and ask the agent in the chat there:
130
+
131
+ > Use the hello action with the name Alice.
132
+
133
+ ### Add your own action
134
+
135
+ Replace the starter action with the first real operation in your domain. This example
136
+ counts words, sentences, and paragraphs in any text you pass it. It computes
137
+ everything locally, so there's nothing to configure and no external service to connect.
138
+
139
+ Create a new file called `analyze-text.ts` in your `actions/` directory:
140
+
141
+ ```ts filename="actions/analyze-text.ts"
142
+ import { defineAction } from "@agent-native/core/action";
143
+ import { z } from "zod";
144
+
145
+ const textStatsSchema = z.object({
67
146
  title: z.string(),
68
- points: z.array(z.object({ day: z.string(), responses: z.number() })),
147
+ points: z.array(z.object({ label: z.string(), value: z.number() })),
69
148
  });
70
149
 
71
150
  export default defineAction({
72
- description: "Analyze recent form responses and render a custom chart.",
151
+ description: "Count words, sentences, and paragraphs in a block of text.",
73
152
  schema: z.object({
74
- formId: z.string().default("demo"),
153
+ text: z
154
+ .string()
155
+ .default(
156
+ "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.",
157
+ ),
75
158
  }),
76
- outputSchema: responseChartResultSchema,
159
+ outputSchema: textStatsSchema,
77
160
  chatUI: {
78
- renderer: "responses.response-chart",
79
- title: "Response chart",
161
+ renderer: "text.stats-chart",
162
+ title: "Text stats",
80
163
  },
81
164
  readOnly: true,
82
- run: async ({ formId }) => ({
83
- title: `Responses for ${formId}`,
165
+ run: async ({ text }) => ({
166
+ title: "Text statistics",
84
167
  points: [
85
- { day: "Mon", responses: 12 },
86
- { day: "Tue", responses: 18 },
87
- { day: "Wed", responses: 24 },
88
- { day: "Thu", responses: 21 },
168
+ { label: "Characters", value: text.length },
169
+ { label: "Words", value: text.split(/\s+/).filter(Boolean).length },
170
+ {
171
+ label: "Sentences",
172
+ value: text.split(/[.!?]+/).filter(Boolean).length,
173
+ },
174
+ {
175
+ label: "Paragraphs",
176
+ value: text.split(/\n\n+/).filter(Boolean).length,
177
+ },
89
178
  ],
90
179
  }),
91
180
  });
92
181
  ```
93
182
 
94
- Try it directly:
183
+ Try it from the terminal:
95
184
 
96
185
  ```bash
97
- pnpm action analyze-responses --formId demo
186
+ pnpm action analyze-text --text "Hello world. How are you today?"
98
187
  ```
99
188
 
100
- Then ask the agent in the browser:
189
+ Or open your app at `http://localhost:8080` and ask the agent in the chat there:
190
+
191
+ > Run the analyze-text action on "Hello world. How are you today?"
192
+
193
+ #### Define once, call from anywhere
194
+
195
+ This action is now reachable from chat, React hooks, CLI, HTTP, MCP, A2A,
196
+ scheduled jobs, and webhooks.
197
+
198
+ TIP: Any time you want the agent to call a specific action without ambiguity, phrasing it as "Run the `<action-name>` action" is most reliable. Natural-language prompts work well once the agent has enough context about your app's domain. For a brand-new app with no data or context yet, explicit is safer.
101
199
 
102
- > Analyze the recent responses for the demo form.
200
+ ## 4. Render the result inline {#render-inline}
103
201
 
104
- One action is now reachable from chat, React hooks, CLI, HTTP, MCP, A2A,
105
- scheduled jobs, and webhooks. Define once, call from anywhere.
202
+ When the agent runs `analyze-text`, it returns structured data: a title and an
203
+ array of counts. By default the agent will describe that data in prose: "The
204
+ text has 9 words, 2 sentences..." and so on. That works, but you can
205
+ also render the result as a real UI component (a bar chart, a table, a card)
206
+ directly inside the chat transcript, right where the agent responded.
106
207
 
107
- ## 3. Render the result inline {#render-inline}
208
+ This is what `chatUI.renderer` in the action does. It's a label that says "when
209
+ this action's result appears in chat, hand it to this React component instead of
210
+ summarizing it in text." The component receives the validated action output as
211
+ props and renders whatever you want.
108
212
 
109
- The action above declares `outputSchema` and a product-specific
110
- `chatUI.renderer`, so the transcript does not have to flatten structured data
111
- into prose. Register a React component for that exact renderer id, for example
112
- by importing `app/chat-renderers.tsx` once from `app/root.tsx`:
213
+ In the next step, you'll create `app/chat-renderers.tsx`, but first, add one import line
214
+ to `app/root.tsx` so it runs on startup:
113
215
 
114
- ```tsx
216
+ ```ts filename="app/root.tsx"
217
+ import "./chat-renderers";
218
+ ```
219
+
220
+ Add it alongside your other imports at the top of the file. That's the only
221
+ change to `root.tsx`. The import just ensures the file runs and registers the
222
+ renderer. Now create the renderer file:
223
+
224
+ ```tsx filename="app/chat-renderers.tsx"
115
225
  import {
116
226
  registerActionChatRenderer,
117
227
  type ToolRendererProps,
118
228
  } from "@agent-native/core/client/chat";
119
229
 
120
- type ResponseChartResult = {
230
+ type TextStatsResult = {
121
231
  title: string;
122
- points: Array<{ day: string; responses: number }>;
232
+ points: Array<{ label: string; value: number }>;
123
233
  };
124
234
 
125
- function ResponseChart({ context }: ToolRendererProps) {
126
- const result = context.resultJson as ResponseChartResult;
127
- const max = Math.max(...result.points.map((point) => point.responses), 1);
235
+ const MAX_BAR_PX = 80;
236
+
237
+ function TextStatsChart({ context }: ToolRendererProps) {
238
+ const result = context.resultJson as TextStatsResult;
239
+ const max = Math.max(...result.points.map((point) => point.value), 1);
128
240
  return (
129
241
  <section className="rounded-lg border bg-card p-4">
130
242
  <h3 className="text-sm font-medium">{result.title}</h3>
131
- <div className="mt-4 flex h-32 items-end gap-2">
243
+ <div className="mt-4 flex items-end gap-2">
132
244
  {result.points.map((point) => (
133
245
  <div
134
- key={point.day}
246
+ key={point.label}
135
247
  className="flex flex-1 flex-col items-center gap-2"
136
248
  >
137
249
  <div
138
- className="w-full rounded-t bg-primary"
139
- style={{ height: `${(point.responses / max) * 100}%` }}
250
+ className="w-full rounded-t bg-blue-500"
251
+ style={{
252
+ height: `${Math.max(Math.round((point.value / max) * MAX_BAR_PX), 2)}px`,
253
+ }}
140
254
  />
141
- <span className="text-xs text-muted-foreground">{point.day}</span>
255
+ <span className="text-xs text-muted-foreground">{point.label}</span>
142
256
  </div>
143
257
  ))}
144
258
  </div>
@@ -147,20 +261,21 @@ function ResponseChart({ context }: ToolRendererProps) {
147
261
  }
148
262
 
149
263
  registerActionChatRenderer({
150
- id: "responses.response-chart",
151
- renderer: "responses.response-chart",
152
- Component: ResponseChart,
264
+ id: "text.stats-chart",
265
+ renderer: "text.stats-chart",
266
+ Component: TextStatsChart,
153
267
  });
154
268
  ```
155
269
 
156
- Chat now renders your app's own chart component inline, with the validated action
157
- result as props through the renderer context.
270
+ Once the renderer is registered, the agent's response looks like this. Instead
271
+ of a paragraph of text, your React component renders directly inside the chat
272
+ transcript:
158
273
 
159
274
  <WireframeBlock id="doc-block-inline-result-wireframe">
160
275
  <Screen
161
276
  surface="desktop"
162
277
  html={
163
- "<div style='min-height:340px;box-sizing:border-box;padding:24px;display:flex;justify-content:center;align-items:center;background:var(--wf-bg)'><div style='width:min(530px,100%);display:flex;flex-direction:column;gap:14px'><div class='wf-card' data-rough style='align-self:flex-end;max-width:70%;padding:12px 14px'><strong>User</strong><p style='margin:6px 0 0'>Analyze demo responses.</p></div><div class='wf-card' data-rough style='align-self:flex-start;width:min(370px,100%);padding:14px'><strong>Agent</strong><p class='wf-muted' style='margin:6px 0 12px'>Rendered with responses.response-chart.</p><section class='wf-card' data-rough style='padding:14px'><h3 style='margin:0 0 12px;font-size:14px'>Response chart</h3><div data-rough='line:bottom' style='height:104px;display:flex;align-items:end;gap:8px;border-bottom:1.4px solid var(--wf-line);padding-bottom:4px'><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:38px;width:100%;background:color-mix(in srgb, var(--wf-accent) 24%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Mon</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:62px;width:100%;background:color-mix(in srgb, var(--wf-accent) 30%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Tue</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:86px;width:100%;background:color-mix(in srgb, var(--wf-accent) 36%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Wed</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:54px;width:100%;background:color-mix(in srgb, var(--wf-accent) 42%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Thu</span></div></div></section></div></div></div>"
278
+ "<div style='min-height:340px;box-sizing:border-box;padding:24px;display:flex;justify-content:center;align-items:center;background:var(--wf-bg)'><div style='width:min(640px,100%);display:flex;flex-direction:column;gap:14px'><div class='wf-card' data-rough style='align-self:flex-end;max-width:70%;padding:12px 14px'><strong>User</strong><p style='margin:6px 0 0'>Run the analyze-text action on \"Hello world. How are you today?\"</p></div><div class='wf-card' data-rough style='align-self:flex-start;width:min(480px,100%);padding:14px'><strong>Agent</strong><p class='wf-muted' style='margin:6px 0 12px'>Rendered with text.stats-chart.</p><section class='wf-card' data-rough style='padding:14px'><h3 style='margin:0 0 12px;font-size:14px'>Text statistics</h3><div data-rough='line:bottom' style='height:104px;display:flex;align-items:end;gap:8px;border-bottom:1.4px solid var(--wf-line);padding-bottom:4px'><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:80px;width:100%;background:color-mix(in srgb, var(--wf-accent) 36%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Characters</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:18px;width:100%;background:color-mix(in srgb, var(--wf-accent) 30%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Words</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:2px;width:100%;background:color-mix(in srgb, var(--wf-accent) 24%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Sentences</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:2px;width:100%;background:color-mix(in srgb, var(--wf-accent) 24%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Paragraphs</span></div></div></section></div></div></div>"
164
279
  }
165
280
  />
166
281
  </WireframeBlock>
@@ -179,69 +294,279 @@ summary/chart/table cards. See [Native Chat UI](/docs/native-chat-ui). For
179
294
  temporary controls the agent creates at runtime, see
180
295
  [Generative UI](/docs/generative-ui).
181
296
 
182
- ## 4. Persist data in SQL {#persist-data}
297
+ ## 5. Persist data in SQL {#persist-data}
298
+
299
+ Right now, every time the agent runs `analyze-text` the result appears in chat
300
+ and then disappears. There's nothing to look back at, nothing the agent can
301
+ reference later, and no way to build a page around the data. Persisting to SQL
302
+ fixes that: the agent writes results to a table, and both the agent and your UI
303
+ can read them back at any time.
304
+
305
+ Agent-Native apps have a SQL database available by default: SQLite locally,
306
+ and your configured provider (Postgres, Turso/libSQL, Cloudflare D1) in
307
+ production.
308
+
309
+ ### Wire up the database plugin
310
+
311
+ The Chat template doesn't include a database plugin by default. Create
312
+ `server/plugins/db.ts` to initialize it. This is what runs migrations and
313
+ makes the database available to your actions:
314
+
315
+ ```ts filename="server/plugins/db.ts"
316
+ import { runMigrations } from "@agent-native/core/db";
317
+
318
+ export default runMigrations(
319
+ [
320
+ {
321
+ version: 1,
322
+ sql: `CREATE TABLE IF NOT EXISTS text_analyses (
323
+ id TEXT PRIMARY KEY,
324
+ input TEXT NOT NULL,
325
+ char_count INTEGER NOT NULL,
326
+ word_count INTEGER NOT NULL,
327
+ sentence_count INTEGER NOT NULL,
328
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
329
+ )`,
330
+ },
331
+ ],
332
+ { table: "text_analyses_migrations" },
333
+ );
334
+ ```
335
+
336
+ Each entry in the array is an additive migration. When you add new columns or
337
+ tables later, append a new version object. Never edit existing ones.
183
338
 
184
- Inline chat is great for the first result. A real app needs durable data the
185
- agent can add, update, and revisit. Add a response-insights table in your schema
186
- and keep reads/writes behind actions:
339
+ ### Define the schema
340
+
341
+ Create `server/db/schema.ts`. The `server/db/` directory may not exist yet,
342
+ so create it if needed. This file describes your tables using typed helpers so
343
+ your actions get full TypeScript autocomplete:
187
344
 
188
345
  ```ts filename="server/db/schema.ts"
189
346
  import { integer, now, table, text } from "@agent-native/core/db/schema";
190
347
 
191
- export const responseInsights = table("response_insights", {
348
+ export const textAnalyses = table("text_analyses", {
192
349
  id: text("id").primaryKey(),
193
- formId: text("form_id").notNull(),
194
- title: text("title").notNull(),
195
- summary: text("summary").notNull(),
196
- responseCount: integer("response_count").notNull(),
350
+ input: text("input").notNull(),
351
+ charCount: integer("char_count").notNull(),
352
+ wordCount: integer("word_count").notNull(),
353
+ sentenceCount: integer("sentence_count").notNull(),
197
354
  createdAt: text("created_at").notNull().default(now()),
198
- updatedAt: text("updated_at").notNull().default(now()),
199
355
  });
200
356
  ```
201
357
 
202
- Use the framework schema helpers rather than `sqliteTable`, `pgTable`, or
203
- dialect-specific column imports. They choose the configured SQL backend, so the
204
- same schema can run locally on SQLite and in production on Postgres,
205
- Turso/libSQL, D1, or another supported SQL provider.
358
+ Use the framework schema helpers (`table`, `text`, `integer`, `now`) rather than
359
+ `sqliteTable`, `pgTable`, or dialect-specific imports. They pick the configured
360
+ SQL backend automatically, so the same schema runs locally on SQLite and in
361
+ production on any supported provider.
362
+
363
+ After adding both files, restart the dev server so the migration runs:
364
+
365
+ ```bash
366
+ pnpm dev
367
+ ```
368
+
369
+ Look for these two lines in the terminal output. They confirm the table was created:
370
+
371
+ ```
372
+ [db] Applying 1 migration(s) on SQLite/libsql…
373
+ [db] Applied migration v1 (1 statement)
374
+ ```
206
375
 
207
- Then split the work into focused actions:
376
+ The `NitroViteError` lines, `BETTER_AUTH_SECRET` warning, and
377
+ `SECRETS_ENCRYPTION_KEY` warning that also appear are normal for local dev and
378
+ can be ignored.
208
379
 
209
- - `create-response-insight` writes a new insight row.
210
- - `list-response-insights` reads the rows for the page.
211
- - `update-response-insight` edits an existing row after the agent learns more.
212
- - `analyze-responses` can call the same internal helper and return the native
213
- chat widget for the latest insight.
380
+ ### Add actions for the table
214
381
 
215
- The important rule is that the agent and UI share these actions. Do not add a
216
- separate REST route just for the browser if an action is the operation.
382
+ Now create the action files that read and write the table. These go in your
383
+ `actions/` directory, the same place as `hello.ts` and `analyze-text.ts`. You
384
+ create them yourself, one file per operation. The agent and your UI will call
385
+ them the same way they call any other action.
217
386
 
218
- ## 5. Add a page the agent can open {#add-a-page}
387
+ **`actions/save-text-analysis.ts`** writes a result row to the database.
388
+ Call this after running `analyze-text` to make the result durable:
219
389
 
220
- Now add a durable page for the data behind the chat result:
390
+ ```ts filename="actions/save-text-analysis.ts"
391
+ import { defineAction } from "@agent-native/core/action";
392
+ import { getDbExec } from "@agent-native/core/db";
393
+ import { z } from "zod";
221
394
 
222
- ```tsx filename="app/routes/response-insights.tsx"
395
+ export default defineAction({
396
+ description: "Save a text analysis result to the database.",
397
+ schema: z.object({
398
+ input: z.string(),
399
+ charCount: z.number(),
400
+ wordCount: z.number(),
401
+ sentenceCount: z.number(),
402
+ }),
403
+ run: async ({ input, charCount, wordCount, sentenceCount }) => {
404
+ const id = crypto.randomUUID();
405
+ await getDbExec().execute({
406
+ sql: `INSERT INTO text_analyses (id, input, char_count, word_count, sentence_count)
407
+ VALUES (?, ?, ?, ?, ?)`,
408
+ args: [id, input, charCount, wordCount, sentenceCount],
409
+ });
410
+ return { id };
411
+ },
412
+ });
413
+ ```
414
+
415
+ **`actions/list-text-analyses.ts`** reads all saved results. The agent can
416
+ call this to summarize past analyses, and your UI can use it to populate a page:
417
+
418
+ ```ts filename="actions/list-text-analyses.ts"
419
+ import { defineAction } from "@agent-native/core/action";
420
+ import { getDbExec } from "@agent-native/core/db";
421
+ import { z } from "zod";
422
+
423
+ export default defineAction({
424
+ description: "List all saved text analyses, newest first.",
425
+ schema: z.object({}),
426
+ run: async () => {
427
+ const result = await getDbExec().execute(
428
+ `SELECT id, input, char_count, word_count, sentence_count, created_at
429
+ FROM text_analyses
430
+ ORDER BY created_at DESC`,
431
+ );
432
+ return result.rows;
433
+ },
434
+ });
435
+ ```
436
+
437
+ **`actions/delete-text-analysis.ts`** removes a row by id:
438
+
439
+ ```ts filename="actions/delete-text-analysis.ts"
440
+ import { defineAction } from "@agent-native/core/action";
441
+ import { getDbExec } from "@agent-native/core/db";
442
+ import { z } from "zod";
443
+
444
+ export default defineAction({
445
+ description: "Delete a saved text analysis by id.",
446
+ schema: z.object({ id: z.string() }),
447
+ run: async ({ id }) => {
448
+ await getDbExec().execute({
449
+ sql: `DELETE FROM text_analyses WHERE id = ?`,
450
+ args: [id],
451
+ });
452
+ return { deleted: id };
453
+ },
454
+ });
455
+ ```
456
+
457
+ Once these files are saved the dev server picks them up automatically. No
458
+ restart needed. Try listing analyses from the terminal:
459
+
460
+ ```bash
461
+ pnpm action list-text-analyses
462
+ ```
463
+
464
+ You should see an empty array. The table exists and the action works; there's
465
+ just nothing saved yet:
466
+
467
+ ```
468
+ []
469
+ ```
470
+
471
+ Data is saved to `data/app.db`, a SQLite file in your project directory that
472
+ gets created automatically on first run. In production you'd point
473
+ `DATABASE_URL` at a hosted database instead, but locally this file is all you
474
+ need.
475
+
476
+ To save something, first run `analyze-text` to get the counts:
477
+
478
+ ```bash
479
+ pnpm action analyze-text --text "Hello world"
480
+ ```
481
+
482
+ You'll see output like:
483
+
484
+ ```
485
+ {
486
+ title: 'Text statistics',
487
+ points: [
488
+ { label: 'Characters', value: 11 },
489
+ { label: 'Words', value: 2 },
490
+ { label: 'Sentences', value: 1 },
491
+ { label: 'Paragraphs', value: 1 }
492
+ ]
493
+ }
494
+ ```
495
+
496
+ Then pass those values to `save-text-analysis`:
497
+
498
+ ```bash
499
+ pnpm action save-text-analysis \
500
+ --input "Hello world" \
501
+ --charCount 11 \
502
+ --wordCount 2 \
503
+ --sentenceCount 1
504
+ ```
505
+
506
+ Now run `list-text-analyses` again and you'll see the saved row:
507
+
508
+ ```bash
509
+ pnpm action list-text-analyses
510
+ ```
511
+
512
+ Or ask the agent in the chat at `http://localhost:8080` to do both steps at once:
513
+
514
+ > Run analyze-text on "Hello world", then save the result.
515
+
516
+ ## 6. Add a page the agent can open {#add-a-page}
517
+
518
+ Chat is great for conversational interaction, but some data is better inspected
519
+ in a dedicated UI: a table you can scan, sort, or delete rows from. This step
520
+ adds a React route that displays everything saved in `text_analyses`, using the
521
+ same `list-text-analyses` and `delete-text-analysis` actions you already wrote.
522
+ There's no second data layer. The page is just a view over the same SQL state
523
+ the agent reads and writes.
524
+
525
+ Create the route file at `app/routes/text-analyses.tsx`. Route files in
526
+ `app/routes/` are automatically picked up by the framework. The filename
527
+ becomes the URL path, so this page will be available at
528
+ `http://localhost:8080/text-analyses`.
529
+
530
+ ```tsx filename="app/routes/text-analyses.tsx"
223
531
  import {
224
532
  useActionMutation,
225
533
  useActionQuery,
226
534
  } from "@agent-native/core/client/hooks";
227
- export default function ResponseInsightsRoute() {
228
- const insights = useActionQuery("list-response-insights", {});
229
- const createInsight = useActionMutation("create-response-insight");
535
+
536
+ export default function TextAnalysesRoute() {
537
+ const analyses = useActionQuery("list-text-analyses", {});
538
+ const deleteAnalysis = useActionMutation("delete-text-analysis");
230
539
 
231
540
  return (
232
- <main className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
541
+ <main className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
233
542
  <header>
234
- <h1>Response insights</h1>
235
- <p>Insights the agent created from form responses.</p>
543
+ <h1 className="text-2xl font-semibold">Text analyses</h1>
544
+ <p className="text-muted-foreground">
545
+ Results saved by the agent or triggered manually.
546
+ </p>
236
547
  </header>
237
- <button onClick={() => createInsight.mutate({ formId: "demo" })}>
238
- Analyze demo form
239
- </button>
240
- <section>
241
- {insights.data?.map((insight) => (
242
- <article key={insight.id}>
243
- <h2>{insight.title}</h2>
244
- <p>{insight.summary}</p>
548
+ <section className="flex flex-col gap-3">
549
+ {analyses.data?.length === 0 && (
550
+ <p className="text-muted-foreground">No analyses saved yet.</p>
551
+ )}
552
+ {analyses.data?.map((row: any) => (
553
+ <article
554
+ key={row.id}
555
+ className="flex items-start justify-between rounded-lg border p-4"
556
+ >
557
+ <div className="flex flex-col gap-1">
558
+ <p className="text-sm font-medium">{row.input}</p>
559
+ <p className="text-xs text-muted-foreground">
560
+ {row.word_count} words · {row.char_count} characters ·{" "}
561
+ {row.sentence_count} sentences
562
+ </p>
563
+ </div>
564
+ <button
565
+ className="text-xs text-destructive hover:underline"
566
+ onClick={() => deleteAnalysis.mutate({ id: row.id })}
567
+ >
568
+ Delete
569
+ </button>
245
570
  </article>
246
571
  ))}
247
572
  </section>
@@ -250,29 +575,66 @@ export default function ResponseInsightsRoute() {
250
575
  }
251
576
  ```
252
577
 
253
- The page is not a second implementation. It is a projection of SQL state written
254
- through the same actions the agent uses.
578
+ `useActionQuery` calls `list-text-analyses` and keeps the result live. If the
579
+ agent saves a new row while the page is open, it appears automatically.
580
+ `useActionMutation` calls `delete-text-analysis` when the user clicks Delete,
581
+ then invalidates the query so the list refreshes.
582
+
583
+ Open `http://localhost:8080/text-analyses` in your browser. If you saved an
584
+ analysis in the previous step you'll see it listed. Then ask the agent in chat:
585
+
586
+ > Open the text analyses page.
587
+
588
+ If you get a 404, try restarting your dev server.
589
+
590
+ The agent calls the `navigate` action (already included in the Chat
591
+ template) to send the browser to `/text-analyses`. This is what it looks like
592
+ with a few saved rows:
255
593
 
256
594
  <WireframeBlock id="doc-block-response-insights-page-wireframe">
257
595
  <Screen
258
596
  surface="desktop"
259
597
  html={
260
- "<main style='min-height:440px;box-sizing:border-box;padding:28px;background:var(--wf-bg)'><section style='max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:16px;align-items:stretch'><section class='wf-card' data-rough style='display:flex;flex-direction:column;gap:16px'><header><h2 style='margin:0 0 4px;font-size:28px'>Response insights</h2><p class='wf-muted' style='margin:0'>Insights the agent created from form responses.</p></header><button class='primary' data-rough style='align-self:flex-start'>Analyze demo form</button><section style='display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px'><article class='wf-box' data-rough style='padding:12px'><strong>Pricing theme</strong><p class='wf-muted' style='margin:8px 0 0'>Users ask for a team plan.</p></article><article class='wf-box' data-rough style='padding:12px'><strong>Onboarding drop-off</strong><p class='wf-muted' style='margin:8px 0 0'>Three steps need clearer copy.</p></article></section><section class='wf-card' data-rough style='display:flex;flex-direction:column;gap:10px'><div class='wf-row wf-box' data-rough style='padding:10px 12px;gap:12px;justify-content:space-between'><strong>Demo form</strong><span class='wf-muted'>42 responses analyzed</span></div><div class='wf-row wf-box' data-rough style='padding:10px 12px;gap:12px;justify-content:space-between'><strong>Next step</strong><span class='wf-muted'>Review two draft insights</span></div></section></section><aside class='wf-card' data-rough style='height:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:12px'><strong>Chat</strong><div class='wf-box' data-rough style='align-self:flex-end;max-width:210px;padding:10px 12px'><span class='wf-muted'>You</span><p style='margin:4px 0 0'>Open response insights.</p></div><div class='wf-box' data-rough style='align-self:flex-start;max-width:210px;padding:10px 12px'><span class='wf-muted'>Agent</span><p style='margin:4px 0 0'>Opened response insights.</p></div></aside></section></main>"
598
+ "<main style='min-height:400px;box-sizing:border-box;padding:28px;background:var(--wf-bg)'><div style='max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:20px'><header><h2 style='margin:0 0 4px;font-size:24px;font-weight:600'>Text analyses</h2><p class='wf-muted' style='margin:0;font-size:14px'>Results saved by the agent or triggered manually.</p></header><section style='display:flex;flex-direction:column;gap:10px'><article class='wf-card' data-rough style='display:flex;align-items:center;justify-content:space-between;padding:14px 16px'><div><p style='margin:0 0 4px;font-size:14px;font-weight:500'>Hello world</p><p class='wf-muted' style='margin:0;font-size:12px'>2 words · 11 characters · 1 sentence</p></div><span class='wf-muted' style='font-size:12px'>Delete</span></article><article class='wf-card' data-rough style='display:flex;align-items:center;justify-content:space-between;padding:14px 16px'><div><p style='margin:0 0 4px;font-size:14px;font-weight:500'>The quick brown fox jumps over the lazy dog.</p><p class='wf-muted' style='margin:0;font-size:12px'>9 words · 44 characters · 1 sentence</p></div><span class='wf-muted' style='font-size:12px'>Delete</span></article><article class='wf-card' data-rough style='display:flex;align-items:center;justify-content:space-between;padding:14px 16px'><div><p style='margin:0 0 4px;font-size:14px;font-weight:500'>Pack my box with five dozen liquor jugs.</p><p class='wf-muted' style='margin:0;font-size:12px'>8 words · 40 characters · 1 sentence</p></div><span class='wf-muted' style='font-size:12px'>Delete</span></article></section></div></main>"
261
599
  }
262
600
  />
263
601
  </WireframeBlock>
264
602
 
265
- ## 6. Let the agent navigate {#agent-navigation}
603
+ ## 7. Extend the navigation {#extend-navigation}
604
+
605
+ The sidebar is configured in `app/config/nav.ts`. Open it and add an entry for
606
+ the Text analyses page:
607
+
608
+ ```ts filename="app/config/nav.ts"
609
+ export const navConfig = [
610
+ {
611
+ label: "Chat",
612
+ href: "/",
613
+ icon: "chat",
614
+ },
615
+ {
616
+ label: "Text analyses",
617
+ href: "/text-analyses",
618
+ icon: "list",
619
+ },
620
+ ];
621
+ ```
622
+
623
+ Save the file. The dev server picks up the change automatically and the sidebar
624
+ updates without a restart.
625
+
626
+ ### Agent navigation
627
+
628
+ The sidebar link lets users navigate manually. The agent can also open pages on
629
+ its own using two built-in actions that ship with the Chat template:
266
630
 
267
- The Chat template includes `view-screen` and `navigate` actions. Extend them as
268
- your app grows:
631
+ - **`view-screen`** reads the current route and returns a compact summary of
632
+ what the user is looking at.
633
+ - **`navigate`** writes a same-origin path to the browser's history.
269
634
 
270
- - `view-screen` should read application state and return the current route,
271
- selected insight, active filters, and any compact page context the agent needs.
272
- - `navigate` should write a same-origin path such as `/response-insights` when
273
- the agent decides the user should inspect the result visually.
274
- - Action results can include links such as `href: "/response-insights"` so chat
275
- can offer an explicit "Open response insights" button.
635
+ As you add more pages, keep `navigate` updated so the agent knows what
636
+ destinations exist. Document available paths in `AGENTS.md` so the model can
637
+ reason about them.
276
638
 
277
639
  When the app has both a full-page chat route and an app page, use the shared chat
278
640
  handoff helpers described in [Agent Surfaces](/docs/agent-surfaces#rich-chat):
@@ -295,7 +657,7 @@ my-app/
295
657
 
296
658
  ## Want a full analytics starting point? {#analytics-starting-point}
297
659
 
298
- The response-insights example above is intentionally small so you can see the
660
+ The text-analyses example above is intentionally small so you can see the
299
661
  framework pieces. If you are building a real analytics product, start from
300
662
  [Analytics](/docs/template-analytics) instead. It is the robust starting point:
301
663
  connect your providers, use the existing dashboards and agent actions, then
@@ -303,15 +665,15 @@ customize the app from there.
303
665
 
304
666
  ## What's next {#next}
305
667
 
306
- - **[Actions](/docs/actions)** schemas, auth, approvals, hooks, and transport.
307
- - **[Native Chat UI](/docs/native-chat-ui)** render action results as tables,
668
+ - **[Actions](/docs/actions)**: schemas, auth, approvals, hooks, and transport.
669
+ - **[Native Chat UI](/docs/native-chat-ui)**: render action results as tables,
308
670
  charts, and typed cards.
309
- - **[Chat Template](/docs/template-chat)** the minimal chat-first app you just
671
+ - **[Chat Template](/docs/template-chat)**: the minimal chat-first app you just
310
672
  created.
311
- - **[Analytics Template](/docs/template-analytics)** a robust analytics app
673
+ - **[Analytics Template](/docs/template-analytics)**: a robust analytics app
312
674
  starting point; connect providers and customize from there.
313
- - **[Context Awareness](/docs/context-awareness)** `view-screen`, `navigate`,
675
+ - **[Context Awareness](/docs/context-awareness)**: `view-screen`, `navigate`,
314
676
  route state, and selected objects.
315
- - **[Agent Surfaces](/docs/agent-surfaces)** chat, inline UI, app pages,
677
+ - **[Agent Surfaces](/docs/agent-surfaces)**: chat, inline UI, app pages,
316
678
  embedded sidecars, automation, and external agents.
317
- - **[Deployment](/docs/deployment)** put your app on your own domain.
679
+ - **[Deployment](/docs/deployment)**: put your app on your own domain.