@electric-ax/agents 0.2.1 → 0.2.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.
Files changed (49) hide show
  1. package/dist/entrypoint.js +5 -3
  2. package/dist/index.cjs +5 -3
  3. package/dist/index.js +5 -3
  4. package/docs/entities/agents/horton.md +89 -0
  5. package/docs/entities/agents/worker.md +102 -0
  6. package/docs/entities/patterns/blackboard.md +111 -0
  7. package/docs/entities/patterns/dispatcher.md +77 -0
  8. package/docs/entities/patterns/manager-worker.md +127 -0
  9. package/docs/entities/patterns/map-reduce.md +81 -0
  10. package/docs/entities/patterns/pipeline.md +101 -0
  11. package/docs/entities/patterns/reactive-observers.md +125 -0
  12. package/docs/examples/mega-draw.md +106 -0
  13. package/docs/examples/playground.md +46 -0
  14. package/docs/index.md +208 -0
  15. package/docs/quickstart.md +201 -0
  16. package/docs/reference/agent-config.md +82 -0
  17. package/docs/reference/agent-tool.md +58 -0
  18. package/docs/reference/built-in-collections.md +334 -0
  19. package/docs/reference/cli.md +238 -0
  20. package/docs/reference/entity-definition.md +57 -0
  21. package/docs/reference/entity-handle.md +63 -0
  22. package/docs/reference/entity-registry.md +73 -0
  23. package/docs/reference/handler-context.md +108 -0
  24. package/docs/reference/runtime-handler.md +136 -0
  25. package/docs/reference/shared-state-handle.md +74 -0
  26. package/docs/reference/state-collection-proxy.md +41 -0
  27. package/docs/reference/wake-event.md +132 -0
  28. package/docs/usage/app-setup.md +165 -0
  29. package/docs/usage/clients-and-react.md +191 -0
  30. package/docs/usage/configuring-the-agent.md +136 -0
  31. package/docs/usage/context-composition.md +204 -0
  32. package/docs/usage/defining-entities.md +181 -0
  33. package/docs/usage/defining-tools.md +229 -0
  34. package/docs/usage/embedded-builtins.md +180 -0
  35. package/docs/usage/managing-state.md +93 -0
  36. package/docs/usage/overview.md +284 -0
  37. package/docs/usage/programmatic-runtime-client.md +216 -0
  38. package/docs/usage/shared-state.md +169 -0
  39. package/docs/usage/spawning-and-coordinating.md +165 -0
  40. package/docs/usage/testing.md +76 -0
  41. package/docs/usage/waking-entities.md +148 -0
  42. package/docs/usage/writing-handlers.md +267 -0
  43. package/package.json +2 -1
  44. package/skills/quickstart/scaffold/package.json +16 -3
  45. package/skills/quickstart/scaffold/tsconfig.json +8 -3
  46. package/skills/quickstart/scaffold/vite.config.ts +21 -0
  47. package/skills/quickstart/scaffold-ui/index.html +12 -0
  48. package/skills/quickstart/scaffold-ui/main.tsx +235 -0
  49. package/skills/quickstart.md +244 -334
@@ -76,35 +76,47 @@ Once the directory is confirmed, read `server.ts` in that directory:
76
76
 
77
77
  - **Has `registerPerspectives`**: resume from where they left off (read `entities/perspectives.ts` to determine the step)
78
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.
79
+ - **No `server.ts`**: scaffold the project — spawn a worker (`tools: ["bash"]`, systemPrompt: `"Set up an Electric Agents app project."`, initialMessage: `"cp -r SKILL_DIR/scaffold/. TARGET/ && cd TARGET && npm install && npm run dev &"` — replace SKILL_DIR and TARGET). While the worker runs, proceed to Step 1 (explain concepts + show code). Wait for the worker to finish AND for user confirmation before writing any files.
80
+
81
+ When the scaffold finishes, explain to the user:
82
+
83
+ - The scaffold created a basic Electric Agents app with a `server.ts` that registers entity types, connects to the agent server, and listens for webhook callbacks on port 3000.
84
+ - `npm run dev` started the server in the background with `tsx --watch`, so any file changes will auto-reload.
85
+ - The `entities/` directory is where we'll add our agent code — it's empty for now.
86
+ - The server is already connected to the Electric Agents coordinator (on port 4437) and ready to handle entities once we define them.
87
+ - `server.ts` is a plain Node.js HTTP server — you can add your own routes, middleware, or serve a frontend from it. We'll do exactly that in later steps.
80
88
 
