@electric-ax/agents 0.2.2 → 0.2.4

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 +40 -12
  2. package/dist/index.cjs +40 -12
  3. package/dist/index.js +40 -12
  4. package/docs/entities/agents/coder.md +99 -0
  5. package/docs/entities/agents/horton.md +16 -13
  6. package/docs/entities/agents/worker.md +18 -18
  7. package/docs/entities/patterns/blackboard.md +6 -6
  8. package/docs/entities/patterns/dispatcher.md +1 -1
  9. package/docs/entities/patterns/manager-worker.md +1 -1
  10. package/docs/entities/patterns/map-reduce.md +1 -1
  11. package/docs/entities/patterns/pipeline.md +1 -1
  12. package/docs/entities/patterns/reactive-observers.md +2 -2
  13. package/docs/examples/playground.md +42 -26
  14. package/docs/index.md +23 -23
  15. package/docs/quickstart.md +13 -13
  16. package/docs/reference/agent-config.md +20 -12
  17. package/docs/reference/agent-tool.md +1 -1
  18. package/docs/reference/built-in-collections.md +21 -21
  19. package/docs/reference/cli.md +39 -30
  20. package/docs/reference/entity-definition.md +9 -9
  21. package/docs/reference/entity-handle.md +2 -2
  22. package/docs/reference/entity-registry.md +1 -1
  23. package/docs/reference/handler-context.md +69 -18
  24. package/docs/reference/runtime-handler.md +25 -23
  25. package/docs/reference/shared-state-handle.md +7 -7
  26. package/docs/reference/state-collection-proxy.md +1 -1
  27. package/docs/reference/wake-event.md +23 -23
  28. package/docs/usage/app-setup.md +24 -23
  29. package/docs/usage/clients-and-react.md +44 -36
  30. package/docs/usage/configuring-the-agent.md +25 -19
  31. package/docs/usage/context-composition.md +12 -12
  32. package/docs/usage/defining-entities.md +36 -36
  33. package/docs/usage/defining-tools.md +45 -45
  34. package/docs/usage/embedded-builtins.md +48 -47
  35. package/docs/usage/managing-state.md +12 -12
  36. package/docs/usage/overview.md +52 -45
  37. package/docs/usage/programmatic-runtime-client.md +50 -47
  38. package/docs/usage/shared-state.md +32 -32
  39. package/docs/usage/spawning-and-coordinating.md +9 -9
  40. package/docs/usage/testing.md +14 -14
  41. package/docs/usage/waking-entities.md +13 -13
  42. package/docs/usage/writing-handlers.md +57 -26
  43. package/package.json +5 -2
  44. package/scripts/sync-docs.mjs +42 -0
  45. package/skills/quickstart/scaffold/package.json +1 -0
  46. package/skills/quickstart/scaffold-ui/index.html +1 -1
  47. package/skills/quickstart/scaffold-ui/main.tsx +221 -16
  48. package/skills/quickstart.md +49 -94
  49. package/docs/examples/mega-draw.md +0 -106
@@ -1,4 +1,12 @@
1
- import { useState, useEffect, useRef } from 'react'
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react'
2
10
  import { createRoot } from 'react-dom/client'
3
11
  import { createAgentsClient, entity } from '@electric-ax/agents-runtime'
4
12
  import { useChat } from '@electric-ax/agents-runtime/react'
@@ -11,12 +19,132 @@ import {
11
19
  TextField,
12
20
  Button,
13
21
  Card,
22
+ IconButton,
14
23
  } from '@radix-ui/themes'
24
+ import { SunIcon, MoonIcon, DesktopIcon } from '@radix-ui/react-icons'
15
25
  import { Streamdown } from 'streamdown'
16
26
  import '@radix-ui/themes/styles.css'
17
27
  import 'streamdown/styles.css'
18
28
  import type { EntityStreamDB } from '@electric-ax/agents-runtime'
