electric-sql / electric-sql/electric
Add `electric agents run` using spawn-and-claim
- Dominant language
- TypeScript
- Stars
- 10.4k
- Forks
- 375
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 18
Description
## Summary
Add a Docker-style `electric agents run` command for one-shot local/CI agent execution.
The command should spawn an existing Electric Agent entity type, immediately claim the resulting wake for the current process using an API token, run the normal entity handler locally, stream progress, complete/fail the claim, and exit.
This gives us an imperative “run this agent” workflow without changing how entities are defined.
## Motivation
Our entity definition model is already good:
```ts
defineEntity(`horton`, {
async handler(ctx, wake) {
ctx.useAgent(...)
await ctx.agent.run()
},
})
```
What’s missing is a simple way to run an existing entity directly from the CLI or CI, similar to how Flue supports one-shot agent invocation.
The command can be implemented as a small runner wrapper around primitives we already have:
1. spawn entity
2. claim wake
3. execute handler
4. heartbeat claim
5. complete/fail claim
This fits naturally with the existing Docker-inspired CLI model:
```sh
electric agents run horton task-123 "Fix the failing tests"
```
## Relationship to `electric agents spawn`
`run` is the attached execution form of `spawn`.
`electric agents spawn` should stay control-plane only: it creates the durable entity, optionally enqueues an initial message/wake, returns entity details, and exits. It does not need the entity implementation locally and it does not run anything in the CLI process. A separate long-running worker/runtime can pick up the wake later.
`electric agents run` does more: it requires the runner process to have the entity code/registry available locally, because that same process immediately claims the wake and executes the normal entity handler. It blocks until the claimed run completes and exits with the handler status.
In short:
```txt
spawn = create/enqueue durable work, no local code required
run = create/enqueue + claim + execute here, local registry required
exec = send/enqueue to existing entity + claim + execute here, local registry required
```
This is why `run` needs a server-side spawn-and-claim operation rather than just composing existing `spawn` behavior client-side: the claim should be assigned to the runner before another worker can pick up the wake.
## Local registry discovery
Unlike `electric agents spawn`, `electric agents run` executes entity code in the CLI process. Therefore the CLI must load a local entity registry that contains the requested type.
There is no server-side or built-in fallback for execution. If the registry cannot be found, or the requested type is not registered, `run` should fail before claiming work.
Proposed resolution order:
1. `--registry `
2. package config, e.g. `package.json`
3. convention file such as `electric.agent-registry.ts`
4. error
Example registry module:
```ts
// electric.agent-registry.ts
import { createEntityRegistry } from '@electric-ax/agents-runtime'
import { registerHorton } from '@electric-ax/agents'
import { registerReviewer } from './src/agents/reviewer'
export default async function createAgentRegistry(options: {
workingDirectory: string
}) {
const registry = createEntityRegistry()
registerHorton(registry, {
workingDirectory: options.workingDirectory,
})
registerReviewer(registry)
return registry
}
```
The module can export either an `EntityRegistry` or a factory returning one. The CLI should verify the requested entity type is registered before calling `spawn-and-claim`.
## Proposed UX
Canonical form:
```sh
electric agents run [--registry ] [id] [message]
```
The examples below assume a registry is discoverable via `electric.agent-registry.ts`, package config, or `--registry`.
Examples:
```sh
electric agents run horton "Summarize the latest project status"
```
Auto-generate an entity id.
```sh
electric agents run horton project-status \
"Summarize the latest project status and list any open questions"
```
Explicit entity id.
```sh
electric agents run --registry ./electric.agent-registry.ts worker worker-1 \
--args '{"workingDirectory":"/Users/kylemathews/programs/electric"}' \
--message "Run tests and report failures"
```
Structured spawn args + explicit message.
```sh
electric agents run horton bugfix-42 \
-m "Fix the failing typecheck" \
--cwd /Users/kylemathews/programs/electric \
--tag repo=electric \
--tag source=cli
```
## Proposed flags
MVP flags:
```sh
--server, -H Agents server URL
--token Runner/API token
--registry Local entity registry module
--args JSON spawn args
--message, -m Initial message
--tag, -t Repeatable key=value tag
--cwd Convenience for built-in agents / working directory
--json Machine-readable output
```
Environment fallbacks:
```sh
ELECTRIC_AGENTS_SERVER_URL
ELECTRIC_AGENTS_RUNNER_TOKEN
```
Potential future flags:
```sh
--name Docker-style alias for explicit id
--detach, -d Spawn without attached local execution
--rm Delete entity after successful run
--if-exists reuse | fail | replace
```
## Server API
Add a server endpoint:
```http
POST /_electric/runner/spawn-and-claim
Authorization: Bearer
Content-Type: application/json
```
Request:
```json
{
"type": "horton",
"id": "task-123",
"args": {},
"initialMessage": "Fix the failing tests",
"tags": {
"runner": "cli"
},
"consumerId": "runner-local-abc123"
}
```
Response:
```json
{
"entity": {
"url": "/horton/task-123",
"type": "horton",
"streams": {
"main": "/horton/task-123/main"
}
},
"claim": {
"consumerId": "runner-local-abc123",
"primaryStream": "/horton/task-123/main",
"writeToken": "..."
},
"wake": {
"...": "same enriched payload the runtime handler expects"
}
}
```
The exact claim shape can follow the existing claim/callback machinery. The important property is that the runner receives everything needed to run the entity handler immediately and write stream events under the active claim.
## Programmatic API
Expose the same runner flow as a small TypeScript API so apps, tests, and CI scripts can run entities without shelling out to the CLI.
Basic example:
```ts
import { createEntityRegistry } from '@electric-ax/agents-runtime'
import { runEntityOnce } from '@electric-ax/agents/runner'
import { registerHorton } from '@electric-ax/agents'
const registry = createEntityRegistry()
registerHorton(registry, { workingDirectory: process.cwd() })
await runEntityOnce({
serverUrl: process.env.ELECTRIC_AGENTS_SERVER_URL!,
token: process.env.ELECTRIC_AGENTS_RUNNER_TOKEN!,
registry,
type: `horton`,
id: `task-123`,
args: {
workingDirectory: `/Users/kylemathews/programs/electric`,
},
initialMessage: `Fix the failing tests`,
tags: {
runner: `script`,
repo: `electric`,
},
})
```
With multiple/custom entity types:
```ts
import { createEntityRegistry } from '@electric-ax/agents-runtime'
import { runEntityOnce } from '@electric-ax/agents/runner'
import { registerHorton, registerWorker } from '@electric-ax/agents'
const registry = createEntityRegistry()
registerHorton(registry, {
workingDirectory: process.cwd(),
})
registerWorker(registry, {
workingDirectory: process.cwd(),
})
await runEntityOnce({
serverUrl: process.env.ELECTRIC_AGENTS_SERVER_URL!,
token: process.env.ELECTRIC_AGENTS_RUNNER_TOKEN!,
registry,
type: `horton`,
id: `task-123`,
args: {},
initialMessage: `Summarize the latest project status and list any open questions`,
})
```
Proposed API shape:
```ts
export interface RunEntityOnceOptions {
serverUrl: string
token: string
type: string
id?: string
args?: Record
initialMessage?: unknown
tags?: Record
/**
* Local registry containing the requested entity type.
*/
registry: EntityRegistry
/**
* Optional working directory convenience for built-in agents.
*/
workingDirectory?: string
/**
* Optional stable consumer id. If omitted, generate one.
*/
consumerId?: string
/**
* Optional lifecycle hooks for logs/events/claim state.
*/
onEvent?: (event: RunnerEvent) => void
signal?: AbortSignal
}
export interface RunEntityOnceResult {
entityUrl: string
entityType: string
streamPath: string
id: string
status: `completed` | `failed`
}
```
Lower-level API for callers that want to own execution:
```ts
import { createRuntimeServerClient } from '@electric-ax/agents-runtime'
const client = createRuntimeServerClient({
baseUrl: process.env.ELECTRIC_AGENTS_SERVER_URL!,
token: process.env.ELECTRIC_AGENTS_RUNNER_TOKEN!,
})
const { entity, claim, wake } = await client.spawnAndClaimEntity({
type: `horton`,
id: `task-123`,
args: {},
initialMessage: `Fix the failing tests`,
tags: { runner: `script` },
})
try {
await runLocalHandler({ entity, claim, wake })
await client.completeClaim(claim)
} catch (error) {
await client.failClaim(claim, {
error: error instanceof Error ? error.message : String(error),
})
throw error
}
```
Potential follow-up API for `exec`:
```ts
await execEntityOnce({
serverUrl,
token,
entityUrl: `/horton/task-123`,
message: `Continue investigating the failing tests`,
})
```
And the lower-level client equivalent:
```ts
const { entity, claim, wake } = await client.sendAndClaimEntity({
entityUrl: `/horton/task-123`,
payload: `Continue investigating the failing tests`,
type: `message`,
})
```
## Server behavior
`spawn-and-claim` should:
1. authenticate the runner token
2. spawn the requested entity type/id using existing manager logic
3. include spawn args, tags, and optional initial message
4. handle spawn conflicts consistently with current spawn behavior
5. immediately create/assign a claim for the entity’s primary stream
6. mark the entity as `running`
7. return the enriched wake payload expected by the runtime handler
8. rely on existing claim heartbeat / stale claim / done semantics
This should be a thin composition of existing capabilities, not a new execution system.
## Runner behavior
`electric agents run` should:
1. load config/env
2. ensure it has server URL and runner token
3. load the local entity registry from `--registry`, package config, or convention
4. verify the requested entity type is registered locally
5. initialize the local runtime with that registry
6. call `spawn-and-claim`
7. start claim heartbeat using existing claim mechanisms
8. execute the normal entity handler locally with the returned wake payload
9. stream progress/events to stdout/stderr
10. complete the claim on success
11. fail the claim on error
12. exit with a meaningful status code
Default mode should be attached: the command runs until the entity handler completes.
## Follow-up: `electric agents exec`
Once `spawn-and-claim` exists, we should also add a Docker-style `exec` path for existing entities:
```sh
electric agents exec horton/task-123 "Continue investigating the failing tests"
```
or:
```sh
electric agents exec /horton/task-123 \
--message "Summarize what you have learned so far"
```
Semantically, `exec` would:
1. target an existing entity
2. send a new message/wake
3. immediately claim that wake for the current process
4. run the normal handler locally
5. heartbeat and complete/fail the claim as usual
So `run` is:
```txt
spawn + claim + execute
```
and `exec` is:
```txt
send + claim + execute
```
This would make it easy to attach local one-shot work to an existing durable agent without creating a new entity.
## Implementation notes
Likely package touchpoints:
- `packages/agents-server`
- add `POST /_electric/runner/spawn-and-claim`
- add runner token auth
- compose existing spawn + claim + payload enrichment logic
- `packages/agents-runtime`
- add runtime server client method, e.g.:
```ts
spawnAndClaimEntity(...)
```
- optionally expose helpers for claim completion/heartbeat if not already public
- `packages/agents`
- export `runEntityOnce` from `@electric-ax/agents/runner`
- later export `execEntityOnce`
- add `electric agents run`
- load local entity registry via `--registry`, package config, or `electric.agent-registry.ts`
- fail before claiming if the registry is missing or the requested type is not registered
- parse Docker-style flags
- call spawn-and-claim and run handler
## Non-goals
- Do not change `defineEntity`
- Do not introduce a new session abstraction
- Do not bypass durable entity streams
- Do not create a separate persistence model
- Do not replace normal webhook/long-running worker execution
- Do not reimplement existing CLI commands like `ps` / `inspect`
This is just an imperative runner path for existing entity types.
## Why this is useful
This unlocks:
- local one-shot agent runs
- CI workflows
- scripted issue triage
- local coding/research agents
- easier demos
- easier onboarding
- later `electric agents exec` support for existing entities
- imperative “run this agent” ergonomics
while preserving all Electric Agents semantics:
- same entity definitions
- same timelines
- same durable streams
- same status transitions
- same claim lifecycle
- same UI visibility
- same tools/spawn/observe behavior
## Acceptance criteria
- `electric agents run horton "message"` spawns and runs Horton locally
- command authenticates using `ELECTRIC_AGENTS_RUNNER_TOKEN` or `--token`
- command requires a local entity registry via `--registry`, package config, or convention discovery
- command fails before claiming work if no registry is found
- command fails before claiming work if the requested entity type is not registered locally
- server exposes `POST /_electric/runner/spawn-and-claim`
- spawned entity appears in the existing Agents UI
- timeline/events are written to the entity stream as usual
- claim heartbeat works during execution
- claim is completed on success
- claim is failed/released on error
- command exits non-zero on execution failure
- `runEntityOnce(...)` exposes the same behavior programmatically without invoking the CLI
- `runEntityOnce(...)` requires an `EntityRegistry`
- `run` is documented as the attached execution form of `spawn`
- `run` requires the runner process to have the entity code/registry available locally
- no changes are required to existing entity definitions
Contributor guide
Assessment
This issue has not been assessed yet.