@electric-ax/agents 0.4.19 → 0.6.0

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.
Files changed (40) hide show
  1. package/docs/entities/agents/horton.md +22 -17
  2. package/docs/entities/agents/worker.md +13 -6
  3. package/docs/entities/patterns/blackboard.md +1 -1
  4. package/docs/entities/patterns/dispatcher.md +1 -1
  5. package/docs/entities/patterns/manager-worker.md +10 -5
  6. package/docs/entities/patterns/map-reduce.md +1 -1
  7. package/docs/entities/patterns/pipeline.md +1 -1
  8. package/docs/entities/patterns/reactive-observers.md +1 -1
  9. package/docs/index.md +6 -4
  10. package/docs/quickstart.md +2 -2
  11. package/docs/reference/agent-config.md +13 -3
  12. package/docs/reference/built-in-collections.md +128 -9
  13. package/docs/reference/cli.md +34 -4
  14. package/docs/reference/entity-definition.md +39 -7
  15. package/docs/reference/entity-handle.md +19 -1
  16. package/docs/reference/handler-context.md +130 -5
  17. package/docs/reference/runtime-handler.md +42 -14
  18. package/docs/reference/wake-event.md +29 -1
  19. package/docs/usage/app-setup.md +38 -7
  20. package/docs/usage/attachments.md +129 -0
  21. package/docs/usage/clients-and-react.md +23 -2
  22. package/docs/usage/configuring-the-agent.md +15 -5
  23. package/docs/usage/context-composition.md +2 -1
  24. package/docs/usage/defining-entities.md +9 -5
  25. package/docs/usage/defining-tools.md +1 -1
  26. package/docs/usage/embedded-builtins.md +82 -31
  27. package/docs/usage/managing-state.md +5 -0
  28. package/docs/usage/mcp-servers.md +16 -8
  29. package/docs/usage/overview.md +39 -14
  30. package/docs/usage/permissions-and-principals.md +160 -0
  31. package/docs/usage/programmatic-runtime-client.md +158 -16
  32. package/docs/usage/sandboxing.md +162 -0
  33. package/docs/usage/signals.md +138 -0
  34. package/docs/usage/spawning-and-coordinating.md +30 -11
  35. package/docs/usage/testing.md +1 -1
  36. package/docs/usage/waking-entities.md +34 -6
  37. package/docs/usage/webhook-sources.md +171 -0
  38. package/docs/usage/writing-handlers.md +13 -55
  39. package/docs/walkthrough.md +13 -5
  40. package/package.json +3 -3
