@electric-ax/agents 0.4.18 → 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.
- package/dist/entrypoint.js +88 -14
- package/dist/index.cjs +87 -13
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +88 -14
- package/docs/entities/agents/horton.md +22 -17
- package/docs/entities/agents/worker.md +13 -6
- package/docs/entities/patterns/blackboard.md +1 -1
- package/docs/entities/patterns/dispatcher.md +1 -1
- package/docs/entities/patterns/manager-worker.md +10 -5
- package/docs/entities/patterns/map-reduce.md +1 -1
- package/docs/entities/patterns/pipeline.md +1 -1
- package/docs/entities/patterns/reactive-observers.md +1 -1
- package/docs/index.md +6 -4
- package/docs/quickstart.md +2 -2
- package/docs/reference/agent-config.md +13 -3
- package/docs/reference/built-in-collections.md +128 -9
- package/docs/reference/cli.md +34 -4
- package/docs/reference/entity-definition.md +39 -7
- package/docs/reference/entity-handle.md +19 -1
- package/docs/reference/handler-context.md +130 -5
- package/docs/reference/runtime-handler.md +42 -14
- package/docs/reference/wake-event.md +29 -1
- package/docs/usage/app-setup.md +38 -7
- package/docs/usage/attachments.md +129 -0
- package/docs/usage/clients-and-react.md +23 -2
- package/docs/usage/configuring-the-agent.md +15 -5
- package/docs/usage/context-composition.md +2 -1
- package/docs/usage/defining-entities.md +9 -5
- package/docs/usage/defining-tools.md +1 -1
- package/docs/usage/embedded-builtins.md +82 -31
- package/docs/usage/managing-state.md +5 -0
- package/docs/usage/mcp-servers.md +16 -8
- package/docs/usage/overview.md +39 -14
- package/docs/usage/permissions-and-principals.md +160 -0
- package/docs/usage/programmatic-runtime-client.md +158 -16
- package/docs/usage/sandboxing.md +162 -0
- package/docs/usage/signals.md +138 -0
- package/docs/usage/spawning-and-coordinating.md +30 -11
- package/docs/usage/testing.md +1 -1
- package/docs/usage/waking-entities.md +34 -6
- package/docs/usage/webhook-sources.md +171 -0
- package/docs/usage/writing-handlers.md +13 -55
- package/docs/walkthrough.md +13 -5
- 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
|
|
22
|
-
|
|
|
23
|
-
| `type`
|
|
24
|
-
| `id`
|
|
25
|
-
| `args`
|
|
26
|
-
| `opts.initialMessage`
|
|
27
|
-
| `opts.
|
|
28
|
-
| `opts.
|
|
29
|
-
| `opts.
|
|
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:
|
|
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",
|
package/docs/usage/testing.md
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
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.
|