81
89
  ## Steps
82
90
 
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.
91
+ IMPORTANT: Never write files until the user explicitly confirms. "Ask to write" means show the code, then wait for the user to say yes. Do not write files automatically after scaffolding completes or after showing code.
84
92
 
85
- **Step 2After 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.
93
+ **Step 1Welcome + first entity.** In one message: introduce Electric Agents using the Core Concepts above, preview the perspectives analyzer, and show the Step 1 code. Ask the user if they want you to write it. Do NOT write until they confirm.
86
94
 
87
- **Step 3 — After confirmation:** write the updated file. Give CLI commands. Explain coordination, show Step 3 code (adds critic + state). Ask to write.
95
+ **Step 2 — After confirmation:** write `entities/perspectives.ts` with Step 1 code. Also update `server.ts` to import and register the entity (replace the placeholder comments with `import { registerPerspectives } from './entities/perspectives'` and `registerPerspectives(registry)`). Give CLI commands. Explain spawning briefly, show Step 2 code (adds one worker). Ask to write.
88
96
 
89
- **Step 4 — After confirmation:** write the updated file. Give CLI commands.
97
+ **Step 3 — After confirmation:** write the updated file. Give CLI commands. Explain coordination, show Step 3 code (adds critic + state). Ask to write. After confirmation, write the file and encourage the user to try the CLI commands.
90
98
 
91
- **Step 5 Wire up.** Read `server.ts`, show the import change, ask to write, update it.
99
+ **Checkpoint.** After Step 3 is confirmed and working, congratulate the user: they have a working multi-agent system with a manager that spawns workers and synthesizes results. Recap what they've built so far (entity, spawning, state coordination). Then present options for what to do next:
92
100
 
93
- **Step 6After 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.
101
+ - **Continue building**add an HTTP API route and a React frontend to this app so users can interact with the analyzer from the browser.
102
+ - **Start a new app** — use the `agents-chat-starter` template for a full multi-agent chat app with rooms, agent spawning, and a Slack-style UI. Load the init skill or tell them to type `/init`.
103
+ - **Explore the docs** — read about other coordination patterns (blackboard, pipeline, map-reduce), dive into the API reference, or learn about shared state, context assembly, and other advanced features. Use `search_durable_agents_docs` to look things up.
94
104
 
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.
105
+ Wait for the user to choose. Only proceed to Step 4 if they want to continue building.
96
106
 
97
- **Step 8After 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.
107
+ **Step 4API route.** Show the updated server.ts with a `POST /api/analyze` route using `createRuntimeServerClient`. Explain what's new in the code. Ask to write do NOT write until they confirm. After writing, give curl test commands.
98
108
 
99
- **Step 9Recap.**
109
+ **Step 5After confirmation:** scaffold the UI. Spawn a worker (`tools: ["bash"]`, systemPrompt: `"Copy UI scaffold files into the project."`, initialMessage: `"cp -r SKILL_DIR/scaffold-ui/. TARGET/ui/"` — replace SKILL_DIR and TARGET). While the worker copies, explain the frontend architecture: `createAgentsClient` connects to the agent server, `entity(url)` subscribes to an entity's stream, `useChat` reactively assembles text from deltas, and Radix Themes provides the UI components. Walk through the key parts of `ui/main.tsx` — the `useEntityDb` hook (with retry for workers that are spawned asynchronously), `useAgentMessages`, `MessageBubble` (color-coded by agent: blue for analyser, green for optimist, red for critic), and the `App` component's flow (POST to `/api/analyze` → subscribe to all three entity streams → messages appear inline as chat bubbles). After the worker finishes, tell the user to run `npm run dev:all` to start both the server and UI, then open `http://localhost:5175`.
110
+
111
+ **Step 6 — Recap.**
100
112
 
101
113
  ## Rules
102
114
 