@@ -0,0 +1,162 @@
1
+ ---
2
+ title: Sandboxing
3
+ titleTemplate: "... - Electric Agents"
4
+ description: >-
5
+ Isolate file, process, and network access for LLM-driven tools with Electric Agents sandbox profiles.
6
+ outline: [2, 3]
7
+ ---
8
+
9
+ # Sandboxing
10
+
11
+ Electric Agents runs LLM-driven file, shell, and fetch tools through `ctx.sandbox`. The sandbox owns filesystem path resolution, subprocess execution, and network egress for the current wake session.
12
+
13
+ Sandboxing is configured by the runtime host, advertised to the server as named **sandbox profiles**, and selected when an entity is spawned.
14
+
15
+ ## Runtime profiles
16
+
17
+ Register sandbox profiles on the runtime:
18
+
19
+ ```ts
20
+ import { createRuntimeHandler } from "@electric-ax/agents-runtime"
21
+ import {
22
+ remoteSandbox,
23
+ unrestrictedSandbox,
24
+ } from "@electric-ax/agents-runtime/sandbox"
25
+
26
+ const runtime = createRuntimeHandler({
27
+ baseUrl: "http://localhost:4437",
28
+ registry,
29
+ sandboxProfiles: [
30
+ {
31
+ name: "local",
32
+ label: "Local",
33
+ description: "Trusted local development sandbox",
34
+ factory: ({ args }) =>
35
+ unrestrictedSandbox({
36
+ workingDirectory:
37
+ typeof args.workingDirectory === "string"
38
+ ? args.workingDirectory
39
+ : process.cwd(),
40
+ }),
41
+ },
42
+ {
43
+ name: "e2b",
44
+ label: "E2B",
45
+ description: "Remote VM sandbox",
46
+ remote: true,
47
+ factory: ({ sandboxKey, persistent, owner }) =>
48
+ remoteSandbox({
49
+ provider: "e2b",
50
+ sandboxKey,
51
+ persistent,
52
+ owner,
53
+ initialNetworkPolicy: { mode: "allow-all" },
54
+ }),
55
+ },
56
+ ],
57
+ })
58
+ ```
59
+
60
+ The runtime sends profile descriptors to the server during type/runtime registration. The factory stays local to the runtime; only names, labels, descriptions, and `remote` metadata cross the wire.
61
+
62
+ ## Built-in profiles
63
+
64
+ The sandbox package exports:
65
+
66
+ ```ts
67
+ import {
68
+ chooseDefaultSandbox,
69
+ unrestrictedSandbox,
70
+ remoteSandbox,
71
+ } from "@electric-ax/agents-runtime/sandbox"
72
+ import { dockerSandbox } from "@electric-ax/agents-runtime/sandbox/docker"
73
+ ```
74
+
75
+ | Provider | Use case | Notes |
76
+ | -------- | -------- | ----- |
77
+ | `unrestrictedSandbox()` | Trusted local development | Shares the host filesystem and process namespace. It is convenient, not a security boundary. |
78
+ | `dockerSandbox()` | Local isolation for multi-entity hosts | Requires Docker and `dockerode`. Recommended for untrusted or multi-tenant local workloads. |
79
+ | `remoteSandbox({ provider: "e2b" })` | Remote VM isolation | Requires the optional `e2b` package and provider credentials. Mark the profile `remote: true`. |
80
+ | `chooseDefaultSandbox()` | Built-in local default | Chooses the default local profile for built-in Horton and Worker runtimes. |
81
+
82
+ ## Handler access
83
+
84
+ Handlers and custom tools use `ctx.sandbox`:
85
+
86
+ ```ts
87
+ async handler(ctx) {
88
+ const result = await ctx.sandbox.exec({
89
+ command: "ls -la",
90
+ timeoutMs: 10_000,
91
+ signal: ctx.signal,
92
+ })
93
+
94
+ const readme = await ctx.sandbox.readFile("README.md")
95
+ const res = await ctx.sandbox.fetch("https://example.com")
96
+ }
97
+ ```
98
+
99
+ Pass paths straight to the sandbox. Do not pre-resolve paths against the host filesystem; the sandbox may be a container or remote VM with a different root.
100
+
101
+ The runtime owns sandbox disposal. Handlers should not call `ctx.sandbox.dispose()`.
102
+
103
+ ## Spawn-time selection
104
+
105
+ Select or inherit a sandbox when spawning:
106
+
107
+ ```ts
108
+ await ctx.spawn(
109
+ "worker",
110
+ "analysis",
111
+ { systemPrompt: "Inspect the workspace", tools: ["read", "bash"] },
112
+ {
113
+ initialMessage: "Start with package.json",
114
+ sandbox: "inherit",
115
+ }
116
+ )
117
+ ```
118
+
119
+ Object form gives more control:
120
+
121
+ ```ts
122
+ await client.spawnEntity({
123
+ type: "worker",
124
+ id: "isolated",
125
+ sandbox: {
126
+ profile: "docker",
127
+ scope: "entity",
128
+ persistent: true,
129
+ },
130
+ })
131
+ ```
132
+
133
+ Sandbox selection fields:
134
+
135
+ | Field | Meaning |
136
+ | ----- | ------- |
137
+ | `profile` | Named runtime profile to use. |
138
+ | `inherit` | Reuse the parent's resolved sandbox selection. |
139
+ | `key` | Explicit shared sandbox identity. |
140
+ | `scope` | `entity` for per-entity identity, or `wake` for per-wake identity. |
141
+ | `persistent` | Preserve sandbox state between wake sessions when supported. |
142
+ | `owner` | Whether this entity owns lifecycle teardown for the sandbox. |
143
+
144
+ ## Network policy
145
+
146
+ Sandbox network policy supports:
147
+
148
+ ```ts
149
+ type NetworkPolicy =
150
+ | { mode: "allow-all" }
151
+ | { mode: "deny-all" }
152
+ | { mode: "allowlist"; allow: string[] }
153
+ ```
154
+
155
+ `deny-all` is the strongest isolation mode on isolated providers. `allowlist` is provider-dependent: remote providers can enforce it at the VM boundary, while Docker currently uses it for sandbox `fetch()` paths rather than as a complete process-level egress boundary. Use `deny-all` when you need network isolation.
156
+
157
+ ## Security notes
158
+
159
+ - `unrestrictedSandbox()` is for trusted local code. It can reduce accidental path escapes, but it is not a security boundary.
160
+ - Built-in file tools now rely on the active sandbox for containment and do not forward the host `process.env` into shell commands.
161
+ - Remote and Docker sandboxes isolate more, but credentials and mounted data still need careful scoping.
162
+ - Use a per-entity or explicit sandbox key when a worker needs state to survive across wakes.
@@ -0,0 +1,138 @@
1
+ ---
2
+ title: Signals
3
+ titleTemplate: "... - Electric Agents"
4
+ description: >-
5
+ Interrupt, pause, resume, terminate, and notify Electric Agents entities with lifecycle signals.
6
+ outline: [2, 3]
7
+ ---
8
+
9
+ # Signals
10
+
11
+ Signals are lifecycle controls for entities. They let users and hosts interrupt active work, pause or resume entities, kill entities, and deliver custom lifecycle notifications to handlers.
12
+
13
+ Signal records are written to the entity's `signals` collection and appear in timeline helpers.
14
+
15
+ ## Supported signals
16
+
17
+ | Signal | Runtime behavior |
18
+ | ------ | ---------------- |
19
+ | `SIGINT` | Abort the active handler invocation through `ctx.signal`. Use for "stop current run". |
20
+ | `SIGSTOP` | Runtime-controlled pause. |
21
+ | `SIGCONT` | Runtime-controlled resume. |
22
+ | `SIGKILL` | Terminal kill/delete signal. |
23
+ | `SIGHUP` | Delivered to `ctx.onSignal()` handlers. |
24
+ | `SIGTERM` | Delivered to `ctx.onSignal()` handlers for graceful shutdown-style behavior. |
25
+ | `SIGUSR` | Delivered to `ctx.onSignal()` handlers for application-defined behavior. |
26
+
27
+ Runtime-controlled signals are handled by the runtime and are not delivered to `ctx.onSignal()`.
28
+
29
+ ## CLI
30
+
31
+ Send a signal from the CLI:
32
+
33
+ ```sh
34
+ electric agents signal /horton/onboarding SIGINT --reason "stop current run"
35
+ electric agents signal /horton/onboarding SIGUSR --payload '{"refresh":true}'
36
+ ```
37
+
38
+ `kill` is shorthand for a terminal signal:
39
+
40
+ ```sh
41
+ electric agents kill /horton/onboarding
42
+ ```
43
+
44
+ ## Programmatic clients
45
+
46
+ Use `createAgentsClient()` for UI-style clients:
47
+
48
+ ```ts
49
+ const client = createAgentsClient({
50
+ baseUrl: "http://localhost:4437",
51
+ principalKey: "user:sam",
52
+ })
53
+
54
+ await client.signal({
55
+ entityUrl: "/horton/onboarding",
56
+ signal: "SIGINT",
57
+ reason: "User clicked stop",
58
+ })
59
+
60
+ await client.kill("/horton/onboarding", "User deleted the session")
61
+ ```
62
+
63
+ Use `createRuntimeServerClient()` when you need the lower-level server client:
64
+
65
+ ```ts
66
+ await runtimeClient.signalEntity({
67
+ entityUrl: "/worker/analysis",
68
+ signal: "SIGUSR",
69
+ payload: { refresh: true },
70
+ })
71
+ ```
72
+
73
+ The caller needs `signal` permission on the entity, or `manage`.
74
+
75
+ ## Handler cancellation
76
+
77
+ Every handler receives `ctx.signal`, an `AbortSignal` that fires when the current wake should stop early. Pass it to cancellable work:
78
+
79
+ ```ts
80
+ async handler(ctx) {
81
+ const res = await fetch("https://api.example.com/data", {
82
+ signal: ctx.signal,
83
+ })
84
+
85
+ await ctx.sandbox.exec({
86
+ command: "npm test",
87
+ signal: ctx.signal,
88
+ timeoutMs: 60_000,
89
+ })
90
+ }
91
+ ```
92
+
93
+ `SIGINT` aborts this signal. Runtime shutdown can also abort it.
94
+
95
+ ## Handler-delivered signals
96
+
97
+ Use `ctx.onSignal()` for `SIGHUP`, `SIGTERM`, and `SIGUSR`:
98
+
99
+ ```ts
100
+ async handler(ctx) {
101
+ ctx.onSignal(async ({ signal, reason, payload }) => {
102
+ if (signal === "SIGUSR") {
103
+ ctx.insertContext("refresh-request", {
104
+ name: "Refresh request",
105
+ content: JSON.stringify({ reason, payload }),
106
+ attrs: {},
107
+ })
108
+ }
109
+ })
110
+
111
+ await ctx.agent.run()
112
+ }
113
+ ```
114
+
115
+ Handlers should keep signal callbacks short and idempotent. If the signal should trigger substantial work, record state or context and let the normal handler flow pick it up.
116
+
117
+ ## Signal records
118
+
119
+ Signal rows include the signal name, sender, reason, payload, handling status, outcome, and state transition fields:
120
+
121
+ ```ts
122
+ interface Signal {
123
+ key: string
124
+ signal: "SIGINT" | "SIGHUP" | "SIGTERM" | "SIGKILL" | "SIGSTOP" | "SIGCONT" | "SIGUSR"
125
+ status: "unhandled" | "handled"
126
+ sender?: string
127
+ reason?: string
128
+ payload?: unknown
129
+ timestamp: string
130
+ handled_at?: string
131
+ handled_by?: string
132
+ outcome?: "transitioned" | "ignored" | "invalid_for_state" | "delivered" | "aborted" | "shutdown_requested" | "failed"
133
+ previous_state?: ChildStatusEntry["status"]
134
+ new_state?: ChildStatusEntry["status"]
135
+ }
136
+ ```
137
+
138
+ See [Built-in collections](../reference/built-in-collections#signal) for the full row shape.
@@ -18,15 +18,17 @@ Create a child entity:
18
18
  const child = await ctx.spawn(type, id, args?, opts?)
19
19
  ```
20
20
 
21
- | Parameter | Type | Description |
22
- | --------------------- | ------------------------- | -------------------------------------- |
23
- | `type` | `string` | Entity type name (must be registered) |
24
- | `id` | `string` | Unique child ID |
25
- | `args` | `Record<string, unknown>` | Passed to child handler as `ctx.args` |
26
- | `opts.initialMessage` | `unknown` | First message delivered to child |
27
- | `opts.wake` | `Wake` | When to wake the parent (see below) |
28
- | `opts.tags` | `Record<string, string>` | Key-value tags applied to the child |
29
- | `opts.observe` | `boolean` | Also observe the child (default: true) |
21
+ | Parameter | Type | Description |
22
+ | ------------------------- | ------------------------- | -------------------------------------- |
23
+ | `type` | `string` | Entity type name (must be registered) |
24
+ | `id` | `string` | Unique child ID |
25
+ | `args` | `Record<string, unknown>` | Passed to child handler as `ctx.args` |
26
+ | `opts.initialMessage` | `unknown` | First message delivered to child |
27
+ | `opts.initialMessageType` | `string` | Optional inbox message type for `initialMessage` |
28
+ | `opts.wake` | `Wake` | When to wake the parent (see below) |
29
+ | `opts.tags` | `Record<string, string>` | Key-value tags applied to the child |
30
+ | `opts.observe` | `boolean` | Also observe the child (default: true) |
31
+ | `opts.sandbox` | `SpawnSandboxOption` | Sandbox profile or inheritance for the child |
30
32
 
31
33
  `spawn` is a creation-only operation. Calling it with a `(type, id)` pair that already exists in the entity's manifest throws an error. Use `observe(entity(url))` to get a handle to an existing child.
32
34
 
@@ -38,6 +40,23 @@ The `wake` option controls when the parent's handler is re-invoked:
38
40
 
39
41
  Returns an [`EntityHandle`](#entityhandle).
40
42
 
43
+ Use [Sandboxing](./sandboxing) when children need isolated filesystem, process, or network access, or when a worker should inherit its parent's sandbox.
44
+
45
+ ## fork
46
+
47
+ Forking creates a new entity from another entity's history at its latest completed run. Use it when you want to branch a session and try a different continuation:
48
+
49
+ ```ts
50
+ const fork = await ctx.forkSelf("variant-a", {
51
+ initialMessage: { text: "Explore the risky option instead." },
52
+ tags: { branch: "variant-a" },
53
+ })
54
+ ```
55
+
56
+ `ctx.fork(sourceEntityUrl, id, opts?)` forks another entity; `ctx.forkSelf(id, opts?)` forks the current entity. The new fork is a child of the forking entity by default and registers a `runFinished` wake with `includeResponse: true`, so the parent wakes when the fork's next run finishes. Options mirror `spawn` where they apply: `initialMessage`, `wake`, `tags`, and `observe`.
57
+
58
+ Pass `observe: false` for fire-and-forget branching with no parent relationship or wake subscription.
59
+
41
60
  ## EntityHandle
42
61
 
43
62
  Returned by `spawn` and `observe`:
@@ -73,7 +92,7 @@ async handler(ctx, wake) {
73
92
  )
74
93
 
75
94
  ctx.state.children.insert({
76
- key: "analyst",
95
+ key: child.entityUrl,
77
96
  url: child.entityUrl,
78
97
  status: "running",
79
98
  })
@@ -130,7 +149,7 @@ The entity remains alive and can be woken again by incoming messages or observed
130
149
  After spawning children in `firstWake`, use `observe` on subsequent wakes to get handles:
131
150
 
132
151
  ```ts
133
- async handler(ctx) {
152
+ async handler(ctx, wake) {
134
153
  if (ctx.firstWake) {
135
154
  await ctx.spawn(
136
155
  "worker",
@@ -15,7 +15,7 @@ Test agent handlers without calling the LLM by providing canned responses:
15
15
  ```ts
16
16
  ctx.useAgent({
17
17
  systemPrompt: "...",
18
- model: "claude-sonnet-4-5-20250929",
18
+ model: "claude-sonnet-4-6",
19
19
  tools: [...ctx.electricTools],
20
20
  testResponses: ["Hello! How can I help?"],
21
21
  })
@@ -8,9 +8,9 @@ outline: [2, 3]
8
8
 
9
9
  # Waking entities
10
10
 
11
- Entities in Electric Agents are driven by **wakes**. A wake is a single handler invocation triggered by something outside the handler: a new message, a child finishing, a change in an observed stream, or a schedule. Between wakes the entity is idle — no process, no memory, no running handler.
11
+ Entities in Electric Agents are driven by **wakes**. A wake is a single handler invocation triggered by something outside the handler: a new message, a child finishing, a change in an observed stream, a schedule, a webhook source, or a Postgres sync source. Between wakes the entity is idle — no process, no memory, no running handler.
12
12
 
13
- Everything you do to make an entity respond to something — `ctx.spawn(..., { wake })`, `ctx.observe(..., { wake })`, `ctx.send()`, `upsertCronSchedule()` — is ultimately a way to produce a wake.
13
+ Everything you do to make an entity respond to something — `ctx.spawn(..., { wake })`, `ctx.observe(..., { wake })`, `ctx.send()`, the `upsert_cron_schedule` tool, `client.upsertCronSchedule()`, `subscribe_webhook_source`, or `pgSync()` observation — is ultimately a way to produce a wake.
14
14
 
15
15
  ## The mental model
16
16
 
@@ -18,7 +18,7 @@ Everything you do to make an entity respond to something — `ctx.spawn(..., { w
18
18
  external event ─► wake entry (persisted) ─► handler invocation ─► WakeEvent passed to handler
19
19
  ```
20
20
 
21
- 1. **External event.** A message arrives, a child transitions, a watched collection changes, a cron fires.
21
+ 1. **External event.** A message arrives, a child transitions, a watched collection changes, a cron fires, or a subscribed webhook source receives matching data.
22
22
  2. **Wake entry is persisted** to the entity's stream. This is the durability guarantee — wakes survive process restarts, network blips, and crashes. A wake that was written will eventually be delivered to a handler.
23
23
  3. **Handler is invoked.** The runtime picks up the wake, loads the entity's state, and calls your handler with a `WakeEvent` describing what triggered this invocation.
24
24
  4. **Handler runs.** You read `ctx.events`, inspect `wake`, configure the agent, emit new events. When the handler returns (or calls `ctx.sleep()`), the entity goes idle until the next wake.
@@ -27,7 +27,7 @@ This means handlers are re-entrant: the same handler function is called fresh on
27
27
 
28
28
  ## What produces a wake
29
29
 
30
- There are five things that can wake an entity:
30
+ There are seven things that can wake an entity:
31
31
 
32
32
  ### 1. An incoming message
33
33
 
@@ -85,6 +85,32 @@ await ctx.observe(db("board-1", schema), {
85
85
 
86
86
  Runtime hosts can expose schedule-management tools through `ctx.electricTools`. The current schedule tool set is `list_schedules`, `upsert_cron_schedule`, `upsert_future_send`, and `delete_schedule`. Schedule entries live on the entity's manifest, so they survive restarts and can be updated or cancelled idempotently.
87
87
 
88
+ ### 6. A webhook source
89
+
90
+ Runtime hosts can expose webhook-source tools through `ctx.electricTools`. An entity can subscribe to external webhook-backed feeds with `subscribe_webhook_source`; matching future events wake the entity with hydrated event data.
91
+
92
+ See [Webhook sources](./webhook-sources).
93
+
94
+ ### 7. A Postgres sync source
95
+
96
+ `pgSync()` observes an Electric Postgres shape stream and wakes the entity when matching row changes arrive:
97
+
98
+ ```ts
99
+ import { pgSync } from "@electric-ax/agents-runtime"
100
+
101
+ await ctx.observe(
102
+ pgSync({
103
+ url: "http://localhost:3000/v1/shape",
104
+ table: "todos",
105
+ where: "project_id = $1",
106
+ params: ["docs"],
107
+ }),
108
+ { wake: { on: "change", ops: ["insert", "update"] } }
109
+ )
110
+ ```
111
+
112
+ Built-in Horton also exposes this as the `observe_pg_sync` tool when the runtime host has pg-sync configured, and `unobserve_pg_sync` to remove an existing pg-sync observation.
113
+
88
114
  ## Reading a WakeEvent
89
115
 
90
116
  Your handler signature is:
@@ -104,7 +130,7 @@ async handler(ctx, wake) {
104
130
  return
105
131
  }
106
132
 
107
- // everything else (child finished, change, cron, timeout) arrives as type "wake".
133
+ // everything else (child finished, change, cron, webhook source, timeout) arrives as type "wake".
108
134
  // Inspect wake.payload for the specific sub-kind.
109
135
  ctx.sleep()
110
136
  }
@@ -113,7 +139,7 @@ async handler(ctx, wake) {
113
139
  Two wake types reach handlers directly:
114
140
 
115
141
  - `"inbox"` — an external message was delivered to this entity's inbox.
116
- - `"wake"` — a synthesised wake for anything else (child finished, collection change, cron, timeout). The specifics are on `wake.payload`. A future-send schedule delivers a message, so it arrives as `"inbox"`.
142
+ - `"wake"` — a synthesised wake for anything else (child finished, collection change, cron, webhook source, timeout). The specifics are on `wake.payload`. A future-send schedule delivers a message, so it arrives as `"inbox"`.
117
143
 
118
144
  For the full payload shape (`changes[]`, `finished_child`, `other_children`, `timeout`), see the [wake-type catalog](../reference/wake-event#wake-type-catalog) in the reference.
119
145
 
@@ -145,4 +171,6 @@ When the handler finishes (or calls `ctx.sleep()`), the entity returns to idle.
145
171
  - [WakeEvent](../reference/wake-event) — full type reference and wake-type catalog.
146
172
  - [Spawning & coordinating](./spawning-and-coordinating) — using `wake` with `spawn` and `observe`.
147
173
  - [Shared state](./shared-state) — using `wake` with `observe(db(...))`.
174
+ - [Webhook sources](./webhook-sources) — subscribing entities to external webhook feeds.
175
+ - [Signals](./signals) — lifecycle controls that can interrupt or notify active entities.
148
176
  - [Writing handlers](./writing-handlers) — `HandlerContext` and `firstWake` patterns.
@@ -0,0 +1,171 @@
1
+ ---
2
+ title: Webhook sources
3
+ titleTemplate: "... - Electric Agents"
4
+ description: >-
5
+ Let agents discover and subscribe to external webhook-backed feeds that wake entities with matching event data.
6
+ outline: [2, 3]
7
+ ---
8
+
9
+ # Webhook sources
10
+
11
+ Webhook sources let agents subscribe to external feeds such as GitHub, Stripe, email, CI, or other webhook integrations. A subscription persists on the entity manifest and wakes the entity when matching external events arrive.
12
+
13
+ Built-in Horton runtimes expose webhook-source tools through `ctx.electricTools` by default.
14
+
15
+ ## Contracts
16
+
17
+ A webhook source contract describes what an agent can subscribe to:
18
+
19
+ ```ts
20
+ type WebhookSourceContract = {
21
+ serviceId?: string
22
+ webhookKey: string
23
+ sourceType: "webhook"
24
+ endpointKey: string
25
+ status: "active" | "disabled" | "revoked"
26
+ label: string
27
+ description?: string
28
+ agentVisible: boolean
29
+ buckets: WebhookSourceBucket[]
30
+ updatedAt?: string
31
+ revision: number
32
+ }
33
+ ```
34
+
35
+ Buckets describe path templates and parameters:
36
+
37
+ ```ts
38
+ type WebhookSourceBucket = {
39
+ key: string
40
+ label: string
41
+ description?: string
42
+ pathTemplate: string
43
+ paramsSchema: Record<string, unknown>
44
+ eventTypes?: string[]
45
+ filters?: WebhookSourceFilter[]
46
+ }
47
+ ```
48
+
49
+ Agents should call `list_webhook_sources` first and use the advertised `webhookKey`, `bucketKey`, `paramsSchema`, and optional `filterKey`.
50
+
51
+ ## Built-in tools
52
+
53
+ The runtime tool factory can add four tools:
54
+
55
+ | Tool | Purpose |
56
+ | ---- | ------- |
57
+ | `list_webhook_sources` | List external webhook feeds the entity can subscribe to. |
58
+ | `list_webhook_source_subscriptions` | List active subscriptions for this entity. |
59
+ | `subscribe_webhook_source` | Subscribe the entity to a source or bucket. |
60
+ | `unsubscribe_webhook_source` | Remove a subscription by id. |
61
+
62
+ Horton receives these tools from the built-in runtime. Custom runtimes can provide them with `createWebhookSourceTools()` or by passing `createElectricTools` through `createRuntimeHandler()`. Built-in runtimes also add schedule tools by default; if you replace `createElectricTools`, include both tool sets when you want Horton to keep both capabilities.
63
+
64
+ ```ts
65
+ import { createWebhookSourceTools } from "@electric-ax/agents-runtime/tools"
66
+
67
+ const runtime = createRuntimeHandler({
68
+ baseUrl: "http://localhost:4437",
69
+ registry,
70
+ createElectricTools: (context) => createWebhookSourceTools(context),
71
+ })
72
+ ```
73
+
74
+ ## Subscribing from tools
75
+
76
+ `subscribe_webhook_source` accepts:
77
+
78
+ ```ts
79
+ type WebhookSourceSubscriptionInput = {
80
+ id?: string
81
+ webhookKey: string
82
+ bucketKey?: string
83
+ params?: Record<string, unknown>
84
+ filterKey?: string
85
+ lifetime?: SubscriptionLifetime
86
+ reason?: string
87
+ }
88
+ ```
89
+
90
+ If `id` is omitted, the runtime derives a deterministic id from the source, bucket, params, and filter.
91
+
92
+ Lifetimes:
93
+
94
+ ```ts
95
+ type SubscriptionLifetime =
96
+ | { kind: "until_entity_stopped" }
97
+ | { kind: "expires_at"; at: string }
98
+ | { kind: "manual" }
99
+ ```
100
+
101
+ The default lifetime is `until_entity_stopped`.
102
+
103
+ ## Programmatic subscriptions
104
+
105
+ Host code can subscribe directly with `createRuntimeServerClient()`:
106
+
107
+ ```ts
108
+ await client.subscribeToWebhookSource({
109
+ entityUrl: "/horton/onboarding",
110
+ webhookKey: "github",
111
+ bucketKey: "repo",
112
+ params: { repo: "electric-sql/electric" },
113
+ reason: "Watch repo activity for this session",
114
+ })
115
+
116
+ await client.unsubscribeFromWebhookSource({
117
+ entityUrl: "/horton/onboarding",
118
+ id: "github-main",
119
+ })
120
+ ```
121
+
122
+ Use `listWebhookSources()` to inspect available contracts:
123
+
124
+ ```ts
125
+ const sources = await client.listWebhookSources()
126
+ ```
127
+
128
+ ## Wake payloads
129
+
130
+ When a subscribed source fires, the entity is woken with a hydrated webhook-source payload:
131
+
132
+ ```ts
133
+ type HydratedWebhookSourceWake = {
134
+ type: "webhook_source_wake"
135
+ source: string
136
+ sourceType: "webhook"
137
+ endpointKey: string
138
+ webhookKey: string
139
+ subscription: {
140
+ id: string
141
+ bucketKey?: string
142
+ params: Record<string, unknown>
143
+ filterKey?: string
144
+ reason?: string
145
+ }
146
+ bucket: string | null
147
+ changes: Array<{
148
+ collection: string
149
+ kind: "insert" | "update" | "delete"
150
+ key: string
151
+ }>
152
+ events: WebhookEventRow[]
153
+ missingEventKeys?: string[]
154
+ }
155
+ ```
156
+
157
+ Handlers can inspect `wake.payload` or use the normal agent context. Horton includes hydrated webhook-source data in the trigger message so the model can react without doing a second lookup.
158
+
159
+ ## Manifest entries
160
+
161
+ Subscriptions are stored as `manifest` rows with `kind: "source"` and a stable manifest key:
162
+
163
+ ```ts
164
+ webhook-source:<subscription-id>
165
+ ```
166
+
167
+ This lets the entity list and remove subscriptions across wakes.
168
+
169
+ ## Filters
170
+
171
+ `filterKey` selects a named filter advertised by the source. Filters are intended to narrow external webhook feeds. In the current version, filters are advisory until server-side webhook filters are enabled, so agents should still handle unexpected events defensively.