@electric-ax/agents 0.2.1 → 0.2.3

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 +3 -2
  44. package/skills/quickstart/scaffold/package.json +17 -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 +440 -0
  49. package/skills/quickstart.md +209 -344
@@ -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
 
@@ -140,7 +152,7 @@ import { registerPerspectives } from './entities/perspectives'
140
152
  registerPerspectives(registry)
141
153
  ```
142
154
 
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`
155
+ Test: `npx electric-ax agents spawn /perspectives/test-1 && npx electric-ax agents send /perspectives/test-1 "Is remote work better than office work?" && npx electric-ax agents observe /perspectives/test-1`
144
156
 
145
157
  ## Step 2: One worker
146
158
 
@@ -202,7 +214,7 @@ export function registerPerspectives(registry: EntityRegistry) {
202
214
  }
203
215
  ```
204
216
 
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`
217
+ Test: `npx electric-ax agents spawn /perspectives/test-2 && npx electric-ax agents send /perspectives/test-2 "Is remote work better than office work?" && npx electric-ax agents observe /perspectives/test-2`
206
218
 
207
219
  ## Step 3: Two workers + state
208
220
 
@@ -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({
@@ -271,7 +283,7 @@ export function registerPerspectives(registry: EntityRegistry) {
271
283
  state: { children: { primaryKey: 'key' } },
272
284
  async handler(ctx) {
273
285
  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.`,
286
+ systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. Tell the user you are spawning an optimist and a critic to analyze their question and that you will synthesize their perspectives once they finish.\n3. End your turn immediately — say nothing else.\n\nYou will be woken once per worker that finishes. Each wake includes finished_child and other_children (with status "running" or "finished").\n\nCRITICAL: When woken, check other_children. If ANY child still has status "running", you MUST respond with ONLY the exact text "waiting" and nothing else — no commentary, no status updates, no emoji. Just the single word "waiting".\n\nOnly when ALL children show status "finished" should you synthesize a balanced response using both perspectives.`,
275
287
  model: 'claude-sonnet-4-6',
276
288
  tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
277
289
  })
@@ -281,392 +293,245 @@ export function registerPerspectives(registry: EntityRegistry) {
281
293
  }
282
294
  ```
283
295
 
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
296
+ Test: `npx electric-ax agents spawn /perspectives/test-3 && npx electric-ax agents send /perspectives/test-3 "Is remote work better than office work?" && npx electric-ax agents observe /perspectives/test-3`
287
297
 
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**.
298
+ ## Step 4: Server routes
289
299
 
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.
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.
291
301
 
292
- Let's build a chatroom: a shared message log that agents post to using a `send_message` tool.
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.
293
303
 
294
- `entities/chatroom-schema.ts`:
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.
295
305
 
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
- ```
306
+ Update `server.ts` — add the runtime server client import and an `/api/analyze` route:
316
307
 
317
- `entities/chat-agent.ts`:
308
+ Add `createRuntimeServerClient` to your imports from `@electric-ax/agents-runtime`, then create a client instance:
318
309
 
319
310
  ```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,
311
+ import {
312
+ createEntityRegistry,
313
+ createRuntimeHandler,
314
+ createRuntimeServerClient, // add this
328
315
  } from '@electric-ax/agents-runtime'
329
316
 
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
- }
317
+ // ... existing setup ...
427
318
 
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
319
+ const client = createRuntimeServerClient({ baseUrl: ELECTRIC_AGENTS_URL })
320
+ ```
432
321
 
433
- // On first wake, just register the wake don't call the LLM
434
- if (ctx.firstWake) return
322
+ Then add this route handler inside `http.createServer`, after the existing `/webhook` handler:
435
323
 
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
- ],
324
+ ```typescript
325
+ if (req.url === '/api/analyze' && req.method === 'POST') {
326
+ try {
327
+ const body = (await readJson(req)) as { question?: string }
328
+ if (!body.question) {
329
+ res.writeHead(400, { 'content-type': 'application/json' })
330
+ res.end(JSON.stringify({ error: 'Missing "question" field' }))
331
+ return
332
+ }
333
+
334
+ const id = `analysis-${crypto.randomUUID().slice(0, 8)}`
335
+
336
+ await client.spawnEntity({
337
+ type: 'perspectives',
338
+ id,
339
+ initialMessage: body.question,
340
+ })
341
+
342
+ res.writeHead(200, { 'content-type': 'application/json' })
343
+ res.end(
344
+ JSON.stringify({
345
+ entityUrl: `/perspectives/${id}`,
346
+ optimistUrl: `/worker/${id}-optimist`,
347
+ criticUrl: `/worker/${id}-critic`,
444
348
  })
445
- await ctx.agent.run()
446
- },
447
- })
349
+ )
350
+ } catch (err) {
351
+ res.writeHead(500, { 'content-type': 'application/json' })
352
+ res.end(
353
+ JSON.stringify({
354
+ error: err instanceof Error ? err.message : String(err),
355
+ })
356
+ )
357
+ }
358
+ return
448
359
  }
449
360
  ```
450
361
 
451
- Add to `server.ts`:
362
+ Test with curl:
452
363
 
453
- ```typescript
454
- import { registerChatAgent } from './entities/chat-agent'
455
- registerChatAgent(registry)
364
+ ```bash
365
+ curl -X POST http://localhost:3000/api/analyze \
366
+ -H "Content-Type: application/json" \
367
+ -d '{"question":"Is remote work better than office work?"}'
456
368
  ```
457
369
 
458
- Test:
370
+ Then observe the spawned entity:
459
371
 
460
372
  ```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
373
+ npx electric-ax agents observe /perspectives/analysis-<id>
464
374
  ```
465
375
 
466
376
  **Key concepts:**
467
377
 
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
378
+ - `createRuntimeServerClient({ baseUrl })` — programmatic client for the agent server
379
+ - `client.spawnEntity({ type, id, initialMessage })` — spawns an entity and sends a message in one call
380
+ - Entity URLs are addressable child worker URLs are deterministic (`<id>-optimist`, `<id>-critic`)
473
381
 
474
- ## Step 7: Context assembly agents that remember
382
+ ## Step 5: Frontendlive results
475
383
 
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.
384
+ The UI files are pre-built in the scaffold. Copy them into the project, then read and explain them.
477
385
 
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:
386
+ The scaffold includes:
479
387
 
480
- Update the handler in `entities/chat-agent.ts` — add `useContext` between `observe` and `useAgent`:
388
+ - `ui/index.html` — HTML shell
389
+ - `ui/main.tsx` — React app with Radix Themes, `createAgentsClient`, `useChat` hook
390
+ - `vite.config.ts` — Vite dev server with proxy to the app server
481
391
 
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
- })
392
+ The `vite.config.ts` was already included in the initial scaffold. The `ui/` files are copied from `SKILL_DIR/scaffold-ui/`.
503
393
 
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.
394
+ After copying, read `ui/main.tsx` with the user and walk through the key parts with code snippets:
516
395
 
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
396
+ ### Connecting to an entity stream
525
397
 
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.
398
+ The `useEntityDb` hook subscribes to a single entity's durable stream. This is the bridge between the agent server and React:
527
399
 
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`:
400
+ ```tsx
401
+ function useEntityDb(url: string | null, retryMs = 0) {
402
+ const [db, setDb] = useState<EntityStreamDB | null>(null)
531
403
 
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>
404
+ useEffect(() => {
405
+ if (!url) {
406
+ setDb(null)
407
+ return
408
+ }
409
+ let cancelled = false
410
+ const connect = () => {
411
+ const client = createAgentsClient({ baseUrl: AGENTS_URL })
412
+ client.observe(entity(url)).then(
413
+ (observed) => {
414
+ if (!cancelled) setDb(observed as EntityStreamDB)
415
+ },
416
+ () => {
417
+ if (!cancelled && retryMs > 0) setTimeout(connect, retryMs)
418
+ }
419
+ )
420
+ }
421
+ connect()
422
+ return () => {
423
+ cancelled = true
424
+ }
425
+ }, [url, retryMs])
426
+
427
+ return db
428
+ }
545
429
  ```
546
430
 
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'
431
+ `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
432
 
557
- const AGENTS_URL = 'http://localhost:4437'
558
- const ROOM_ID = 'room-1'
433
+ ### Extracting messages with useChat
559
434
 
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)
435
+ The `useAgentMessages` hook takes an entity stream and extracts text messages using `useChat`:
564
436
 
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]
437
+ ```tsx
438
+ function useAgentMessages(
439
+ url: string | null,
440
+ agent: string,
441
+ retryMs = 0
442
+ ): AgentMessage[] {
443
+ const db = useEntityDb(url, retryMs)
444
+ const chat = useChat(db)
445
+
446
+ return chat.runs.flatMap((r, ri) =>
447
+ r.texts
448
+ .filter((t) => t.text.trim().length > 0)
449
+ .map((t, ti) => ({
450
+ agent,
451
+ text: t.text,
452
+ isStreaming:
453
+ chat.state === 'working' &&
454
+ ri === chat.runs.length - 1 &&
455
+ ti === r.texts.length - 1,
456
+ }))
582
457
  )
458
+ }
459
+ ```
583
460
 
584
- useEffect(() => {
585
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
586
- }, [messages.length])
461
+ `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).
462
+
463
+ ### Rendering messages
587
464
 
