@electric-ax/agents 0.1.5 → 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 +653 -408
- package/dist/index.cjs +671 -419
- package/dist/index.d.cts +36 -3
- package/dist/index.d.ts +36 -3
- package/dist/index.js +656 -411
- 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 +6 -9
- package/skills/init.md +71 -0
- package/skills/quickstart/scaffold/package.json +30 -0
- package/skills/{tutorial → 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 +582 -0
- package/skills/tutorial/scaffold/package.json +0 -17
- package/skills/tutorial.md +0 -282
- /package/skills/{tutorial → quickstart}/scaffold/entities/.gitkeep +0 -0
- /package/skills/{tutorial → quickstart}/scaffold/lib/electric-tools.ts +0 -0
- /package/skills/{tutorial → quickstart}/scaffold/server.ts +0 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Guided quickstart — build a full Electric Agents app from entity to frontend
|
|
3
|
+
whenToUse: User is new to Electric Agents, wants to learn how agents work, asks for a quickstart or getting started guide
|
|
4
|
+
keywords:
|
|
5
|
+
- quickstart
|
|
6
|
+
- getting started
|
|
7
|
+
- learn
|
|
8
|
+
- multi-agent
|
|
9
|
+
- manager-worker
|
|
10
|
+
- entity
|
|
11
|
+
- app
|
|
12
|
+
- frontend
|
|
13
|
+
user-invocable: true
|
|
14
|
+
max: 35000
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Quickstart: Build a Perspectives Analyzer
|
|
18
|
+
|
|
19
|
+
Build a `perspectives` entity that analyzes questions from an optimist and a critic using the manager-worker pattern. Use the exact code below — do not invent different code.
|
|
20
|
+
|
|
21
|
+
## Core Concepts
|
|
22
|
+
|
|
23
|
+
### What is Electric Agents?
|
|
24
|
+
|
|
25
|
+
Electric Agents is a runtime for spawning and orchestrating collaborative AI agents on serverless compute.
|
|
26
|
+
|
|
27
|
+
The core idea: agent sessions and communication are backed by **durable streams**. Each agent is an **entity** with its own stream of events. All agent activity — runs, tool calls, text output — is persisted to this stream. This means agents can scale to zero, survive restarts, and maintain full session history.
|
|
28
|
+
|
|
29
|
+
**Why this matters for multi-agent systems**: Because everything is durable and observable, agents can spawn children, wait for results (even across restarts), observe each other's state changes, and coordinate through structured primitives — all without worrying about losing state.
|
|
30
|
+
|
|
31
|
+
### Entities
|
|
32
|
+
|
|
33
|
+
An entity is a durable, addressable unit of computation. Each entity has:
|
|
34
|
+
|
|
35
|
+
- A **type** (e.g., `assistant`, `worker`, `research-team`) — defined once, instantiated many times
|
|
36
|
+
- A **URL** (e.g., `/research-team/my-team`) — its unique address
|
|
37
|
+
- A **handler** — the function that runs each time the entity wakes up
|
|
38
|
+
- **State** — persistent collections that survive across wakes
|
|
39
|
+
|
|
40
|
+
You define entity types with `registry.define()` and create instances by spawning them.
|
|
41
|
+
|
|
42
|
+
### Handlers and Wakes
|
|
43
|
+
|
|
44
|
+
An entity's handler runs in response to **wake events**:
|
|
45
|
+
|
|
46
|
+
- A message arrives in the entity's inbox
|
|
47
|
+
- A child entity finishes its run
|
|
48
|
+
- A cron schedule fires
|
|
49
|
+
- A state change in an observed entity
|
|
50
|
+
|
|
51
|
+
The handler is **not** a long-running process. It wakes, does its work (usually running an LLM agent loop), and goes back to sleep.
|
|
52
|
+
|
|
53
|
+
### The Agent Loop
|
|
54
|
+
|
|
55
|
+
`ctx.useAgent()` configures an LLM agent and `ctx.agent.run()` starts it. The agent receives conversation history, calls tools as needed, and generates a response — all persisted to the entity's durable stream.
|
|
56
|
+
|
|
57
|
+
### Spawning Children
|
|
58
|
+
|
|
59
|
+
Any entity can spawn child entities. When a child finishes (and the parent registered `wake: "runFinished"`), the parent's handler runs again. The wake event includes the child's response and the status of sibling children.
|
|
60
|
+
|
|
61
|
+
### The Worker Entity
|
|
62
|
+
|
|
63
|
+
The built-in `worker` type is a generic agent substrate. You configure it at spawn time with a `systemPrompt` and `tools` array (at least one tool required).
|
|
64
|
+
|
|
65
|
+
### State Collections
|
|
66
|
+
|
|
67
|
+
Entities can declare persistent state collections that survive across wakes, allowing coordination patterns like tracking which children have completed.
|
|
68
|
+
|
|
69
|
+
## Before starting
|
|
70
|
+
|
|
71
|
+
**Ask the user where they want the project.** Suggest a sensible default (e.g., `./perspectives-app` relative to the working directory) but let them choose. Do not create files or directories until the user confirms the location.
|
|
72
|
+
|
|
73
|
+
**Ensure the user has an `ANTHROPIC_API_KEY` set.** The app's `.env` file (in the project root) must contain `ANTHROPIC_API_KEY=sk-ant-...`. If there is no `.env` file yet, ask the user to create one or provide their key so you can write it. Without this key, agents cannot call the LLM and will fail at runtime.
|
|
74
|
+
|
|
75
|
+
Once the directory is confirmed, read `server.ts` in that directory:
|
|
76
|
+
|
|
77
|
+
- **Has `registerPerspectives`**: resume from where they left off (read `entities/perspectives.ts` to determine the step)
|
|
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: `"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.
|
|
88
|
+
|
|
89
|
+
## Steps
|
|
90
|
+
|
|
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.
|
|
92
|
+
|
|
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.
|
|
94
|
+
|
|
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.
|
|
96
|
+
|
|
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.
|
|
98
|
+
|
|
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:
|
|
100
|
+
|
|
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.
|
|
104
|
+
|
|
105
|
+
Wait for the user to choose. Only proceed to Step 4 if they want to continue building.
|
|
106
|
+
|
|
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.
|
|
108
|
+
|
|
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.**
|
|
112
|
+
|
|
113
|
+
## Rules
|
|
114
|
+
|
|
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.
|
|
116
|
+
- `server.ts` is at the working directory root. Entity files go in `entities/`.
|
|
117
|
+
- Worker spawn args MUST include `tools` array (at least one tool, e.g. `tools: ["bash"]`).
|
|
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.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
# Code
|
|
124
|
+
|
|
125
|
+
## Step 1: Minimal entity
|
|
126
|
+
|
|
127
|
+
`entities/perspectives.ts`:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import type { EntityRegistry } from '@electric-ax/agents-runtime'
|
|
131
|
+
|
|
132
|
+
export function registerPerspectives(registry: EntityRegistry) {
|
|
133
|
+
registry.define('perspectives', {
|
|
134
|
+
description: 'Analyzes questions from multiple perspectives',
|
|
135
|
+
async handler(ctx) {
|
|
136
|
+
ctx.useAgent({
|
|
137
|
+
systemPrompt:
|
|
138
|
+
'You are a balanced analyst. When given a question, provide a thoughtful analysis.',
|
|
139
|
+
model: 'claude-sonnet-4-6',
|
|
140
|
+
tools: [...ctx.electricTools],
|
|
141
|
+
})
|
|
142
|
+
await ctx.agent.run()
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`server.ts` additions:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { registerPerspectives } from './entities/perspectives'
|
|
152
|
+
registerPerspectives(registry)
|
|
153
|
+
```
|
|
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`
|
|
156
|
+
|
|
157
|
+
## Step 2: One worker
|
|
158
|
+
|
|
159
|
+
Full `entities/perspectives.ts`:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import type {
|
|
163
|
+
EntityRegistry,
|
|
164
|
+
HandlerContext,
|
|
165
|
+
} from '@electric-ax/agents-runtime'
|
|
166
|
+
import { Type } from '@sinclair/typebox'
|
|
167
|
+
|
|
168
|
+
function createAnalyzeTool(ctx: HandlerContext) {
|
|
169
|
+
return {
|
|
170
|
+
name: 'analyze_question',
|
|
171
|
+
label: 'Analyze Question',
|
|
172
|
+
description: 'Spawns an optimist worker to analyze a question.',
|
|
173
|
+
parameters: Type.Object({
|
|
174
|
+
question: Type.String({ description: 'The question to analyze' }),
|
|
175
|
+
}),
|
|
176
|
+
execute: async (_toolCallId: string, params: unknown) => {
|
|
177
|
+
const { question } = params as { question: string }
|
|
178
|
+
const parentId = ctx.entityUrl.split('/').pop()
|
|
179
|
+
await ctx.spawn(
|
|
180
|
+
'worker',
|
|
181
|
+
`${parentId}-optimist`,
|
|
182
|
+
{
|
|
183
|
+
systemPrompt:
|
|
184
|
+
'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
|
|
185
|
+
tools: ['bash', 'read'],
|
|
186
|
+
},
|
|
187
|
+
{ initialMessage: question, wake: 'runFinished' }
|
|
188
|
+
)
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: 'text' as const,
|
|
193
|
+
text: "Spawned optimist worker. You'll be woken when it finishes.",
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
details: {},
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function registerPerspectives(registry: EntityRegistry) {
|
|
203
|
+
registry.define('perspectives', {
|
|
204
|
+
description: 'Analyzes questions from multiple perspectives',
|
|
205
|
+
async handler(ctx) {
|
|
206
|
+
ctx.useAgent({
|
|
207
|
+
systemPrompt: `You are a balanced analyst.\n\nWhen given a question:\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken when the worker finishes.\n3. When woken, finished_child.response contains the analysis.\n4. Present it to the user.`,
|
|
208
|
+
model: 'claude-sonnet-4-6',
|
|
209
|
+
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
|
|
210
|
+
})
|
|
211
|
+
await ctx.agent.run()
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
```
|
|
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`
|
|
218
|
+
|
|
219
|
+
## Step 3: Two workers + state
|
|
220
|
+
|
|
221
|
+
Full `entities/perspectives.ts`:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import type {
|
|
225
|
+
EntityRegistry,
|
|
226
|
+
HandlerContext,
|
|
227
|
+
} from '@electric-ax/agents-runtime'
|
|
228
|
+
import { Type } from '@sinclair/typebox'
|
|
229
|
+
|
|
230
|
+
const PERSPECTIVES = [
|
|
231
|
+
{
|
|
232
|
+
id: 'optimist',
|
|
233
|
+
systemPrompt:
|
|
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.',
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: 'critic',
|
|
238
|
+
systemPrompt:
|
|
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.',
|
|
240
|
+
},
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
function createAnalyzeTool(ctx: HandlerContext) {
|
|
244
|
+
return {
|
|
245
|
+
name: 'analyze_question',
|
|
246
|
+
label: 'Analyze Question',
|
|
247
|
+
description: 'Spawns optimist and critic workers to analyze a question.',
|
|
248
|
+
parameters: Type.Object({
|
|
249
|
+
question: Type.String({ description: 'The question to analyze' }),
|
|
250
|
+
}),
|
|
251
|
+
execute: async (_toolCallId: string, params: unknown) => {
|
|
252
|
+
const { question } = params as { question: string }
|
|
253
|
+
const parentId = ctx.entityUrl.split('/').pop()
|
|
254
|
+
for (const p of PERSPECTIVES) {
|
|
255
|
+
const childId = `${parentId}-${p.id}`
|
|
256
|
+
await ctx.spawn(
|
|
257
|
+
'worker',
|
|
258
|
+
childId,
|
|
259
|
+
{ systemPrompt: p.systemPrompt, tools: ['bash'] },
|
|
260
|
+
{ initialMessage: question, wake: 'runFinished' }
|
|
261
|
+
)
|
|
262
|
+
ctx.db.actions.children_insert({
|
|
263
|
+
row: { key: p.id, url: `/worker/${childId}` },
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
content: [
|
|
268
|
+
{
|
|
269
|
+
type: 'text' as const,
|
|
270
|
+
text: 'Spawned optimist and critic workers.',
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
details: {},
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function registerPerspectives(registry: EntityRegistry) {
|
|
280
|
+
registry.define('perspectives', {
|
|
281
|
+
description:
|
|
282
|
+
'Analyzes questions from two perspectives: optimist and critic',
|
|
283
|
+
state: { children: { primaryKey: 'key' } },
|
|
284
|
+
async handler(ctx) {
|
|
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.`,
|
|
287
|
+
model: 'claude-sonnet-4-6',
|
|
288
|
+
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
|
|
289
|
+
})
|
|
290
|
+
await ctx.agent.run()
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
```
|
|
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`
|
|
297
|
+
|
|
298
|
+
## Step 4: Server routes
|
|
299
|
+
|
|
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.
|
|
301
|
+
|
|
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.
|
|
303
|
+
|
|
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
|
+
|
|
306
|
+
Update `server.ts` — add the runtime server client and an `/api/analyze` route:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
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'
|
|
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
|
+
|
|
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
|
+
})
|
|
345
|
+
|
|
346
|
+
const client = createRuntimeServerClient({ baseUrl: ELECTRIC_AGENTS_URL })
|
|
347
|
+
|
|
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
|
+
}
|
|
353
|
+
|
|
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
|
+
}
|
|
359
|
+
|
|
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
|
+
}
|
|
368
|
+
|
|
369
|
+
const id = `analysis-${crypto.randomUUID().slice(0, 8)}`
|
|
370
|
+
|
|
371
|
+
await client.spawnEntity({
|
|
372
|
+
type: 'perspectives',
|
|
373
|
+
id,
|
|
374
|
+
initialMessage: body.question,
|
|
375
|
+
})
|
|
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
|
|
394
|
+
}
|
|
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
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Test with curl:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
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?"}'
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Then observe the spawned entity:
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
pnpm electric-agents observe /perspectives/analysis-<id>
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Key concepts:**
|
|
421
|
+
|
|
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`)
|
|
425
|
+
|
|
426
|
+
## Step 5: Frontend — live results
|
|
427
|
+
|
|
428
|
+
The UI files are pre-built in the scaffold. Copy them into the project, then read and explain them.
|
|
429
|
+
|
|
430
|
+
The scaffold includes:
|
|
431
|
+
|
|
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
|
|
435
|
+
|
|
436
|
+
The `vite.config.ts` was already included in the initial scaffold. The `ui/` files are copied from `SKILL_DIR/scaffold-ui/`.
|
|
437
|
+
|
|
438
|
+
After copying, read `ui/main.tsx` with the user and walk through the key parts with code snippets:
|
|
439
|
+
|
|
440
|
+
### Connecting to an entity stream
|
|
441
|
+
|
|
442
|
+
The `useEntityDb` hook subscribes to a single entity's durable stream. This is the bridge between the agent server and React:
|
|
443
|
+
|
|
444
|
+
```tsx
|
|
445
|
+
function useEntityDb(url: string | null, retryMs = 0) {
|
|
446
|
+
const [db, setDb] = useState<EntityStreamDB | null>(null)
|
|
447
|
+
|
|
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
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
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).
|
|
476
|
+
|
|
477
|
+
### Extracting messages with useChat
|
|
478
|
+
|
|
479
|
+
The `useAgentMessages` hook takes an entity stream and extracts text messages using `useChat`:
|
|
480
|
+
|
|
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
|
+
}))
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
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
|
|
508
|
+
|
|
509
|
+
Each message is a color-coded card with markdown rendering via `Streamdown`:
|
|
510
|
+
|
|
511
|
+
```tsx
|
|
512
|
+
function MessageBubble({ msg }: { msg: AgentMessage }) {
|
|
513
|
+
const colors = AGENT_COLORS[msg.agent]
|
|
514
|
+
return (
|
|
515
|
+
<Card
|
|
516
|
+
size="1"
|
|
517
|
+
style={{
|
|
518
|
+
background: colors.bg,
|
|
519
|
+
borderLeft: `3px solid ${colors.border}`,
|
|
520
|
+
}}
|
|
521
|
+
>
|
|
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>
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
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.
|
|
536
|
+
|
|
537
|
+
### Wiring it together
|
|
538
|
+
|
|
539
|
+
The `App` component subscribes to all three entities and renders messages inline:
|
|
540
|
+
|
|
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
|
+
]
|
|
555
|
+
```
|
|
556
|
+
|
|
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`.
|
|
560
|
+
|
|
561
|
+
**Key concepts:**
|
|
562
|
+
|
|
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
|
|
569
|
+
|
|
570
|
+
## What you learned
|
|
571
|
+
|
|
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 |
|
|
579
|
+
|
|
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.
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "my-electric-agents-app",
|
|
3
|
-
"private": true,
|
|
4
|
-
"type": "module",
|
|
5
|
-
"scripts": {
|
|
6
|
-
"start": "tsx server.ts",
|
|
7
|
-
"dev": "tsx --watch server.ts"
|
|
8
|
-
},
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"@electric-ax/agents-runtime": "latest",
|
|
11
|
-
"@sinclair/typebox": "^0.34.49"
|
|
12
|
-
},
|
|
13
|
-
"devDependencies": {
|
|
14
|
-
"tsx": "^4.19.0",
|
|
15
|
-
"typescript": "^5.7.0"
|
|
16
|
-
}
|
|
17
|
-
}
|