@electric-ax/agents 0.1.5 → 0.2.1
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 +650 -407
- package/dist/index.cjs +668 -418
- package/dist/index.d.cts +36 -3
- package/dist/index.d.ts +36 -3
- package/dist/index.js +653 -410
- package/package.json +5 -9
- package/skills/init.md +71 -0
- package/skills/quickstart.md +672 -0
- 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/package.json +0 -0
- /package/skills/{tutorial → quickstart}/scaffold/server.ts +0 -0
- /package/skills/{tutorial → quickstart}/scaffold/tsconfig.json +0 -0
|
@@ -0,0 +1,672 @@
|
|
|
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: `"mkdir -p TARGET/lib TARGET/entities && cp SKILL_DIR/scaffold/* TARGET/ && cp SKILL_DIR/scaffold/lib/* TARGET/lib/ && cd TARGET && pnpm install && pnpm dev &"` — replace SKILL_DIR and TARGET). Then proceed to Step 1 while the worker runs. Wait for the worker to finish before writing files.
|
|
80
|
+
|
|
81
|
+
## Steps
|
|
82
|
+
|
|
83
|
+
**Step 1 — Welcome + first entity.** In one message: introduce Electric Agents using the Core Concepts above, preview the perspectives analyzer, and show the Step 1 code. Ask to write.
|
|
84
|
+
|
|
85
|
+
**Step 2 — After confirmation:** write `entities/perspectives.ts` with Step 1 code. Give CLI commands. Explain spawning briefly, show Step 2 code (adds one worker). Ask to write.
|
|
86
|
+
|
|
87
|
+
**Step 3 — After confirmation:** write the updated file. Give CLI commands. Explain coordination, show Step 3 code (adds critic + state). Ask to write.
|
|
88
|
+
|
|
89
|
+
**Step 4 — After confirmation:** write the updated file. Give CLI commands.
|
|
90
|
+
|
|
91
|
+
**Step 5 — Wire up.** Read `server.ts`, show the import change, ask to write, update it.
|
|
92
|
+
|
|
93
|
+
**Step 6 — After confirmation:** explain shared state as cross-entity coordination. Show Step 6 code (chatroom schema + chat-agent entity with `ctx.mkdb` and `ctx.observe`). Write files, give CLI commands to test. Ask to continue.
|
|
94
|
+
|
|
95
|
+
**Step 7 — After confirmation:** explain context assembly. Show the `ctx.useContext()` addition. Update the entity file. Test with two agents in the same room. Ask to continue.
|
|
96
|
+
|
|
97
|
+
**Step 8 — After confirmation:** explain live frontend queries. Show Step 8 code (React + TanStack DB `useLiveQuery`). Create UI files, add deps, give commands to run. Show how updates appear in real time.
|
|
98
|
+
|
|
99
|
+
**Step 9 — Recap.**
|
|
100
|
+
|
|
101
|
+
## Rules
|
|
102
|
+
|
|
103
|
+
- Use the exact code below. Write files with your write tool.
|
|
104
|
+
- `server.ts` is at the working directory root. Entity files go in `entities/`.
|
|
105
|
+
- Worker spawn args MUST include `tools` array (e.g. `tools: ["bash", "read"]`).
|
|
106
|
+
- Prefer showing what changed between steps rather than repeating the entire file.
|
|
107
|
+
- Use `edit` tool for small changes (like updating server.ts). Use `write` for full entity file updates.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
# Code
|
|
112
|
+
|
|
113
|
+
## Step 1: Minimal entity
|
|
114
|
+
|
|
115
|
+
`entities/perspectives.ts`:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import type { EntityRegistry } from '@electric-ax/agents-runtime'
|
|
119
|
+
|
|
120
|
+
export function registerPerspectives(registry: EntityRegistry) {
|
|
121
|
+
registry.define('perspectives', {
|
|
122
|
+
description: 'Analyzes questions from multiple perspectives',
|
|
123
|
+
async handler(ctx) {
|
|
124
|
+
ctx.useAgent({
|
|
125
|
+
systemPrompt:
|
|
126
|
+
'You are a balanced analyst. When given a question, provide a thoughtful analysis.',
|
|
127
|
+
model: 'claude-sonnet-4-6',
|
|
128
|
+
tools: [...ctx.electricTools],
|
|
129
|
+
})
|
|
130
|
+
await ctx.agent.run()
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`server.ts` additions:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { registerPerspectives } from './entities/perspectives'
|
|
140
|
+
registerPerspectives(registry)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Test: `pnpm electric-agents spawn /perspectives/test-1 && pnpm electric-agents send /perspectives/test-1 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-1`
|
|
144
|
+
|
|
145
|
+
## Step 2: One worker
|
|
146
|
+
|
|
147
|
+
Full `entities/perspectives.ts`:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import type {
|
|
151
|
+
EntityRegistry,
|
|
152
|
+
HandlerContext,
|
|
153
|
+
} from '@electric-ax/agents-runtime'
|
|
154
|
+
import { Type } from '@sinclair/typebox'
|
|
155
|
+
|
|
156
|
+
function createAnalyzeTool(ctx: HandlerContext) {
|
|
157
|
+
return {
|
|
158
|
+
name: 'analyze_question',
|
|
159
|
+
label: 'Analyze Question',
|
|
160
|
+
description: 'Spawns an optimist worker to analyze a question.',
|
|
161
|
+
parameters: Type.Object({
|
|
162
|
+
question: Type.String({ description: 'The question to analyze' }),
|
|
163
|
+
}),
|
|
164
|
+
execute: async (_toolCallId: string, params: unknown) => {
|
|
165
|
+
const { question } = params as { question: string }
|
|
166
|
+
const parentId = ctx.entityUrl.split('/').pop()
|
|
167
|
+
await ctx.spawn(
|
|
168
|
+
'worker',
|
|
169
|
+
`${parentId}-optimist`,
|
|
170
|
+
{
|
|
171
|
+
systemPrompt:
|
|
172
|
+
'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
|
|
173
|
+
tools: ['bash', 'read'],
|
|
174
|
+
},
|
|
175
|
+
{ initialMessage: question, wake: 'runFinished' }
|
|
176
|
+
)
|
|
177
|
+
return {
|
|
178
|
+
content: [
|
|
179
|
+
{
|
|
180
|
+
type: 'text' as const,
|
|
181
|
+
text: "Spawned optimist worker. You'll be woken when it finishes.",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
details: {},
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function registerPerspectives(registry: EntityRegistry) {
|
|
191
|
+
registry.define('perspectives', {
|
|
192
|
+
description: 'Analyzes questions from multiple perspectives',
|
|
193
|
+
async handler(ctx) {
|
|
194
|
+
ctx.useAgent({
|
|
195
|
+
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.`,
|
|
196
|
+
model: 'claude-sonnet-4-6',
|
|
197
|
+
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
|
|
198
|
+
})
|
|
199
|
+
await ctx.agent.run()
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Test: `pnpm electric-agents spawn /perspectives/test-2 && pnpm electric-agents send /perspectives/test-2 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-2`
|
|
206
|
+
|
|
207
|
+
## Step 3: Two workers + state
|
|
208
|
+
|
|
209
|
+
Full `entities/perspectives.ts`:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import type {
|
|
213
|
+
EntityRegistry,
|
|
214
|
+
HandlerContext,
|
|
215
|
+
} from '@electric-ax/agents-runtime'
|
|
216
|
+
import { Type } from '@sinclair/typebox'
|
|
217
|
+
|
|
218
|
+
const PERSPECTIVES = [
|
|
219
|
+
{
|
|
220
|
+
id: 'optimist',
|
|
221
|
+
systemPrompt:
|
|
222
|
+
'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'critic',
|
|
226
|
+
systemPrompt:
|
|
227
|
+
'You are a critical analyst. Provide a sharp analysis focusing on risks, downsides, and challenges.',
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
function createAnalyzeTool(ctx: HandlerContext) {
|
|
232
|
+
return {
|
|
233
|
+
name: 'analyze_question',
|
|
234
|
+
label: 'Analyze Question',
|
|
235
|
+
description: 'Spawns optimist and critic workers to analyze a question.',
|
|
236
|
+
parameters: Type.Object({
|
|
237
|
+
question: Type.String({ description: 'The question to analyze' }),
|
|
238
|
+
}),
|
|
239
|
+
execute: async (_toolCallId: string, params: unknown) => {
|
|
240
|
+
const { question } = params as { question: string }
|
|
241
|
+
const parentId = ctx.entityUrl.split('/').pop()
|
|
242
|
+
for (const p of PERSPECTIVES) {
|
|
243
|
+
const childId = `${parentId}-${p.id}`
|
|
244
|
+
await ctx.spawn(
|
|
245
|
+
'worker',
|
|
246
|
+
childId,
|
|
247
|
+
{ systemPrompt: p.systemPrompt, tools: ['bash', 'read'] },
|
|
248
|
+
{ initialMessage: question, wake: 'runFinished' }
|
|
249
|
+
)
|
|
250
|
+
ctx.db.actions.children_insert({
|
|
251
|
+
row: { key: p.id, url: `/worker/${childId}` },
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
content: [
|
|
256
|
+
{
|
|
257
|
+
type: 'text' as const,
|
|
258
|
+
text: 'Spawned optimist and critic workers.',
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
details: {},
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function registerPerspectives(registry: EntityRegistry) {
|
|
268
|
+
registry.define('perspectives', {
|
|
269
|
+
description:
|
|
270
|
+
'Analyzes questions from two perspectives: optimist and critic',
|
|
271
|
+
state: { children: { primaryKey: 'key' } },
|
|
272
|
+
async handler(ctx) {
|
|
273
|
+
ctx.useAgent({
|
|
274
|
+
systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken as each worker finishes.\n3. Each wake includes finished_child.response and other_children.\n4. Once both are done, synthesize a balanced response.`,
|
|
275
|
+
model: 'claude-sonnet-4-6',
|
|
276
|
+
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
|
|
277
|
+
})
|
|
278
|
+
await ctx.agent.run()
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Test: `pnpm electric-agents spawn /perspectives/test-3 && pnpm electric-agents send /perspectives/test-3 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-3`
|
|
285
|
+
|
|
286
|
+
## Step 6: Shared state — a chatroom
|
|
287
|
+
|
|
288
|
+
In the perspectives analyzer, workers reported back to a parent via `runFinished`. But what if agents need to coordinate in real time — reading and writing to the same data? That's **shared state**.
|
|
289
|
+
|
|
290
|
+
Shared state is a durable stream that multiple entities can observe. One entity creates it with `ctx.mkdb()`, others connect with `ctx.observe(db(...))`. Both sides read and write the same collections.
|
|
291
|
+
|
|
292
|
+
Let's build a chatroom: a shared message log that agents post to using a `send_message` tool.
|
|
293
|
+
|
|
294
|
+
`entities/chatroom-schema.ts`:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
import { z } from 'zod'
|
|
298
|
+
|
|
299
|
+
export const messageSchema = z.object({
|
|
300
|
+
key: z.string().min(1),
|
|
301
|
+
role: z.enum(['user', 'agent']),
|
|
302
|
+
sender: z.string().min(1),
|
|
303
|
+
senderName: z.string().min(1),
|
|
304
|
+
text: z.string().min(1),
|
|
305
|
+
timestamp: z.number(),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
export const chatroomSchema = {
|
|
309
|
+
messages: {
|
|
310
|
+
schema: messageSchema,
|
|
311
|
+
type: 'shared:message',
|
|
312
|
+
primaryKey: 'key',
|
|
313
|
+
},
|
|
314
|
+
} as const
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`entities/chat-agent.ts`:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { db } from '@electric-ax/agents-runtime'
|
|
321
|
+
import { z } from 'zod'
|
|
322
|
+
import { Type } from '@sinclair/typebox'
|
|
323
|
+
import { chatroomSchema } from './chatroom-schema'
|
|
324
|
+
import type {
|
|
325
|
+
EntityRegistry,
|
|
326
|
+
SharedStateHandle,
|
|
327
|
+
AgentTool,
|
|
328
|
+
} from '@electric-ax/agents-runtime'
|
|
329
|
+
|
|
330
|
+
type ChatroomState = SharedStateHandle<typeof chatroomSchema>
|
|
331
|
+
|
|
332
|
+
const chatAgentArgs = z.object({ chatroomId: z.string().min(1) })
|
|
333
|
+
|
|
334
|
+
function createSendMessageTool(
|
|
335
|
+
messages: ChatroomState['messages'],
|
|
336
|
+
senderName: string
|
|
337
|
+
): AgentTool {
|
|
338
|
+
return {
|
|
339
|
+
name: 'send_message',
|
|
340
|
+
description: 'Post a message to the chatroom.',
|
|
341
|
+
parameters: Type.Object({
|
|
342
|
+
text: Type.String({ description: 'The message text' }),
|
|
343
|
+
}),
|
|
344
|
+
execute: async (_id, params) => {
|
|
345
|
+
const { text } = params as { text: string }
|
|
346
|
+
;(messages as any).insert({
|
|
347
|
+
key: crypto.randomUUID(),
|
|
348
|
+
role: 'agent',
|
|
349
|
+
sender: senderName,
|
|
350
|
+
senderName,
|
|
351
|
+
text,
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
})
|
|
354
|
+
return {
|
|
355
|
+
content: [{ type: 'text' as const, text: 'Message sent.' }],
|
|
356
|
+
details: {},
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function createWebSearchTool(): AgentTool {
|
|
363
|
+
return {
|
|
364
|
+
name: 'web_search',
|
|
365
|
+
description: 'Search the web for current information.',
|
|
366
|
+
parameters: Type.Object({
|
|
367
|
+
query: Type.String({ description: 'The search query' }),
|
|
368
|
+
}),
|
|
369
|
+
execute: async (_id, params) => {
|
|
370
|
+
const apiKey = process.env.BRAVE_SEARCH_API_KEY
|
|
371
|
+
if (!apiKey) {
|
|
372
|
+
return {
|
|
373
|
+
content: [
|
|
374
|
+
{
|
|
375
|
+
type: 'text' as const,
|
|
376
|
+
text: 'Web search unavailable: BRAVE_SEARCH_API_KEY not set.',
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
details: {},
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const { query } = params as { query: string }
|
|
383
|
+
const res = await fetch(
|
|
384
|
+
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`,
|
|
385
|
+
{ headers: { 'X-Subscription-Token': apiKey } }
|
|
386
|
+
)
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
return {
|
|
389
|
+
content: [
|
|
390
|
+
{ type: 'text' as const, text: `Search failed: ${res.status}` },
|
|
391
|
+
],
|
|
392
|
+
details: {},
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const data = (await res.json()) as {
|
|
396
|
+
web?: {
|
|
397
|
+
results?: Array<{ title: string; url: string; description: string }>
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const results = data.web?.results ?? []
|
|
401
|
+
const formatted = results
|
|
402
|
+
.map(
|
|
403
|
+
(r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`
|
|
404
|
+
)
|
|
405
|
+
.join('\n\n')
|
|
406
|
+
return {
|
|
407
|
+
content: [
|
|
408
|
+
{ type: 'text' as const, text: formatted || 'No results found.' },
|
|
409
|
+
],
|
|
410
|
+
details: { resultCount: results.length },
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function registerChatAgent(registry: EntityRegistry) {
|
|
417
|
+
registry.define('chat-agent', {
|
|
418
|
+
description: 'Chat agent that reads and writes to a shared chatroom',
|
|
419
|
+
creationSchema: chatAgentArgs,
|
|
420
|
+
async handler(ctx) {
|
|
421
|
+
const args = chatAgentArgs.parse(ctx.args)
|
|
422
|
+
|
|
423
|
+
// First wake: create the shared state
|
|
424
|
+
if (ctx.firstWake) {
|
|
425
|
+
ctx.mkdb(args.chatroomId, chatroomSchema)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Observe shared state — wake when messages change
|
|
429
|
+
const chatroom = (await ctx.observe(db(args.chatroomId, chatroomSchema), {
|
|
430
|
+
wake: { on: 'change', collections: ['shared:message'] },
|
|
431
|
+
})) as unknown as ChatroomState
|
|
432
|
+
|
|
433
|
+
// On first wake, just register the wake — don't call the LLM
|
|
434
|
+
if (ctx.firstWake) return
|
|
435
|
+
|
|
436
|
+
ctx.useAgent({
|
|
437
|
+
systemPrompt:
|
|
438
|
+
'You are a helpful chat agent. Use web_search to find information and send_message to reply.',
|
|
439
|
+
model: 'claude-sonnet-4-6',
|
|
440
|
+
tools: [
|
|
441
|
+
createSendMessageTool(chatroom.messages, ctx.entityUrl),
|
|
442
|
+
createWebSearchTool(),
|
|
443
|
+
],
|
|
444
|
+
})
|
|
445
|
+
await ctx.agent.run()
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Add to `server.ts`:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
import { registerChatAgent } from './entities/chat-agent'
|
|
455
|
+
registerChatAgent(registry)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Test:
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
pnpm electric-agents spawn /chat-agent/agent-1 '{"chatroomId":"room-1"}' \
|
|
462
|
+
&& pnpm electric-agents send /chat-agent/agent-1 "Hello! What can you help me with?" \
|
|
463
|
+
&& pnpm electric-agents observe /chat-agent/agent-1
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Key concepts:**
|
|
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.
|
|
477
|
+
|
|
478
|
+
`ctx.useContext()` injects external data into the agent's context before the LLM call. You configure sources with a token budget and cache strategy:
|
|
479
|
+
|
|
480
|
+
Update the handler in `entities/chat-agent.ts` — add `useContext` between `observe` and `useAgent`:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
// Read conversation history from shared state
|
|
484
|
+
const allMessages = (chatroom.messages as any).toArray as Array<{
|
|
485
|
+
senderName: string
|
|
486
|
+
text: string
|
|
487
|
+
}>
|
|
488
|
+
const history = allMessages
|
|
489
|
+
.map((m) => `[${m.senderName}]: ${m.text}`)
|
|
490
|
+
.join('\n')
|
|
491
|
+
|
|
492
|
+
// Inject as volatile context (changes every wake)
|
|
493
|
+
ctx.useContext({
|
|
494
|
+
sourceBudget: 50_000,
|
|
495
|
+
sources: {
|
|
496
|
+
conversation: {
|
|
497
|
+
cache: 'volatile',
|
|
498
|
+
content: async () =>
|
|
499
|
+
history ? `Conversation so far:\n${history}` : '',
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
ctx.useAgent({
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Test — spawn two agents in the same room to see them share context:
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
pnpm electric-agents spawn /chat-agent/agent-2 '{"chatroomId":"room-1"}' \
|
|
511
|
+
&& pnpm electric-agents send /chat-agent/agent-2 "What has been discussed so far?" \
|
|
512
|
+
&& pnpm electric-agents observe /chat-agent/agent-2
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Agent 2 sees agent 1's messages because both observe the same shared state.
|
|
516
|
+
|
|
517
|
+
**Key concepts:**
|
|
518
|
+
|
|
519
|
+
- `ctx.useContext()` — injects data into the LLM context (separate from the system prompt)
|
|
520
|
+
- `sourceBudget` — limits total tokens so long conversations don't overflow
|
|
521
|
+
- `cache: 'volatile'` — content changes every wake (vs `'stable'` for static docs, `'pinned'` for always-include)
|
|
522
|
+
- The content function is `async` — you can fetch from any source
|
|
523
|
+
|
|
524
|
+
## Step 8: Frontend — live queries
|
|
525
|
+
|
|
526
|
+
The agents are chatting, but we can't see it. Let's build a frontend that subscribes to the shared state and updates in real time — no polling.
|
|
527
|
+
|
|
528
|
+
The frontend uses `createAgentsClient` to connect to the agents server, then `useLiveQuery` from TanStack DB for reactive queries on durable stream collections.
|
|
529
|
+
|
|
530
|
+
`ui/index.html`:
|
|
531
|
+
|
|
532
|
+
```html
|
|
533
|
+
<!DOCTYPE html>
|
|
534
|
+
<html lang="en">
|
|
535
|
+
<head>
|
|
536
|
+
<meta charset="UTF-8" />
|
|
537
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
538
|
+
<title>Chat</title>
|
|
539
|
+
</head>
|
|
540
|
+
<body>
|
|
541
|
+
<div id="root"></div>
|
|
542
|
+
<script type="module" src="./main.tsx"></script>
|
|
543
|
+
</body>
|
|
544
|
+
</html>
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
`ui/main.tsx`:
|
|
548
|
+
|
|
549
|
+
```tsx
|
|
550
|
+
import { useState, useEffect, useRef } from 'react'
|
|
551
|
+
import { createRoot } from 'react-dom/client'
|
|
552
|
+
import { createAgentsClient, db } from '@electric-ax/agents-runtime'
|
|
553
|
+
import { useLiveQuery } from '@tanstack/react-db'
|
|
554
|
+
import type { Collection } from '@tanstack/db'
|
|
555
|
+
import { chatroomSchema } from '../entities/chatroom-schema'
|
|
556
|
+
|
|
557
|
+
const AGENTS_URL = 'http://localhost:4437'
|
|
558
|
+
const ROOM_ID = 'room-1'
|
|
559
|
+
|
|
560
|
+
function Chat() {
|
|
561
|
+
const [collection, setCollection] = useState<Collection<any> | null>(null)
|
|
562
|
+
const [error, setError] = useState<string | null>(null)
|
|
563
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
564
|
+
|
|
565
|
+
useEffect(() => {
|
|
566
|
+
const client = createAgentsClient({ baseUrl: AGENTS_URL })
|
|
567
|
+
client
|
|
568
|
+
.observe(db(ROOM_ID, chatroomSchema))
|
|
569
|
+
.then((sdb: any) => setCollection(sdb.collections.messages))
|
|
570
|
+
.catch((e: Error) => setError(e.message))
|
|
571
|
+
}, [])
|
|
572
|
+
|
|
573
|
+
const { data: messages = [] } = useLiveQuery(
|
|
574
|
+
collection
|
|
575
|
+
? (q) =>
|
|
576
|
+
q
|
|
577
|
+
.from({ m: collection })
|
|
578
|
+
.orderBy(({ m }) => (m as any).timestamp, 'asc')
|
|
579
|
+
.select(({ m }) => m)
|
|
580
|
+
: () => null,
|
|
581
|
+
[collection]
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
586
|
+
}, [messages.length])
|
|
587
|
+
|
|
588
|
+
if (error) return <div>Error: {error}</div>
|
|
589
|
+
if (!collection) return <div>Connecting...</div>
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<div
|
|
593
|
+
style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'system-ui' }}
|
|
594
|
+
>
|
|
595
|
+
<h1>Chatroom: {ROOM_ID}</h1>
|
|
596
|
+
<div
|
|
597
|
+
style={{
|
|
598
|
+
border: '1px solid #ddd',
|
|
599
|
+
borderRadius: 8,
|
|
600
|
+
padding: 16,
|
|
601
|
+
minHeight: 300,
|
|
602
|
+
}}
|
|
603
|
+
>
|
|
604
|
+
{messages.map((m: any) => (
|
|
605
|
+
<div key={m.key} style={{ marginBottom: 8 }}>
|
|
606
|
+
<strong>{m.senderName}:</strong> {m.text}
|
|
607
|
+
</div>
|
|
608
|
+
))}
|
|
609
|
+
<div ref={bottomRef} />
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
createRoot(document.getElementById('root')!).render(<Chat />)
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
Add dependencies: `pnpm add @tanstack/db @tanstack/react-db react react-dom` and dev dependencies: `pnpm add -D @types/react @types/react-dom @vitejs/plugin-react vite`
|
|
619
|
+
|
|
620
|
+
Add a Vite config (`vite.config.ts`):
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
import path from 'node:path'
|
|
624
|
+
import { defineConfig } from 'vite'
|
|
625
|
+
import react from '@vitejs/plugin-react'
|
|
626
|
+
|
|
627
|
+
export default defineConfig({
|
|
628
|
+
root: 'ui',
|
|
629
|
+
plugins: [react()],
|
|
630
|
+
resolve: {
|
|
631
|
+
alias: {
|
|
632
|
+
'@tanstack/db': path.resolve(
|
|
633
|
+
import.meta.dirname,
|
|
634
|
+
'node_modules/@tanstack/db'
|
|
635
|
+
),
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
server: { port: 5175 },
|
|
639
|
+
build: { outDir: '../dist', emptyOutDir: true },
|
|
640
|
+
})
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Run: `npx vite` (in a new terminal). Open `http://localhost:5175`.
|
|
644
|
+
|
|
645
|
+
Then send a message to an agent in the room:
|
|
646
|
+
|
|
647
|
+
```bash
|
|
648
|
+
pnpm electric-agents send /chat-agent/agent-1 "What's the weather like today?"
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
Watch the frontend update in real time as the agent responds.
|
|
652
|
+
|
|
653
|
+
**Key concepts:**
|
|
654
|
+
|
|
655
|
+
- `createAgentsClient({ baseUrl })` — connects the frontend to the agents server
|
|
656
|
+
- `client.observe(db(roomId, schema))` — subscribes to a shared state stream (SSE)
|
|
657
|
+
- `useLiveQuery` — reactive query that re-renders when the collection changes
|
|
658
|
+
- `.select(({ m }) => m)` — flattens the query result (removes the alias wrapper)
|
|
659
|
+
- No polling — the durable stream pushes updates to the browser via SSE
|
|
660
|
+
|
|
661
|
+
## What you learned
|
|
662
|
+
|
|
663
|
+
| Step | Concept | API |
|
|
664
|
+
| ---- | ----------------------- | --------------------------------------------------------------- |
|
|
665
|
+
| 1 | Entity types & handlers | `registry.define()`, `ctx.useAgent()`, `ctx.agent.run()` |
|
|
666
|
+
| 2 | Spawning children | `ctx.spawn()`, `wake: 'runFinished'` |
|
|
667
|
+
| 3 | State collections | `state: { children: { primaryKey: 'key' } }` |
|
|
668
|
+
| 6 | Shared state | `ctx.mkdb()`, `ctx.observe(db(...))`, cross-entity coordination |
|
|
669
|
+
| 7 | Context assembly | `ctx.useContext()`, `sourceBudget`, cache tiers |
|
|
670
|
+
| 8 | Live frontend | `createAgentsClient`, `useLiveQuery`, `.select()` |
|
|
671
|
+
|
|
672
|
+
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.
|