588
- if (error) return <div>Error: {error}</div>
589
- if (!collection) return <div>Connecting...</div>
465
+ Each message is a color-coded card with markdown rendering via `Streamdown`:
590
466
 
467
+ ```tsx
468
+ function MessageBubble({ msg }: { msg: AgentMessage }) {
469
+ const colors = AGENT_COLORS[msg.agent]
591
470
  return (
592
- <div
593
- style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'system-ui' }}
471
+ <Card
472
+ size="1"
473
+ style={{
474
+ background: colors.bg,
475
+ borderLeft: `3px solid ${colors.border}`,
476
+ }}
594
477
  >
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>
478
+ <Text size="1" weight="bold" style={{ textTransform: 'capitalize' }}>
479
+ {msg.agent}
480
+ </Text>
481
+ <Box mt="1" style={{ fontSize: 'var(--font-size-2)' }}>
482
+ <Streamdown isAnimating={msg.isStreaming} controls={false}>
483
+ {msg.text}
484
+ </Streamdown>
485
+ </Box>
486
+ </Card>
612
487
  )
613
488
  }
614
-
615
- createRoot(document.getElementById('root')!).render(<Chat />)
616
489
  ```
617
490
 
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
- ```
491
+ 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
492
 
643
- Run: `npx vite` (in a new terminal). Open `http://localhost:5175`.
493
+ ### Wiring it together
644
494
 
