@electric-ax/agents 0.1.4 → 0.2.1

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.
@@ -0,0 +1,672 @@
1
+ ---
2
+ description: Guided quickstart — build a full Electric Agents app from entity to frontend
3
+ whenToUse: User is new to Electric Agents, wants to learn how agents work, asks for a quickstart or getting started guide
4
+ keywords:
5
+ - quickstart
6
+ - getting started
7
+ - learn
8
+ - multi-agent
9
+ - manager-worker
10
+ - entity
11
+ - app
12
+ - frontend
13
+ user-invocable: true
14
+ max: 35000
15
+ ---
16
+
17
+ # Quickstart: Build a Perspectives Analyzer
18
+
19
+ Build a `perspectives` entity that analyzes questions from an optimist and a critic using the manager-worker pattern. Use the exact code below — do not invent different code.
20
+
21
+ ## Core Concepts
22
+
23
+ ### What is Electric Agents?
24
+
25
+ Electric Agents is a runtime for spawning and orchestrating collaborative AI agents on serverless compute.
26
+
27
+ The core idea: agent sessions and communication are backed by **durable streams**. Each agent is an **entity** with its own stream of events. All agent activity — runs, tool calls, text output — is persisted to this stream. This means agents can scale to zero, survive restarts, and maintain full session history.
28
+
29
+ **Why this matters for multi-agent systems**: Because everything is durable and observable, agents can spawn children, wait for results (even across restarts), observe each other's state changes, and coordinate through structured primitives — all without worrying about losing state.
30
+
31
+ ### Entities
32
+
33
+ An entity is a durable, addressable unit of computation. Each entity has:
34
+
35
+ - A **type** (e.g., `assistant`, `worker`, `research-team`) — defined once, instantiated many times
36
+ - A **URL** (e.g., `/research-team/my-team`) — its unique address
37
+ - A **handler** — the function that runs each time the entity wakes up
38
+ - **State** — persistent collections that survive across wakes
39
+
40
+ You define entity types with `registry.define()` and create instances by spawning them.
41
+
42
+ ### Handlers and Wakes
43
+
44
+ An entity's handler runs in response to **wake events**:
45
+
46
+ - A message arrives in the entity's inbox
47
+ - A child entity finishes its run
48
+ - A cron schedule fires
49
+ - A state change in an observed entity
50
+
51
+ The handler is **not** a long-running process. It wakes, does its work (usually running an LLM agent loop), and goes back to sleep.
52
+
53
+ ### The Agent Loop
54
+
55
+ `ctx.useAgent()` configures an LLM agent and `ctx.agent.run()` starts it. The agent receives conversation history, calls tools as needed, and generates a response — all persisted to the entity's durable stream.
56
+
57
+ ### Spawning Children
58
+
59
+ Any entity can spawn child entities. When a child finishes (and the parent registered `wake: "runFinished"`), the parent's handler runs again. The wake event includes the child's response and the status of sibling children.
60
+
61
+ ### The Worker Entity
62
+
63
+ The built-in `worker` type is a generic agent substrate. You configure it at spawn time with a `systemPrompt` and `tools` array (at least one tool required).
64
+
65
+ ### State Collections
66
+
67
+ Entities can declare persistent state collections that survive across wakes, allowing coordination patterns like tracking which children have completed.
68
+
69
+ ## Before starting
70
+
71
+ **Ask the user where they want the project.** Suggest a sensible default (e.g., `./perspectives-app` relative to the working directory) but let them choose. Do not create files or directories until the user confirms the location.
72
+
73
+ **Ensure the user has an `ANTHROPIC_API_KEY` set.** The app's `.env` file (in the project root) must contain `ANTHROPIC_API_KEY=sk-ant-...`. If there is no `.env` file yet, ask the user to create one or provide their key so you can write it. Without this key, agents cannot call the LLM and will fail at runtime.
74
+
75
+ Once the directory is confirmed, read `server.ts` in that directory:
76
+
77
+ - **Has `registerPerspectives`**: resume from where they left off (read `entities/perspectives.ts` to determine the step)
78
+ - **Has `server.ts` but no perspectives**: go to Step 1
79
+ - **No `server.ts`**: scaffold the project — spawn a worker (`tools: ["bash"]`, systemPrompt: `"Set up an Electric Agents app project."`, initialMessage: `"mkdir -p TARGET/lib TARGET/entities && cp SKILL_DIR/scaffold/* TARGET/ && cp SKILL_DIR/scaffold/lib/* TARGET/lib/ && cd TARGET && pnpm install && pnpm dev &"` — replace SKILL_DIR and TARGET). Then proceed to Step 1 while the worker runs. Wait for the worker to finish before writing files.
80
+
81
+ ## Steps
82
+
83
+ **Step 1 — Welcome + first entity.** In one message: introduce Electric Agents using the Core Concepts above, preview the perspectives analyzer, and show the Step 1 code. Ask to write.
84
+
85
+ **Step 2 — After confirmation:** write `entities/perspectives.ts` with Step 1 code. Give CLI commands. Explain spawning briefly, show Step 2 code (adds one worker). Ask to write.
86
+
87
+ **Step 3 — After confirmation:** write the updated file. Give CLI commands. Explain coordination, show Step 3 code (adds critic + state). Ask to write.
88
+
89
+ **Step 4 — After confirmation:** write the updated file. Give CLI commands.
90
+
91
+ **Step 5 — Wire up.** Read `server.ts`, show the import change, ask to write, update it.
92
+
93
+ **Step 6 — After confirmation:** explain shared state as cross-entity coordination. Show Step 6 code (chatroom schema + chat-agent entity with `ctx.mkdb` and `ctx.observe`). Write files, give CLI commands to test. Ask to continue.
94
+
95
+ **Step 7 — After confirmation:** explain context assembly. Show the `ctx.useContext()` addition. Update the entity file. Test with two agents in the same room. Ask to continue.
96
+
97
+ **Step 8 — After confirmation:** explain live frontend queries. Show Step 8 code (React + TanStack DB `useLiveQuery`). Create UI files, add deps, give commands to run. Show how updates appear in real time.
98
+
99
+ **Step 9 — Recap.**
100
+
101
+ ## Rules
102
+
103
+ - Use the exact code below. Write files with your write tool.
104
+ - `server.ts` is at the working directory root. Entity files go in `entities/`.
105
+ - Worker spawn args MUST include `tools` array (e.g. `tools: ["bash", "read"]`).
106
+ - Prefer showing what changed between steps rather than repeating the entire file.
107
+ - Use `edit` tool for small changes (like updating server.ts). Use `write` for full entity file updates.
108
+
109
+ ---
110
+
111
+ # Code
112
+
113
+ ## Step 1: Minimal entity
114
+
115
+ `entities/perspectives.ts`:
116
+
117
+ ```typescript
118
+ import type { EntityRegistry } from '@electric-ax/agents-runtime'
119
+
120
+ export function registerPerspectives(registry: EntityRegistry) {
121
+ registry.define('perspectives', {
122
+ description: 'Analyzes questions from multiple perspectives',
123
+ async handler(ctx) {
124
+ ctx.useAgent({
125
+ systemPrompt:
126
+ 'You are a balanced analyst. When given a question, provide a thoughtful analysis.',
127
+ model: 'claude-sonnet-4-6',
128
+ tools: [...ctx.electricTools],
129
+ })
130
+ await ctx.agent.run()
131
+ },
132
+ })
133
+ }
134
+ ```
135
+
136
+ `server.ts` additions:
137
+
138
+ ```typescript
139
+ import { registerPerspectives } from './entities/perspectives'
140
+ registerPerspectives(registry)
141
+ ```
142
+
143
+ Test: `pnpm electric-agents spawn /perspectives/test-1 && pnpm electric-agents send /perspectives/test-1 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-1`
144
+
145
+ ## Step 2: One worker
146
+
147
+ Full `entities/perspectives.ts`:
148
+
149
+ ```typescript
150
+ import type {
151
+ EntityRegistry,
152
+ HandlerContext,
153
+ } from '@electric-ax/agents-runtime'
154
+ import { Type } from '@sinclair/typebox'
155
+
156
+ function createAnalyzeTool(ctx: HandlerContext) {
157
+ return {
158
+ name: 'analyze_question',
159
+ label: 'Analyze Question',
160
+ description: 'Spawns an optimist worker to analyze a question.',
161
+ parameters: Type.Object({
162
+ question: Type.String({ description: 'The question to analyze' }),
163
+ }),
164
+ execute: async (_toolCallId: string, params: unknown) => {
165
+ const { question } = params as { question: string }
166
+ const parentId = ctx.entityUrl.split('/').pop()
167
+ await ctx.spawn(
168
+ 'worker',
169
+ `${parentId}-optimist`,
170
+ {
171
+ systemPrompt:
172
+ 'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
173
+ tools: ['bash', 'read'],
174
+ },
175
+ { initialMessage: question, wake: 'runFinished' }
176
+ )
177
+ return {
178
+ content: [
179
+ {
180
+ type: 'text' as const,
181
+ text: "Spawned optimist worker. You'll be woken when it finishes.",
182
+ },
183
+ ],
184
+ details: {},
185
+ }
186
+ },
187
+ }
188
+ }
189
+
190
+ export function registerPerspectives(registry: EntityRegistry) {
191
+ registry.define('perspectives', {
192
+ description: 'Analyzes questions from multiple perspectives',
193
+ async handler(ctx) {
194
+ ctx.useAgent({
195
+ systemPrompt: `You are a balanced analyst.\n\nWhen given a question:\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken when the worker finishes.\n3. When woken, finished_child.response contains the analysis.\n4. Present it to the user.`,
196
+ model: 'claude-sonnet-4-6',
197
+ tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
198
+ })
199
+ await ctx.agent.run()
200
+ },
201
+ })
202
+ }
203
+ ```
204
+
205
+ Test: `pnpm electric-agents spawn /perspectives/test-2 && pnpm electric-agents send /perspectives/test-2 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-2`
206
+
207
+ ## Step 3: Two workers + state
208
+
209
+ Full `entities/perspectives.ts`:
210
+
211
+ ```typescript
212
+ import type {
213
+ EntityRegistry,
214
+ HandlerContext,
215
+ } from '@electric-ax/agents-runtime'
216
+ import { Type } from '@sinclair/typebox'
217
+
218
+ const PERSPECTIVES = [
219
+ {
220
+ id: 'optimist',
221
+ systemPrompt:
222
+ 'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
223
+ },
224
+ {
225
+ id: 'critic',
226
+ systemPrompt:
227
+ 'You are a critical analyst. Provide a sharp analysis focusing on risks, downsides, and challenges.',
228
+ },
229
+ ]
230
+
231
+ function createAnalyzeTool(ctx: HandlerContext) {
232
+ return {
233
+ name: 'analyze_question',
234
+ label: 'Analyze Question',
235
+ description: 'Spawns optimist and critic workers to analyze a question.',
236
+ parameters: Type.Object({
237
+ question: Type.String({ description: 'The question to analyze' }),
238
+ }),
239
+ execute: async (_toolCallId: string, params: unknown) => {
240
+ const { question } = params as { question: string }
241
+ const parentId = ctx.entityUrl.split('/').pop()
242
+ for (const p of PERSPECTIVES) {
243
+ const childId = `${parentId}-${p.id}`
244
+ await ctx.spawn(
245
+ 'worker',
246
+ childId,
247
+ { systemPrompt: p.systemPrompt, tools: ['bash', 'read'] },
248
+ { initialMessage: question, wake: 'runFinished' }
249
+ )
250
+ ctx.db.actions.children_insert({
251
+ row: { key: p.id, url: `/worker/${childId}` },
252
+ })
253
+ }
254
+ return {
255
+ content: [
256
+ {
257
+ type: 'text' as const,
258
+ text: 'Spawned optimist and critic workers.',
259
+ },
260
+ ],
261
+ details: {},
262
+ }
263
+ },
264
+ }
265
+ }
266
+
267
+ export function registerPerspectives(registry: EntityRegistry) {
268
+ registry.define('perspectives', {
269
+ description:
270
+ 'Analyzes questions from two perspectives: optimist and critic',
271
+ state: { children: { primaryKey: 'key' } },
272
+ async handler(ctx) {
273
+ ctx.useAgent({
274
+ systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken as each worker finishes.\n3. Each wake includes finished_child.response and other_children.\n4. Once both are done, synthesize a balanced response.`,
275
+ model: 'claude-sonnet-4-6',
276
+ tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
277
+ })
278
+ await ctx.agent.run()
279
+ },
280
+ })
281
+ }
282
+ ```
283
+
284
+ Test: `pnpm electric-agents spawn /perspectives/test-3 && pnpm electric-agents send /perspectives/test-3 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-3`
285
+
286
+ ## Step 6: Shared state — a chatroom
287
+
288
+ In the perspectives analyzer, workers reported back to a parent via `runFinished`. But what if agents need to coordinate in real time — reading and writing to the same data? That's **shared state**.
289
+
290
+ Shared state is a durable stream that multiple entities can observe. One entity creates it with `ctx.mkdb()`, others connect with `ctx.observe(db(...))`. Both sides read and write the same collections.
291
+
292
+ Let's build a chatroom: a shared message log that agents post to using a `send_message` tool.
293
+
294
+ `entities/chatroom-schema.ts`:
295
+
296
+ ```typescript
297
+ import { z } from 'zod'
298
+
299
+ export const messageSchema = z.object({
300
+ key: z.string().min(1),
301
+ role: z.enum(['user', 'agent']),
302
+ sender: z.string().min(1),
303
+ senderName: z.string().min(1),
304
+ text: z.string().min(1),
305
+ timestamp: z.number(),
306
+ })
307
+
308
+ export const chatroomSchema = {
309
+ messages: {
310
+ schema: messageSchema,
311
+ type: 'shared:message',
312
+ primaryKey: 'key',
313
+ },
314
+ } as const
315
+ ```
316
+
317
+ `entities/chat-agent.ts`:
318
+
319
+ ```typescript
320
+ import { db } from '@electric-ax/agents-runtime'
321
+ import { z } from 'zod'
322
+ import { Type } from '@sinclair/typebox'
323
+ import { chatroomSchema } from './chatroom-schema'
324
+ import type {
325
+ EntityRegistry,
326
+ SharedStateHandle,
327
+ AgentTool,
328
+ } from '@electric-ax/agents-runtime'
329
+
330
+ type ChatroomState = SharedStateHandle<typeof chatroomSchema>
331
+
332
+ const chatAgentArgs = z.object({ chatroomId: z.string().min(1) })
333
+
334
+ function createSendMessageTool(
335
+ messages: ChatroomState['messages'],
336
+ senderName: string
337
+ ): AgentTool {
338
+ return {
339
+ name: 'send_message',
340
+ description: 'Post a message to the chatroom.',
341
+ parameters: Type.Object({
342
+ text: Type.String({ description: 'The message text' }),
343
+ }),
344
+ execute: async (_id, params) => {
345
+ const { text } = params as { text: string }
346
+ ;(messages as any).insert({
347
+ key: crypto.randomUUID(),
348
+ role: 'agent',
349
+ sender: senderName,
350
+ senderName,
351
+ text,
352
+ timestamp: Date.now(),
353
+ })
354
+ return {
355
+ content: [{ type: 'text' as const, text: 'Message sent.' }],
356
+ details: {},
357
+ }
358
+ },
359
+ }
360
+ }
361
+
362
+ function createWebSearchTool(): AgentTool {
363
+ return {
364
+ name: 'web_search',
365
+ description: 'Search the web for current information.',
366
+ parameters: Type.Object({
367
+ query: Type.String({ description: 'The search query' }),
368
+ }),
369
+ execute: async (_id, params) => {
370
+ const apiKey = process.env.BRAVE_SEARCH_API_KEY
371
+ if (!apiKey) {
372
+ return {
373
+ content: [
374
+ {
375
+ type: 'text' as const,
376
+ text: 'Web search unavailable: BRAVE_SEARCH_API_KEY not set.',
377
+ },
378
+ ],
379
+ details: {},
380
+ }
381
+ }
382
+ const { query } = params as { query: string }
383
+ const res = await fetch(
384
+ `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`,
385
+ { headers: { 'X-Subscription-Token': apiKey } }
386
+ )
387
+ if (!res.ok) {
388
+ return {
389
+ content: [
390
+ { type: 'text' as const, text: `Search failed: ${res.status}` },
391
+ ],
392
+ details: {},
393
+ }
394
+ }
395
+ const data = (await res.json()) as {
396
+ web?: {
397
+ results?: Array<{ title: string; url: string; description: string }>
398
+ }
399
+ }
400
+ const results = data.web?.results ?? []
401
+ const formatted = results
402
+ .map(
403
+ (r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`
404
+ )
405
+ .join('\n\n')
406
+ return {
407
+ content: [
408
+ { type: 'text' as const, text: formatted || 'No results found.' },
409
+ ],
410
+ details: { resultCount: results.length },
411
+ }
412
+ },
413
+ }
414
+ }
415
+
416
+ export function registerChatAgent(registry: EntityRegistry) {
417
+ registry.define('chat-agent', {
418
+ description: 'Chat agent that reads and writes to a shared chatroom',
419
+ creationSchema: chatAgentArgs,
420
+ async handler(ctx) {
421
+ const args = chatAgentArgs.parse(ctx.args)
422
+
423
+ // First wake: create the shared state
424
+ if (ctx.firstWake) {
425
+ ctx.mkdb(args.chatroomId, chatroomSchema)
426
+ }
427
+
428
+ // Observe shared state — wake when messages change
429
+ const chatroom = (await ctx.observe(db(args.chatroomId, chatroomSchema), {
430
+ wake: { on: 'change', collections: ['shared:message'] },
431
+ })) as unknown as ChatroomState
432
+
433
+ // On first wake, just register the wake — don't call the LLM
434
+ if (ctx.firstWake) return
435
+
436
+ ctx.useAgent({
437
+ systemPrompt:
438
+ 'You are a helpful chat agent. Use web_search to find information and send_message to reply.',
439
+ model: 'claude-sonnet-4-6',
440
+ tools: [
441
+ createSendMessageTool(chatroom.messages, ctx.entityUrl),
442
+ createWebSearchTool(),
443
+ ],
444
+ })
445
+ await ctx.agent.run()
446
+ },
447
+ })
448
+ }
449
+ ```
450
+
451
+ Add to `server.ts`:
452
+
453
+ ```typescript
454
+ import { registerChatAgent } from './entities/chat-agent'
455
+ registerChatAgent(registry)
456
+ ```
457
+
458
+ Test:
459
+
460
+ ```bash
461
+ pnpm electric-agents spawn /chat-agent/agent-1 '{"chatroomId":"room-1"}' \
462
+ && pnpm electric-agents send /chat-agent/agent-1 "Hello! What can you help me with?" \
463
+ && pnpm electric-agents observe /chat-agent/agent-1
464
+ ```
465
+
466
+ **Key concepts:**
467
+
468
+ - `ctx.mkdb(id, schema)` — creates a shared state stream (only on first wake)
469
+ - `ctx.observe(db(id, schema), { wake })` — connects to shared state and wakes on changes
470
+ - `wake: { on: 'change', collections: ['shared:message'] }` — wake when specific event types appear
471
+ - The observe + wake must run on first wake (before early return) so the wake registers
472
+ - Custom tools (`send_message`, `web_search`) — agents interact with the world through tools you define
473
+
474
+ ## Step 7: Context assembly — agents that remember
475
+
476
+ When an agent wakes, it only sees the current message in its inbox. But in a chatroom, it needs the full conversation history to respond intelligently. And if a new agent joins mid-conversation, it should catch up.
477
+
478
+ `ctx.useContext()` injects external data into the agent's context before the LLM call. You configure sources with a token budget and cache strategy:
479
+
480
+ Update the handler in `entities/chat-agent.ts` — add `useContext` between `observe` and `useAgent`:
481
+
482
+ ```typescript
483
+ // Read conversation history from shared state
484
+ const allMessages = (chatroom.messages as any).toArray as Array<{
485
+ senderName: string
486
+ text: string
487
+ }>
488
+ const history = allMessages
489
+ .map((m) => `[${m.senderName}]: ${m.text}`)
490
+ .join('\n')
491
+
492
+ // Inject as volatile context (changes every wake)
493
+ ctx.useContext({
494
+ sourceBudget: 50_000,
495
+ sources: {
496
+ conversation: {
497
+ cache: 'volatile',
498
+ content: async () =>
499
+ history ? `Conversation so far:\n${history}` : '',
500
+ },
501
+ },
502
+ })
503
+
504
+ ctx.useAgent({
505
+ ```
506
+
507
+ Test — spawn two agents in the same room to see them share context:
508
+
509
+ ```bash
510
+ pnpm electric-agents spawn /chat-agent/agent-2 '{"chatroomId":"room-1"}' \
511
+ && pnpm electric-agents send /chat-agent/agent-2 "What has been discussed so far?" \
512
+ && pnpm electric-agents observe /chat-agent/agent-2
513
+ ```
514
+
515
+ Agent 2 sees agent 1's messages because both observe the same shared state.
516
+
517
+ **Key concepts:**
518
+
519
+ - `ctx.useContext()` — injects data into the LLM context (separate from the system prompt)
520
+ - `sourceBudget` — limits total tokens so long conversations don't overflow
521
+ - `cache: 'volatile'` — content changes every wake (vs `'stable'` for static docs, `'pinned'` for always-include)
522
+ - The content function is `async` — you can fetch from any source
523
+
524
+ ## Step 8: Frontend — live queries
525
+
526
+ The agents are chatting, but we can't see it. Let's build a frontend that subscribes to the shared state and updates in real time — no polling.
527
+
528
+ The frontend uses `createAgentsClient` to connect to the agents server, then `useLiveQuery` from TanStack DB for reactive queries on durable stream collections.
529
+
530
+ `ui/index.html`:
531
+
532
+ ```html
533
+ <!DOCTYPE html>
534
+ <html lang="en">
535
+ <head>
536
+ <meta charset="UTF-8" />
537
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
538
+ <title>Chat</title>
539
+ </head>
540
+ <body>
541
+ <div id="root"></div>
542
+ <script type="module" src="./main.tsx"></script>
543
+ </body>
544
+ </html>
545
+ ```
546
+
547
+ `ui/main.tsx`:
548
+
549
+ ```tsx
550
+ import { useState, useEffect, useRef } from 'react'
551
+ import { createRoot } from 'react-dom/client'
552
+ import { createAgentsClient, db } from '@electric-ax/agents-runtime'
553
+ import { useLiveQuery } from '@tanstack/react-db'
554
+ import type { Collection } from '@tanstack/db'
555
+ import { chatroomSchema } from '../entities/chatroom-schema'
556
+
557
+ const AGENTS_URL = 'http://localhost:4437'
558
+ const ROOM_ID = 'room-1'
559
+
560
+ function Chat() {
561
+ const [collection, setCollection] = useState<Collection<any> | null>(null)
562
+ const [error, setError] = useState<string | null>(null)
563
+ const bottomRef = useRef<HTMLDivElement>(null)
564
+
565
+ useEffect(() => {
566
+ const client = createAgentsClient({ baseUrl: AGENTS_URL })
567
+ client
568
+ .observe(db(ROOM_ID, chatroomSchema))
569
+ .then((sdb: any) => setCollection(sdb.collections.messages))
570
+ .catch((e: Error) => setError(e.message))
571
+ }, [])
572
+
573
+ const { data: messages = [] } = useLiveQuery(
574
+ collection
575
+ ? (q) =>
576
+ q
577
+ .from({ m: collection })
578
+ .orderBy(({ m }) => (m as any).timestamp, 'asc')
579
+ .select(({ m }) => m)
580
+ : () => null,
581
+ [collection]
582
+ )
583
+
584
+ useEffect(() => {
585
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
586
+ }, [messages.length])
587
+
588
+ if (error) return <div>Error: {error}</div>
589
+ if (!collection) return <div>Connecting...</div>
590
+
591
+ return (
592
+ <div
593
+ style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'system-ui' }}
594
+ >
595
+ <h1>Chatroom: {ROOM_ID}</h1>
596
+ <div
597
+ style={{
598
+ border: '1px solid #ddd',
599
+ borderRadius: 8,
600
+ padding: 16,
601
+ minHeight: 300,
602
+ }}
603
+ >
604
+ {messages.map((m: any) => (
605
+ <div key={m.key} style={{ marginBottom: 8 }}>
606
+ <strong>{m.senderName}:</strong> {m.text}
607
+ </div>
608
+ ))}
609
+ <div ref={bottomRef} />
610
+ </div>
611
+ </div>
612
+ )
613
+ }
614
+
615
+ createRoot(document.getElementById('root')!).render(<Chat />)
616
+ ```
617
+
618
+ Add dependencies: `pnpm add @tanstack/db @tanstack/react-db react react-dom` and dev dependencies: `pnpm add -D @types/react @types/react-dom @vitejs/plugin-react vite`
619
+
620
+ Add a Vite config (`vite.config.ts`):
621
+
622
+ ```typescript
623
+ import path from 'node:path'
624
+ import { defineConfig } from 'vite'
625
+ import react from '@vitejs/plugin-react'
626
+
627
+ export default defineConfig({
628
+ root: 'ui',
629
+ plugins: [react()],
630
+ resolve: {
631
+ alias: {
632
+ '@tanstack/db': path.resolve(
633
+ import.meta.dirname,
634
+ 'node_modules/@tanstack/db'
635
+ ),
636
+ },
637
+ },
638
+ server: { port: 5175 },
639
+ build: { outDir: '../dist', emptyOutDir: true },
640
+ })
641
+ ```
642
+
643
+ Run: `npx vite` (in a new terminal). Open `http://localhost:5175`.
644
+
645
+ Then send a message to an agent in the room:
646
+
647
+ ```bash
648
+ pnpm electric-agents send /chat-agent/agent-1 "What's the weather like today?"
649
+ ```
650
+
651
+ Watch the frontend update in real time as the agent responds.
652
+
653
+ **Key concepts:**
654
+
655
+ - `createAgentsClient({ baseUrl })` — connects the frontend to the agents server
656
+ - `client.observe(db(roomId, schema))` — subscribes to a shared state stream (SSE)
657
+ - `useLiveQuery` — reactive query that re-renders when the collection changes
658
+ - `.select(({ m }) => m)` — flattens the query result (removes the alias wrapper)
659
+ - No polling — the durable stream pushes updates to the browser via SSE
660
+
661
+ ## What you learned
662
+
663
+ | Step | Concept | API |
664
+ | ---- | ----------------------- | --------------------------------------------------------------- |
665
+ | 1 | Entity types & handlers | `registry.define()`, `ctx.useAgent()`, `ctx.agent.run()` |
666
+ | 2 | Spawning children | `ctx.spawn()`, `wake: 'runFinished'` |
667
+ | 3 | State collections | `state: { children: { primaryKey: 'key' } }` |
668
+ | 6 | Shared state | `ctx.mkdb()`, `ctx.observe(db(...))`, cross-entity coordination |
669
+ | 7 | Context assembly | `ctx.useContext()`, `sourceBudget`, cache tiers |
670
+ | 8 | Live frontend | `createAgentsClient`, `useLiveQuery`, `.select()` |
671
+
672
+ For a complete multi-agent chat app with rooms, agent spawning, and a Slack-style UI, see the [agents-chat-starter](https://github.com/electric-sql/electric/tree/main/examples/agents-chat-starter) example.