103
- - Use the exact code below. Write files with your write tool.
115
+ - Show only the key changes in each step, not the full file. Explain what's new and why, then use the write/edit tool to apply the changes. The code below is a reference — do not dump the entire file on the user.
104
116
  - `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.
117
+ - Worker spawn args MUST include `tools` array (at least one tool, e.g. `tools: ["bash"]`).
107
118
  - Use `edit` tool for small changes (like updating server.ts). Use `write` for full entity file updates.
119
+ - If the user asks a question about Electric Agents concepts, APIs, or patterns between steps, use the `search_durable_agents_docs` tool to look up the answer in the built-in documentation before guessing or searching the web.
108
120
 
109
121
  ---
110
122
 
@@ -219,12 +231,12 @@ const PERSPECTIVES = [
219
231
  {
220
232
  id: 'optimist',
221
233
  systemPrompt:
222
- 'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
234
+ 'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits. Answer directly — do not comment on tools or capabilities.',
223
235
  },
224
236
  {
225
237
  id: 'critic',
226
238
  systemPrompt:
227
- 'You are a critical analyst. Provide a sharp analysis focusing on risks, downsides, and challenges.',
239
+ 'You are a critical analyst. Provide a sharp analysis focusing on risks, downsides, and challenges. Answer directly — do not comment on tools or capabilities.',
228
240
  },
229
241
  ]
230
242
 
@@ -244,7 +256,7 @@ function createAnalyzeTool(ctx: HandlerContext) {
244
256
  await ctx.spawn(
245
257
  'worker',
246
258
  childId,
247
- { systemPrompt: p.systemPrompt, tools: ['bash', 'read'] },
259
+ { systemPrompt: p.systemPrompt, tools: ['bash'] },
248
260
  { initialMessage: question, wake: 'runFinished' }
249
261
  )
250
262
  ctx.db.actions.children_insert({
@@ -283,390 +295,288 @@ export function registerPerspectives(registry: EntityRegistry) {
283
295
 
284
296
  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
297
 
286
- ## Step 6: Shared state — a chatroom
298
+ ## Step 4: Server routes
287
299
 
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**.
300
+ You now have a working multi-agent system: a manager entity that spawns optimist and critic workers, coordinates their responses, and synthesizes the analysis. Everything works from the CLI.
289
301
 
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.
302
+ Next, we'll turn this into a real app. The server.ts you've been running is a plain Node.js HTTP server we can add routes to it, serve a frontend, and let users interact with the analyzer from a browser. First step: an API route.
291
303
 
292
- Let's build a chatroom: a shared message log that agents post to using a `send_message` tool.
304
+ `createRuntimeServerClient()` gives you a programmatic client for spawning entities and sending messages from your server code — the same operations you've been doing with the CLI.
293
305
 
294
- `entities/chatroom-schema.ts`:
306
+ Update `server.ts` — add the runtime server client and an `/api/analyze` route:
295
307
 
296
308
  ```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
- })
309
+ import http from 'node:http'
310
+ import path from 'node:path'
311
+ import { fileURLToPath } from 'node:url'
312
+ import {
313
+ createEntityRegistry,
314
+ createRuntimeHandler,
315
+ createRuntimeServerClient,
316
+ } from '@electric-ax/agents-runtime'
317
+ import { createElectricTools } from './lib/electric-tools'
318
+ import { registerPerspectives } from './entities/perspectives'
307
319
 
308
- export const chatroomSchema = {
309
- messages: {
310
- schema: messageSchema,
311
- type: 'shared:message',
312
- primaryKey: 'key',
313
- },
314
- } as const
315
- ```
320
+ try {
321
+ const here = path.dirname(fileURLToPath(import.meta.url))
322
+ process.loadEnvFile(path.resolve(here, '.env'))
323
+ } catch {}
316
324
 
317
- `entities/chat-agent.ts`:
325
+ if (!process.env.ANTHROPIC_API_KEY) {
326
+ console.warn(
327
+ '[app] ANTHROPIC_API_KEY is not set — agent.run() will throw on the first wake.'
328
+ )
329
+ }
318
330
 
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'
331
+ const ELECTRIC_AGENTS_URL =
332
+ process.env.ELECTRIC_AGENTS_URL ?? 'http://localhost:4437'
333
+ const PORT = Number(process.env.PORT ?? 3000)
334
+ const SERVE_URL = process.env.SERVE_URL ?? `http://localhost:${PORT}`
329
335
 