19
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Dark-mode hook (inlined – single-file scaffold)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const DARK_MODE_STORAGE_KEY = `electric-perspectives.dark-mode`
35
+
36
+ type ThemePreference = `light` | `dark` | `system`
37
+
38
+ type DarkModeContextValue = {
39
+ darkMode: boolean
40
+ preference: ThemePreference
41
+ cyclePreference: () => void
42
+ }
43
+
44
+ const DarkModeContext = createContext<DarkModeContextValue | null>(null)
45
+
46
+ function readInitialPreference(): ThemePreference {
47
+ if (typeof window === `undefined`) return `system`
48
+ const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY)
49
+ if (stored === `true` || stored === `dark`) return `dark`
50
+ if (stored === `false` || stored === `light`) return `light`
51
+ return `system`
52
+ }
53
+
54
+ function systemPrefersDark(): boolean {
55
+ if (typeof window === `undefined`) return false
56
+ return window.matchMedia?.(`(prefers-color-scheme: dark)`).matches ?? false
57
+ }
58
+
59
+ const cycleOrder: Record<ThemePreference, ThemePreference> = {
60
+ light: `dark`,
61
+ dark: `system`,
62
+ system: `light`,
63
+ }
64
+
65
+ function DarkModeProvider({
66
+ children,
67
+ }: {
68
+ children: React.ReactNode
69
+ }): React.ReactElement {
70
+ const [preference, setPreference] = useState<ThemePreference>(
71
+ readInitialPreference
72
+ )
73
+ const [systemDark, setSystemDark] = useState<boolean>(systemPrefersDark)
74
+
75
+ useEffect(() => {
76
+ if (typeof window === `undefined`) return
77
+ const mql = window.matchMedia(`(prefers-color-scheme: dark)`)
78
+ const onChange = (e: MediaQueryListEvent): void => setSystemDark(e.matches)
79
+ mql.addEventListener(`change`, onChange)
80
+ return () => mql.removeEventListener(`change`, onChange)
81
+ }, [])
82
+
83
+ const darkMode = preference === `system` ? systemDark : preference === `dark`
84
+
85
+ useEffect(() => {
86
+ document.documentElement.classList.toggle(`dark`, darkMode)
87
+ }, [darkMode])
88
+
89
+ const cyclePreference = useCallback(() => {
90
+ setPreference((current) => {
91
+ const next = cycleOrder[current]
92
+ window.localStorage.setItem(DARK_MODE_STORAGE_KEY, next)
93
+ return next
94
+ })
95
+ }, [])
96
+
97
+ const value = useMemo(
98
+ () => ({ darkMode, preference, cyclePreference }),
99
+ [darkMode, preference, cyclePreference]
100
+ )
101
+
102
+ return (
103
+ <DarkModeContext.Provider value={value}>
104
+ {children}
105
+ </DarkModeContext.Provider>
106
+ )
107
+ }
108
+
109
+ function useDarkModeContext(): DarkModeContextValue {
110
+ const value = useContext(DarkModeContext)
111
+ if (!value) {
112
+ throw new Error(`useDarkModeContext must be used inside DarkModeProvider`)
113
+ }
114
+ return value
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Theme toggle button
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function themeButtonIcon(preference: ThemePreference) {
122
+ if (preference === `light`) return <SunIcon />
123
+ if (preference === `dark`) return <MoonIcon />
124
+ return <DesktopIcon />
125
+ }
126
+
127
+ function themeButtonAriaLabel(preference: ThemePreference): string {
128
+ if (preference === `light`) return `Switch to dark mode`
129
+ if (preference === `dark`) return `Switch to system theme`
130
+ return `Switch to light mode`
131
+ }
132
+
133
+ function ThemeToggle() {
134
+ const { preference, cyclePreference } = useDarkModeContext()
135
+ return (
136
+ <IconButton
137
+ variant="ghost"
138
+ size="2"
139
+ color="gray"
140
+ onClick={cyclePreference}
141
+ aria-label={themeButtonAriaLabel(preference)}
142
+ >
143
+ {themeButtonIcon(preference)}
144
+ </IconButton>
145
+ )
146
+ }
147
+
20
148
  const AGENTS_URL = `http://localhost:4437`
21
149
 
22
150
  const AGENT_COLORS: Record<string, { bg: string; border: string }> = {
@@ -121,13 +249,29 @@ function MessageBubble({ msg }: { msg: AgentMessage }) {
121
249
  )
122
250
  }
123
251
 
252
+ function urlsFromId(id: string) {
253
+ return {
254
+ entityUrl: `/perspectives/${id}`,
255
+ optimistUrl: `/worker/${id}-optimist`,
256
+ criticUrl: `/worker/${id}-critic`,
257
+ }
258
+ }
259
+
260
+ function getIdFromHash(): string | null {
261
+ const hash = window.location.hash.slice(1)
262
+ return hash || null
263
+ }
264
+
124
265
  function App() {
125
266
  const [question, setQuestion] = useState(``)
126
267
  const [urls, setUrls] = useState<{
127
268
  entityUrl: string
128
269
  optimistUrl: string
129
270
  criticUrl: string
130
- } | null>(null)
271
+ } | null>(() => {
272
+ const id = getIdFromHash()
273
+ return id ? urlsFromId(id) : null
274
+ })
131
275
  const [loading, setLoading] = useState(false)
132
276
  const bottomRef = useRef<HTMLDivElement>(null)
133
277
 
@@ -143,11 +287,15 @@ function App() {
143
287
  2000
144
288
  )
145
289
 
146
- const allMessages = [
147
- ...analyserMessages,
148
- ...optimistMessages,
149
- ...criticMessages,
150
- ]
290
+ const hasOptimist = optimistMessages.length > 0
291
+ const hasCritic = criticMessages.length > 0
292
+ const hasWorkerMessages = hasOptimist || hasCritic
293
+ const hasBothWorkers = hasOptimist && hasCritic
294
+ // First analyser message is the intro; the rest is synthesis after workers
295
+ const analyserIntro = analyserMessages.slice(0, 1)
296
+ const analyserSynthesis = hasWorkerMessages
297
+ ? analyserMessages.slice(1).filter((m) => m.text.trim() !== `waiting`)
298
+ : []
151
299
 
152
300
  useEffect(() => {
153
301
  const el = bottomRef.current?.parentElement
@@ -177,6 +325,8 @@ function App() {
177
325
  optimistUrl: string
178
326
  criticUrl: string
179
327
  }
328
+ const id = data.entityUrl.split(`/`).pop()!
329
+ window.location.hash = id
180
330
  setUrls(data)
181
331
  } catch (err) {
182
332
  console.error(`Analyze failed:`, err)
@@ -186,12 +336,13 @@ function App() {
186
336
  }
187
337
 
188
338
  return (
189
- <Theme appearance="light" accentColor="blue" radius="medium">
339
+ <>
190
340
  <style>{`@keyframes blink { 50% { opacity: 0; } }`}</style>
191
- <Box maxWidth="700px" mx="auto" p="5">
192
- <Heading size="6" mb="1">
193
- Perspectives Analyzer
194
- </Heading>
341
+ <Box maxWidth="900px" mx="auto" p="5">
342
+ <Flex justify="between" align="center" mb="1">
343
+ <Heading size="6">Perspectives Analyzer</Heading>
344
+ <ThemeToggle />
345
+ </Flex>
195
346
  <Text size="2" color="gray" mb="5" as="p">
196
347
  Ask a question and get two perspectives — an optimist and a critic —
197
348
  then a balanced analysis.
@@ -217,19 +368,73 @@ function App() {
217
368
  </Flex>
218
369
 
219
370
  <Flex direction="column" gap="3">
220
- {urls && allMessages.length === 0 && (
371
+ {urls && analyserIntro.length === 0 && (
221
372
  <Text color="gray" size="2">
222
373
  Waiting for agents to respond...
223
374
  </Text>
224
375
  )}
225
- {allMessages.map((msg, i) => (
226
- <MessageBubble key={`${msg.agent}-${i}`} msg={msg} />
376
+
377
+ {/* Analyser intro — full width */}
378
+ {analyserIntro.map((msg, i) => (
379
+ <MessageBubble
380
+ key={`analyser-intro-${i}`}
381
+ msg={hasWorkerMessages ? { ...msg, isStreaming: false } : msg}
382
+ />
227
383
  ))}
384
+
385
+ {/* Workers — single column until both respond, then side-by-side */}
386
+ {hasWorkerMessages && !hasBothWorkers && (
387
+ <Flex direction="column" gap="3">
388
+ {optimistMessages.map((msg, i) => (
389
+ <MessageBubble key={`optimist-${i}`} msg={msg} />
390
+ ))}
391
+ {criticMessages.map((msg, i) => (
392
+ <MessageBubble key={`critic-${i}`} msg={msg} />
393
+ ))}
394
+ </Flex>
395
+ )}
396
+ {hasBothWorkers && (
397
+ <Flex gap="3">
398
+ <Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
399
+ {optimistMessages.map((msg, i) => (
400
+ <MessageBubble key={`optimist-${i}`} msg={msg} />
401
+ ))}
402
+ </Flex>
403
+ <Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
404
+ {criticMessages.map((msg, i) => (
405
+ <MessageBubble key={`critic-${i}`} msg={msg} />
406
+ ))}
407
+ </Flex>
408
+ </Flex>
409
+ )}
410
+
411
+ {/* Analyser synthesis — full width */}
412
+ {analyserSynthesis.map((msg, i) => (
413
+ <MessageBubble key={`analyser-synth-${i}`} msg={msg} />
414
+ ))}
415
+
228
416
  <div ref={bottomRef} />
229
417
  </Flex>
230
418
  </Box>
419
+ </>
420
+ )
421
+ }
422
+
423
+ function ThemedApp() {
424
+ const { darkMode } = useDarkModeContext()
425
+ return (
426
+ <Theme
427
+ appearance={darkMode ? `dark` : `light`}
428
+ accentColor="blue"
429
+ radius="medium"
430
+ >
431
+ <App />
231
432
  </Theme>
232
433
  )
233
434
  }
234
435
 
235
- createRoot(document.getElementById(`root`)!).render(<App />)
436
+ createRoot(document.getElementById(`root`)!).render(
437
+ <DarkModeProvider>
438
+ <ThemedApp />
439
+ </DarkModeProvider>
440
+ )
@@ -152,7 +152,7 @@ import { registerPerspectives } from './entities/perspectives'
152
152
  registerPerspectives(registry)
153
153
  ```
154
154
 
155
- 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`
156
156
 
157
157
  ## Step 2: One worker
158
158
 
@@ -214,7 +214,7 @@ export function registerPerspectives(registry: EntityRegistry) {
214
214
  }
215
215
  ```
216
216
 
217
- 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`
218
218
 
219
219
  ## Step 3: Two workers + state
220
220
 
@@ -283,7 +283,7 @@ export function registerPerspectives(registry: EntityRegistry) {
283
283
  state: { children: { primaryKey: 'key' } },
284
284
  async handler(ctx) {
285
285
  ctx.useAgent({
286
- 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.`,
287
287
  model: 'claude-sonnet-4-6',
288
288
  tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
289
289
  })
@@ -293,7 +293,7 @@ export function registerPerspectives(registry: EntityRegistry) {
293
293
  }
294
294
  ```
295
295
 
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`
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`
297
297
 
298
298
  ## Step 4: Server routes
299
299
 
@@ -303,104 +303,60 @@ Next, we'll turn this into a real app. The server.ts you've been running is a pl
303
303
 
304
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.
305
305
 
306
- Update `server.ts` — add the runtime server client and an `/api/analyze` route:
306
+ Update `server.ts` — add the runtime server client import and an `/api/analyze` route:
307
+
308
+ Add `createRuntimeServerClient` to your imports from `@electric-ax/agents-runtime`, then create a client instance:
307
309
 
308
310
  ```typescript
309
- import http from 'node:http'
310
- import path from 'node:path'
311
- import { fileURLToPath } from 'node:url'
312
311
  import {
313
312
  createEntityRegistry,
314
313
  createRuntimeHandler,
315
- createRuntimeServerClient,
314
+ createRuntimeServerClient, // ← add this
316
315
  } from '@electric-ax/agents-runtime'
317
- import { createElectricTools } from './lib/electric-tools'
318
- import { registerPerspectives } from './entities/perspectives'
319
-
320
- try {
321
- const here = path.dirname(fileURLToPath(import.meta.url))
322
- process.loadEnvFile(path.resolve(here, '.env'))
323
- } catch {}
324
-
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
- }
330
-
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}`
335
316
 
336
- const registry = createEntityRegistry()
337
- registerPerspectives(registry)
338
-
339
- const runtime = createRuntimeHandler({
340
- baseUrl: ELECTRIC_AGENTS_URL,
341
- serveEndpoint: `${SERVE_URL}/webhook`,
342
- registry,
343
- createElectricTools,
344
- })
317
+ // ... existing setup ...
345
318
 
346
319
  const client = createRuntimeServerClient({ baseUrl: ELECTRIC_AGENTS_URL })
320
+ ```
347
321
 
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'))
352
- }
322
+ Then add this route handler inside `http.createServer`, after the existing `/webhook` handler:
353
323
 
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
- }
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
+ }
359
333
 
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
367
- }
334
+ const id = `analysis-${crypto.randomUUID().slice(0, 8)}`
368
335
 
369
- const id = `analysis-${crypto.randomUUID().slice(0, 8)}`
336
+ await client.spawnEntity({
337
+ type: 'perspectives',
338
+ id,
339
+ initialMessage: body.question,
340
+ })
370
341
 
371
- await client.spawnEntity({
372
- type: 'perspectives',
373
- id,
374
- initialMessage: body.question,
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`,
375
348
  })
376
-
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
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
+ )
394
357
  }
395
-
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
- })
358
+ return
359
+ }
404
360
  ```
405
361
 
406
362
  Test with curl:
@@ -414,7 +370,7 @@ curl -X POST http://localhost:3000/api/analyze \
414
370
  Then observe the spawned entity:
415
371
 
416
372
  ```bash
417
- pnpm electric-agents observe /perspectives/analysis-<id>
373
+ npx electric-ax agents observe /perspectives/analysis-<id>
418
374
  ```
419
375
 
420
376
  **Key concepts:**
@@ -536,7 +492,7 @@ Blue for the analyser, green for the optimist, red for the critic. `Streamdown`
536
492
 
537
493
  ### Wiring it together
538
494
 
539
- The `App` component subscribes to all three entities and renders messages inline:
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:
540
496
 
541
497
  ```tsx
542
498
  const analyserMessages = useAgentMessages(urls?.entityUrl ?? null, 'analyser')
@@ -547,14 +503,13 @@ const optimistMessages = useAgentMessages(
547
503
  )
548
504
  const criticMessages = useAgentMessages(urls?.criticUrl ?? null, 'critic', 2000)
549
505
 
550
- const allMessages = [
551
- ...analyserMessages,
552
- ...optimistMessages,
553
- ...criticMessages,
554
- ]
506
+ const hasWorkerMessages =
507
+ optimistMessages.length > 0 || criticMessages.length > 0
508
+ const analyserIntro = analyserMessages.slice(0, 1)
509
+ const analyserSynthesis = hasWorkerMessages ? analyserMessages.slice(1) : []
555
510
  ```
556
511
 
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.
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.
558
513
 
559
514
  After explaining, tell the user to restart with `npm run dev:all` (starts both server and Vite). Open `http://localhost:5175`.
560
515
 
@@ -1,106 +0,0 @@
1
- ---
2
- title: Mega Draw
3
- titleTemplate: '... - Electric Agents'
4
- description: >-
5
- Multi-agent collaborative drawing example with coordinator-worker patterns and 100 tile agents.
6
- outline: [2, 3]
7
- ---
8
-
9
- # Mega Draw
10
-
11
- A collaborative multi-agent drawing app where 100 AI agents each own a tile of a shared 1000x1000 pixel canvas and work together to produce a drawing from a single text prompt. Located at `examples/mega-draw/` in the repository.
12
-
13
- ## What it demonstrates
14
-
15
- - **Coordinator + worker pattern** at scale (1 coordinator spawning 100 tile agents)
16
- - **Custom drawing tools** --- `fill_rect`, `draw_line`, `draw_circle`, `fill_gradient`, `set_pixels`
17
- - **Shared canvas** --- in-memory pixel buffer flushed to PNG, served via a live viewer
18
- - **Follow-up instructions** --- send a new prompt and only affected tiles get re-instructed
19
- - **Two-pass workflow** --- coordinator does a quick first pass for backgrounds, then a detail pass
20
-
21
- ## Architecture
22
-
23
- ```
24
- User
25
-
26
- │ spawn /coordinator/my-drawing
27
- │ send "Draw a sunset over mountains"
28
-
29
- ┌────────────────────────────────┐
30
- │ Coordinator Agent │
31
- │ - Receives prompt │
32
- │ - Plans composition + palette │
33
- │ - Spawns 100 tile agents │
34
- │ - Can re-instruct tiles │
35
- └──────────┬─────────────────────┘
36
- │ spawn tile-agent (10×10 grid)
37
-
38
- ┌────────┐ ┌────────┐
39
- │Tile 0,0│ │Tile 1,0│ ... (10 columns)
40
- └────────┘ └────────┘
41
- ┌────────┐ ┌────────┐
42
- │Tile 0,1│ │Tile 1,1│ ...
43
- └────────┘ └────────┘
44
- ... ... (10 rows = 100 tiles)
45
- ```
46
-
47
- Each tile agent:
48
-
49
- - Owns a 100x100 pixel region
50
- - Can **see** 50px beyond its borders (200x200 viewport) for edge coordination
51
- - Can only **draw** within its own tile
52
- - Receives drawing instructions from the coordinator
53
-
54
- ## Key files
55
-
56
- ### `src/server.ts`
57
-
58
- Entry point. Creates the registry, runtime handler, and two HTTP servers (one for the Electric Agents webhook, one for the canvas viewer).
59
-
60
- ```ts
61
- const registry = createEntityRegistry()
62
- registerCoordinator(registry, WEB_PORT)
63
- registerTileAgent(registry)
64
-
65
- const runtime = createRuntimeHandler({
66
- baseUrl: ELECTRIC_AGENTS_URL,
67
- serveEndpoint: `${SERVE_URL}/webhook`,
68
- registry,
69
- })
70
- ```
71
-
72
- ### `src/coordinator.ts`
73
-
74
- The coordinator entity. Defines two custom tools:
75
-
76
- - `set_drawing_plan` --- sets the composition description and color palette
77
- - `instruct_tile` --- spawns or re-instructs a tile agent with drawing directions
78
-
79
- ### `src/tile-agent.ts`
80
-
81
- The tile agent entity. Each instance gets drawing tools scoped to its tile:
82
-
83
- - `read_viewport` --- see current pixel state (own tile + neighbors)
84
- - `fill_rect`, `draw_line`, `draw_circle`, `fill_gradient`, `set_pixels`
85
-
86
- All coordinates are tile-relative (0--99) and automatically clipped to tile bounds.
87
-
88
- ## Running it
89
-
90
- ```bash
91
- cd examples/mega-draw
92
- pnpm install
93
- cp ../../.env.template .env # Set ANTHROPIC_API_KEY
94
- pnpm dev
95
- ```
96
-
97
- Requires a running Electric Agents runtime server at `http://localhost:4437`.
98
-
99
- Then in another terminal:
100
-
101
- ```bash
102
- npx electric-ax agents spawn /coordinator/my-drawing
103
- npx electric-ax agents send /coordinator/my-drawing 'Draw a sunset over mountains'
104
- ```
105
-
106
- View the canvas live at `http://localhost:3000/my-drawing` --- it auto-refreshes as tiles draw.