645
- Then send a message to an agent in the room:
495
+ The `App` component subscribes to all three entities and renders them in sections — the analyser's intro at full width, then the optimist and critic side-by-side in two columns, then the analyser's synthesis at full width:
646
496
 
647
- ```bash
648
- pnpm electric-agents send /chat-agent/agent-1 "What's the weather like today?"
497
+ ```tsx
498
+ const analyserMessages = useAgentMessages(urls?.entityUrl ?? null, 'analyser')
499
+ const optimistMessages = useAgentMessages(
500
+ urls?.optimistUrl ?? null,
501
+ 'optimist',
502
+ 2000
503
+ )
504
+ const criticMessages = useAgentMessages(urls?.criticUrl ?? null, 'critic', 2000)
505
+
506
+ const hasWorkerMessages =
507
+ optimistMessages.length > 0 || criticMessages.length > 0
508
+ const analyserIntro = analyserMessages.slice(0, 1)
509
+ const analyserSynthesis = hasWorkerMessages ? analyserMessages.slice(1) : []
649
510
  ```
650
511
 
651
- Watch the frontend update in real time as the agent responds.
512
+ The layout transitions naturally: first the analyser's intro appears full-width, then two columns open up as workers start streaming, and finally the synthesis appears full-width below. Workers get `retryMs=2000` because they're spawned asynchronously.
513
+
514
+ After explaining, tell the user to restart with `npm run dev:all` (starts both server and Vite). Open `http://localhost:5175`.
652
515
 
653
516
  **Key concepts:**
654
517
 
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
518
+ - `createAgentsClient({ baseUrl })` — connects the frontend to the agent server
519
+ - `client.observe(entity(url))` — subscribes to an entity's durable stream via SSE
520
+ - `useChat(db)` — reactive hook that assembles text from `textDeltas`, tracks agent state
521
+ - `chat.state` `'working'` means the agent is actively generating text
522
+ - `Streamdown`renders markdown with streaming cursor support
523
+ - No polling — the durable stream pushes updates to the browser
660
524
 
661
525
  ## What you learned
662
526
 
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()` |
527
+ | Step | Concept | API |
528
+ | ---- | ----------------------- | ----------------------------------------------------------- |
529
+ | 1 | Entity types & handlers | `registry.define()`, `ctx.useAgent()`, `ctx.agent.run()` |
530
+ | 2 | Spawning children | `ctx.spawn()`, `wake: 'runFinished'` |
531
+ | 3 | State collections | `state: { children: { primaryKey: 'key' } }` |
532
+ | 4 | Server routes | `createRuntimeServerClient()`, `client.spawnEntity()` |
533
+ | 5 | Live frontend | `createAgentsClient`, `entity()`, `useChat`, streaming text |
671
534
 
672
535
  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.
536
+
537
+ 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.