330
- type ChatroomState = SharedStateHandle<typeof chatroomSchema>
336
+ const registry = createEntityRegistry()
337
+ registerPerspectives(registry)
331
338
 
332
- const chatAgentArgs = z.object({ chatroomId: z.string().min(1) })
339
+ const runtime = createRuntimeHandler({
340
+ baseUrl: ELECTRIC_AGENTS_URL,
341
+ serveEndpoint: `${SERVE_URL}/webhook`,
342
+ registry,
343
+ createElectricTools,
344
+ })
333
345
 
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
- }
346
+ const client = createRuntimeServerClient({ baseUrl: ELECTRIC_AGENTS_URL })
361
347
 
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
- }
348
+ async function readJson(req: http.IncomingMessage): Promise<unknown> {
349
+ const chunks: Array<Buffer> = []
350
+ for await (const chunk of req) chunks.push(chunk as Buffer)
351
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
414
352
  }
415
353
 
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)
354
+ const server = http.createServer(async (req, res) => {
355
+ if (req.url === '/webhook' && req.method === 'POST') {
356
+ await runtime.onEnter(req, res)
357
+ return
358
+ }
422
359
 
423
- // First wake: create the shared state
424
- if (ctx.firstWake) {
425
- ctx.mkdb(args.chatroomId, chatroomSchema)
360
+ if (req.url === '/api/analyze' && req.method === 'POST') {
361
+ try {
362
+ const body = (await readJson(req)) as { question?: string }
363
+ if (!body.question) {
364
+ res.writeHead(400, { 'content-type': 'application/json' })
365
+ res.end(JSON.stringify({ error: 'Missing "question" field' }))
366
+ return
426
367
  }
427
368
 
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
369
+ const id = `analysis-${crypto.randomUUID().slice(0, 8)}`
435
370
 
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
- ],
371
+ await client.spawnEntity({
372
+ type: 'perspectives',
373
+ id,
374
+ initialMessage: body.question,
444
375
  })
445
- await ctx.agent.run()
446
- },
447
- })
448
- }
449
- ```
450
376
 
451
- Add to `server.ts`:
377
+ res.writeHead(200, { 'content-type': 'application/json' })
378
+ res.end(
379
+ JSON.stringify({
380
+ entityUrl: `/perspectives/${id}`,
381
+ optimistUrl: `/worker/${id}-optimist`,
382
+ criticUrl: `/worker/${id}-critic`,
383
+ })
384
+ )
385
+ } catch (err) {
386
+ res.writeHead(500, { 'content-type': 'application/json' })
387
+ res.end(
388
+ JSON.stringify({
389
+ error: err instanceof Error ? err.message : String(err),
390
+ })
391
+ )
392
+ }
393
+ return
394
+ }
452
395
 
453
- ```typescript
454
- import { registerChatAgent } from './entities/chat-agent'
455
- registerChatAgent(registry)
396
+ res.writeHead(404)
397
+ res.end()
398
+ })
399
+
400
+ server.listen(PORT, async () => {
401
+ await runtime.registerTypes()
402
+ console.log(`App server ready on port ${PORT}`)
403
+ })
456
404
  ```
457
405
 
458
- Test:
406
+ Test with curl:
459
407
 
460
408
  ```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
409
+ curl -X POST http://localhost:3000/api/analyze \
410
+ -H "Content-Type: application/json" \
411
+ -d '{"question":"Is remote work better than office work?"}'
464
412
  ```
465
413
 
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.
414
+ Then observe the spawned entity:
477
415
 
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`:
416
+ ```bash
417
+ pnpm electric-agents observe /perspectives/analysis-<id>
418
+ ```
481
419
 
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
- })
420
+ **Key concepts:**
503
421
 
