diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts index 93134e25eb..b22d9537cf 100644 --- a/website/.vitepress/config.mts +++ b/website/.vitepress/config.mts @@ -260,6 +260,10 @@ const agentsDocsSidebar = [ text: 'Managing state', link: '/docs/agents/usage/managing-state', }, + { + text: 'Permissions & principals', + link: '/docs/agents/usage/permissions-and-principals', + }, { text: 'Spawning & coordinating', link: '/docs/agents/usage/spawning-and-coordinating', @@ -268,6 +272,9 @@ const agentsDocsSidebar = [ text: 'Waking entities', link: '/docs/agents/usage/waking-entities', }, + { text: 'Signals', link: '/docs/agents/usage/signals' }, + { text: 'Sandboxing', link: '/docs/agents/usage/sandboxing' }, + { text: 'Attachments', link: '/docs/agents/usage/attachments' }, { text: 'Shared state', link: '/docs/agents/usage/shared-state' }, { text: 'Clients & React', @@ -282,6 +289,7 @@ const agentsDocsSidebar = [ text: 'Embedded built-ins', link: '/docs/agents/usage/embedded-builtins', }, + { text: 'Event sources', link: '/docs/agents/usage/event-sources' }, { text: 'MCP servers', link: '/docs/agents/usage/mcp-servers' }, { text: 'Testing', link: '/docs/agents/usage/testing' }, ], diff --git a/website/docs/agents/entities/agents/horton.md b/website/docs/agents/entities/agents/horton.md index 8f4fac8127..fed3c43a93 100644 --- a/website/docs/agents/entities/agents/horton.md +++ b/website/docs/agents/entities/agents/horton.md @@ -32,25 +32,25 @@ Horton is configured with `ctx.electricTools` plus the base Horton tool set: | `read` | Read a file. Tracked in a per-wake `readSet`. | | `write` | Create or overwrite a file. | | `edit` | Targeted string replacement (file must be `read` first). | -| `brave_search` | Web search via the Brave Search API. | +| `web_search` | Web search via the configured search provider. | | `fetch_url` | Fetch a URL and return it as markdown. | | `spawn_worker` | Dispatch a subagent for an isolated subtask. | -`brave_search` requires `BRAVE_SEARCH_API_KEY` in the environment; without it the tool errors at call time. +`web_search` uses the search provider configured by the built-in runtime; Brave search requires `BRAVE_SEARCH_API_KEY`. When docs support or skills are available, Horton also adds the docs search tool and skill tools during bootstrap. ## Title generation -After the first agent run completes, Horton calls `generateTitle()` (Haiku) to summarise the user's first message into a 3-5 word session title and stores it via `ctx.setTag('title', title)`. Failures are logged and ignored — the entity continues without a title. +After the first agent run completes, Horton calls `generateTitle()` using the configured low-cost model to summarise the user's first message into a 3-5 word session title and stores it via `ctx.setTag('title', title)`. Failures are logged and ignored — the entity continues without a title. ## Details | Property | Value | | ----------------- | ------------------------------------------------- | | Type name | `horton` | -| Model | `HORTON_MODEL` (`claude-sonnet-4-5-20250929`) | -| Title model | `claude-haiku-4-5-20251001` | +| Model | `HORTON_MODEL` (`claude-sonnet-4-6` by default) | +| Title model | Configured low-cost model | | Tools | `ctx.electricTools` + base Horton tool set, plus docs/skill tools when configured | | Working directory | Passed at bootstrap (defaults to `process.cwd()`) | | Title generation | Yes, after the first run if no title tag exists | diff --git a/website/docs/agents/entities/agents/worker.md b/website/docs/agents/entities/agents/worker.md index a784819a3f..b6ec07e997 100644 --- a/website/docs/agents/entities/agents/worker.md +++ b/website/docs/agents/entities/agents/worker.md @@ -40,9 +40,10 @@ type WorkerToolName = | "read" | "write" | "edit" - | "brave_search" + | "web_search" | "fetch_url" | "spawn_worker" + | "send" ``` These are the same primitives Horton uses. Pick the smallest subset the worker needs — tools are the worker's permission set. @@ -57,7 +58,7 @@ The canonical way to spawn a worker is the `spawn_worker` tool, which Horton cal spawn_worker({ systemPrompt: "You are a focused researcher. Find the three most-cited papers on X and return their titles, authors, and DOIs as a markdown table.", - tools: ["brave_search", "fetch_url"], + tools: ["web_search", "fetch_url"], initialMessage: "Begin research now.", }) ``` @@ -75,7 +76,7 @@ The spawn uses `wake: { on: 'runFinished', includeResponse: true }`, so the spaw 1. Parses `ctx.args` into `WorkerArgs`. Throws if `systemPrompt` is empty, if `tools` contains an unknown name, or if neither `tools` nor `sharedDb` is provided. 2. Builds the requested tool instances against the worker's `workingDirectory` (and a fresh per-wake `readSet` for the read-first-then-edit guard). 3. If `sharedDb` is present, connects with `ctx.observe(db(id, schema))` and exposes generated `read_*`, `write_*`, `update_*`, and `delete_*` tools (`write_*` only in `"write-only"` mode). -4. Configures the agent with `HORTON_MODEL` (`claude-sonnet-4-5-20250929`), the provided system prompt (with a brief reporting-back footer appended), and the assembled tool list. +4. Configures the agent with `HORTON_MODEL` (`claude-sonnet-4-6` by default), the provided system prompt (with a brief reporting-back footer appended), and the assembled tool list. 5. Runs the agent until the LLM stops. ::: warning Least-privilege sandbox @@ -96,7 +97,7 @@ When you finish, respond with a concise report covering what was done and any ke | Property | Value | | ----------------- | --------------------------------------------------------------------- | | Type name | `worker` | -| Model | `HORTON_MODEL` (`claude-sonnet-4-5-20250929`) | -| Tools | Subset of 7 primitives plus optional shared-state tools. **No `ctx.electricTools`.** | +| Model | `HORTON_MODEL` (`claude-sonnet-4-6` by default) | +| Tools | Subset of 8 primitives plus optional shared-state tools. **No `ctx.electricTools`.** | | Working directory | Provided to `registerWorker` at bootstrap | | Description | `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).` | diff --git a/website/docs/agents/entities/patterns/blackboard.md b/website/docs/agents/entities/patterns/blackboard.md index 839290bc61..a33538b729 100644 --- a/website/docs/agents/entities/patterns/blackboard.md +++ b/website/docs/agents/entities/patterns/blackboard.md @@ -58,7 +58,7 @@ export function registerDebate(registry: EntityRegistry) { ctx.useAgent({ systemPrompt: DEBATE_SYSTEM_PROMPT, - model: `claude-sonnet-4-5-20250929`, + model: `claude-sonnet-4-6`, tools: [...ctx.electricTools, startTool, checkTool, endTool], }) await ctx.agent.run() diff --git a/website/docs/agents/entities/patterns/dispatcher.md b/website/docs/agents/entities/patterns/dispatcher.md index 520118bf5a..03362f62fb 100644 --- a/website/docs/agents/entities/patterns/dispatcher.md +++ b/website/docs/agents/entities/patterns/dispatcher.md @@ -24,7 +24,7 @@ export function registerDispatcher(registry: EntityRegistry) { ctx.useAgent({ systemPrompt: DISPATCHER_SYSTEM_PROMPT, - model: `claude-sonnet-4-5-20250929`, + model: `claude-sonnet-4-6`, tools: [...ctx.electricTools, dispatchTool], }) await ctx.agent.run() diff --git a/website/docs/agents/entities/patterns/manager-worker.md b/website/docs/agents/entities/patterns/manager-worker.md index 0963fbea31..08339507eb 100644 --- a/website/docs/agents/entities/patterns/manager-worker.md +++ b/website/docs/agents/entities/patterns/manager-worker.md @@ -27,7 +27,7 @@ export function registerManagerWorker(registry: EntityRegistry) { ctx.useAgent({ systemPrompt: MANAGER_SYSTEM_PROMPT, - model: `claude-sonnet-4-5-20250929`, + model: `claude-sonnet-4-6`, tools: [...ctx.electricTools, analyzeTool], }) await ctx.agent.run() diff --git a/website/docs/agents/entities/patterns/map-reduce.md b/website/docs/agents/entities/patterns/map-reduce.md index aa8e48c587..d0ba80266d 100644 --- a/website/docs/agents/entities/patterns/map-reduce.md +++ b/website/docs/agents/entities/patterns/map-reduce.md @@ -25,7 +25,7 @@ export function registerMapReduce(registry: EntityRegistry) { async handler(ctx) { ctx.useAgent({ systemPrompt: MAP_REDUCE_SYSTEM_PROMPT, - model: `claude-sonnet-4-5-20250929`, + model: `claude-sonnet-4-6`, tools: [...ctx.electricTools, createMapChunksTool(ctx)], }) await ctx.agent.run() diff --git a/website/docs/agents/entities/patterns/pipeline.md b/website/docs/agents/entities/patterns/pipeline.md index 638ec15475..b1350c5d4a 100644 --- a/website/docs/agents/entities/patterns/pipeline.md +++ b/website/docs/agents/entities/patterns/pipeline.md @@ -25,7 +25,7 @@ export function registerPipeline(registry: EntityRegistry) { async handler(ctx) { ctx.useAgent({ systemPrompt: PIPELINE_SYSTEM_PROMPT, - model: `claude-sonnet-4-5-20250929`, + model: `claude-sonnet-4-6`, tools: [...ctx.electricTools, createRunStageTool(ctx)], }) await ctx.agent.run() diff --git a/website/docs/agents/entities/patterns/reactive-observers.md b/website/docs/agents/entities/patterns/reactive-observers.md index 9416c59c35..7c7c0494a4 100644 --- a/website/docs/agents/entities/patterns/reactive-observers.md +++ b/website/docs/agents/entities/patterns/reactive-observers.md @@ -50,7 +50,7 @@ export function registerMonitor(registry: EntityRegistry) { ctx.useAgent({ systemPrompt: MONITOR_SYSTEM_PROMPT, - model: `claude-sonnet-4-5-20250929`, + model: `claude-sonnet-4-6`, tools: [...ctx.electricTools, observeTool], }) await ctx.agent.run() diff --git a/website/docs/agents/index.md b/website/docs/agents/index.md index 849ea604be..54789df91d 100644 --- a/website/docs/agents/index.md +++ b/website/docs/agents/index.md @@ -63,7 +63,7 @@ registry.define("support", { if (wake.type === "inbox") { ctx.useAgent({ systemPrompt: "You are a support agent.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools, searchKbTool], }) await ctx.agent.run() @@ -124,7 +124,7 @@ The core pattern is [`ctx.useAgent()`](/docs/agents/reference/agent-config) foll ```ts ctx.useAgent({ systemPrompt: "You are a helpful assistant.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools, myCustomTool], }) @@ -206,5 +206,7 @@ console.log(db.collections.texts.toArray) - [Writing handlers](/docs/agents/usage/writing-handlers) — handler lifecycle and the `ctx` API. - [Configuring the agent](/docs/agents/usage/configuring-the-agent) — `useAgent`, models, tools, and streaming. - [Spawning & coordinating](/docs/agents/usage/spawning-and-coordinating) — multi-entity topologies and shared state. -- [Built-in agents](/docs/agents/entities/agents/horton) — Horton, Worker, and Coder, the agents that ship with the runtime. +- [Permissions & principals](/docs/agents/usage/permissions-and-principals) — entity access control and principal-scoped clients. +- [Sandboxing](/docs/agents/usage/sandboxing), [Attachments](/docs/agents/usage/attachments), [Signals](/docs/agents/usage/signals), and [Event sources](/docs/agents/usage/event-sources) — newer runtime capabilities for hosted agents. +- [Built-in agents](/docs/agents/entities/agents/horton) — Horton and Worker, the agents that ship with the runtime. - [Examples](/docs/agents/examples/playground) — pattern walkthroughs and demo apps. diff --git a/website/docs/agents/quickstart.md b/website/docs/agents/quickstart.md index d858a5ccf3..298b0f0e66 100644 --- a/website/docs/agents/quickstart.md +++ b/website/docs/agents/quickstart.md @@ -112,7 +112,7 @@ registry.define("assistant", { async handler(ctx) { ctx.useAgent({ systemPrompt: "You are a helpful assistant.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools], }) await ctx.agent.run() @@ -198,4 +198,4 @@ See the [CLI reference](./reference/cli#start) for the full set of commands. - [Defining entities](./usage/defining-entities) — entity types, schemas, and configuration. - [Writing handlers](./usage/writing-handlers) — handler lifecycle and the `ctx` API. - [Configuring the agent](./usage/configuring-the-agent) — `useAgent`, models, tools, and streaming. -- [Built-in agents](./entities/agents/horton) — Horton, Worker, and Coder, the agents that ship with the runtime. +- [Built-in agents](./entities/agents/horton) — Horton and Worker, the agents that ship with the runtime. diff --git a/website/docs/agents/reference/agent-config.md b/website/docs/agents/reference/agent-config.md index c83760f722..c5db631193 100644 --- a/website/docs/agents/reference/agent-config.md +++ b/website/docs/agents/reference/agent-config.md @@ -32,7 +32,7 @@ interface AgentConfig { | Field | Type | Required | Description | | --------------- | ---------------------------- | -------- | --------------------------------------------------------------------------------------------------- | | `systemPrompt` | `string` | Yes | System prompt sent to the LLM on each step. | -| `model` | `string \| Model` | Yes | Model identifier (e.g. `"claude-sonnet-4-5-20250929"`) or a resolved model object. | +| `model` | `string \| Model` | Yes | Model identifier (e.g. `"claude-sonnet-4-6"`) or a resolved model object. | | `provider` | `KnownProvider` | No | Provider to use when `model` is a string. Defaults to `"anthropic"`. | | `tools` | `AgentTool[]` | Yes | Tools available to the LLM. Spread `ctx.electricTools` when your runtime host provides runtime-level tools. See [`AgentTool`](./agent-tool). | | `streamFn` | `StreamFn` | No | Optional streaming callback passed to the underlying agent. | diff --git a/website/docs/agents/reference/built-in-collections.md b/website/docs/agents/reference/built-in-collections.md index 0020c11761..ea582bbc3b 100644 --- a/website/docs/agents/reference/built-in-collections.md +++ b/website/docs/agents/reference/built-in-collections.md @@ -2,13 +2,13 @@ title: Built-in collections titleTemplate: "... - Electric Agents" description: >- - Reference for the 17 runtime-managed collections: runs, steps, texts, toolCalls, inbox, errors, and more. + Reference for the 18 runtime-managed collections: runs, steps, texts, toolCalls, inbox, signals, errors, and more. outline: [2, 3] --- # Built-in collections -Every entity automatically has these 17 collections, populated by the runtime as the agent operates. Custom state collections defined in `EntityDefinition.state` are merged with these at creation time. +Every entity automatically has these 18 collections, populated by the runtime as the agent operates. Custom state collections defined in `EntityDefinition.state` are merged with these at creation time. **Source:** `@electric-ax/agents-runtime` -- `entity-schema.ts` @@ -27,14 +27,15 @@ Every entity automatically has these 17 collections, populated by the runtime as | `wakes` | `wake` | `WakeEntry` | Wake delivery records | | `entityCreated` | `entity_created` | `EntityCreated` | Entity bootstrap metadata | | `entityStopped` | `entity_stopped` | `EntityStopped` | Entity shutdown signal | +| `signals` | `signal` | `Signal` | Lifecycle signal records | | `childStatus` | `child_status` | `ChildStatusEntry` | Child/observed entity status | | `tags` | `tags` | `TagEntry` | Entity tags | +| `manifests` | `manifest` | `Manifest` | Durable resource manifests | | `contextInserted` | `context_inserted` | `ContextInserted` | Context additions | | `contextRemoved` | `context_removed` | `ContextRemoved` | Context removals | -| `manifests` | `manifest` | `Manifest` | Durable resource manifests | | `replayWatermarks` | `replay_watermark` | `ReplayWatermark` | Replay progress tracking | -All collections use `key` as the primary key. +All collections use `key` as the primary key. Runtime-managed timeline rows may also include `_timeline_order` for stable timeline sorting. ## Type definitions @@ -90,6 +91,7 @@ interface TextDelta { interface ToolCall { key: string run_id?: string + tool_call_id?: string tool_name: string status: "started" | "args_complete" | "executing" | "completed" | "failed" args?: unknown @@ -126,10 +128,15 @@ interface ErrorEvent { ```ts interface MessageReceived { key: string - from: string + from?: string payload?: unknown - timestamp: string + timestamp?: string message_type?: string + mode?: "immediate" | "queued" | "paused" | "steer" + status?: "pending" | "processed" | "cancelled" + position?: string + processed_at?: string + cancelled_at?: string } ``` @@ -150,6 +157,10 @@ interface WakeChangeEntry { collection: string kind: "insert" | "update" | "delete" key: string + from?: string + payload?: unknown + timestamp?: string + message_type?: string } interface WakeFinishedChildEntry { @@ -163,7 +174,7 @@ interface WakeFinishedChildEntry { interface WakeOtherChildEntry { url: string type: string - status: "spawning" | "running" | "idle" | "stopped" + status: "spawning" | "running" | "idle" | "paused" | "stopping" | "stopped" | "killed" } ``` @@ -189,6 +200,25 @@ interface EntityStopped { } ``` +### Signal + +```ts +interface Signal { + key: string + signal: "SIGINT" | "SIGHUP" | "SIGTERM" | "SIGKILL" | "SIGSTOP" | "SIGCONT" | "SIGUSR" + status: "unhandled" | "handled" + sender?: string + reason?: string + payload?: unknown + timestamp: string + handled_at?: string + handled_by?: string + outcome?: "transitioned" | "ignored" | "invalid_for_state" | "delivered" | "aborted" | "shutdown_requested" | "failed" + previous_state?: ChildStatusEntry["status"] + new_state?: ChildStatusEntry["status"] +} +``` + ### ChildStatusEntry ```ts @@ -196,7 +226,7 @@ interface ChildStatusEntry { key: string entity_url: string entity_type: string - status: "spawning" | "running" | "idle" | "stopped" + status: "spawning" | "running" | "idle" | "paused" | "stopping" | "stopped" | "killed" } ``` @@ -243,6 +273,7 @@ type Manifest = | ManifestSourceEntry | ManifestSharedStateEntry | ManifestEffectEntry + | ManifestAttachmentEntry | ManifestContextEntry | ManifestCronScheduleEntry | ManifestFutureSendScheduleEntry @@ -283,6 +314,27 @@ interface ManifestEffectEntry { config: unknown } +interface ManifestAttachmentEntry { + key: string + kind: "attachment" + id: string + streamPath: string + status: "pending" | "complete" | "failed" + subject: { + type: "inbox" | "run" | "text" | "tool_call" | "context" + key: string + } + role: "input" | "output" + mimeType: string + filename?: string + byteLength?: number + sha256?: string + createdAt: string + createdBy?: string + error?: string + meta?: Record +} + interface ManifestContextEntry { key: string kind: "context" diff --git a/website/docs/agents/reference/cli.md b/website/docs/agents/reference/cli.md index ea5bbb8659..abf1840944 100644 --- a/website/docs/agents/reference/cli.md +++ b/website/docs/agents/reference/cli.md @@ -23,7 +23,8 @@ npm install -g electric-ax | `ELECTRIC_AGENTS_PRINCIPAL` | - | Optional principal key sent as `Electric-Principal` | | `ELECTRIC_AGENTS_SERVER_HEADERS` | - | Optional JSON object of additional server headers | | `ELECTRIC_AGENTS_PORT` | `4437` | Port used by `start` / `quickstart` | -| `ELECTRIC_AGENTS_BUILTIN_PORT` | `4448` | Webhook port for `start-builtin` | +| `ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID` | `builtin-{identity}` | Pull-wake runner id for `start-builtin` | +| `PULL_WAKE_RUNNER_ID` | - | Legacy alias for `ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID` | | `ELECTRIC_AGENTS_COMPOSE_PROJECT` | `electric-agents` | Docker Compose project name | | `ANTHROPIC_API_KEY` | - | Required for `start-builtin` and `quickstart` | @@ -94,6 +95,19 @@ electric agents observe /chat/my-convo --from 0 | ----------------- | -------------------------------- | | `--from ` | Start streaming from this offset | +### view <url> [--from <offset>] {#view-url-from-offset} + +Print an entity conversation once. + +```bash +electric agents view /chat/my-convo +electric agents view /chat/my-convo --from 0 +``` + +| Option | Description | +| ----------------- | --------------------------- | +| `--from ` | Start reading from this offset | + ### inspect <url> {#inspect-url} Show entity details. Outputs JSON. @@ -120,9 +134,23 @@ electric agents ps --parent /manager/my-manager Output shows `URL`, `STATUS`, `CREATED`, and `LAST ACTIVE` columns with human-readable relative timestamps. Results are sorted by most recently active first. +### signal <url> <signal> [--reason <text>] [--payload <json>] {#signal-url-signal} + +Send a lifecycle signal to an entity. + +```bash +electric agents signal /chat/my-convo SIGINT --reason "stop current run" +electric agents signal /chat/my-convo SIGUSR --payload '{"refresh":true}' +``` + +| Option | Description | +| ------------------ | ----------------------------------- | +| `--reason ` | Human-readable signal reason | +| `--payload ` | JSON payload to attach to the signal | + ### kill <url> {#kill-url} -Delete an entity. +Send `SIGKILL` to an entity. ```bash electric agents kill /chat/my-convo @@ -138,7 +166,7 @@ electric agents start ### start-builtin [--anthropic-api-key <key>] {#start-builtin} -Start the built-in Horton runtime and register built-in agent types with the coordinator server. +Start the built-in Horton and worker runtime, register built-in agent types, and run a pull-wake runner. ```bash electric agents start-builtin --anthropic-api-key sk-ant-... diff --git a/website/docs/agents/reference/entity-definition.md b/website/docs/agents/reference/entity-definition.md index 0252978ef5..50851aad49 100644 --- a/website/docs/agents/reference/entity-definition.md +++ b/website/docs/agents/reference/entity-definition.md @@ -21,7 +21,8 @@ interface EntityDefinition { ) => Record void> creationSchema?: StandardJSONSchemaV1 inboxSchemas?: Record - outputSchemas?: Record + stateSchemas?: Record + permissionGrants?: EntityTypePermissionGrantDefinition[] handler(ctx: HandlerContext, wake: WakeEvent): void | Promise } ``` @@ -35,7 +36,8 @@ interface EntityDefinition { | `actions` | `(collections) => Record void>` | No | Factory for custom non-CRUD actions. Receives TanStack DB collections, returns named action functions exposed on `ctx.actions`. | | `creationSchema` | `StandardJSONSchemaV1` | No | JSON Schema for spawn arguments validation. | | `inboxSchemas` | `Record` | No | JSON Schemas for inbound message types, keyed by message type. | -| `outputSchemas` | `Record` | No | JSON Schemas for output event types. Defaults are provided by the runtime. | +| `stateSchemas` | `Record` | No | Additional JSON Schemas included in the registered entity type's state schema map. | +| `permissionGrants` | `EntityTypePermissionGrantDefinition[]` | No | Initial permission grants applied when this entity type is registered. | | `handler` | `(ctx, wake) => void \| Promise` | Yes | The function invoked on each wake. Receives [`HandlerContext`](./handler-context) and [`WakeEvent`](./wake-event). | ## CollectionDefinition @@ -55,3 +57,25 @@ interface CollectionDefinition { | `schema` | `StandardSchemaV1` | - | Zod or Standard Schema validator for the row type. | | `type` | `string` | `"state:{name}"` | Event type string used in the durable stream. | | `primaryKey` | `string` | `"key"` | Primary key field name on the row. | + +## Permission grants + +`permissionGrants` lets an entity type declare the initial access grants that the server stores for entities of that type. + +```ts +registry.define("worker", { + description: "Internal worker agent", + permissionGrants: [ + { + subject_kind: "principal_kind", + subject_value: "user", + permission: "spawn", + }, + ], + async handler(ctx, wake) { + // ... + }, +}) +``` + +The server currently recognizes `read`, `write`, `delete`, `signal`, `fork`, `schedule`, `spawn`, and `manage` permissions. Grants can target a specific principal or a principal kind, and may include propagation options depending on the server route that creates them. diff --git a/website/docs/agents/reference/entity-handle.md b/website/docs/agents/reference/entity-handle.md index df05a45a6b..dd80743365 100644 --- a/website/docs/agents/reference/entity-handle.md +++ b/website/docs/agents/reference/entity-handle.md @@ -20,7 +20,7 @@ interface EntityHandle { events: ChangeEvent[] run: Promise text(): Promise - send(msg: unknown): void + send(msg: unknown): Promise status(): ChildStatus | undefined } ``` @@ -35,7 +35,7 @@ interface EntityHandle { | `events` | `ChangeEvent[]` | All change events received from this entity's stream. | | `run` | `Promise` | Promise that resolves when the entity's current run completes. Useful with `await`. | | `text()` | `Promise` | Returns all text outputs from the entity's stream. | -| `send(msg)` | `void` | Send a message to this entity. | +| `send(msg)` | `Promise` | Send a message to this entity. | | `status()` | `ChildStatus \| undefined` | Current status of the entity, or `undefined` if unknown. | ## ChildStatus @@ -49,7 +49,7 @@ interface ChildStatusEntry { key: string entity_url: string entity_type: string - status: "spawning" | "running" | "idle" | "stopped" + status: "spawning" | "running" | "idle" | "paused" | "stopping" | "stopped" | "killed" } ``` @@ -60,4 +60,7 @@ Status values: | `spawning` | Entity creation is in progress. | | `running` | Handler is currently executing. | | `idle` | Handler has completed; entity is waiting for the next wake. | +| `paused` | Entity is paused. | +| `stopping` | Entity is stopping and rejects normal writes. | | `stopped` | Entity has been stopped or deleted. | +| `killed` | Entity was killed by a terminal lifecycle signal. | diff --git a/website/docs/agents/reference/handler-context.md b/website/docs/agents/reference/handler-context.md index c9a4ed1908..2e89563184 100644 --- a/website/docs/agents/reference/handler-context.md +++ b/website/docs/agents/reference/handler-context.md @@ -16,6 +16,7 @@ The handler context is passed as the first argument to every entity handler. It interface HandlerContext { firstWake: boolean tags: Readonly + principal?: RuntimePrincipal entityUrl: string entityType: string args: Readonly> @@ -24,6 +25,8 @@ interface HandlerContext { events: Array actions: Record unknown> electricTools: AgentTool[] + signal: AbortSignal + sandbox: Sandbox useAgent(config: AgentConfig): AgentHandle useContext(config: UseContextConfig): void timelineMessages(opts?: TimelineProjectionOpts): Array @@ -41,6 +44,7 @@ interface HandlerContext { wake?: Wake tags?: Record observe?: boolean + sandbox?: SpawnSandboxOption } ): Promise observe( @@ -63,15 +67,23 @@ interface HandlerContext { entityUrl: string, payload: unknown, opts?: { type?: string; afterMs?: number } + ): Promise + attachments: AttachmentsApi + onSignal( + handler: (signal: { + signal: EntitySignal + reason?: string + payload?: unknown + }) => void | Promise ): void recordRun(): RunHandle setTag(key: string, value: string): Promise - removeTag(key: string): Promise + deleteTag(key: string): Promise sleep(): void } ``` -> **Tip:** Use the helper functions `entity()`, `cron()`, `entities()`, and `db()` from `@electric-ax/agents-runtime` to construct `ObservationSource` values for `observe()`. +> **Tip:** Use the helper functions `entity()`, `cron()`, `entities()`, `db()`, and `webhook()` from `@electric-ax/agents-runtime` to construct `ObservationSource` values for `observe()`. ## Properties @@ -79,6 +91,7 @@ interface HandlerContext { | ------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `firstWake` | `boolean` | `true` during the initial setup pass while the entity has no persisted manifest entries. Use state checks for one-time plain state initialization. | | `tags` | `Readonly` | Entity tags — key/value metadata associated with this entity. | +| `principal` | `RuntimePrincipal \| undefined` | Principal that caused the current wake, when the server supplied one. | | `entityUrl` | `string` | URL path of this entity (e.g. `"/chat/my-convo"`). | | `entityType` | `string` | Registered type name (e.g. `"chat"`). | | `args` | `Readonly>` | Spawn arguments passed when the entity was created. | @@ -87,6 +100,9 @@ interface HandlerContext { | `events` | `Array` | Change events that triggered this wake. | | `actions` | `Record unknown>` | Custom non-CRUD actions from the entity definition's `actions` factory. Auto-generated CRUD actions live on `ctx.db.actions` and `ctx.state`. | | `electricTools` | `AgentTool[]` | Host-provided runtime-level tools to spread into agent config when needed. May be empty. | +| `signal` | `AbortSignal` | Aborts when the current wake should stop early, such as during shutdown or `SIGINT`. Pass it to cancellable work. | +| `sandbox` | `Sandbox` | Active sandbox for this wake session. Runtime-provided tools use this for filesystem, process, and network access. | +| `attachments` | `AttachmentsApi` | Read and create manifest-backed attachments for this entity. | ## Methods @@ -100,15 +116,47 @@ interface HandlerContext { | `getContext(id)` | `ContextEntry \| undefined` | Get a context entry by id, or `undefined` if not found. | | `listContext()` | `Array` | List all context entries. | | `agent.run(input?)` | `Promise` | Run the configured agent loop. Optional `input` string is appended as a user message before the loop starts. | -| `spawn(type, id, args?, opts?)` | `Promise` | Spawn a child entity. `opts` accepts `tags`, `observe`, `initialMessage`, and `wake`. See [`EntityHandle`](./entity-handle). | +| `spawn(type, id, args?, opts?)` | `Promise` | Spawn a child entity. `opts` accepts `tags`, `observe`, `initialMessage`, `wake`, and `sandbox`. See [`EntityHandle`](./entity-handle). | | `observe(source, opts?)` | `Promise` | Observe a source. Return type depends on source type: `EntityHandle` for entities, `SharedStateHandle & ObservationHandle` for db, `ObservationHandle` otherwise. Use `entity()`, `cron()`, `entities()`, `db()` helpers to build sources. | | `mkdb(id, schema)` | `SharedStateHandle` | Create a new shared state stream. See [`SharedStateHandle`](./shared-state-handle). | -| `send(entityUrl, payload, opts?)` | `void` | Send a message to another entity. `opts` accepts `type` and `afterMs` (delay in milliseconds). | +| `send(entityUrl, payload, opts?)` | `Promise` | Send a message to another entity. `opts` accepts `type` and `afterMs` (delay in milliseconds). | +| `onSignal(handler)` | `void` | Register a handler for lifecycle signals delivered during this wake. Runtime-controlled signals such as `SIGINT`, `SIGSTOP`, `SIGCONT`, and `SIGKILL` are handled by the runtime. | | `recordRun()` | `RunHandle` | Record a non-LLM run in the built-in `runs` collection, so observers using `wake: "runFinished"` are notified when external work completes. | | `setTag(key, value)` | `Promise` | Set a tag on this entity. | -| `removeTag(key)` | `Promise` | Remove a tag from this entity. | +| `deleteTag(key)` | `Promise` | Delete a tag from this entity. | | `sleep()` | `void` | End the handler without running an agent. The entity remains idle until the next wake. | +## Sandbox + +`ctx.sandbox` is selected from the entity's sandbox profile at wake-session start. The runtime owns disposal; handlers should not call `sandbox.dispose()` directly. Use it when writing custom tools that need filesystem, subprocess, or network access so the behavior follows the active sandbox profile. + +Spawned children can inherit or select a sandbox: + +```ts +await ctx.spawn("worker", "analysis", args, { + sandbox: "inherit", + initialMessage: "Review the current workspace.", +}) +``` + +## Attachments + +`ctx.attachments` exposes manifest-backed attachments associated with the entity. It is used by the runtime to hydrate image and file context and can also be used by custom handlers or tools that need to inspect uploaded files. + +## Lifecycle Signals + +Use `ctx.signal` for cancellable work and `ctx.onSignal()` for handler-delivered lifecycle signals: + +```ts +ctx.onSignal(async ({ signal, reason }) => { + if (signal === "SIGTERM") { + await cleanup(reason) + } +}) +``` + +`SIGINT` aborts the active handler invocation through `ctx.signal`. `SIGSTOP`, `SIGCONT`, and `SIGKILL` are runtime-controlled. + ## RunHandle `recordRun()` is for handlers that perform work outside `ctx.agent.run()` but still want to expose run lifecycle events. diff --git a/website/docs/agents/reference/wake-event.md b/website/docs/agents/reference/wake-event.md index e339238330..60c3376770 100644 --- a/website/docs/agents/reference/wake-event.md +++ b/website/docs/agents/reference/wake-event.md @@ -76,7 +76,7 @@ type WakeMessage = { other_children?: Array<{ url: string type: string - status: "spawning" | "running" | "idle" | "stopped" + status: "spawning" | "running" | "idle" | "paused" | "stopping" | "stopped" | "killed" }> } ``` diff --git a/website/docs/agents/usage/app-setup.md b/website/docs/agents/usage/app-setup.md index 453180c878..a49b1e6571 100644 --- a/website/docs/agents/usage/app-setup.md +++ b/website/docs/agents/usage/app-setup.md @@ -40,8 +40,11 @@ interface RuntimeRouterConfig { handlerUrl?: string // legacy alias for serveEndpoint registry?: EntityRegistry subscriptionPathForType?: (typeName: string) => string + defaultDispatchPolicyForType?: (typeName: string) => DispatchPolicy | undefined + serverHeaders?: HeadersProvider + webhookSignature?: false | Partial idleTimeout?: number // ms before closing idle wake (default: 20000) - heartbeatInterval?: number // ms between heartbeats (default: 30000) + heartbeatInterval?: number // ms between heartbeats (default: 10000) createElectricTools?: (context: { entityUrl: string entityType: string @@ -65,12 +68,32 @@ interface RuntimeRouterConfig { messageType?: string }): Promise<{ txid: string }> deleteSchedule(opts: { id: string }): Promise<{ txid: string }> + listEventSources(): Promise> + subscribeToEventSource( + opts: EventSourceSubscriptionInput + ): Promise<{ txid: string; subscription: EventSourceSubscription }> + unsubscribeFromEventSource(opts: { id: string }): Promise<{ txid: string }> }) => AgentTool[] | Promise // factory for extra agent tools onWakeError?: (error: Error) => boolean | void // return true to mark handled registrationConcurrency?: number // max concurrent type registrations (default: 8) + sandboxProfiles?: ReadonlyArray + publicUrl?: string + name?: string } ``` +Key fields: + +| Field | Description | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `serveEndpoint` | Public webhook callback URL. When present, type registration includes webhook dispatch unless a default dispatch policy overrides it. | +| `serverHeaders` | Headers sent on control-plane requests to the agents server, including type registration and wake claims. | +| `webhookSignature` | Webhook signature verification config. Enabled by default against `${baseUrl}/__ds/jwks.json`; set to `false` only for trusted in-process tests. | +| `defaultDispatchPolicyForType` | Override the default dispatch policy registered per entity type. Use this for pull-wake runner targets. | +| `sandboxProfiles` | Named sandbox profiles advertised by this runtime. Spawn requests can select one by profile name. | +| `publicUrl` | Public URL for this runtime, surfaced by server runtime metadata APIs when available. | +| `name` | Human-readable runtime name. Defaults to `"default"`. | + ## HTTP server Your app needs an HTTP server to receive webhook callbacks from the Electric Agents runtime server. Forward webhook POSTs to the runtime handler: @@ -103,10 +126,7 @@ Must be called after your app starts listening. await runtime.registerTypes() ``` -This makes two requests per entity type: - -1. `POST /_electric/entity-types` — registers the type definition and schemas. -2. `PUT /{type}/**?subscription={type}-handler` — creates a webhook subscription for the type. +This sends `POST /_electric/entity-types` for each entity type. The request includes the type definition, state schemas, permission grants, optional `serve_endpoint`, and optional default dispatch policy. When `serveEndpoint` is set and no custom default dispatch policy is provided, registration uses webhook dispatch to that endpoint. ## RuntimeHandler @@ -142,7 +162,7 @@ interface RuntimeDebugState { | `waitForSettled` | Waits for all in-flight wakes; throws on errors | | `abortWakes` | Cancels all in-flight wake handlers immediately | | `debugState` | Returns a snapshot of internal runtime state for diagnostics | -| `registerTypes` | Registers entity types and webhook subscriptions with the Electric Agents runtime server | +| `registerTypes` | Registers entity types, schemas, permission grants, and default dispatch policy with the Electric Agents runtime server | ## createRuntimeRouter diff --git a/website/docs/agents/usage/attachments.md b/website/docs/agents/usage/attachments.md new file mode 100644 index 0000000000..57321f5af8 --- /dev/null +++ b/website/docs/agents/usage/attachments.md @@ -0,0 +1,129 @@ +--- +title: Attachments +titleTemplate: "... - Electric Agents" +description: >- + Upload, reference, read, and hydrate files and images for Electric Agents entities. +outline: [2, 3] +--- + +# Attachments + +Attachments are files associated with an entity. They are uploaded through entity routes, stored in private attachment streams, and referenced by `manifest` rows on the entity stream. + +Attachments are useful for image inputs, user-uploaded files, generated artifacts, and tool outputs that should be tracked alongside the entity timeline. + +## Upload from clients + +Use `createRuntimeServerClient().createAttachment()`: + +```ts +import { createRuntimeServerClient } from "@electric-ax/agents-runtime" + +const client = createRuntimeServerClient({ + baseUrl: "http://localhost:4437", + principalKey: "user:sam", +}) + +const { attachment } = await client.createAttachment({ + entityUrl: "/horton/onboarding", + attachment: { + bytes: imageBytes, + mimeType: "image/png", + filename: "screenshot.png", + subject: { type: "inbox", key: "message-1" }, + role: "input", + meta: { source: "upload" }, + }, +}) +``` + +The server writes a manifest entry like: + +```ts +interface ManifestAttachmentEntry { + kind: "attachment" + id: string + streamPath: string + status: "pending" | "complete" | "failed" + subject: { + type: "inbox" | "run" | "text" | "tool_call" | "context" + key: string + } + role: "input" | "output" + mimeType: string + filename?: string + byteLength?: number + sha256?: string + createdAt: string + createdBy?: string + error?: string + meta?: Record +} +``` + +## Read from clients + +Read bytes by entity URL and attachment id: + +```ts +const bytes = await client.readAttachment({ + entityUrl: "/horton/onboarding", + id: attachment.id, +}) +``` + +The caller needs read access to the entity. + +## Handler API + +Handlers access attachments through `ctx.attachments`: + +```ts +async handler(ctx) { + const inputs = ctx.attachments.list({ role: "input" }) + const first = inputs[0] + if (!first) return + + const bytes = await ctx.attachments.read(first.id) + // Use bytes in a custom tool or external API call. +} +``` + +Available operations: + +| Method | Purpose | +| ------ | ------- | +| `list(filter?)` | List manifest-backed attachments, optionally by role or subject. | +| `get(id)` | Return one attachment manifest entry by id. | +| `read(id)` | Read attachment bytes. | +| `create(input)` | Create a new attachment associated with this entity. | + +## Subjects and roles + +The `subject` links an attachment to the timeline object it belongs to: + +| Subject type | Typical use | +| ------------ | ----------- | +| `inbox` | User-uploaded input attached to a message | +| `run` | Artifact associated with an agent run | +| `text` | File linked to generated text | +| `tool_call` | Tool input or output artifact | +| `context` | Durable context material | + +`role` is either `input` or `output`. Input attachments are usually supplied by users or the host app. Output attachments are usually created by handlers or tools. + +## Images in agent context + +When image attachments are associated with inbox messages, the runtime can hydrate supported image inputs into model messages. The UI should hide image upload controls for models that do not advertise image input support. + +To keep context bounded, image hydration uses newest-first byte/count guardrails. Large or older images may remain as attachment descriptors rather than inline model content. + +## Failure and rollback + +Attachment uploads can fail independently of message sends. UI flows should roll back uploaded attachments if the send that references them fails, or leave an explicit failed manifest row when the failure should be visible to the entity. + +## Related APIs + +- [`HandlerContext`](../reference/handler-context) documents `ctx.attachments`. +- [`Built-in collections`](../reference/built-in-collections) documents attachment manifest rows. +- [`Programmatic runtime client`](./programmatic-runtime-client) documents `createAttachment()` and `readAttachment()`. diff --git a/website/docs/agents/usage/clients-and-react.md b/website/docs/agents/usage/clients-and-react.md index f925d26ce1..5139839dd7 100644 --- a/website/docs/agents/usage/clients-and-react.md +++ b/website/docs/agents/usage/clients-and-react.md @@ -17,7 +17,6 @@ Use the client APIs when you need to observe agents from application code rather ```ts import { - codingSession, createAgentsClient, entity, entities, @@ -43,17 +42,27 @@ console.log(membersDb.collections.members.toArray) interface AgentsClientConfig { baseUrl: string fetch?: typeof globalThis.fetch + principalKey?: string } interface AgentsClient { observe( source: ObservationSource ): Promise + signal(options: { + entityUrl: string + signal: EntitySignal + reason?: string + payload?: unknown + }): Promise<{ txid: number }> + kill(entityUrl: string, reason?: string): Promise<{ txid: number }> } ``` `observe(entity(url))` returns an `EntityStreamDB`. `observe(entities(...))` and `observe(db(...))` return an `ObservationStreamDB`. +Use `principalKey` when observing or signalling against a server that enforces principal-scoped access. + :::: warning `client.observe(cron(...))` is not currently supported. Use cron sources from handler wake subscriptions, or schedule tools exposed through `ctx.electricTools`. :::: diff --git a/website/docs/agents/usage/configuring-the-agent.md b/website/docs/agents/usage/configuring-the-agent.md index 7accad8dba..35ed9ff404 100644 --- a/website/docs/agents/usage/configuring-the-agent.md +++ b/website/docs/agents/usage/configuring-the-agent.md @@ -42,7 +42,7 @@ interface AgentConfig { async handler(ctx) { ctx.useAgent({ systemPrompt: 'You are a helpful assistant.', - model: 'claude-sonnet-4-5-20250929', + model: 'claude-sonnet-4-6', tools: [...ctx.electricTools], }) await ctx.agent.run() @@ -106,7 +106,7 @@ You must call `useAgent` before calling `run()`. Calling `ctx.agent.run()` witho When `model` is a string, the runtime resolves it through the configured `provider` (default `"anthropic"`). You can also pass a resolved `Model` object directly. ```ts -model: "claude-sonnet-4-5-20250929" +model: "claude-sonnet-4-6" provider: "anthropic" ``` @@ -119,7 +119,7 @@ For testing handlers without making LLM calls, pass `testResponses`. Two forms a ```ts ctx.useAgent({ systemPrompt: "...", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools], testResponses: ["Hello! How can I help?", "Sure, I can do that."], }) diff --git a/website/docs/agents/usage/context-composition.md b/website/docs/agents/usage/context-composition.md index 893e05e773..d46f8ca811 100644 --- a/website/docs/agents/usage/context-composition.md +++ b/website/docs/agents/usage/context-composition.md @@ -17,6 +17,7 @@ Most entities don't need `useContext` -- the default timeline assembly works wel - **Budget token space** across multiple content sources (docs, conversation history, retrieved context) - **Mix static and dynamic content** with different caching behavior - **Inject external content** (documentation, search results, knowledge bases) alongside conversation history +- **Hydrate uploaded files or images** through manifest-backed [attachments](./attachments) ## UseContextConfig @@ -190,7 +191,7 @@ async handler(ctx, wake) { ctx.useAgent({ systemPrompt: "You are a helpful assistant.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools, }) await ctx.agent.run() diff --git a/website/docs/agents/usage/defining-entities.md b/website/docs/agents/usage/defining-entities.md index 1394768da8..961ae7c4c0 100644 --- a/website/docs/agents/usage/defining-entities.md +++ b/website/docs/agents/usage/defining-entities.md @@ -24,7 +24,7 @@ registry.define("assistant", { async handler(ctx) { ctx.useAgent({ systemPrompt: "You are a helpful assistant.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools], }) await ctx.agent.run() @@ -45,7 +45,8 @@ interface EntityDefinition { ) => Record void> creationSchema?: StandardJSONSchemaV1 inboxSchemas?: Record - outputSchemas?: Record + stateSchemas?: Record + permissionGrants?: EntityTypePermissionGrantDefinition[] handler: (ctx: HandlerContext, wake: WakeEvent) => void | Promise } ``` @@ -57,9 +58,12 @@ interface EntityDefinition { | `actions` | Factory that returns custom non-CRUD action functions exposed on `ctx.actions`. | | `creationSchema` | JSON Schema for arguments passed when the entity is spawned. | | `inboxSchemas` | JSON Schemas for typed inbox message categories. | -| `outputSchemas` | JSON Schemas for typed output message categories. | +| `stateSchemas` | Additional JSON Schemas registered with the entity type's state schema map. | +| `permissionGrants` | Initial permission grants applied when this entity type is registered. | | `handler` | The function that runs each time the entity wakes. Required. | +See [Permissions & principals](./permissions-and-principals) for the access-control model behind `permissionGrants`. + ## Custom state Declare named collections in the `state` field. Each collection is a `CollectionDefinition`: @@ -145,7 +149,7 @@ export function registerAssistant(registry: EntityRegistry) { async handler(ctx) { ctx.useAgent({ systemPrompt: "You are a helpful assistant.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools], }) await ctx.agent.run() @@ -158,7 +162,7 @@ This keeps each entity type isolated and the registry composition explicit. ## Schemas -`creationSchema`, `inboxSchemas`, and `outputSchemas` accept [`StandardJSONSchemaV1`](https://github.com/standard-schema/standard-schema) objects. Any schema library implementing the Standard JSON Schema interface works (e.g. Zod v4). These schemas are used for validation and for generating UI and documentation in the Electric Agents dashboard. +`creationSchema`, `inboxSchemas`, and `stateSchemas` accept [`StandardJSONSchemaV1`](https://github.com/standard-schema/standard-schema) objects. Any schema library implementing the Standard JSON Schema interface works (e.g. Zod v4). These schemas are used for validation and for generating UI and documentation in the Electric Agents dashboard. ```ts import { z } from "zod/v4" diff --git a/website/docs/agents/usage/defining-tools.md b/website/docs/agents/usage/defining-tools.md index 7f1753c3a4..042b188bd5 100644 --- a/website/docs/agents/usage/defining-tools.md +++ b/website/docs/agents/usage/defining-tools.md @@ -218,7 +218,7 @@ registry.define("assistant", { ctx.useAgent({ systemPrompt: "You are a helpful assistant with persistent memory.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools, memoryTool, dispatchTool, calculatorTool], }) await ctx.agent.run() diff --git a/website/docs/agents/usage/embedded-builtins.md b/website/docs/agents/usage/embedded-builtins.md index d099976360..08b4d5e27f 100644 --- a/website/docs/agents/usage/embedded-builtins.md +++ b/website/docs/agents/usage/embedded-builtins.md @@ -13,21 +13,24 @@ The CLI commands `electric agents start-builtin` and `electric agents quickstart ## BuiltinAgentsServer -`BuiltinAgentsServer` starts an HTTP webhook server, registers `horton` and `worker`, and forwards Electric Agents webhook wakes to the built-in handler. +`BuiltinAgentsServer` registers `horton` and `worker`, advertises the runtime's sandbox profiles, and starts a pull-wake runner that claims wakes from the Electric Agents server. This is the same model used by the CLI and desktop app. ```ts import { BuiltinAgentsServer } from "@electric-ax/agents" const server = new BuiltinAgentsServer({ agentServerUrl: "http://localhost:4437", - port: 4448, workingDirectory: process.cwd(), + loadProjectMcpConfig: true, + pullWake: { + runnerId: "builtin-agents", + ownerPrincipal: "/principal/system%3Abuiltin-agents", + registerRunner: true, + }, }) -await server.start() - -console.log(server.url) -console.log(server.registeredBaseUrl) +const runtimeUrl = await server.start() +console.log(runtimeUrl) // "pull-wake:builtin-agents" // Later, during shutdown: await server.stop() @@ -42,12 +45,22 @@ type CreateElectricTools = RuntimeRouterConfig["createElectricTools"] interface BuiltinAgentsServerOptions { agentServerUrl: string - baseUrl?: string - port: number - host?: string workingDirectory?: string mockStreamFn?: StreamFn - webhookPath?: string + pullWake: { + runnerId: string + ownerPrincipal?: string + label?: string + registerRunner?: boolean + headers?: HeadersProvider + claimHeaders?: HeadersProvider + claimTokenHeader?: ClaimTokenHeader + heartbeatIntervalMs?: number + eventHeartbeatThrottleMs?: number + leaseMs?: number + } + enabledModelValues?: readonly string[] | null + baseSkillsDir?: string createElectricTools?: CreateElectricTools // MCP integration extraMcpServers?: ReadonlyArray @@ -61,24 +74,48 @@ interface BuiltinAgentsServerOptions { | Field | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agentServerUrl` | Electric Agents coordinator server URL. | -| `baseUrl` | Public base URL used when registering the webhook. Defaults to local URL. | -| `port` | Local webhook server port. | -| `host` | Bind host. Defaults to `127.0.0.1`. | | `workingDirectory` | Directory used by Horton and worker file tools. Defaults to `process.cwd()`. | -| `mockStreamFn` | Optional test stream function. Lets you run without `ANTHROPIC_API_KEY`. | -| `webhookPath` | Webhook path. Defaults to `/_electric/builtin-agent-handler`. | +| `mockStreamFn` | Optional test stream function. Lets you run without a real model provider. | +| `pullWake` | Pull-wake runner configuration. `runnerId` identifies this runtime to the server. Set `registerRunner: true` when this process should create/update the runner record. | +| `enabledModelValues` | Optional allowlist of model values exposed by built-in agent creation schemas. Values use the model catalog's `provider:model` form. | +| `baseSkillsDir` | Override for the bundled skills directory, useful when an embedder packages `@electric-ax/agents`. | | `createElectricTools` | Optional factory for extra tools injected into built-in agent handlers. | | `extraMcpServers` | MCP servers contributed by the embedder. On name conflict with `mcp.json`, `mcp.json` wins. `authorizationCode` servers are auto-wired with `keychainPersistence`. | -| `loadProjectMcpConfig` | Load `/mcp.json` (and watch it). Off by default — stdio MCP servers can spawn local commands, so the embedder must opt in. The Electron desktop and `electric-ax` CLI opt in. | -| `mcpOAuthRedirectBase` | Base for OAuth redirect URIs (full URI is `/oauth/callback/`). MUST be stable across restarts so DCR client info stays valid; required when listening on `port: 0`. The runtime never listens at this URI — the embedder intercepts the redirect. | +| `loadProjectMcpConfig` | Load `/mcp.json` (and watch it). Off by default because stdio MCP servers can spawn local commands, so embedders must opt in. The Electron desktop and `electric-ax` CLI opt in. | +| `mcpOAuthRedirectBase` | Base for OAuth redirect URIs (full URI is `/oauth/callback/`). Must be stable across restarts so DCR client info stays valid. The runtime never listens at this URI; the embedder intercepts the redirect. | | `openAuthorizeUrl` | Hook invoked when an `authorizationCode` MCP server first needs user consent. Receives the SDK-generated authorize URL. The desktop opens it in a sandboxed `BrowserWindow`; headless embedders can read the URL from the `authenticating` envelope of `addServer` and surface it themselves. | | `onConfigError` | Invoked when applying an MCP config (initial boot or watcher reload) fails. Errors are always logged; this hook is for surfacing them programmatically. | -Without `mockStreamFn`, `ANTHROPIC_API_KEY` must be present before the built-in handler starts. +Without `mockStreamFn`, at least one supported provider must be configured before the built-in handler starts: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `MOONSHOT_API_KEY`, or a valid OpenAI Codex CLI auth file for the `openai-codex` provider. + +### Pull-wake headers and principals + +When the server enforces principals or auth, pass the same headers to runner registration and wake claims: + +```ts +const serverHeaders = { + "electric-principal": "service:local-runtime", + authorization: `Bearer ${process.env.ELECTRIC_AGENTS_TOKEN}`, +} + +const server = new BuiltinAgentsServer({ + agentServerUrl: "http://localhost:4437", + pullWake: { + runnerId: "local-runtime", + ownerPrincipal: "/principal/service%3Alocal-runtime", + registerRunner: true, + headers: serverHeaders, + claimHeaders: serverHeaders, + claimTokenHeader: "electric-claim-token", + }, +}) +``` + +Use `claimTokenHeader: "electric-claim-token"` when your `authorization` header is reserved for server auth. Otherwise the default claim token transport is the standard `Authorization: Bearer ` header. ## createBuiltinAgentHandler -Use `createBuiltinAgentHandler()` when you already have an HTTP server and only need the request handler and runtime objects. +Use `createBuiltinAgentHandler()` when you need the lower-level registry/runtime objects. If you pass `serveEndpoint`, `registerTypes()` registers webhook dispatch for the built-in types. If you are using pull-wake, prefer `BuiltinAgentsServer`, which wires runner registration, MCP, sandbox profiles, and wake claiming for you. ```ts import { @@ -93,7 +130,7 @@ const bootstrap = await createBuiltinAgentHandler({ }) if (!bootstrap) { - throw new Error("ANTHROPIC_API_KEY is required for built-in agents") + throw new Error("No supported model provider is configured") } await registerBuiltinAgentTypes(bootstrap) @@ -119,11 +156,15 @@ interface AgentHandlerResult { Both `BuiltinAgentsServer` and `createBuiltinAgentHandler()` accept `createElectricTools`. The factory receives the same context shape as `RuntimeRouterConfig.createElectricTools` and can add host-specific tools to Horton. ```ts +import { BuiltinAgentsServer } from "@electric-ax/agents" import { Type } from "@sinclair/typebox" const server = new BuiltinAgentsServer({ agentServerUrl: "http://localhost:4437", - port: 4448, + pullWake: { + runnerId: "builtin-agents", + registerRunner: true, + }, createElectricTools: ({ entityUrl, upsertCronSchedule }) => [ { name: "schedule_daily_summary", @@ -165,15 +206,17 @@ await server.stop() Environment variables: -| Variable | Description | -| -------------------------------- | ----------------------------------------------------- | -| `ELECTRIC_AGENTS_SERVER_URL` | Required coordinator server URL. | -| `ELECTRIC_AGENTS_PRINCIPAL` | Optional principal key sent as `Electric-Principal`. | -| `ELECTRIC_AGENTS_SERVER_HEADERS` | Optional JSON object of additional server headers. | -| `ELECTRIC_AGENTS_BUILTIN_BASE_URL` | Public webhook base URL for the built-in server. | -| `ELECTRIC_AGENTS_BUILTIN_HOST` | Bind host. | -| `ELECTRIC_AGENTS_BUILTIN_PORT` | Built-in server port. Defaults to `4448`. | -| `ELECTRIC_AGENTS_WORKING_DIRECTORY` | Working directory for file tools. | +| Variable | Description | +| ------------------------------------------ | --------------------------------------------------------------------------- | +| `ELECTRIC_AGENTS_SERVER_URL` | Required coordinator server URL. | +| `ELECTRIC_AGENTS_BASE_URL` | Legacy alias for `ELECTRIC_AGENTS_SERVER_URL`. | +| `ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID` | Required pull-wake runner id. | +| `PULL_WAKE_RUNNER_ID` | Legacy alias for `ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID`. | +| `ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER` | Set to `true` or `1` to register/update the runner record before claiming. | +| `ELECTRIC_AGENTS_PRINCIPAL` | Optional principal key sent as `Electric-Principal`. | +| `ELECTRIC_AGENTS_SERVER_HEADERS` | Optional JSON object of additional server headers. | +| `ELECTRIC_AGENTS_WORKING_DIRECTORY` | Working directory for file tools. | +| `WORKING_DIRECTORY` | Legacy alias for `ELECTRIC_AGENTS_WORKING_DIRECTORY`. | ## Built-in Agent APIs diff --git a/website/docs/agents/usage/event-sources.md b/website/docs/agents/usage/event-sources.md new file mode 100644 index 0000000000..3b5b0c00e0 --- /dev/null +++ b/website/docs/agents/usage/event-sources.md @@ -0,0 +1,167 @@ +--- +title: Event sources +titleTemplate: "... - Electric Agents" +description: >- + Let agents discover and subscribe to external webhook-backed event feeds that wake entities with matching event data. +outline: [2, 3] +--- + +# Event sources + +Event 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. + +Built-in Horton runtimes expose event-source tools through `ctx.electricTools` by default. + +## Contracts + +An event source contract describes what an agent can subscribe to: + +```ts +type EventSourceContract = { + sourceKey: string + sourceType: "webhook" + endpointKey: string + status: "active" | "disabled" | "revoked" + label: string + description?: string + agentVisible: boolean + buckets: EventSourceBucket[] + revision: number +} +``` + +Buckets describe path templates and parameters: + +```ts +type EventSourceBucket = { + key: string + label: string + pathTemplate: string + paramsSchema: Record + filters?: EventSourceFilter[] +} +``` + +Agents should call `list_event_sources` first and use the advertised `sourceKey`, `bucketKey`, `paramsSchema`, and optional `filterKey`. + +## Built-in tools + +The runtime tool factory can add four tools: + +| Tool | Purpose | +| ---- | ------- | +| `list_event_sources` | List external feeds the entity can subscribe to. | +| `list_event_source_subscriptions` | List active subscriptions for this entity. | +| `subscribe_event_source` | Subscribe the entity to a source or bucket. | +| `unsubscribe_event_source` | Remove a subscription by id. | + +Horton receives these tools from the built-in runtime. Custom runtimes can provide them with `createEventSourceTools()` or by passing `createElectricTools` through `createRuntimeHandler()`. + +```ts +import { createEventSourceTools } from "@electric-ax/agents-runtime/tools" + +const runtime = createRuntimeHandler({ + baseUrl: "http://localhost:4437", + registry, + createElectricTools: (context) => createEventSourceTools(context), +}) +``` + +## Subscribing from tools + +`subscribe_event_source` accepts: + +```ts +type EventSourceSubscriptionInput = { + id?: string + sourceKey: string + bucketKey?: string + params?: Record + filterKey?: string + lifetime?: SubscriptionLifetime + reason?: string +} +``` + +If `id` is omitted, the runtime derives a deterministic id from the source, bucket, params, and filter. + +Lifetimes: + +```ts +type SubscriptionLifetime = + | { kind: "until_entity_stopped" } + | { kind: "expires_at"; at: string } + | { kind: "manual" } +``` + +The default lifetime is `until_entity_stopped`. + +## Programmatic subscriptions + +Host code can subscribe directly with `createRuntimeServerClient()`: + +```ts +await client.subscribeToEventSource({ + entityUrl: "/horton/onboarding", + sourceKey: "github", + bucketKey: "repo", + params: { repo: "electric-sql/electric" }, + reason: "Watch repo activity for this session", +}) + +await client.unsubscribeFromEventSource({ + entityUrl: "/horton/onboarding", + id: "github-main", +}) +``` + +Use `listEventSources()` to inspect available contracts: + +```ts +const sources = await client.listEventSources() +``` + +## Wake payloads + +When a subscribed source fires, the entity is woken with a hydrated event-source payload: + +```ts +type HydratedEventSourceWake = { + type: "event_source_wake" + source: string + sourceType: "webhook" + endpointKey: string + sourceKey: string + subscription: { + id: string + bucketKey?: string + params: Record + filterKey?: string + reason?: string + } + bucket: string | null + changes: Array<{ + collection: string + kind: "insert" | "update" | "delete" + key: string + }> + events: WebhookEventRow[] + missingEventKeys?: string[] +} +``` + +Handlers can inspect `wake.payload` or use the normal agent context. Horton includes hydrated event-source data in the trigger message so the model can react without doing a second lookup. + +## Manifest entries + +Subscriptions are stored as `manifest` rows with `kind: "source"` and a stable manifest key: + +```ts +event-source: +``` + +This lets the entity list and remove subscriptions across wakes. + +## Filters + +`filterKey` selects a named filter advertised by the source. Filters are intended to narrow external event feeds. In the current version, filters are advisory until server-side source filters are enabled, so agents should still handle unexpected events defensively. diff --git a/website/docs/agents/usage/mcp-servers.md b/website/docs/agents/usage/mcp-servers.md index 484b976f68..71dc8ebb25 100644 --- a/website/docs/agents/usage/mcp-servers.md +++ b/website/docs/agents/usage/mcp-servers.md @@ -26,8 +26,11 @@ import { BuiltinAgentsServer } from "@electric-ax/agents" const server = new BuiltinAgentsServer({ agentServerUrl: "http://localhost:4437", - port: 4448, workingDirectory: process.cwd(), + pullWake: { + runnerId: "builtin-agents", + registerRunner: true, + }, }) await server.start() @@ -137,10 +140,10 @@ Programmatic embedders (other than the desktop) pass the resolved set as an arra ## Per-agent allowlist -Entity definitions opt into MCP servers explicitly via the `mcp.tools()` helper from `@electric-ax/agents-runtime`: +Entity definitions opt into MCP servers explicitly via the `mcp.tools()` helper from `@electric-ax/agents-mcp`: ```ts -import { mcp } from "@electric-ax/agents-runtime" +import { mcp } from "@electric-ax/agents-mcp" registry.define("research-agent", { async handler(ctx) { diff --git a/website/docs/agents/usage/overview.md b/website/docs/agents/usage/overview.md index f158468bea..0ac8e521ea 100644 --- a/website/docs/agents/usage/overview.md +++ b/website/docs/agents/usage/overview.md @@ -21,7 +21,7 @@ And schemas: - `creationSchema` -- validated spawn args - `inboxSchemas` -- typed message contracts -- `outputSchemas` -- what the entity emits (for UI binding) +- `stateSchemas` -- additional registered state schemas See [Defining entities](/docs/agents/usage/defining-entities) and [EntityDefinition reference](/docs/agents/reference/entity-definition). @@ -53,7 +53,7 @@ The context API passed into the handler: | `ctx.observe(db(id, schema), opts)` | Join existing shared state | | `ctx.recordRun()` | Record non-LLM work as a run for `runFinished` observers | | `ctx.setTag(key, value)` | Set a tag on this entity | -| `ctx.removeTag(key)` | Remove a tag from this entity | +| `ctx.deleteTag(key)` | Delete a tag from this entity | See [Writing handlers](/docs/agents/usage/writing-handlers) and [HandlerContext reference](/docs/agents/reference/handler-context). @@ -62,7 +62,7 @@ See [Writing handlers](/docs/agents/usage/writing-handlers) and [HandlerContext ```ts ctx.useAgent({ systemPrompt: string, - model: string | Model, // e.g. 'claude-sonnet-4-5-20250929' + model: string | Model, // e.g. 'claude-sonnet-4-6' provider?: KnownProvider, // defaults to 'anthropic' for string models tools: AgentTool[], // [...ctx.electricTools, ...custom] streamFn?: StreamFn, // optional streaming callback @@ -188,7 +188,17 @@ See [Managing state](/docs/agents/usage/managing-state). See [Spawning & coordinating](/docs/agents/usage/spawning-and-coordinating) and [EntityHandle reference](/docs/agents/reference/entity-handle). -## 7. Shared state (cross-entity) +## 7. Runtime capabilities + +Use the dedicated guides for runtime features that cut across handlers, clients, and hosted built-ins: + +- [Permissions & principals](/docs/agents/usage/permissions-and-principals) — principal-scoped access to types and entities. +- [Sandboxing](/docs/agents/usage/sandboxing) — filesystem, process, and network isolation for LLM-driven tools. +- [Attachments](/docs/agents/usage/attachments) — upload, read, and hydrate files and images. +- [Signals](/docs/agents/usage/signals) — interrupt, pause, resume, kill, and notify entities. +- [Event sources](/docs/agents/usage/event-sources) — subscribe entities to external webhook-backed feeds. + +## 8. Shared state (cross-entity) Define a schema map, then create/connect: @@ -209,9 +219,9 @@ shared.findings.insert({ key: "f1", text: "..." }) See [Shared state](/docs/agents/usage/shared-state) and [SharedStateHandle reference](/docs/agents/reference/shared-state-handle). -## 8. Built-in collections +## 9. Built-in collections -Every entity automatically has 17 `ctx.db.collections`: +Every entity automatically has 18 `ctx.db.collections`: | Collection | Purpose | Key fields | | ------------------ | ------------------------- | ---------------------------------------------------------------------- | @@ -226,8 +236,9 @@ Every entity automatically has 17 `ctx.db.collections`: | `wakes` | Wake event history | `source, timeout, changes` | | `entityCreated` | Bootstrap metadata | `entity_type, args, parent_url` | | `entityStopped` | Shutdown signal | `timestamp, reason` | +| `signals` | Lifecycle signal records | `signal, status, outcome` | | `childStatus` | Child entity status | `entity_url, status` | -| `manifests` | Wiring declarations | discriminated union: child/source/shared-state/effect/context/schedule | +| `manifests` | Wiring declarations | discriminated union: child/source/shared-state/effect/attachment/context/schedule | | `replayWatermarks` | Replay offset tracking | `source_id, offset` | | `tags` | Entity tags/labels | `key, value` | | `contextInserted` | Context additions | `id, name, attrs, content, timestamp` | @@ -235,7 +246,7 @@ Every entity automatically has 17 `ctx.db.collections`: See [Built-in collections](/docs/agents/reference/built-in-collections). -## 9. CLI (`electric agents`) +## 10. CLI (`electric agents`) Interact with the system using the Electric Agents CLI: @@ -246,6 +257,8 @@ Interact with the system using the Electric Agents CLI: | `electric agents spawn /type/id --args '{...}'` | Create entity | | `electric agents send /type/id 'message'` | Send message | | `electric agents observe /type/id` | Stream entity events | +| `electric agents view /type/id` | Print entity conversation once | +| `electric agents signal /type/id SIGINT` | Send a lifecycle signal | | `electric agents inspect /type/id` | Show entity state | | `electric agents ps [--type --status --parent]` | List entities | | `electric agents kill /type/id` | Delete entity | @@ -257,7 +270,7 @@ Interact with the system using the Electric Agents CLI: See [CLI reference](/docs/agents/reference/cli). -## 10. App setup +## 11. App setup ```ts const registry = createEntityRegistry() @@ -275,7 +288,7 @@ await runtime.registerTypes() // register all types with runtime server See [App setup](/docs/agents/usage/app-setup) and [RuntimeHandler reference](/docs/agents/reference/runtime-handler). -## 11. App clients and embedded built-ins +## 12. App clients and embedded built-ins Use the client and embedding APIs when you need to work with agents outside an entity handler: diff --git a/website/docs/agents/usage/permissions-and-principals.md b/website/docs/agents/usage/permissions-and-principals.md new file mode 100644 index 0000000000..0ce402649b --- /dev/null +++ b/website/docs/agents/usage/permissions-and-principals.md @@ -0,0 +1,144 @@ +--- +title: Permissions & principals +titleTemplate: "... - Electric Agents" +description: >- + Control who can spawn, read, write, signal, fork, schedule, and manage Electric Agents entities using principals and grants. +outline: [2, 3] +--- + +# Permissions & principals + +Electric Agents servers authorize requests using a **principal** and permission grants. A principal identifies the caller; grants decide what that caller can do to entity types and entity instances. + +## Principals + +Pass a principal key as the `Electric-Principal` header. The key shape is: + +```text +: +``` + +Supported principal kinds are `user`, `agent`, `service`, and `system`. The server turns a key such as `user:sam` into the canonical principal URL `/principal/user%3Asam`. + +From clients, use `principalKey`: + +```ts +import { createRuntimeServerClient } from "@electric-ax/agents-runtime" + +const client = createRuntimeServerClient({ + baseUrl: "http://localhost:4437", + principalKey: "user:sam", +}) +``` + +The CLI can pass the same value through the environment: + +```sh +ELECTRIC_AGENTS_PRINCIPAL=user:sam electric agents ps +``` + +Servers may also accept additional auth headers through `ELECTRIC_AGENTS_SERVER_HEADERS` or `serverHeaders`, depending on the host. + +## Entity type permissions + +Entity type grants control who can spawn or manage entities of a type. Type-level permissions are: + +| Permission | Allows | +| ---------- | ------ | +| `spawn` | Spawn entities of this type | +| `manage` | Manage the entity type and acts as the broader type-level permission | + +Declare initial type grants in an entity definition: + +```ts +registry.define("worker", { + description: "Internal worker", + permissionGrants: [ + { + subject_kind: "principal_kind", + subject_value: "user", + permission: "spawn", + }, + ], + async handler(ctx) { + // ... + }, +}) +``` + +`subject_kind` can be `principal` for one principal URL/key or `principal_kind` for every principal of a kind. + +## Entity permissions + +Entity grants control access to existing entities. Entity-level permissions are: + +| Permission | Allows | +| ---------- | ------ | +| `read` | Read entity metadata and streams | +| `write` | Send messages and write entity-owned resources | +| `delete` | Delete or kill the entity | +| `signal` | Send lifecycle signals | +| `fork` | Fork from entity history | +| `schedule` | Create, update, or delete schedules | +| `spawn` | Spawn children from this entity | +| `manage` | Manage grants and acts as the broader entity-level permission | + +Server spawn routes can include initial entity grants: + +```ts +await fetch("http://localhost:4437/_electric/entities/assistant/support-ticket-42", { + method: "PUT", + headers: { + "content-type": "application/json", + "electric-principal": "user:sam", + }, + body: JSON.stringify({ + grants: [ + { + subject_kind: "principal", + subject_value: "/principal/user%3Asam", + permission: "read", + }, + ], + }), +}) +``` + +When spawning from a parent, broad delegation requires `manage` on the parent. This applies to grants such as `manage`, principal-kind grants, descendant propagation, and `copy_to_children`. + +## Grant propagation + +Entity grants may include propagation options: + +```ts +{ + subject_kind: "principal", + subject_value: "/principal/user%3Asam", + permission: "read", + propagation: "descendants", + copy_to_children: true, +} +``` + +- `propagation: "self"` applies to the entity itself. +- `propagation: "descendants"` applies through descendant entities. +- `copy_to_children: true` copies the grant when children are spawned. +- `expires_at` can set a grant expiry timestamp. + +## Claim-scoped write tokens + +Some low-level writes are protected by claim-scoped write tokens. Handler APIs such as `ctx.setTag()` and `ctx.deleteTag()` already have the active claim context. External clients should usually send messages instead of directly mutating entity-owned state. + +If a host reserves the `Authorization` header for server auth, configure write token transport with `writeTokenHeader` or `claimTokenHeader`: + +```ts +const client = createRuntimeServerClient({ + baseUrl: "http://localhost:4437", + headers: { authorization: `Bearer ${serverToken}` }, + writeTokenHeader: "electric-claim-token", +}) +``` + +## Development fallback + +Local development servers can use a development principal fallback. Production deployments should authenticate requests and provide an explicit `Electric-Principal` header for every request. diff --git a/website/docs/agents/usage/programmatic-runtime-client.md b/website/docs/agents/usage/programmatic-runtime-client.md index 4cdd41e619..37b9cd6c56 100644 --- a/website/docs/agents/usage/programmatic-runtime-client.md +++ b/website/docs/agents/usage/programmatic-runtime-client.md @@ -25,15 +25,21 @@ const client = createRuntimeServerClient({ interface RuntimeServerClientConfig { baseUrl: string fetch?: typeof globalThis.fetch + headers?: HeadersProvider + writeTokenHeader?: ClaimTokenHeader track?: (promise: Promise) => Promise + principalKey?: string } ``` -| Field | Description | -| --------- | ------------------------------------------------------------------------- | -| `baseUrl` | Base URL for the Electric Agents server. | -| `fetch` | Optional fetch implementation, useful in tests or non-standard runtimes. | -| `track` | Optional wrapper for all requests, useful for telemetry or pending state. | +| Field | Description | +| ------------------ | --------------------------------------------------------------------------- | +| `baseUrl` | Base URL for the Electric Agents server. | +| `fetch` | Optional fetch implementation, useful in tests or non-standard runtimes. | +| `headers` | Static or async headers added to requests, useful for auth or tenant scope. | +| `writeTokenHeader` | Header transport for claim-scoped write tokens: `authorization`, `electric-claim-token`, or `both`. | +| `track` | Optional wrapper for all requests, useful for telemetry or pending state. | +| `principalKey` | Principal key sent as `Electric-Principal` on requests. | ## Entity Lifecycle @@ -46,6 +52,7 @@ const info = await client.spawnEntity({ args: { timezone: "Europe/London" }, initialMessage: "Help me get started.", tags: { project: "docs" }, + sandbox: { profile: "local", scope: "entity" }, }) console.log(info.entityUrl) // "/horton/onboarding" @@ -61,6 +68,15 @@ interface SpawnEntityOptions { parentUrl?: string initialMessage?: unknown tags?: Record + sandbox?: { + profile?: string + key?: string + scope?: "entity" | "wake" + persistent?: boolean + owner?: boolean + inherit?: boolean + } + dispatch_policy?: DispatchPolicy wake?: { subscriberUrl: string condition: @@ -73,14 +89,15 @@ interface SpawnEntityOptions { debounceMs?: number timeoutMs?: number includeResponse?: boolean + manifestKey?: string } } ``` -### getEntityInfo +### getEntity ```ts -const info = await client.getEntityInfo("/horton/onboarding") +const info = await client.getEntity("/horton/onboarding") // { entityUrl, entityType, streamPath } ``` @@ -98,8 +115,8 @@ Deleting an already-missing entity is treated as success. await client.sendEntityMessage({ targetUrl: "/horton/onboarding", payload: "What changed since last time?", - from: "support-ui", type: "user_message", + mode: "queued", }) ``` @@ -107,13 +124,54 @@ await client.sendEntityMessage({ interface SendEntityMessageOptions { targetUrl: string payload: unknown - from?: string type?: string afterMs?: number + mode?: "immediate" | "queued" | "paused" | "steer" + position?: string } ``` -`afterMs` asks the server to deliver the message later. +`afterMs` asks the server to deliver the message later. `mode` controls how the server queues or applies the message. + +## Signals + +Send lifecycle signals to an entity: + +```ts +await client.signalEntity({ + entityUrl: "/horton/onboarding", + signal: "SIGINT", + reason: "User stopped the current run", +}) +``` + +`deleteEntity()` sends `SIGKILL` and treats an already-missing entity as success: + +```ts +await client.deleteEntity("/horton/onboarding") +``` + +## Attachments + +Attachments are uploaded through entity routes, stored in private attachment streams, and referenced by manifest entries: + +```ts +const { attachment } = await client.createAttachment({ + entityUrl: "/horton/onboarding", + attachment: { + bytes: imageBytes, + mimeType: "image/png", + filename: "diagram.png", + subject: { type: "inbox", key: "message-1" }, + role: "input", + }, +}) + +const bytes = await client.readAttachment({ + entityUrl: "/horton/onboarding", + id: attachment.id, +}) +``` ## Shared State @@ -156,24 +214,46 @@ await client.registerWake({ }) ``` -### registerCronSource +### ensureCronStream ```ts -const streamUrl = await client.registerCronSource( +const streamUrl = await client.ensureCronStream( "0 9 * * *", "Europe/London" ) ``` -### registerEntitiesSource +### ensureEntitiesMembershipStream ```ts -const source = await client.registerEntitiesSource({ project: "docs" }) +const source = await client.ensureEntitiesMembershipStream({ project: "docs" }) // { streamUrl, sourceRef } ``` This is the lower-level operation behind observing `entities({ tags })`. +### Event sources + +Event-source APIs expose webhook-backed feeds that agents can subscribe to: + +```ts +const sources = await client.listEventSources() + +await client.subscribeToEventSource({ + entityUrl: "/horton/onboarding", + id: "github-main", + sourceKey: "github", + bucketKey: "repo", + params: { repo: "electric-sql/electric" }, + lifetime: { kind: "until_entity_stopped" }, +}) + +await client.unsubscribeFromEventSource({ + entityUrl: "/horton/onboarding", + id: "github-main", +}) +``` + ## Schedules Schedules are stored on an entity manifest and return the write transaction id. @@ -202,11 +282,11 @@ await client.deleteSchedule({ ## Tags -`setTag()` and `removeTag()` are primarily for handler/runtime-owned flows that already hold the current claim-scoped write token. External clients should prefer `send()` and write only to an entity's inbox rather than writing entity state directly. +`setTag()` and `deleteTag()` are primarily for handler/runtime-owned flows that already hold the current claim-scoped write token. External clients should prefer `send()` and write only to an entity's inbox rather than writing entity state directly. ```ts await client.setTag("/horton/onboarding", "title", "Onboarding", writeToken) -await client.removeTag("/horton/onboarding", "title", writeToken) +await client.deleteTag("/horton/onboarding", "title", writeToken) ``` ## Choosing a Client diff --git a/website/docs/agents/usage/sandboxing.md b/website/docs/agents/usage/sandboxing.md new file mode 100644 index 0000000000..801115eb85 --- /dev/null +++ b/website/docs/agents/usage/sandboxing.md @@ -0,0 +1,162 @@ +--- +title: Sandboxing +titleTemplate: "... - Electric Agents" +description: >- + Isolate file, process, and network access for LLM-driven tools with Electric Agents sandbox profiles. +outline: [2, 3] +--- + +# Sandboxing + +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. + +Sandboxing is configured by the runtime host, advertised to the server as named **sandbox profiles**, and selected when an entity is spawned. + +## Runtime profiles + +Register sandbox profiles on the runtime: + +```ts +import { createRuntimeHandler } from "@electric-ax/agents-runtime" +import { + remoteSandbox, + unrestrictedSandbox, +} from "@electric-ax/agents-runtime/sandbox" + +const runtime = createRuntimeHandler({ + baseUrl: "http://localhost:4437", + registry, + sandboxProfiles: [ + { + name: "local", + label: "Local", + description: "Trusted local development sandbox", + factory: ({ args }) => + unrestrictedSandbox({ + workingDirectory: + typeof args.workingDirectory === "string" + ? args.workingDirectory + : process.cwd(), + }), + }, + { + name: "e2b", + label: "E2B", + description: "Remote VM sandbox", + remote: true, + factory: ({ sandboxKey, persistent, owner }) => + remoteSandbox({ + provider: "e2b", + sandboxKey, + persistent, + owner, + initialNetworkPolicy: { mode: "allow-all" }, + }), + }, + ], +}) +``` + +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. + +## Built-in profiles + +The sandbox package exports: + +```ts +import { + chooseDefaultSandbox, + unrestrictedSandbox, + remoteSandbox, +} from "@electric-ax/agents-runtime/sandbox" +import { dockerSandbox } from "@electric-ax/agents-runtime/sandbox/docker" +``` + +| Provider | Use case | Notes | +| -------- | -------- | ----- | +| `unrestrictedSandbox()` | Trusted local development | Shares the host filesystem and process namespace. It is convenient, not a security boundary. | +| `dockerSandbox()` | Local isolation for multi-entity hosts | Requires Docker and `dockerode`. Recommended for untrusted or multi-tenant local workloads. | +| `remoteSandbox({ provider: "e2b" })` | Remote VM isolation | Requires the optional `e2b` package and provider credentials. Mark the profile `remote: true`. | +| `chooseDefaultSandbox()` | Built-in local default | Chooses the default local profile for built-in Horton and Worker runtimes. | + +## Handler access + +Handlers and custom tools use `ctx.sandbox`: + +```ts +async handler(ctx) { + const result = await ctx.sandbox.exec({ + command: "ls -la", + timeoutMs: 10_000, + signal: ctx.signal, + }) + + const readme = await ctx.sandbox.readFile("README.md") + const res = await ctx.sandbox.fetch("https://example.com") +} +``` + +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. + +The runtime owns sandbox disposal. Handlers should not call `ctx.sandbox.dispose()`. + +## Spawn-time selection + +Select or inherit a sandbox when spawning: + +```ts +await ctx.spawn( + "worker", + "analysis", + { systemPrompt: "Inspect the workspace", tools: ["read", "bash"] }, + { + initialMessage: "Start with package.json", + sandbox: "inherit", + } +) +``` + +Object form gives more control: + +```ts +await client.spawnEntity({ + type: "worker", + id: "isolated", + sandbox: { + profile: "docker", + scope: "entity", + persistent: true, + }, +}) +``` + +Sandbox selection fields: + +| Field | Meaning | +| ----- | ------- | +| `profile` | Named runtime profile to use. | +| `inherit` | Reuse the parent's resolved sandbox selection. | +| `key` | Explicit shared sandbox identity. | +| `scope` | `entity` for per-entity identity, or `wake` for per-wake identity. | +| `persistent` | Preserve sandbox state between wake sessions when supported. | +| `owner` | Whether this entity owns lifecycle teardown for the sandbox. | + +## Network policy + +Sandbox network policy supports: + +```ts +type NetworkPolicy = + | { mode: "allow-all" } + | { mode: "deny-all" } + | { mode: "allowlist"; allow: string[] } +``` + +`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. + +## Security notes + +- `unrestrictedSandbox()` is for trusted local code. It can reduce accidental path escapes, but it is not a security boundary. +- Built-in file tools now rely on the active sandbox for containment and do not forward the host `process.env` into shell commands. +- Remote and Docker sandboxes isolate more, but credentials and mounted data still need careful scoping. +- Use a per-entity or explicit sandbox key when a worker needs state to survive across wakes. diff --git a/website/docs/agents/usage/signals.md b/website/docs/agents/usage/signals.md new file mode 100644 index 0000000000..285d7d64f8 --- /dev/null +++ b/website/docs/agents/usage/signals.md @@ -0,0 +1,132 @@ +--- +title: Signals +titleTemplate: "... - Electric Agents" +description: >- + Interrupt, pause, resume, terminate, and notify Electric Agents entities with lifecycle signals. +outline: [2, 3] +--- + +# Signals + +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. + +Signal records are written to the entity's `signals` collection and appear in timeline helpers. + +## Supported signals + +| Signal | Runtime behavior | +| ------ | ---------------- | +| `SIGINT` | Abort the active handler invocation through `ctx.signal`. Use for "stop current run". | +| `SIGSTOP` | Runtime-controlled pause. | +| `SIGCONT` | Runtime-controlled resume. | +| `SIGKILL` | Terminal kill/delete signal. | +| `SIGHUP` | Delivered to `ctx.onSignal()` handlers. | +| `SIGTERM` | Delivered to `ctx.onSignal()` handlers for graceful shutdown-style behavior. | +| `SIGUSR` | Delivered to `ctx.onSignal()` handlers for application-defined behavior. | + +Runtime-controlled signals are handled by the runtime and are not delivered to `ctx.onSignal()`. + +## CLI + +Send a signal from the CLI: + +```sh +electric agents signal /horton/onboarding SIGINT --reason "stop current run" +electric agents signal /horton/onboarding SIGUSR --payload '{"refresh":true}' +``` + +`kill` is shorthand for a terminal signal: + +```sh +electric agents kill /horton/onboarding +``` + +## Programmatic clients + +Use `createAgentsClient()` for UI-style clients: + +```ts +const client = createAgentsClient({ + baseUrl: "http://localhost:4437", + principalKey: "user:sam", +}) + +await client.signal({ + entityUrl: "/horton/onboarding", + signal: "SIGINT", + reason: "User clicked stop", +}) + +await client.kill("/horton/onboarding", "User deleted the session") +``` + +Use `createRuntimeServerClient()` when you need the lower-level server client: + +```ts +await runtimeClient.signalEntity({ + entityUrl: "/worker/analysis", + signal: "SIGUSR", + payload: { refresh: true }, +}) +``` + +The caller needs `signal` permission on the entity, or `manage`. + +## Handler cancellation + +Every handler receives `ctx.signal`, an `AbortSignal` that fires when the current wake should stop early. Pass it to cancellable work: + +```ts +async handler(ctx) { + const res = await fetch("https://api.example.com/data", { + signal: ctx.signal, + }) + + await ctx.sandbox.exec({ + command: "npm test", + signal: ctx.signal, + timeoutMs: 60_000, + }) +} +``` + +`SIGINT` aborts this signal. Runtime shutdown can also abort it. + +## Handler-delivered signals + +Use `ctx.onSignal()` for `SIGHUP`, `SIGTERM`, and `SIGUSR`: + +```ts +async handler(ctx) { + ctx.onSignal(async ({ signal, reason, payload }) => { + if (signal === "SIGUSR") { + ctx.insertContext("refresh-request", { + name: "Refresh request", + content: JSON.stringify({ reason, payload }), + attrs: {}, + }) + } + }) + + await ctx.agent.run() +} +``` + +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. + +## Signal records + +Signal rows include the signal name, sender, reason, payload, handling status, outcome, and state transition fields: + +```ts +interface Signal { + signal: "SIGINT" | "SIGHUP" | "SIGTERM" | "SIGKILL" | "SIGSTOP" | "SIGCONT" | "SIGUSR" + status: "unhandled" | "handled" + sender?: string + reason?: string + payload?: unknown + outcome?: "transitioned" | "ignored" | "invalid_for_state" | "delivered" | "aborted" | "shutdown_requested" | "failed" +} +``` + +See [Built-in collections](../reference/built-in-collections#signal) for the full row shape. diff --git a/website/docs/agents/usage/spawning-and-coordinating.md b/website/docs/agents/usage/spawning-and-coordinating.md index 658c02cf98..b6b1020376 100644 --- a/website/docs/agents/usage/spawning-and-coordinating.md +++ b/website/docs/agents/usage/spawning-and-coordinating.md @@ -27,6 +27,7 @@ const child = await ctx.spawn(type, id, args?, opts?) | `opts.wake` | `Wake` | When to wake the parent (see below) | | `opts.tags` | `Record` | Key-value tags applied to the child | | `opts.observe` | `boolean` | Also observe the child (default: true) | +| `opts.sandbox` | `SpawnSandboxOption` | Sandbox profile or inheritance for the child | `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. @@ -38,6 +39,8 @@ The `wake` option controls when the parent's handler is re-invoked: Returns an [`EntityHandle`](#entityhandle). +Use [Sandboxing](./sandboxing) when children need isolated filesystem, process, or network access, or when a worker should inherit its parent's sandbox. + ## EntityHandle Returned by `spawn` and `observe`: @@ -50,7 +53,7 @@ interface EntityHandle { events: ChangeEvent[] run: Promise // Resolves when child's run completes text(): Promise // Get completed text outputs - send(msg: unknown): void // Send follow-up message + send(msg: unknown): Promise // Send follow-up message status(): ChildStatus | undefined } ``` diff --git a/website/docs/agents/usage/testing.md b/website/docs/agents/usage/testing.md index 44ed98efc8..9517bd8278 100644 --- a/website/docs/agents/usage/testing.md +++ b/website/docs/agents/usage/testing.md @@ -15,7 +15,7 @@ Test agent handlers without calling the LLM by providing canned responses: ```ts ctx.useAgent({ systemPrompt: "...", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools], testResponses: ["Hello! How can I help?"], }) diff --git a/website/docs/agents/usage/waking-entities.md b/website/docs/agents/usage/waking-entities.md index eb10ffbe87..9969e7be9e 100644 --- a/website/docs/agents/usage/waking-entities.md +++ b/website/docs/agents/usage/waking-entities.md @@ -8,9 +8,9 @@ outline: [2, 3] # Waking entities -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. +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, or an external event source. Between wakes the entity is idle — no process, no memory, no running handler. -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. +Everything you do to make an entity respond to something — `ctx.spawn(..., { wake })`, `ctx.observe(..., { wake })`, `ctx.send()`, `upsertCronSchedule()`, or `subscribe_event_source` — is ultimately a way to produce a wake. ## The mental model @@ -18,7 +18,7 @@ Everything you do to make an entity respond to something — `ctx.spawn(..., { w external event ─► wake entry (persisted) ─► handler invocation ─► WakeEvent passed to handler ``` -1. **External event.** A message arrives, a child transitions, a watched collection changes, a cron fires. +1. **External event.** A message arrives, a child transitions, a watched collection changes, a cron fires, or a subscribed event source receives matching data. 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. 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. 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 ## What produces a wake -There are five things that can wake an entity: +There are six things that can wake an entity: ### 1. An incoming message @@ -85,6 +85,12 @@ await ctx.observe(db("board-1", schema), { 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. +### 6. An event source + +Runtime hosts can expose event-source tools through `ctx.electricTools`. An entity can subscribe to external webhook-backed feeds with `subscribe_event_source`; matching future events wake the entity with hydrated event data. + +See [Event sources](./event-sources). + ## Reading a WakeEvent Your handler signature is: @@ -145,4 +151,6 @@ When the handler finishes (or calls `ctx.sleep()`), the entity returns to idle. - [WakeEvent](../reference/wake-event) — full type reference and wake-type catalog. - [Spawning & coordinating](./spawning-and-coordinating) — using `wake` with `spawn` and `observe`. - [Shared state](./shared-state) — using `wake` with `observe(db(...))`. +- [Event sources](./event-sources) — subscribing entities to external event feeds. +- [Signals](./signals) — lifecycle controls that can interrupt or notify active entities. - [Writing handlers](./writing-handlers) — `HandlerContext` and `firstWake` patterns. diff --git a/website/docs/agents/usage/writing-handlers.md b/website/docs/agents/usage/writing-handlers.md index 4d6077e7d5..cba1d3dc55 100644 --- a/website/docs/agents/usage/writing-handlers.md +++ b/website/docs/agents/usage/writing-handlers.md @@ -61,10 +61,10 @@ interface HandlerContext { entityUrl: string, payload: unknown, opts?: { type?: string; afterMs?: number } - ) => void + ) => Promise recordRun: () => RunHandle setTag: (key: string, value: string) => Promise - removeTag: (key: string) => Promise + deleteTag: (key: string) => Promise sleep: () => void } ``` @@ -97,7 +97,7 @@ interface HandlerContext { | `send` | Sends a message to another entity's inbox. Supports delayed delivery via `afterMs`. | | `recordRun` | Records non-LLM work in the built-in `runs` collection so `runFinished` observers are woken. | | `setTag` | Sets a tag on this entity. | -| `removeTag` | Removes a tag from this entity. | +| `deleteTag` | Deletes a tag from this entity. | | `sleep` | Returns the entity to idle without re-waking. | ## WakeEvent @@ -146,7 +146,7 @@ registry.define("assistant", { ctx.useAgent({ systemPrompt: "You are a helpful assistant.", - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", tools: [...ctx.electricTools], }) await ctx.agent.run() @@ -242,7 +242,7 @@ async handler(ctx) { const { systemPrompt } = ctx.args as { systemPrompt: string } ctx.useAgent({ systemPrompt, - model: 'claude-sonnet-4-5-20250929', + model: 'claude-sonnet-4-6', tools: [...ctx.electricTools], }) await ctx.agent.run() @@ -274,7 +274,7 @@ async handler(ctx) { ctx.useAgent({ systemPrompt: 'You are an assistant with lookup capabilities.', - model: 'claude-sonnet-4-5-20250929', + model: 'claude-sonnet-4-6', tools: [...ctx.electricTools, myTool], }) await ctx.agent.run()