@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.
- 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 +3 -2
- package/skills/quickstart/scaffold/package.json +17 -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 +440 -0
- package/skills/quickstart.md +209 -344
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
|
|
|
@@ -140,7 +152,7 @@ import { registerPerspectives } from './entities/perspectives'
|
|
|
140
152
|
registerPerspectives(registry)
|
|
141
153
|
```
|
|
142
154
|
|
|
143
|
-
Test: `
|
|
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: `
|
|
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'
|
|
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
|
|
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: `
|
|
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
|
-
|
|
298
|
+
## Step 4: Server routes
|
|
289
299
|
|
|
290
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
308
|
+
Add `createRuntimeServerClient` to your imports from `@electric-ax/agents-runtime`, then create a client instance:
|
|
318
309
|
|
|
319
310
|
```typescript
|
|
320
|
-
import {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
wake: { on: 'change', collections: ['shared:message'] },
|
|
431
|
-
})) as unknown as ChatroomState
|
|
319
|
+
const client = createRuntimeServerClient({ baseUrl: ELECTRIC_AGENTS_URL })
|
|
320
|
+
```
|
|
432
321
|
|
|
433
|
-
|
|
434
|
-
if (ctx.firstWake) return
|
|
322
|
+
Then add this route handler inside `http.createServer`, after the existing `/webhook` handler:
|
|
435
323
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
+
Test with curl:
|
|
452
363
|
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
370
|
+
Then observe the spawned entity:
|
|
459
371
|
|
|
460
372
|
```bash
|
|
461
|
-
|
|
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
|
-
- `
|
|
469
|
-
- `
|
|
470
|
-
-
|
|
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
|
|
382
|
+
## Step 5: Frontend — live results
|
|
475
383
|
|
|
476
|
-
|
|
384
|
+
The UI files are pre-built in the scaffold. Copy them into the project, then read and explain them.
|
|
477
385
|
|
|
478
|
-
|
|
386
|
+
The scaffold includes:
|
|
479
387
|
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
400
|
+
```tsx
|
|
401
|
+
function useEntityDb(url: string | null, retryMs = 0) {
|
|
402
|
+
const [db, setDb] = useState<EntityStreamDB | null>(null)
|
|
531
403
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
558
|
-
const ROOM_ID = 'room-1'
|
|
433
|
+
### Extracting messages with useChat
|
|
559
434
|
|
|
560
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
593
|
-
|
|
471
|
+
<Card
|
|
472
|
+
size="1"
|
|
473
|
+
style={{
|
|
474
|
+
background: colors.bg,
|
|
475
|
+
borderLeft: `3px solid ${colors.border}`,
|
|
476
|
+
}}
|
|
594
477
|
>
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
493
|
+
### Wiring it together
|
|
644
494
|
|
|
645
|
-
|
|
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
|
-
```
|
|
648
|
-
|
|
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
|
-
|
|
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
|
|
656
|
-
- `client.observe(
|
|
657
|
-
- `
|
|
658
|
-
-
|
|
659
|
-
-
|
|
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
|
-
|
|
|
669
|
-
|
|
|
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.
|