504
- ctx.useAgent({
505
- ```
422
+ - `createRuntimeServerClient({ baseUrl })` — programmatic client for the agent server
423
+ - `client.spawnEntity({ type, id, initialMessage })` — spawns an entity and sends a message in one call
424
+ - Entity URLs are addressable — child worker URLs are deterministic (`<id>-optimist`, `<id>-critic`)
506
425
 
507
- Test spawn two agents in the same room to see them share context:
426
+ ## Step 5: Frontend live results
508
427
 
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
- ```
428
+ The UI files are pre-built in the scaffold. Copy them into the project, then read and explain them.
514
429
 
515
- Agent 2 sees agent 1's messages because both observe the same shared state.
430
+ The scaffold includes:
516
431
 
517
- **Key concepts:**
432
+ - `ui/index.html` — HTML shell
433
+ - `ui/main.tsx` — React app with Radix Themes, `createAgentsClient`, `useChat` hook
434
+ - `vite.config.ts` — Vite dev server with proxy to the app server
518
435
 
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
436
+ The `vite.config.ts` was already included in the initial scaffold. The `ui/` files are copied from `SKILL_DIR/scaffold-ui/`.
523
437
 
524
- ## Step 8: Frontend live queries
438
+ After copying, read `ui/main.tsx` with the user and walk through the key parts with code snippets:
525
439
 
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.
440
+ ### Connecting to an entity stream
527
441
 
528
- The frontend uses `createAgentsClient` to connect to the agents server, then `useLiveQuery` from TanStack DB for reactive queries on durable stream collections.
442
+ The `useEntityDb` hook subscribes to a single entity's durable stream. This is the bridge between the agent server and React:
529
443
 
530
- `ui/index.html`:
444
+ ```tsx
445
+ function useEntityDb(url: string | null, retryMs = 0) {
446
+ const [db, setDb] = useState<EntityStreamDB | null>(null)
531
447
 
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>
448
+ useEffect(() => {
449
+ if (!url) {
450
+ setDb(null)
451
+ return
452
+ }
453
+ let cancelled = false
454
+ const connect = () => {
455
+ const client = createAgentsClient({ baseUrl: AGENTS_URL })
456
+ client.observe(entity(url)).then(
457
+ (observed) => {
458
+ if (!cancelled) setDb(observed as EntityStreamDB)
459
+ },
460
+ () => {
461
+ if (!cancelled && retryMs > 0) setTimeout(connect, retryMs)
462
+ }
463
+ )
464
+ }
465
+ connect()
466
+ return () => {
467
+ cancelled = true
468
+ }
469
+ }, [url, retryMs])
470
+
471
+ return db
472
+ }
545
473
  ```
546
474
 
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'
475
+ `createAgentsClient` connects to the agent server. `entity(url)` tells it which entity to observe. The connection is via SSE — updates arrive in real time, no polling. `retryMs` handles workers that don't exist yet (the manager spawns them asynchronously).
556
476
 
557
- const AGENTS_URL = 'http://localhost:4437'
558
- const ROOM_ID = 'room-1'
477
+ ### Extracting messages with useChat
559
478
 
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)
479
+ The `useAgentMessages` hook takes an entity stream and extracts text messages using `useChat`:
564
480
 
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]
481
+ ```tsx
482
+ function useAgentMessages(
483
+ url: string | null,
484
+ agent: string,
485
+ retryMs = 0
486
+ ): AgentMessage[] {
487
+ const db = useEntityDb(url, retryMs)
488
+ const chat = useChat(db)
489
+
490
+ return chat.runs.flatMap((r, ri) =>
491
+ r.texts
492
+ .filter((t) => t.text.trim().length > 0)
493
+ .map((t, ti) => ({
494
+ agent,
495
+ text: t.text,
496
+ isStreaming:
497
+ chat.state === 'working' &&
498
+ ri === chat.runs.length - 1 &&
499
+ ti === r.texts.length - 1,
500
+ }))
582
501
  )
502
+ }
503
+ ```
583
504
 
584
- useEffect(() => {
585
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
586
- }, [messages.length])
505
+ `useChat(db)` is the key hook — it reactively assembles text from the entity's `textDeltas` collection (token-by-token streaming) into complete text blocks. `chat.runs` contains each agent run with its `texts`. `chat.state` tells you if the agent is `'working'` (still generating) or `'idle'` (done).
506
+
507
+ ### Rendering messages
587
508
 
