@agent-native/core 0.135.1 → 0.135.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.135.1",
3
+ "version": "0.135.2",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -67,6 +67,10 @@ export default defineAction({
67
67
 
68
68
  if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
69
69
 
70
+ // Floor to the nearest second so nearby comments land on the same
71
+ // timestamp bucket for scrubber grouping and the playback overlay.
72
+ const videoTimestampMs = Math.floor(args.videoTimestampMs / 1000) * 1000;
73
+
70
74
  await db.insert(schema.recordingComments).values({
71
75
  id,
72
76
  recordingId: args.recordingId,
@@ -76,7 +80,7 @@ export default defineAction({
76
80
  authorEmail,
77
81
  authorName: args.authorName ?? null,
78
82
  content: args.content,
79
- videoTimestampMs: args.videoTimestampMs,
83
+ videoTimestampMs,
80
84
  createdAt: now,
81
85
  updatedAt: now,
82
86
  });
@@ -94,7 +98,7 @@ export default defineAction({
94
98
  await writeAppState("refresh-signal", { ts: Date.now() });
95
99
 
96
100
  console.log(
97
- `Added comment to recording ${args.recordingId} @ ${args.videoTimestampMs}ms (thread: ${threadId})`,
101
+ `Added comment to recording ${args.recordingId} @ ${videoTimestampMs}ms (thread: ${threadId})`,
98
102
  );
99
103
 
100
104
  return { id, threadId, notified };
@@ -1,4 +1,7 @@
1
- import { useActionMutation } from "@agent-native/core/client/hooks";
1
+ import {
2
+ useActionMutation,
3
+ useAvatarUrl,
4
+ } from "@agent-native/core/client/hooks";
2
5
  import { useT } from "@agent-native/core/client/i18n";
3
6
  import {
4
7
  IconSend,
@@ -12,7 +15,7 @@ import {
12
15
  import { useQueryClient } from "@tanstack/react-query";
13
16
  import { type Ref, useEffect, useMemo, useRef, useState } from "react";
14
17
 
15
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
18
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
16
19
  import { Button } from "@/components/ui/button";
17
20
  import {
18
21
  DropdownMenu,
@@ -860,10 +863,14 @@ function CommentCard({
860
863
  }
861
864
 
862
865
  const commentContent = linkifyCommentContent(comment.content);
866
+ const avatarUrl = useAvatarUrl(comment.authorEmail);
863
867
 
864
868
  return (
865
869
  <div className={cn("flex gap-2", comment.resolved && "opacity-60")}>
866
870
  <Avatar className="h-7 w-7 shrink-0">
871
+ {avatarUrl ? (
872
+ <AvatarImage src={avatarUrl} alt={displayName(comment)} />
873
+ ) : null}
867
874
  <AvatarFallback className="text-[10px] bg-primary text-primary-foreground">
868
875
  {initials(displayName(comment))}
869
876
  </AvatarFallback>
@@ -1,4 +1,9 @@
1
- export const PLAYBACK_COMMENT_VISIBLE_MS = 4_000;
1
+ import { useAvatarUrl } from "@agent-native/core/client/hooks";
2
+
3
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export const PLAYBACK_COMMENT_VISIBLE_MS = 1_000;
2
7
 
3
8
  export function getPlaybackCommentVisibleMs(playbackRate = 1): number {
4
9
  const safePlaybackRate =
@@ -50,50 +55,62 @@ export function PlaybackCommentOverlay({
50
55
  comments,
51
56
  currentMs,
52
57
  playbackRate = 1,
58
+ onClick,
53
59
  }: {
54
60
  comments: PlaybackComment[] | undefined;
55
61
  currentMs: number;
56
62
  playbackRate?: number;
63
+ onClick?: () => void;
57
64
  }) {
58
65
  const activeComments = getActivePlaybackComments(
59
66
  comments,
60
67
  currentMs,
61
68
  playbackRate,
62
69
  );
70
+ const avatarUrl = useAvatarUrl(activeComments[0]?.authorEmail);
63
71
  if (activeComments.length === 0) return null;
64
72
 
73
+ const [comment, ...rest] = activeComments;
74
+ const author = displayAuthor(comment);
75
+ const Card = (onClick ? "button" : "div") as "button" | "div";
76
+
65
77
  return (
66
78
  <div
67
79
  data-player-ui
68
80
  className="pointer-events-none absolute inset-x-3 bottom-[5.25rem] z-20 flex justify-center sm:inset-x-6"
69
81
  aria-live="polite"
70
82
  >
71
- <div className="flex w-full max-w-xl flex-col items-center gap-2">
72
- {activeComments.map((comment) => {
73
- const author = displayAuthor(comment);
74
- return (
75
- <div
76
- key={comment.id}
77
- className="animate-in fade-in slide-in-from-bottom-2 flex max-w-full items-start gap-2.5 rounded-xl bg-black/85 px-3 py-2.5 text-white shadow-2xl ring-1 ring-white/15 backdrop-blur-md duration-200"
78
- >
79
- <span
80
- aria-hidden="true"
81
- className="flex size-7 shrink-0 items-center justify-center rounded-full bg-white/15 text-[10px] font-semibold text-white"
82
- >
83
- {initials(author)}
84
- </span>
85
- <div className="min-w-0">
86
- <p className="truncate text-xs font-semibold text-white/80">
87
- {author}
88
- </p>
89
- <p className="line-clamp-3 break-words text-sm leading-5 text-white">
90
- {comment.content}
91
- </p>
92
- </div>
93
- </div>
94
- );
95
- })}
96
- </div>
83
+ <Card
84
+ key={comment.id}
85
+ type={onClick ? "button" : undefined}
86
+ onClick={onClick}
87
+ className={cn(
88
+ "animate-in fade-in slide-in-from-bottom-2 flex w-full max-w-xl flex-col gap-1.5 rounded-xl bg-black/85 px-3 py-2.5 text-left text-white shadow-2xl ring-1 ring-white/15 backdrop-blur-md duration-200",
89
+ onClick && "pointer-events-auto cursor-pointer hover:bg-black/95",
90
+ )}
91
+ >
92
+ <div className="flex max-w-full items-start gap-2.5">
93
+ <Avatar aria-hidden="true" className="size-7 shrink-0">
94
+ {avatarUrl ? <AvatarImage src={avatarUrl} alt={author} /> : null}
95
+ <AvatarFallback className="bg-white/15 text-[10px] font-semibold text-white">
96
+ {initials(author)}
97
+ </AvatarFallback>
98
+ </Avatar>
99
+ <div className="min-w-0">
100
+ <p className="truncate text-xs font-semibold text-white/80">
101
+ {author}
102
+ </p>
103
+ <p className="line-clamp-3 break-words text-sm leading-5 text-white">
104
+ {comment.content}
105
+ </p>
106
+ </div>
107
+ </div>
108
+ {rest.length > 0 && (
109
+ <p className="pl-[2.375rem] text-xs text-white/60">
110
+ +{rest.length} other comment{rest.length > 1 ? "s" : ""}
111
+ </p>
112
+ )}
113
+ </Card>
97
114
  </div>
98
115
  );
99
116
  }
@@ -1,3 +1,4 @@
1
+ import { IconMessage2Filled } from "@tabler/icons-react";
1
2
  import { useMemo, useRef, useState } from "react";
2
3
 
3
4
  import { cn } from "@/lib/utils";
@@ -225,10 +226,17 @@ export function Scrubber(props: ScrubberProps) {
225
226
  e.stopPropagation();
226
227
  onSeek(ms);
227
228
  }}
228
- className="absolute -top-1 -translate-x-1/2 h-3.5 w-3.5 rounded-full bg-yellow-400 border-2 border-black/50 hover:scale-125 transition-transform"
229
+ className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md ring-2 ring-background transition-transform hover:scale-125"
229
230
  style={{ left: (ms / Math.max(1, durationMs)) * 100 + "%" }}
230
231
  aria-label={`${list.length} comment${list.length > 1 ? "s" : ""}`}
231
- />
232
+ >
233
+ <IconMessage2Filled className="h-2.5 w-2.5" />
234
+ {list.length > 1 && (
235
+ <span className="absolute -top-1.5 -right-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-bold leading-none text-primary-foreground ring-2 ring-background">
236
+ {list.length}
237
+ </span>
238
+ )}
239
+ </button>
232
240
  ))}
233
241
 
234
242
  {/* Reaction dots */}
@@ -218,6 +218,8 @@ export interface VideoPlayerProps {
218
218
  * lifecycle instead of polling an imperative-handle getter.
219
219
  */
220
220
  onVideoElementChange?: (video: HTMLVideoElement | null) => void;
221
+ /** Called when the viewer clicks the timestamped-comment overlay. */
222
+ onCommentClick?: () => void;
221
223
  }
222
224
 
223
225
  export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
@@ -255,6 +257,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
255
257
  recordingId,
256
258
  role,
257
259
  onVideoElementChange,
260
+ onCommentClick,
258
261
  } = props;
259
262
 
260
263
  const resolvedVideoSrc = useMemo(
@@ -1651,6 +1654,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
1651
1654
  comments={comments}
1652
1655
  currentMs={currentMs}
1653
1656
  playbackRate={speed}
1657
+ onClick={onCommentClick}
1654
1658
  />
1655
1659
  ) : null}
1656
1660
 
@@ -261,15 +261,26 @@ export default function RecordingPage() {
261
261
  // The compact layout stacks the panel below the video, so switching tabs
262
262
  // alone leaves the user looking at the player. Desktop always renders the
263
263
  // side aside, so nothing to scroll there.
264
- const openInsightsPanel = useCallback(() => {
265
- setPanel("insights");
266
- if (!isCompactLayout) return;
267
- requestAnimationFrame(() => {
268
- document
269
- .getElementById("clip-activity-panel")
270
- ?.scrollIntoView({ block: "start" });
271
- });
272
- }, [isCompactLayout]);
264
+ const openSidePanel = useCallback(
265
+ (next: SidePanel) => {
266
+ setPanel(next);
267
+ if (!isCompactLayout) return;
268
+ requestAnimationFrame(() => {
269
+ document
270
+ .getElementById("clip-activity-panel")
271
+ ?.scrollIntoView({ block: "start" });
272
+ });
273
+ },
274
+ [isCompactLayout],
275
+ );
276
+ const openInsightsPanel = useCallback(
277
+ () => openSidePanel("insights"),
278
+ [openSidePanel],
279
+ );
280
+ const openCommentsPanel = useCallback(
281
+ () => openSidePanel("comments"),
282
+ [openSidePanel],
283
+ );
273
284
  const transcriptKickedRef = useRef<string | null>(null);
274
285
  // When the recording lands in the processing state but never flips to
275
286
  // 'ready', stop spinning forever and surface an error banner so the user
@@ -1608,6 +1619,7 @@ export default function RecordingPage() {
1608
1619
  cta={firstCta}
1609
1620
  onCtaClick={() => tracking.reportCtaClick()}
1610
1621
  onTimeUpdate={(ms) => setCurrentMs(ms)}
1622
+ onCommentClick={openCommentsPanel}
1611
1623
  className="h-full w-full rounded-none sm:rounded-xl"
1612
1624
  />
1613
1625
  {commentOpen ? (
@@ -1667,12 +1679,7 @@ export default function RecordingPage() {
1667
1679
  className="shrink-0"
1668
1680
  onOpen={() => {
1669
1681
  if (isCompactLayout) {
1670
- setPanel("comments");
1671
- requestAnimationFrame(() => {
1672
- document
1673
- .getElementById("clip-activity-panel")
1674
- ?.scrollIntoView({ block: "start" });
1675
- });
1682
+ openCommentsPanel();
1676
1683
  return;
1677
1684
  }
1678
1685
  setCommentAtMs(resolvePlaybackMs());
@@ -997,6 +997,7 @@ export default function ShareRoute() {
997
997
  cta={firstCta}
998
998
  onCtaClick={() => tracking.reportCtaClick()}
999
999
  onTimeUpdate={(ms) => setCurrentMs(ms)}
1000
+ onCommentClick={() => setPanel("comments")}
1000
1001
  className="h-full w-full rounded-none sm:rounded-xl"
1001
1002
  />
1002
1003
  </div>
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
- error?: undefined;
45
44
  summary: import("./types.js").TraceSummary;
46
45
  spans: import("./types.js").TraceSpan[];
47
46
  id?: undefined;
47
+ error?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
- error?: undefined;
51
50
  summary?: undefined;
52
51
  spans?: undefined;
53
52
  id: string;
53
+ error?: undefined;
54
54
  ok?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
@@ -59,10 +59,10 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
59
59
  error: any;
60
60
  ok?: undefined;
61
61
  } | {
62
- error?: undefined;
63
62
  summary?: undefined;
64
63
  spans?: undefined;
65
64
  id?: undefined;
65
+ error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
68
68
  //# sourceMappingURL=routes.d.ts.map
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
48
48
  }>;
49
49
  /** DELETE /_agent-native/resources/:id — delete a resource */
50
50
  export declare function handleDeleteResource(event: any): Promise<{
51
- ok?: undefined;
52
51
  error: string;
52
+ ok?: undefined;
53
53
  } | {
54
54
  error?: undefined;
55
55
  ok: boolean;
@@ -34,37 +34,37 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
34
34
  /** POST /_agent-native/secrets/:key — write a secret. */
35
35
  export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
36
36
  error: string;
37
- ok?: undefined;
38
37
  status?: undefined;
38
+ ok?: undefined;
39
39
  } | {
40
- error?: undefined;
41
40
  ok: boolean;
42
41
  status: string;
42
+ error?: undefined;
43
43
  } | {
44
- ok?: undefined;
45
44
  error: string;
46
45
  removed?: undefined;
46
+ ok?: undefined;
47
47
  } | {
48
- error?: undefined;
49
48
  ok: boolean;
50
49
  removed: boolean;
50
+ error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — re-run the validator against the
54
54
  * current stored value without changing anything. Useful for the "Test" button.
55
55
  */
56
56
  export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
57
- ok?: undefined;
58
57
  error: string;
59
58
  note?: undefined;
59
+ ok?: undefined;
60
60
  } | {
61
- error?: undefined;
62
61
  ok: boolean;
63
62
  note?: undefined;
64
- } | {
65
63
  error?: undefined;
64
+ } | {
66
65
  ok: boolean;
67
66
  note: string;
67
+ error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,12 +95,12 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
- error?: undefined;
99
98
  ok: boolean;
100
99
  key: string;
101
- } | {
102
100
  error?: undefined;
101
+ } | {
103
102
  ok: boolean;
104
103
  removed: boolean;
104
+ error?: undefined;
105
105
  }>>;
106
106
  //# sourceMappingURL=routes.d.ts.map
@@ -20,7 +20,7 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- error?: undefined;
24
23
  text: string;
24
+ error?: undefined;
25
25
  }>>;
26
26
  //# sourceMappingURL=transcribe-voice.d.ts.map
@@ -0,0 +1,235 @@
1
+ ---
2
+ title: "Add an Action"
3
+ description: "Define your first action and render its result as a UI component directly inside the chat transcript."
4
+ ---
5
+
6
+ # Add an Action
7
+
8
+ This is part two of the Getting Started series. In [Getting Started](/docs/getting-started) you created a Chat app and connected an AI engine. Here you'll define your first action and render its result inline in chat.
9
+
10
+ ## Add an action {#add-an-action}
11
+
12
+ An action is a typed operation that both your agent and your UI can call. It's
13
+ how the agent does things in your app. Actions live in the `actions/` directory
14
+ and can be triggered from chat, from React components, from the CLI, or on a
15
+ schedule. You define them once and call them from anywhere.
16
+
17
+ ### Try the starter action
18
+
19
+ The Chat template includes a `hello` action at `actions/hello.ts`:
20
+
21
+ ```ts filename="actions/hello.ts"
22
+ import { defineAction } from "@agent-native/core/action";
23
+ import { z } from "zod";
24
+
25
+ export default defineAction({
26
+ description: "Return a friendly greeting.",
27
+ schema: z.object({
28
+ name: z.string().default("world").describe("Name to greet"),
29
+ }),
30
+ http: { method: "GET" },
31
+ run: async ({ name }) => {
32
+ return { message: `Hello, ${name}!` };
33
+ },
34
+ });
35
+ ```
36
+
37
+ Run it from the terminal (inside your `my-app/` directory):
38
+
39
+ ```bash
40
+ pnpm action hello --name Alice
41
+ ```
42
+
43
+ Or open your app at `http://localhost:8080` and ask the agent in the chat there:
44
+
45
+ > Use the hello action with the name Alice.
46
+
47
+ ### Add your own action
48
+
49
+ Replace the starter action with the first real operation in your domain. This example
50
+ counts words, sentences, and paragraphs in any text you pass it. It computes
51
+ everything locally, so there's nothing to configure and no external service to connect.
52
+
53
+ Create a new file called `analyze-text.ts` in your `actions/` directory:
54
+
55
+ ```ts filename="actions/analyze-text.ts"
56
+ import { defineAction } from "@agent-native/core/action";
57
+ import { z } from "zod";
58
+
59
+ const textStatsSchema = z.object({
60
+ title: z.string(),
61
+ points: z.array(z.object({ label: z.string(), value: z.number() })),
62
+ });
63
+
64
+ export default defineAction({
65
+ description: "Count words, sentences, and paragraphs in a block of text.",
66
+ schema: z.object({
67
+ text: z
68
+ .string()
69
+ .default(
70
+ "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.",
71
+ ),
72
+ }),
73
+ outputSchema: textStatsSchema,
74
+ chatUI: {
75
+ renderer: "text.stats-chart",
76
+ title: "Text stats",
77
+ },
78
+ readOnly: true,
79
+ run: async ({ text }) => ({
80
+ title: "Text statistics",
81
+ points: [
82
+ { label: "Characters", value: text.length },
83
+ { label: "Words", value: text.split(/\s+/).filter(Boolean).length },
84
+ {
85
+ label: "Sentences",
86
+ value: text.split(/[.!?]+/).filter(Boolean).length,
87
+ },
88
+ {
89
+ label: "Paragraphs",
90
+ value: text.split(/\n\n+/).filter(Boolean).length,
91
+ },
92
+ ],
93
+ }),
94
+ });
95
+ ```
96
+
97
+ Try it from the terminal:
98
+
99
+ ```bash
100
+ pnpm action analyze-text --text "Hello world. How are you today?"
101
+ ```
102
+
103
+ Or open your app at `http://localhost:8080` and ask the agent in the chat there:
104
+
105
+ > Run the analyze-text action on "Hello world. How are you today?"
106
+
107
+ #### Define once, call from anywhere
108
+
109
+ This action is now reachable from chat, React hooks, CLI, HTTP, MCP, A2A,
110
+ scheduled jobs, and webhooks.
111
+
112
+ 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.
113
+
114
+ ## Render the result inline {#render-inline}
115
+
116
+ When the agent runs `analyze-text`, it returns structured data: a title and an
117
+ array of counts. By default the agent will describe that data in prose: "The
118
+ text has 9 words, 2 sentences..." and so on. That works, but you can
119
+ also render the result as a real UI component (a bar chart, a table, a card)
120
+ directly inside the chat transcript, right where the agent responded.
121
+
122
+ This is what `chatUI.renderer` in the action does. It's a label that says "when
123
+ this action's result appears in chat, hand it to this React component instead of
124
+ summarizing it in text." The component receives the validated action output as
125
+ props and renders whatever you want.
126
+
127
+ In the next step, you'll create `app/chat-renderers.tsx`, but first, add one import line
128
+ to `app/root.tsx` so it runs on startup:
129
+
130
+ ```ts filename="app/root.tsx"
131
+ import "./chat-renderers";
132
+ ```
133
+
134
+ Add it alongside your other imports at the top of the file. That's the only
135
+ change to `root.tsx`. The import just ensures the file runs and registers the
136
+ renderer. Now create the renderer file:
137
+
138
+ ```tsx filename="app/chat-renderers.tsx"
139
+ import {
140
+ registerActionChatRenderer,
141
+ type ToolRendererProps,
142
+ } from "@agent-native/core/client/chat";
143
+
144
+ type TextStatsResult = {
145
+ title: string;
146
+ points: Array<{ label: string; value: number }>;
147
+ };
148
+
149
+ const MAX_BAR_PX = 80;
150
+
151
+ function TextStatsChart({ context }: ToolRendererProps) {
152
+ const result = context.resultJson as TextStatsResult;
153
+ const max = Math.max(...result.points.map((point) => point.value), 1);
154
+ return (
155
+ <section className="rounded-lg border bg-card p-4">
156
+ <h3 className="text-sm font-medium">{result.title}</h3>
157
+ <div className="mt-4 flex items-end gap-2">
158
+ {result.points.map((point) => (
159
+ <div
160
+ key={point.label}
161
+ className="flex flex-1 flex-col items-center gap-2"
162
+ >
163
+ <div
164
+ className="w-full rounded-t bg-blue-500"
165
+ style={{
166
+ height: `${Math.max(Math.round((point.value / max) * MAX_BAR_PX), 2)}px`,
167
+ }}
168
+ />
169
+ <span className="text-xs text-muted-foreground">{point.label}</span>
170
+ </div>
171
+ ))}
172
+ </div>
173
+ </section>
174
+ );
175
+ }
176
+
177
+ registerActionChatRenderer({
178
+ id: "text.stats-chart",
179
+ renderer: "text.stats-chart",
180
+ Component: TextStatsChart,
181
+ });
182
+ ```
183
+
184
+ Once the renderer is registered, the agent's response looks like this. Instead
185
+ of a paragraph of text, your React component renders directly inside the chat
186
+ transcript:
187
+
188
+ <WireframeBlock id="doc-block-inline-result-wireframe">
189
+ <Screen
190
+ surface="desktop"
191
+ html={
192
+ "<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>"
193
+ }
194
+ />
195
+ </WireframeBlock>
196
+
197
+ Use this step when the result belongs where the agent is speaking:
198
+
199
+ - setup summaries
200
+ - short reports
201
+ - approvals
202
+ - tables or charts small enough to inspect inline
203
+ - links into durable app views
204
+
205
+ For reusable generic outputs, the framework also ships built-in
206
+ `data-chart` and `data-table` renderers, plus `data-insights` for combined
207
+ summary/chart/table cards. See [Native Chat UI](/docs/native-chat-ui). For
208
+ temporary controls the agent creates at runtime, see
209
+ [Generative UI](/docs/generative-ui).
210
+
211
+ ## What's next {#next}
212
+
213
+ <Cards>
214
+
215
+ ### [Persist Data in SQL](/docs/getting-started-database)
216
+
217
+ Save action results to a database so the agent can reference them across
218
+ conversations. Next in the series.
219
+
220
+ ### [Actions](/docs/actions)
221
+
222
+ Schemas, auth, approvals, hooks, and transport — the full reference for what
223
+ actions can do.
224
+
225
+ ### [Native Chat UI](/docs/native-chat-ui)
226
+
227
+ Built-in renderers for tables, charts, and typed cards — beyond the custom
228
+ renderer you just built.
229
+
230
+ ### [Key Concepts](/docs/key-concepts)
231
+
232
+ The architecture underneath this tutorial: SQL, actions, live sync, and context
233
+ awareness.
234
+
235
+ </Cards>