@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.
- package/dist/entrypoint.js +5 -3
- package/dist/index.cjs +5 -3
- package/dist/index.js +5 -3
- package/docs/entities/agents/horton.md +89 -0
- package/docs/entities/agents/worker.md +102 -0
- package/docs/entities/patterns/blackboard.md +111 -0
- package/docs/entities/patterns/dispatcher.md +77 -0
- package/docs/entities/patterns/manager-worker.md +127 -0
- package/docs/entities/patterns/map-reduce.md +81 -0
- package/docs/entities/patterns/pipeline.md +101 -0
- package/docs/entities/patterns/reactive-observers.md +125 -0
- package/docs/examples/mega-draw.md +106 -0
- package/docs/examples/playground.md +46 -0
- package/docs/index.md +208 -0
- package/docs/quickstart.md +201 -0
- package/docs/reference/agent-config.md +82 -0
- package/docs/reference/agent-tool.md +58 -0
- package/docs/reference/built-in-collections.md +334 -0
- package/docs/reference/cli.md +238 -0
- package/docs/reference/entity-definition.md +57 -0
- package/docs/reference/entity-handle.md +63 -0
- package/docs/reference/entity-registry.md +73 -0
- package/docs/reference/handler-context.md +108 -0
- package/docs/reference/runtime-handler.md +136 -0
- package/docs/reference/shared-state-handle.md +74 -0
- package/docs/reference/state-collection-proxy.md +41 -0
- package/docs/reference/wake-event.md +132 -0
- package/docs/usage/app-setup.md +165 -0
- package/docs/usage/clients-and-react.md +191 -0
- package/docs/usage/configuring-the-agent.md +136 -0
- package/docs/usage/context-composition.md +204 -0
- package/docs/usage/defining-entities.md +181 -0
- package/docs/usage/defining-tools.md +229 -0
- package/docs/usage/embedded-builtins.md +180 -0
- package/docs/usage/managing-state.md +93 -0
- package/docs/usage/overview.md +284 -0
- package/docs/usage/programmatic-runtime-client.md +216 -0
- package/docs/usage/shared-state.md +169 -0
- package/docs/usage/spawning-and-coordinating.md +165 -0
- package/docs/usage/testing.md +76 -0
- package/docs/usage/waking-entities.md +148 -0
- package/docs/usage/writing-handlers.md +267 -0
- package/package.json +2 -1
- package/skills/quickstart/scaffold/package.json +16 -3
- package/skills/quickstart/scaffold/tsconfig.json +8 -3
- package/skills/quickstart/scaffold/vite.config.ts +21 -0
- package/skills/quickstart/scaffold-ui/index.html +12 -0
- package/skills/quickstart/scaffold-ui/main.tsx +235 -0
- package/skills/quickstart.md +244 -334
package/skills/quickstart.md
CHANGED
|
@@ -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: `"
|
|
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
|
-
|
|
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
|
|
93
|
+
**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 the user if they want you to write it. Do NOT write until they confirm.
|
|
86
94
|
|
|
87
|
-
**Step
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
105
|
+
Wait for the user to choose. Only proceed to Step 4 if they want to continue building.
|
|
96
106
|
|
|
97
|
-
**Step
|
|
107
|
+
**Step 4 — API 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
|
|
109
|
+
**Step 5 — After 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
|
-
-
|
|
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"
|
|
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'
|
|
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
|
|
298
|
+
## Step 4: Server routes
|
|
287
299
|
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
306
|
+
Update `server.ts` — add the runtime server client and an `/api/analyze` route:
|
|
295
307
|
|
|
296
308
|
```typescript
|
|
297
|
-
import
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
336
|
+
const registry = createEntityRegistry()
|
|
337
|
+
registerPerspectives(registry)
|
|
331
338
|
|
|
332
|
-
const
|
|
339
|
+
const runtime = createRuntimeHandler({
|
|
340
|
+
baseUrl: ELECTRIC_AGENTS_URL,
|
|
341
|
+
serveEndpoint: `${SERVE_URL}/webhook`,
|
|
342
|
+
registry,
|
|
343
|
+
createElectricTools,
|
|
344
|
+
})
|
|
333
345
|
|
|
334
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
416
|
+
```bash
|
|
417
|
+
pnpm electric-agents observe /perspectives/analysis-<id>
|
|
418
|
+
```
|
|
481
419
|
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
+
## Step 5: Frontend — live results
|
|
508
427
|
|
|
509
|
-
|
|
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
|
-
|
|
430
|
+
The scaffold includes:
|
|
516
431
|
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
+
After copying, read `ui/main.tsx` with the user and walk through the key parts with code snippets:
|
|
525
439
|
|
|
526
|
-
|
|
440
|
+
### Connecting to an entity stream
|
|
527
441
|
|
|
528
|
-
The
|
|
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
|
-
|
|
444
|
+
```tsx
|
|
445
|
+
function useEntityDb(url: string | null, retryMs = 0) {
|
|
446
|
+
const [db, setDb] = useState<EntityStreamDB | null>(null)
|
|
531
447
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
558
|
-
const ROOM_ID = 'room-1'
|
|
477
|
+
### Extracting messages with useChat
|
|
559
478
|
|
|
560
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
593
|
-
|
|
515
|
+
<Card
|
|
516
|
+
size="1"
|
|
517
|
+
style={{
|
|
518
|
+
background: colors.bg,
|
|
519
|
+
borderLeft: `3px solid ${colors.border}`,
|
|
520
|
+
}}
|
|
594
521
|
>
|
|
595
|
-
<
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
+
### Wiring it together
|
|
644
538
|
|
|
645
|
-
|
|
539
|
+
The `App` component subscribes to all three entities and renders messages inline:
|
|
646
540
|
|
|
647
|
-
```
|
|
648
|
-
|
|
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
|
-
|
|
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
|
|
656
|
-
- `client.observe(
|
|
657
|
-
- `
|
|
658
|
-
-
|
|
659
|
-
-
|
|
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
|
-
|
|
|
669
|
-
|
|
|
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.
|