588
- if (error) return <div>Error: {error}</div>
589
- if (!collection) return <div>Connecting...</div>
509
+ Each message is a color-coded card with markdown rendering via `Streamdown`:
590
510
 
511
+ ```tsx
512
+ function MessageBubble({ msg }: { msg: AgentMessage }) {
513
+ const colors = AGENT_COLORS[msg.agent]
591
514
  return (
592
- <div
593
- style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'system-ui' }}
515
+ <Card
516
+ size="1"
517
+ style={{
518
+ background: colors.bg,
519
+ borderLeft: `3px solid ${colors.border}`,
520
+ }}
594
521
  >
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>
522
+ <Text size="1" weight="bold" style={{ textTransform: 'capitalize' }}>
523
+ {msg.agent}
524
+ </Text>
525
+ <Box mt="1" style={{ fontSize: 'var(--font-size-2)' }}>
526
+ <Streamdown isAnimating={msg.isStreaming} controls={false}>
527
+ {msg.text}
528
+ </Streamdown>
529
+ </Box>
530
+ </Card>
612
531
  )
613
532
  }
614
-
615
- createRoot(document.getElementById('root')!).render(<Chat />)
616
533
  ```
617
534
 
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
- ```
535
+ Blue for the analyser, green for the optimist, red for the critic. `Streamdown` renders markdown and shows a streaming cursor while `isAnimating` is true.
642
536
 
643
- Run: `npx vite` (in a new terminal). Open `http://localhost:5175`.
537
+ ### Wiring it together
644
538
 
645
- Then send a message to an agent in the room:
539
+ The `App` component subscribes to all three entities and renders messages inline:
646
540
 
647
- ```bash
648
- pnpm electric-agents send /chat-agent/agent-1 "What's the weather like today?"
541
+ ```tsx
542
+ const analyserMessages = useAgentMessages(urls?.entityUrl ?? null, 'analyser')
543
+ const optimistMessages = useAgentMessages(
544
+ urls?.optimistUrl ?? null,
545
+ 'optimist',
546
+ 2000
547
+ )
548
+ const criticMessages = useAgentMessages(urls?.criticUrl ?? null, 'critic', 2000)
549
+
550
+ const allMessages = [
551
+ ...analyserMessages,
552
+ ...optimistMessages,
553
+ ...criticMessages,
554
+ ]
649
555
  ```
650
556
 
651
- Watch the frontend update in real time as the agent responds.
557
+ Workers get `retryMs=2000` because they're spawned asynchronously — the manager calls `analyze_question`, which spawns them, but they may not exist yet when the UI first tries to connect.
558
+
559
+ After explaining, tell the user to restart with `npm run dev:all` (starts both server and Vite). Open `http://localhost:5175`.
652
560
 
653
561
  **Key concepts:**
654
562
 
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
563
+ - `createAgentsClient({ baseUrl })` — connects the frontend to the agent server
564
+ - `client.observe(entity(url))` — subscribes to an entity's durable stream via SSE
565
+ - `useChat(db)` — reactive hook that assembles text from `textDeltas`, tracks agent state
566
+ - `chat.state` `'working'` means the agent is actively generating text
567
+ - `Streamdown`renders markdown with streaming cursor support
568
+ - No polling — the durable stream pushes updates to the browser
660
569
 
661
570
  ## What you learned
662
571
 
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()` |
572
+ | Step | Concept | API |
573
+ | ---- | ----------------------- | ----------------------------------------------------------- |
574
+ | 1 | Entity types & handlers | `registry.define()`, `ctx.useAgent()`, `ctx.agent.run()` |
575
+ | 2 | Spawning children | `ctx.spawn()`, `wake: 'runFinished'` |
576
+ | 3 | State collections | `state: { children: { primaryKey: 'key' } }` |
577
+ | 4 | Server routes | `createRuntimeServerClient()`, `client.spawnEntity()` |
578
+ | 5 | Live frontend | `createAgentsClient`, `entity()`, `useChat`, streaming text |
671
579
 
672
580
  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.
581
+
582
+ Want to keep going? Just ask me anything — I can search the Electric Agents docs for coordination patterns (pipeline, map-reduce, blackboard), API reference, shared state, context assembly, and more.