@electric-ax/agents 0.4.12 → 0.4.13

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.
@@ -0,0 +1,1156 @@
1
+ ---
2
+ title: Walkthrough
3
+ titleTemplate: "... - Electric Agents"
4
+ description: >-
5
+ Walkthrough the steps, one change at a time, to go from a vanilla web app to a dynamic, multi-agent system with Electric Agents.
6
+ outline: [2, 3]
7
+ prev:
8
+ text: 'Quickstart'
9
+ link: '/docs/agents/quickstart'
10
+ next:
11
+ text: 'Usage - Overview'
12
+ link: '/docs/agents/usage/overview'
13
+ ---
14
+
15
+ <script setup>
16
+ import YoutubeEmbed from '../../src/components/YoutubeEmbed.vue'
17
+ </script>
18
+
19
+ <style scoped>
20
+ figure,
21
+ .embed-container {
22
+ margin: 32px 0;
23
+ border-radius: 2px;
24
+ overflow: hidden;
25
+ }
26
+ </style>
27
+
28
+ # Walkthrough
29
+
30
+ This guide walks through the steps to go from a new, or existing, web or mobile application to a dynamic <span class="no-wrap">multi-agent</span> system with [Electric Agents](/agents/).
31
+
32
+ > [!Warning] <span style="font-weight: 700; font-size: 110%;">✨</span>&nbsp; Example app
33
+ > This guide has an accompanying [example app](https://github.com/electric-sql/electric/tree/main/examples/agents-walkthrough) and [walkthrough video](https://youtu.be/beYF8FV019w).
34
+
35
+ <div class="embed-container">
36
+ <YoutubeEmbed video-id="beYF8FV019w" title="Electric Agents walkthrough" />
37
+ </div>
38
+
39
+ ## Getting started
40
+
41
+ The steps in this guide start with setting up a vanilla [Hono](https://hono.dev) app.
42
+
43
+ We've chosen Hono because it's small and simple. You can easily adapt the steps to work with any TypeScript-based framework, such as [Next.js](https://nextjs.org/), [TanStack](https://tanstack.com/start/latest) or [Expo](https://expo.dev).
44
+
45
+ #### Pre-requisites
46
+
47
+ You'll need the same dependencies as the [Quickstart](/docs/agents/quickstart#what-you-ll-need):
48
+
49
+ - [Node.js 18+](https://nodejs.org/en/download/current) (with [pnpm](https://pnpm.io/installation))
50
+ - [Docker](https://docs.docker.com/get-docker/)
51
+ - [Anthropic API key](https://platform.claude.com/settings/keys)
52
+
53
+ #### Create your app
54
+
55
+ Generate a fresh Hono app:
56
+
57
+ ```sh
58
+ pnpm create hono@latest walkthrough \
59
+ --template nodejs \
60
+ --pm pnpm \
61
+ --install
62
+ ```
63
+
64
+ This command uses the nodejs template. Hono supports [various runtimes](https://hono.dev/docs/getting-started/basic), including edge functions to deploy your [agents as serverless functions](/blog/2026/06/04/serverless-agents).
65
+
66
+ Change into the generated folder and run the dev server:
67
+
68
+ ```sh
69
+ cd walkthrough
70
+ pnpm dev
71
+ ```
72
+
73
+ It should show that the server is running on [localhost:3000](localhost:3000):
74
+
75
+ ```
76
+ Server is running on http://localhost:3000
77
+ ```
78
+
79
+ Leave the server running and, in another terminal tab, navigate back to the same `walkthrough` folder and run:
80
+
81
+ ```sh
82
+ curl http://localhost:3000
83
+ ```
84
+
85
+ It should output something like:
86
+
87
+ ```
88
+ Hello Hono!%
89
+ ```
90
+
91
+ The source code for the app is in `src/index.ts`:
92
+
93
+ ```ts
94
+ import { serve } from '@hono/node-server'
95
+ import { Hono } from 'hono'
96
+
97
+ const app = new Hono()
98
+
99
+ app.get('/', (c) => {
100
+ return c.text('Hello Hono!')
101
+ })
102
+
103
+ serve({
104
+ fetch: app.fetch,
105
+ port: 3000
106
+ }, (info) => {
107
+ console.log(`Server is running on http://localhost:${info.port}`)
108
+ })
109
+ ```
110
+
111
+ As you can see, it's a very simple web app. Now let's start ⚡️ electrifying it!
112
+
113
+ #### Install Electric Agents
114
+
115
+ We're going to use the local dev server that comes with Electric Agents. This runs in Docker, so [make sure you have Docker running](https://docs.docker.com/get-started/introduction/get-docker-desktop/).
116
+
117
+ Install the Electric Agents runtime:
118
+
119
+ ```sh
120
+ pnpm add @electric-ax/agents-runtime@latest
121
+ ```
122
+
123
+ Install and run the Electric Agents dev server:
124
+
125
+ ```sh
126
+ pnpx electric-ax@latest agents start
127
+ ```
128
+
129
+ This will pull down and run some containers (Postgres, Electric and the Electric Agents server, which includes a Durable Streams server and the Electric Agents UI).
130
+
131
+ It should finish by outputting:
132
+
133
+ ```
134
+ Electric Agents dev environment is up.
135
+ Server + UI: http://localhost:4437
136
+ Docker project: electric-agents
137
+ ```
138
+
139
+ You can check that it's working [using the CLI](/docs/agents/reference/cli):
140
+
141
+ ```sh
142
+ pnpx electric-ax agents types
143
+ ```
144
+
145
+ Should show something like this:
146
+
147
+ ```
148
+ Built-in agents
149
+ NAME DESCRIPTION
150
+ ─────────────────────── ────────────────────────────────────────
151
+ ```
152
+
153
+ ::: details Where are the default agents?!
154
+
155
+ Because we're running with `agents start` rather than `agents quickstart` we don't get the default entities, like `horton` and `worker`, pre-installed like we do with the [Quickstart](/docs/agents/quickstart).
156
+
157
+ Instead, we get a clean runtime where we can define *our own* agent entities from scratch. Which is what we want for this walkthrough guide.
158
+
159
+ :::
160
+
161
+ #### Setup Caddy
162
+
163
+ Before we do anything else, let's setup Caddy to proxy access to the local agents server, so that we [can access it over HTTP/2](/docs/sync/guides/troubleshooting#slow-shapes-slow-hmr-slow-dev-server-mdash-why-is-my-local-development-slow) in local development.
164
+
165
+ 1. [install Caddy](https://caddyserver.com/docs/install) on your host machine
166
+ 2. run `caddy trust` so Caddy can [install its certificate](https://caddyserver.com/docs/command-line#caddy-trust)
167
+
168
+ Create a `Caddyfile` with the following contents in it:
169
+
170
+ ```caddyfile
171
+ {
172
+ log default {
173
+ level ERROR
174
+ }
175
+ }
176
+
177
+ localhost:4438 {
178
+ reverse_proxy localhost:4437 {
179
+ flush_interval -1
180
+ }
181
+ encode gzip
182
+ header {
183
+ Cache-Control "no-cache, no-transform"
184
+ X-Accel-Buffering "no"
185
+ }
186
+ }
187
+ ```
188
+
189
+ This proxies https://localhost:4438 to http://localhost:4437, which allows your browser to connect to Electric over HTTP/2.
190
+
191
+ In a new terminal tab, navigate back to this folder again and start Caddy:
192
+
193
+ ```sh
194
+ caddy start
195
+ ```
196
+
197
+ This should output some lines ending with something like:
198
+
199
+ ```
200
+ Successfully started Caddy (pid=13701) - Caddy is running in the background
201
+ ```
202
+
203
+ Great! Now one last step, let's configure our environment and API keys.
204
+
205
+ #### Configure API keys
206
+
207
+ Create a `.env` file with an `ANTHROPIC_API_KEY` in it:
208
+
209
+ ```sh
210
+ ANTHROPIC_API_KEY="sk-ant-..."
211
+ ```
212
+
213
+ You can generate API keys at [platform.claude.com/settings/keys](https://platform.claude.com/settings/keys). Make sure your key is valid and not overly rate limited.
214
+
215
+ Then finally update your `package.json` `dev` script to load the .env file by adding `--env-file=.env` to the `tsx watch` command.
216
+
217
+ So your dev script should look like this:
218
+
219
+ ```json
220
+ "scripts": {
221
+ "dev": "tsx watch --env-file=.env src/index.ts",
222
+ ...
223
+ },
224
+ ```
225
+
226
+ OK! We're now ready to define some agents!
227
+
228
+
229
+ ## Step 1 - Basic assistant
230
+
231
+ We're going to do all our work in the `src/index.ts` file. This currently contains just the minimal Hono app that we saw above:
232
+
233
+ ```ts
234
+ import { serve } from '@hono/node-server'
235
+ import { Hono } from 'hono'
236
+
237
+ const app = new Hono()
238
+
239
+ app.get('/', (c) => {
240
+ return c.text('Hello Hono!')
241
+ })
242
+
243
+ serve({
244
+ fetch: app.fetch,
245
+ port: 3000
246
+ }, (info) => {
247
+ console.log(`Server is running on http://localhost:${info.port}`)
248
+ })
249
+ ```
250
+
251
+ Add these lines at the top to import the Electric Agents runtime shim:
252
+
253
+ ```ts
254
+ import {
255
+ createEntityRegistry,
256
+ createRuntimeHandler
257
+ } from '@electric-ax/agents-runtime'
258
+ ```
259
+
260
+ Define where the services are running:
261
+
262
+ ```ts
263
+ const PORT = 3000
264
+ const SERVE_URL = `http://localhost:${PORT}`
265
+ const ELECTRIC_AGENTS_URL = 'http://localhost:4437'
266
+ const MODEL = 'claude-sonnet-4-6'
267
+ ```
268
+
269
+ Here we just hardcode the values (including the `MODEL` our agents will use). You'll want these to be configurable using env vars in production. How you do that depends on how you deploy your app.
270
+
271
+ ### Create entity registry
272
+
273
+ Create a top level [`EntityRegistry`](/docs/agents/usage/defining-entities):
274
+
275
+ ```ts
276
+ const registry = createEntityRegistry()
277
+ ```
278
+
279
+ This registry is where you define your agent entities. We're going to start by defining the simplest entity possible, a general assistant:
280
+
281
+ ```ts
282
+ registry.define("assistant", {
283
+ description: "A general-purpose AI assistant",
284
+ async handler(ctx) {
285
+ ctx.useAgent({
286
+ systemPrompt: "You are a helpful assistant.",
287
+ model: MODEL,
288
+ tools: [],
289
+ })
290
+ await ctx.agent.run()
291
+ }
292
+ })
293
+ ```
294
+
295
+ As you can see, this has a very simple `systemPrompt` and no `tools`. You can chat to it and it can reply to you and that's about it.
296
+
297
+ ### Create runtime handler
298
+
299
+ We then pass the registry to, and create, a [`RuntimeHandler`](/docs/agents/reference/runtime-handler):
300
+
301
+ ```ts
302
+ const runtime = createRuntimeHandler({
303
+ baseUrl: ELECTRIC_AGENTS_URL,
304
+ serveEndpoint: `${SERVE_URL}/electric-agents`,
305
+ registry,
306
+ })
307
+ ```
308
+
309
+ And wire it into the web app as a request handler:
310
+
311
+ ```ts
312
+ app.post('/electric-agents', (c) => {
313
+ return runtime.handleWebhookRequest(c.req.raw)
314
+ })
315
+ ```
316
+
317
+ This is all the boilerplate needed to wire up and expose all of your agents to the runtime server. So they can be [woken and notified](/docs/agents/usage/waking-entities) by the webhook notification system when there are events to consume and respond to.
318
+
319
+ ::: details How does the communication work?
320
+
321
+ All of the actual messaging and communication to and between agents happens over Durable Streams. Specifically using the [built-in StreamDB collections](/docs/agents/reference/built-in-collections).
322
+
323
+ The notification system wakes the agents and tells them that there's new data on the streams to consume. This allows agents to sleep (and thus scale to zero) when not being used.
324
+
325
+ See the [Durable Streams](/blog/2026/04/08/data-primitive-agent-loop) and [Serverless Agents](/blog/2026/06/04/serverless-agents) blog posts for more information.
326
+
327
+ :::
328
+
329
+ ### Register entity types
330
+
331
+ The last step is to [register the entity types](/docs/agents/usage/app-setup#registertypes) with the Electric Agents runtime server. This can be done at startup time, when reloading an app in development or via your build or migration scripts.
332
+
333
+ In this case, we can just add a `runtime.registerTypes()` call to the `serve` callback function that executes once the app is running:
334
+
335
+ ```js
336
+ serve({
337
+ fetch: app.fetch,
338
+ port: 3000
339
+ }, (info) => {
340
+ console.log(`Server is running on http://localhost:${info.port}`)
341
+
342
+ runtime.registerTypes().catch(console.error)
343
+ })
344
+ ```
345
+
346
+ Then when you run the app you should see:
347
+
348
+ ```
349
+ INFO: [agent-runtime] Registered entity type: assistant
350
+ ```
351
+
352
+ Check the registered entities types on the command line:
353
+
354
+ ```sh
355
+ pnpx electric-ax@latest agents types
356
+ ```
357
+
358
+ Which will now show:
359
+
360
+ ```
361
+ http://host.docker.internal:3000/electric-agents
362
+ NAME DESCRIPTION
363
+ ─────────────────────── ────────────────────────────────────────
364
+ assistant A general-purpose AI assistant
365
+
366
+ Built-in agents
367
+ NAME DESCRIPTION
368
+ ─────────────────────── ────────────────────────────────────────
369
+ ```
370
+
371
+ Open the web UI on [https://localhost:4438](https://localhost:4438) (note that this uses HTTPS on port 4438 &mdash; we want the web UI to connect via the Caddy proxy).
372
+
373
+ Click "New session" you'll see your entity type in the list:
374
+
375
+ <figure style="border: 0.5px solid #aaa">
376
+ <a href="https://localhost:4438" target="_blank" class="no-visual">
377
+ <img src="/img/walkthrough/assistant-entity.jpg" />
378
+ </a>
379
+ </figure>
380
+
381
+ Go ahead and spawn an assistant and chat to it!
382
+
383
+ ## Step 2 - Imperative spawning
384
+
385
+ So far we've defined an `assistant` entity and seen how we can spawn and interact with it. In this step, we're going to build our first multi-agent system.
386
+
387
+ Let's start with a deliberately naive approach: defining a manager agent that spawns a worker every time it gets a message. (We'll extend this to more useful patterns later on but let's go one step at a time so the progression is nice and clear).
388
+
389
+ ### Dynamic assistant
390
+
391
+ First let's extend our `assistant` to accept a systemPrompt:
392
+
393
+ ```ts
394
+ registry.define("assistant", {
395
+ description: "A general-purpose AI assistant",
396
+ async handler(ctx) {
397
+ ctx.useAgent({
398
+ systemPrompt: ctx.args.systemPrompt || "You are a helpful assistant.",
399
+ model: MODEL,
400
+ tools: []
401
+ })
402
+
403
+ await ctx.agent.run()
404
+ },
405
+ })
406
+ ```
407
+
408
+ This means the `systemPrompt` for the assistant can be defined when it's spawned.
409
+
410
+ We'll also add a small helper to generate entity IDs, which we'll reuse whenever we spawn a sub-agent:
411
+
412
+ ```ts
413
+ const genId = () => Math.random().toString()
414
+ ```
415
+
416
+ ### Manager agent
417
+
418
+ Then let's define a new `manager` entity type:
419
+
420
+ ```ts
421
+ registry.define("manager", {
422
+ description: "A manager agent that delegates work to assistants",
423
+ async handler(ctx, wake) {
424
+ if (wake.type === 'inbox') {
425
+ await ctx.spawn(
426
+ 'assistant',
427
+ genId(),
428
+ { systemPrompt: `Reverse the user message.` },
429
+ { initialMessage: wake.payload.text, wake: { on: 'runFinished', includeResponse: true } }
430
+ )
431
+ }
432
+
433
+ ctx.useAgent({
434
+ systemPrompt: ctx.args.systemPrompt || "You are a manager agent.",
435
+ model: MODEL,
436
+ tools: []
437
+ })
438
+
439
+ await ctx.agent.run()
440
+ },
441
+ })
442
+ ```
443
+
444
+ This is very similar to our `assistant` type but, as you can see, adds this imperative logic to the beginning of the `handler` function:
445
+
446
+ ```ts
447
+ if (wake.type === 'inbox') {
448
+ await ctx.spawn(
449
+ 'assistant',
450
+ genId(),
451
+ { systemPrompt: `Reverse the user message.` },
452
+ { initialMessage: wake.payload.text, wake: { on: 'runFinished', includeResponse: true } }
453
+ )
454
+ }
455
+ ```
456
+
457
+ What this does is say "if the notification you're responding to comes from the inbox stream", which means it's a user message, then spawn a sub-agent, specifically an `assistant` with a "Reverse the user message" systemPrompt, passing through the user message from `wake.payload.text`.
458
+
459
+ Now if you go back to the web UI on [https://localhost:4438](https://localhost:4438) you can now also create `manager` agents:
460
+
461
+ <figure style="border: 0.5px solid #aaa">
462
+ <a href="https://localhost:4438" target="_blank" class="no-visual">
463
+ <img src="/img/walkthrough/manager-entity.jpg" />
464
+ </a>
465
+ </figure>
466
+
467
+ Create one and send it a message. You'll see the child entity in the UI. In the menu on the left-hand side you'll see it says "manager + 1"; expand that to see the sub-agents in the menu bar.
468
+
469
+ Click through to the sub-agent, you'll see it's reversed the message. Back in the manager agent thread it receives the notification of the sub-agent response, but it doesn't *understand* it:
470
+
471
+ <figure>
472
+ <a href="https://localhost:4438" target="_blank" class="no-visual">
473
+ <img src="/img/walkthrough/manager-interaction.png" />
474
+ </a>
475
+ </figure>
476
+
477
+ It knows it's a manager agent (from its systemPrompt) but it doesn't realise that it spawned the sub-agent or that the sub-agent is responding to its instructions. That's because the sub-agent was spawned in *our imperative code*, not in the session context using a tool call.
478
+
479
+ ## Step 3 - Tool call spawning
480
+
481
+ What we need is to spawn the sub-agent using a tool call that the manager agent can see in its context, because it's tracked in the session log.
482
+
483
+ For this, we're going to define a tool that the manager agent can use.
484
+
485
+ Let's first add a dependency to help with the typing:
486
+
487
+ ```sh
488
+ pnpm add @sinclair/typebox
489
+ ```
490
+
491
+ Import it at the top of the file (we're still working in the same file &mdash; `src/index.ts`):
492
+
493
+ ```ts
494
+ import { Type, type Static } from '@sinclair/typebox'
495
+ ```
496
+
497
+ ### Spawn assistant tool
498
+
499
+ Let's define a tool to spawn an assistant:
500
+
501
+ ```ts
502
+ const taskParameters = Type.Object({
503
+ task: Type.String({ description: "The task for the assistant." }),
504
+ })
505
+ type TaskParams = Static<typeof taskParameters>
506
+
507
+ function createSpawnAssistantTool(ctx) {
508
+ return {
509
+ name: "spawn_assistant",
510
+ label: "Spawn Assistant",
511
+ description: "Spawn an assistant sub-agent to perform a task.",
512
+ parameters: taskParameters,
513
+ execute: async (_toolCallId: string, { task }: TaskParams) => {
514
+ const { entityUrl } = await ctx.spawn(
515
+ 'assistant',
516
+ genId(),
517
+ {},
518
+ { initialMessage: task, wake: { on: 'runFinished', includeResponse: true } },
519
+ )
520
+
521
+ return {
522
+ content: [{
523
+ type: 'text' as const,
524
+ text: `Assistant dispatched at ${entityUrl}.`,
525
+ }],
526
+ details: { entityUrl },
527
+ terminate: true
528
+ }
529
+ },
530
+ }
531
+ }
532
+ ```
533
+
534
+ To follow the code, the `parameters`, in this case the `taskParameters` schema define the input parameters for the tool. These are the values that the LLM generates when requesting the tool call ("spawn an assistant with this task").
535
+
536
+ The [`ctx.spawn`](/docs/agents/usage/spawning-and-coordinating#spawn) call that spawns the sub-agent moves into the tool call `execute` function. The tool call response also returns a response and some structured data, including the `entityUrl` in the `details`.
537
+
538
+ ### Simplify the manager
539
+
540
+ We can now update our manager entity to remove the previous imperative `ctx.spawn` logic and instead pass the spawn assistant tool into the `tools` array:
541
+
542
+ ```ts
543
+ registry.define("manager", {
544
+ description: "A manager agent that delegates work to an assistant",
545
+ async handler(ctx) {
546
+ ctx.useAgent({
547
+ systemPrompt: `
548
+ When given a user message that is a single word, spawn an
549
+ assistant to reverse the user message.
550
+
551
+ When asked direct questions, answer them yourself.
552
+ `,
553
+ model: MODEL,
554
+ tools: [createSpawnAssistantTool(ctx)],
555
+ })
556
+
557
+ await ctx.agent.run()
558
+ },
559
+ })
560
+ ```
561
+
562
+ Now when we spawn a manager and message it, we see the assistant spawn and report back, and the manager agent is aware of the sub-agent. Ask the manager:
563
+
564
+ > who reversed this message? how did that happen/work?
565
+
566
+ It's smart enough to explain what happened.
567
+
568
+ ## Step 4 - Multi-agent
569
+
570
+ Now let's do something a bit more ambitious and useful. Let's define another entity type, a `judge` that itself spawns sub-agents to argue two sides of a debate.
571
+
572
+ ### Judge entity
573
+
574
+ First define the `judge` entity:
575
+
576
+ ```ts
577
+ registry.define("judge", {
578
+ description: "A judge that coordinates a two-sided debate",
579
+ async handler(ctx) {
580
+ ctx.useAgent({
581
+ systemPrompt: `You are a fair, concise judge coordinating a multi-agent debate.
582
+
583
+ Your job is to:
584
+ 1. Spawn exactly two assistant sub-agents:
585
+ - "A" side debater: argues one case (e.g.: beneficial / pro / one side of the argument)
586
+ - "B" side debater: argues the other case (e.g.: harmful / against / the other side)
587
+ 2. Give each assistant a clear brief with the debate topic and the side they must argue.
588
+ 3. Ask each assistant to respond to you with a concise argument and their strongest three points.
589
+ 4. End your turn after spawning them. When each assistant finishes, wait until you have both responses.
590
+ 5. Summarize the key arguments of the debate and provide your judge's verdict to the parent agent.
591
+
592
+ Notes:
593
+ - You are an impartial judge.
594
+ - Use the assistants to gather the two sides.
595
+ - Wait for **all** of the assistants to return **full** responses. Don't respond to partial / in-progress responses.
596
+ - Do not generate/hallucinate the argument yourself. You must wait for the assistants to fully respond and then synthesize their responses. Don't anticipate or make them up.
597
+ - Wait until the debate is fully finished before reporting back to the parent agent.`,
598
+ model: MODEL,
599
+ tools: [createSpawnAssistantTool(ctx)]
600
+ })
601
+
602
+ await ctx.agent.run()
603
+ },
604
+ })
605
+ ```
606
+
607
+ As you can see, most of the work is in the prompt. Note also that the judge is given the spawn assistant tool.
608
+
609
+ ### Spawn judge tool
610
+
611
+ Add a tool to spawn a judge:
612
+
613
+ ```ts
614
+ const topicParameters = Type.Object({
615
+ topic: Type.String({ description: "The topic to debate." }),
616
+ })
617
+ type TopicParams = Static<typeof topicParameters>
618
+
619
+ function createSpawnJudgeTool(ctx) {
620
+ return {
621
+ name: "spawn_judge",
622
+ label: "Spawn Judge",
623
+ description: "Spawn a judge agent that coordinates a two-sided debate and reports the result back here. Use this when the user asks agents to debate a topic.",
624
+ parameters: topicParameters,
625
+ execute: async (_toolCallId: string, { topic }: TopicParams) => {
626
+ const { entityUrl } = await ctx.spawn(
627
+ 'judge',
628
+ genId(),
629
+ {},
630
+ { initialMessage: `Set up a debate on this topic: ${topic}`, wake: { on: 'runFinished', includeResponse: true } },
631
+ )
632
+
633
+ return {
634
+ content: [{
635
+ type: 'text' as const,
636
+ text: `Judge dispatched at ${entityUrl}.`,
637
+ }],
638
+ details: { entityUrl },
639
+ terminate: true
640
+ }
641
+ },
642
+ }
643
+ }
644
+ ```
645
+
646
+ Give the tool to the manager and tweak the manager's systemPrompt:
647
+
648
+ ```ts
649
+ registry.define("manager", {
650
+ description: "A manager agent that delegates work to an assistant",
651
+ async handler(ctx) {
652
+ ctx.useAgent({
653
+ systemPrompt: `
654
+ When asked to debate a topic, spawn a Judge with the debate topic.
655
+
656
+ When given a user message that is a single word, spawn an
657
+ assistant to reverse the user message.
658
+
659
+ When asked direct questions, answer them yourself.
660
+ `,
661
+ model: MODEL,
662
+ tools: [createSpawnAssistantTool(ctx), createSpawnJudgeTool(ctx)],
663
+ })
664
+
665
+ await ctx.agent.run()
666
+ },
667
+ })
668
+ ```
669
+
670
+ Now create a new manager session and instruct it to debate an issue, for example:
671
+
672
+ > Debate 996 vs 4-day-week
673
+
674
+ You'll see it spawn a judge *and* you'll see the judge spawn the two assistants.
675
+
676
+ However, exactly what happens is up to the LLM's interpretation of its system prompt. What happens varies run by run and the judge and manager often hallucinate the debate results without waiting for the arguments to actually come in.
677
+
678
+ ### "Make no mistakes"
679
+
680
+ In an agentic system, we want the LLM to be able to express itself by choosing and configuring the right tool calls in the right way. However, it's often tricky to get the LLM to always do the right thing.
681
+
682
+ In the judge prompt above, we added a series of notes to the instructions to prevent the judge from making the results up and responding too early:
683
+
684
+ ```
685
+ Notes:
686
+ - You are an impartial judge.
687
+ - Use the assistants to gather the two sides.
688
+ - Wait for **all** of the assistants to return **full** responses. Don't respond to partial / in-progress responses.
689
+ - Do not generate/hallucinate the argument yourself. You must wait for the assistants to fully respond and then synthesize their responses. Don't anticipate or make them up.
690
+ - Wait until the debate is fully finished before reporting back to the parent agent.
691
+ ```
692
+
693
+ These kind of instructions may be familiar to you if you're used to instructing LLMs! They often work, especially with better models. However, LLMs are non-deterministic and there's always a small chance they won't follow instructions perfectly.
694
+
695
+ Say we step things up a level and make the debate control flow more complex. Let's add a phase to the debate by instructing the judge to pass each of the assistants' arguments to the opposing side so they can critique and rebut them before the judge summarizes the debate.
696
+
697
+ ```
698
+ Manager Judge Assistant A Assistant B
699
+ │ │ │ │
700
+ ├── topic ──▶│ │ │
701
+ │ │ │ │
702
+ ── phase 1: arguing ───────────────────────────────────────
703
+ │ ├─ spawn + brief ─▶│ │
704
+ │ ├──────────────────┼─ spawn + brief ─▶│
705
+ │ │ │ │
706
+ │ │◀──── argument ───┤ │
707
+ │ │◀─────────────────┼──── argument ────┤
708
+ │ │ │ │
709
+ ── phase 2: critiquing ─────────────────────────────────────
710
+ │ ├── B's argument ─▶│ │
711
+ │ ├──────────────────┼─ A's argument ──▶│
712
+ │ │ │ │
713
+ │ │◀──── rebuttal ───┤ │
714
+ │ │◀─────────────────┼──── rebuttal ────┤
715
+ │ │ │ │
716
+ ── phase 3: verdict ───────────────────────────────────────
717
+ │◀─ verdict ─┤ │ │
718
+ ```
719
+
720
+ We could imagine updating the steps in the system prompt like this:
721
+
722
+ ```
723
+ When (and only when) you receive a user message:
724
+ 1. Spawn exactly two assistant sub-agents:
725
+ - "A" side debater: argues one case (e.g.: beneficial / pro / one side of the argument)
726
+ - "B" side debater: argues the other case (e.g.: harmful / against / the other side)
727
+ 2. Give each assistant a clear brief with the debate topic and the side they must argue.
728
+ 3. Ask each assistant to respond to you with a concise argument and their strongest three points.
729
+ 4. End your turn after spawning them. When each assistant finishes, wait until you have both responses.
730
+ 5. Message the existing assistants (using the entityUrl from step 1) to send each response to the other side to critique. So the A-side debater can critique the B-side debater and vice versa. End your turn and wait for both critique responses.
731
+ 6. Once both assistants return their critiques, review and compare their arguments.
732
+ 7. Summarize the key arguments of the debate and provide your judge's verdict to the parent agent.
733
+ ```
734
+
735
+ These instructions are fairly clear but it would be very easy for the LLM to go off-piste. Rather than using this longer system prompt, let's instead evolve our system to use a hybrid approach that combines the LLM instructions with imperative control flow based on durable state.
736
+
737
+ ## Step 5 - Hybrid control flow
738
+
739
+ In this step, we're going to:
740
+
741
+ 1. define a `start_debate` tool call to spawn the two debating assistants
742
+ 2. add a `debate` collection to the durable state kept by the judge entity
743
+ 3. significantly update the judge entity to use imperative control flow
744
+ 4. update the manager agent to observe the judge's durable state
745
+
746
+ This will make the system much more reliable.
747
+
748
+ ### Start debate tool
749
+
750
+ Rather than prompting the judge to create two assistants to argue each side of the debate, we're going to define a tool call that does this instead.
751
+
752
+ This spawns the two assistants and allows the progress and status of the debate to be tracked in the state layer.
753
+
754
+ First define the parameters for the tool call. Note that the LLM still writes the briefs:
755
+
756
+ ```ts
757
+ const startDebateParameters = Type.Object({
758
+ topic: Type.String({
759
+ description: `Short topic line, e.g. "996 vs 4-day work week".`,
760
+ }),
761
+ aBrief: Type.String({
762
+ description: `Brief for the A debater: topic, side, ask for their concise argument and points.`,
763
+ }),
764
+ bBrief: Type.String({ description: `Brief for the B debater: same shape.` }),
765
+ })
766
+ type StartDebateParams = Static<typeof startDebateParameters>
767
+ ```
768
+
769
+ Define the tool, which uses [`ctx.spawn`](/docs/agents/usage/spawning-and-coordinating#spawn) to spawn the two sub-agents and [`ctx.state.debate.insert`](/docs/agents/usage/managing-state#writing-and-reading-state) to setup the debate state:
770
+
771
+ ```ts
772
+ function createStartDebateTool(ctx: HandlerContext<any, any, any, any>) {
773
+ return {
774
+ name: `start_debate`,
775
+ label: `Start Debate`,
776
+ description: `Spawn the two debaters with their opening briefs. Call exactly once.`,
777
+ parameters: startDebateParameters,
778
+ execute: async (_id: string, params: unknown) => {
779
+ // Spawn the two sub-agents
780
+ const { topic, aBrief, bBrief } = params as StartDebateParams
781
+ const [a, b] = await Promise.all([
782
+ ctx.spawn(
783
+ `assistant`,
784
+ genId(),
785
+ {},
786
+ { initialMessage: aBrief, wake: { on: `runFinished`, includeResponse: true } }
787
+ ),
788
+ ctx.spawn(
789
+ `assistant`,
790
+ genId(),
791
+ {},
792
+ { initialMessage: bBrief, wake: { on: `runFinished`, includeResponse: true } }
793
+ ),
794
+ ])
795
+
796
+ // Setup the debate state
797
+ ctx.state.debate.insert({
798
+ key: `current`,
799
+ topic,
800
+ aUrl: a.entityUrl,
801
+ bUrl: b.entityUrl,
802
+ phase: `arguing`,
803
+ arguments: {},
804
+ rebuttals: {},
805
+ })
806
+
807
+ return {
808
+ content: [
809
+ {
810
+ type: `text` as const,
811
+ text: `Debate started.`,
812
+ },
813
+ ],
814
+ details: {},
815
+ terminate: true,
816
+ }
817
+ },
818
+ }
819
+ }
820
+ ```
821
+
822
+ Lastly, also define this helper function that we'll use below in the judge entity handler logic:
823
+
824
+ ```ts
825
+ const rebut = (arg: string) =>
826
+ `Your opponent argued:\n\n${arg}\n\nRebut their argument(s).`
827
+ ```
828
+
829
+ ### Debate collection
830
+
831
+ Now let's add the `debate` [collection](https://tanstack.com/db/latest/docs/overview#defining-collections) to the entity definition's [`state`](/docs/agents/usage/shared-state). This allows us to track the progress and status of a debate in the [durable state layer](/streams/).
832
+
833
+ Pull in two more imports from `@electric-ax/agents-runtime`:
834
+
835
+ ```ts
836
+ import {
837
+ // ...,
838
+ entity,
839
+ passthrough,
840
+ } from '@electric-ax/agents-runtime'
841
+ ```
842
+
843
+ Define a schema for the collection data:
844
+
845
+ ```ts
846
+ type Debate = {
847
+ key: 'current'
848
+ topic: string
849
+ aUrl: string
850
+ bUrl: string
851
+ phase: 'arguing' | 'critiquing' | 'done'
852
+ arguments: { a?: string; b?: string }
853
+ rebuttals: { a?: boolean; b?: boolean }
854
+ }
855
+ ```
856
+
857
+ > [!Tip] ℹ&nbsp; What is a collection?
858
+ > Electric Agents uses [TanStack DB](/sync/tanstack-db) under the hood. [Collections](https://tanstack.com/db/latest/docs/overview#defining-collections) are the core reactive data abstraction for TanStack DB.
859
+
860
+ Configure the collection on the judge entity's `state`:
861
+
862
+ ```ts
863
+ registry.define('judge', {
864
+ description: `Coordinates a three-phase debate: arguments, mutual rebuttals, verdict.`,
865
+ state: {
866
+ debate: { schema: passthrough<Debate>(), primaryKey: 'key' },
867
+ },
868
+ async handler(ctx, wake) {
869
+ // ...
870
+ }
871
+ })
872
+ ```
873
+
874
+ ### Judge handler logic
875
+
876
+ We can now update the handler logic for the judge entity (the code examples below go inside the `async handler(ctx, wake) { ... }` function shown above).
877
+
878
+ First, let's add a guard that handles inbox messages (the normal user messages from the parent, the manager agent) that ensures we only create one debate at a time and passes in the start debate tool:
879
+
880
+ ```ts
881
+ // Handle inbox messages
882
+ if (wake.type === 'inbox') {
883
+
884
+ // Only allow one debate at a time.
885
+ if (ctx.state.debate.get('current')) {
886
+ return ctx.sleep()
887
+ }
888
+
889
+ // Pass in the start debate tool
890
+ ctx.useAgent({
891
+ systemPrompt: SETUP_PROMPT,
892
+ model: MODEL,
893
+ tools: [createStartDebateTool(ctx)],
894
+ })
895
+
896
+ return ctx.agent.run()
897
+ }
898
+ ```
899
+
900
+ > [!Tip] ℹ&nbsp; Understanding wake notifications
901
+ > The agent receives a [wake notification](/docs/agents/usage/waking-entities#what-produces-a-wake) when there's a new message or a child sub-agent finishes a run. So in this example, the judge will receive wake notifications from the assistants:
902
+ >
903
+ > 1. when they finish generating their initial argument
904
+ > 2. when they critique their opponent's argument
905
+ >
906
+ > These notifications call the entity handler function with a `wake.type` of `'wake'`. As opposed to user messages which have a `wake.type` of `'inbox'`.
907
+
908
+ Because the logic above matches all inbox messages, any other events will be wake notifications from the assistant sub-agents. When handling these, we can use and update the durable state to control and track the progress of the debate.
909
+
910
+ #### Using durable state
911
+
912
+ When receiving a wake notification, check the status of the debate:
913
+
914
+ ```ts
915
+ // Read the durable state
916
+ let debate = ctx.state.debate.get(`current`)
917
+ ```
918
+
919
+ If the debate is in the initial `'arguing'` phase, then the wake notification will be from an assistant responding with their initial argument. In this case, we want to record the argument and then check whether both arguments have been received.
920
+
921
+ If they have, we can [`ctx.send`](/docs/agents/usage/spawning-and-coordinating#send) the arguments to the other assistant to rebut and then update the state of the debate to be in the "critiquing" phase:
922
+
923
+ ```ts
924
+ // If the assistants are still making their first arguments
925
+ if (debate.phase === 'arguing') {
926
+
927
+ // Record the argument
928
+ ctx.state.debate.update('current', d => { d.arguments[side] = finished.response ?? '' })
929
+ debate = ctx.state.debate.get('current')
930
+
931
+ // If we've received both arguments, send them to the other assistant
932
+ // and update the debate state to move into the critiquing phase.
933
+ if (debate.arguments.a !== undefined && debate.arguments.b !== undefined) {
934
+ ctx.send(debate.aUrl, rebut(debate.arguments.b))
935
+ ctx.send(debate.bUrl, rebut(debate.arguments.a))
936
+
937
+ ctx.state.debate.update('current', d => { d.phase = 'critiquing' })
938
+ }
939
+
940
+ return ctx.sleep()
941
+ }
942
+ ```
943
+
944
+ Then when we get notifications and the debate is in the `'critiquing'` phase, record that we've received the rebuttal and check whether both rebuttals have been received. If so, set the debate status to `'done'` and have the LLM write the verdict as its reply:
945
+
946
+ ```ts
947
+ ctx.state.debate.update('current', d => { d.rebuttals[side] = true })
948
+ debate = ctx.state.debate.get('current')
949
+
950
+ if (!debate.rebuttals.a || !debate.rebuttals.b) {
951
+ return ctx.sleep()
952
+ }
953
+
954
+ ctx.state.debate.update('current', d => { d.phase = 'done' })
955
+
956
+ ctx.useAgent({
957
+ systemPrompt: VERDICT_PROMPT,
958
+ model: MODEL,
959
+ tools: [],
960
+ })
961
+
962
+ return ctx.agent.run()
963
+ ```
964
+
965
+ ::: details See the whole judge entity definition
966
+
967
+ The whole entity definition with prompts looks like this:
968
+
969
+ ```ts
970
+ const SETUP_PROMPT = `You are a fair, concise debate judge opening a debate.
971
+ Call start_debate exactly once: pick the topic line, and write a clear brief for each side:
972
+ - "A" argues one case (e.g.: beneficial / pro / one side of the argument)
973
+ - "B" argues the other case (e.g.: harmful / against / the other side)
974
+ Each brief assigns only the topic and that side's position, then asks the debater to make a
975
+ concise argument with their own three strongest points. Do NOT supply, list, or hint at any
976
+ arguments yourself — the debater must devise their own.
977
+ Then end your turn. Do not narrate.`
978
+
979
+ const VERDICT_PROMPT = `You are a fair, concise debate judge closing a debate.
980
+ Both sides have argued and critiqued each other. The full exchange is in your context.
981
+ Weigh it and write your final verdict as your reply: summarise each side's strongest points,
982
+ note how each critique landed, and give your impartial decision. Never argue a side.
983
+ Do not narrate or preface — your reply IS the verdict, and it gets relayed to the user.`
984
+
985
+ registry.define(`judge`, {
986
+ description: `Coordinates a three-phase debate: arguments, mutual rebuttals, verdict.`,
987
+ state: {
988
+ debate: { schema: passthrough<Debate>(), primaryKey: `key` },
989
+ },
990
+ async handler(ctx, wake) {
991
+ // Handle inbox messages by spawning one debate at a time.
992
+ // Using the LLM to formulate the briefs for each side.
993
+
994
+ if (wake.type === `inbox`) {
995
+ if (ctx.state.debate.get(`current`)) {
996
+ return ctx.sleep()
997
+ }
998
+
999
+ ctx.useAgent({
1000
+ systemPrompt: SETUP_PROMPT,
1001
+ model: MODEL,
1002
+ tools: [createStartDebateTool(ctx)],
1003
+ })
1004
+
1005
+ await ctx.agent.run()
1006
+ return
1007
+ }
1008
+
1009
+ // Ignore wake notifications unless they're from finished children
1010
+ // participating in the current debate.
1011
+
1012
+ let debate = ctx.state.debate.get(`current`)
1013
+ if (!debate || debate.phase === `done`) {
1014
+ return ctx.sleep()
1015
+ }
1016
+
1017
+ const finished_child = wake.payload?.finished_child as
1018
+ | FinishedChild
1019
+ | undefined
1020
+ if (!finished_child) {
1021
+ return ctx.sleep()
1022
+ }
1023
+
1024
+ const side =
1025
+ finished_child.url === debate.aUrl
1026
+ ? `a`
1027
+ : finished_child.url === debate.bUrl
1028
+ ? `b`
1029
+ : null
1030
+ if (!side) {
1031
+ return ctx.sleep()
1032
+ }
1033
+
1034
+ // Record this debater's contribution for the current round.
1035
+
1036
+ if (debate.phase === `arguing`) {
1037
+ ctx.state.debate.update(`current`, (d) => {
1038
+ d.arguments[side] = finished_child.response ?? ``
1039
+ })
1040
+ debate = ctx.state.debate.get(`current`)!
1041
+
1042
+ // Proceed once both debaters have reported for this round.
1043
+
1044
+ if (
1045
+ debate.arguments.a !== undefined &&
1046
+ debate.arguments.b !== undefined
1047
+ ) {
1048
+ ctx.send(debate.aUrl, rebut(debate.arguments.b))
1049
+ ctx.send(debate.bUrl, rebut(debate.arguments.a))
1050
+
1051
+ ctx.state.debate.update(`current`, (d) => {
1052
+ d.phase = `critiquing`
1053
+ })
1054
+ }
1055
+
1056
+ return ctx.sleep()
1057
+ }
1058
+
1059
+ // We're in `phase === 'critiquing'`, wait until both are in.
1060
+
1061
+ ctx.state.debate.update(`current`, (d) => {
1062
+ d.rebuttals[side] = true
1063
+ })
1064
+ debate = ctx.state.debate.get(`current`)!
1065
+
1066
+ if (!debate.rebuttals.a || !debate.rebuttals.b) {
1067
+ return ctx.sleep()
1068
+ }
1069
+
1070
+ // Flip the phase to 'done' and have the LLM write the verdict as its reply.
1071
+
1072
+ ctx.state.debate.update(`current`, (d) => {
1073
+ d.phase = `done`
1074
+ })
1075
+
1076
+ ctx.useAgent({
1077
+ systemPrompt: VERDICT_PROMPT,
1078
+ model: MODEL,
1079
+ tools: [],
1080
+ })
1081
+
1082
+ await ctx.agent.run()
1083
+ },
1084
+ })
1085
+ ```
1086
+
1087
+ :::
1088
+
1089
+ ### Manager handler logic
1090
+
1091
+ We can then update the manager entity to ignore notifications from judge sub-agents until their debate is done:
1092
+
1093
+ ```ts
1094
+ registry.define(`manager`, {
1095
+ // ...,
1096
+ async handler(ctx, wake) {
1097
+ // When receiving wake notifications ...
1098
+ if ((wake.type = `wake`)) {
1099
+ const finishedChild = wake.payload?.finished_child
1100
+
1101
+ // ... from a judge sub-agent ...
1102
+ if (finishedChild?.type === `judge` && finishedChild.run_status === `completed`) {
1103
+ const judge = await ctx.observe(entity(finishedChild.url))
1104
+
1105
+ // ... ignore them if the debate is still in progress ...
1106
+ const debate = judge.db.collections.debate.get(`current`)
1107
+ if (debate?.phase !== `done`) {
1108
+ return ctx.sleep()
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ // ...
1114
+ ```
1115
+
1116
+ #### Observing child state
1117
+
1118
+ This uses the [`ctx.observe`](/docs/agents/usage/spawning-and-coordinating#observe) api to monitor the state of the judge agent:
1119
+
1120
+ ```ts
1121
+ const judge = await ctx.observe(entity(finishedChild.url))
1122
+ ```
1123
+
1124
+ This is a very powerful and expressive mechanism, because it means that agents [don't need to pre-define](/blog/2026/06/04/serverless-agents#turning-the-agent-inside-out) their APIs or communication interfaces.
1125
+
1126
+ They can just spawn agents with built in streams and durable state and observe / subscribe to their streams in real-time. For example, here are the wakes that the judge sees over a full debate, and what it actually does:
1127
+
1128
+ | # | Judge wake | Gate decision | Judge LLM run? | Manager woken? | Manager LLM run? |
1129
+ | --- | --- | --- | --- | --- | --- |
1130
+ | 1 | inbox | no debate yet → setup | **yes** (`start_debate`) | yes | no (debate not `done`) |
1131
+ | 2 | A argument | `arguments.b` missing → sleep | no | no | no |
1132
+ | 3 | B argument | both arguments in → forward rebuttals | no (imperative forward) | no | no |
1133
+ | 4 | A rebuttal | `rebuttals.b` missing → sleep | no | no | no |
1134
+ | 5 | B rebuttal | both rebuttals in → verdict | **yes** (write the verdict) | yes | yes (debate is `done`, relays verdict) |
1135
+
1136
+ The debate can't finish early. The judge only summarizes when the arguments and rebuttals are in. The manager only summarizes the verdict when it's properly returned. There's no path for the model to hallucinate answers or get the process wrong.
1137
+
1138
+ #### Hybrid control flow
1139
+
1140
+ That's the whole point of hybrid control flow: let the LLM interpret the prompt, make decisions and be creative when it needs to but otherwise use the durable state to make the control flow deterministic when you can.
1141
+
1142
+ So the LLM can't go off piste and your systems can be both expressive and reliable.
1143
+
1144
+ ## Next steps
1145
+
1146
+ Hopefully this has given you a sense of how to start building with Electric Agents.
1147
+
1148
+ You can see the source code for the steps in this guide in the [agents-walkthrough example app](https://github.com/electric-sql/electric/tree/main/examples/agents-walkthrough) and see an interactive walkthrough in the [screencast video](https://youtu.be/beYF8FV019w) below:
1149
+
1150
+ <div class="embed-container">
1151
+ <YoutubeEmbed video-id="beYF8FV019w" title="Electric Agents walkthrough" />
1152
+ </div>
1153
+
1154
+ See the [Usage overview](./usage/overview) for the full developer surface and see the [Playground example](/docs/agents/examples/playground) for more communication topologies and patterns.
1155
+
1156
+ If you have any questions, let us know on the [Electric Discord](https://discord.electric-sql.com).