A durable command runner for AI agents: start a command now, observe it briefly, then retrieve or control the job later.
Agent harnesses often need to run tests, builds, and deployments whose duration and output size are unknown. A synchronous subprocess can block the agent, while a detached shell command loses structured status and log discovery. agent-exec keeps the process running under a stable job_id and returns machine-readable JSON for later status, tail, wait, kill, or restart calls.
Prebuilt binaries are available for Linux x86_64 and macOS Apple Silicon. This example downloads the latest release, verifies its SHA-256 checksum, and installs it in ~/.local/bin:
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) TARGET=x86_64-unknown-linux-gnu ;;
Darwin-arm64) TARGET=aarch64-apple-darwin ;;
*) echo "No release binary for this platform" >&2; exit 1 ;;
esac
VERSION=$(curl -fsSL https://api.github.com/repos/tumf/agent-exec/releases/latest | sed -n 's/.*"tag_name": "v\([^"]*\)".*/\1/p')
ARCHIVE="agent-exec-v${VERSION}-${TARGET}.tar.gz"
curl -fLO "https://github.com/tumf/agent-exec/releases/download/v${VERSION}/${ARCHIVE}"
curl -fLO "https://github.com/tumf/agent-exec/releases/download/v${VERSION}/${ARCHIVE}.sha256"
shasum -a 256 -c "${ARCHIVE}.sha256"
mkdir -p ~/.local/bin
tar -xzf "$ARCHIVE"
install agent-exec ~/.local/bin/agent-exec
~/.local/bin/agent-exec --versionStart a long-running command without blocking the caller, save its job_id, then retrieve its status and final output:
AGENT_EXEC=~/.local/bin/agent-exec
JOB=$($AGENT_EXEC run --no-wait -- sh -c 'sleep 2; echo done' | sed -n 's/.*"job_id":"\([^"]*\)".*/\1/p')
$AGENT_EXEC status "$JOB"
$AGENT_EXEC wait "$JOB"
# Use tail later to reread bounded logs or inspect their paths.
$AGENT_EXEC tail "$JOB"run normally observes a job for up to 10 seconds, which catches many startup failures without blocking indefinitely. Use --no-wait when the caller must return immediately. wait reports the state, exit code when terminal, and bounded output available at the observation deadline. Use tail later to reread bounded logs. Complete logs remain available at the paths in each response.
| Capability | Plain subprocess | nohup |
agent-exec |
|---|---|---|---|
| Return without stopping the command | No | Yes | Yes |
| Stable job identifier | No | No | Yes |
| Structured status and exit result | Limited | No | Yes |
| Discoverable stdout/stderr logs | Caller-managed | Caller-managed | Yes |
| Later wait, tail, kill, and restart | Caller-managed | Caller-managed | Yes |
Pass ordinary commands as argv after --. Use an explicit shell only when the workload needs pipelines, redirects, expansion, or compound statements.
Response-producing CLI commands write exactly one JSON object to stdout by default. --yaml changes those responses to YAML. Diagnostic logs go to stderr and are controlled by RUST_LOG, -v, and -vv.
This contract applies to commands such as run, status, tail, list, gc, and install-skills. It does not apply to generated shell completions, the MCP stdio protocol, the HTTP server, MCP startup configuration errors, or Clap help and version output.
run, start, restart, and tail include a compressed view by default while preserving the raw excerpt and byte metadata as the canonical output.
- Default mode:
route - CLI selection:
--compress <MODE>or--rtk <MODE> - Modes:
off,route,errors,tests,logs,git,json, andsummary - Compatibility mode:
--compress offomits thecompressionfield - Configuration:
[compression].default = "off"or another supported mode
Precedence is --compress or --rtk, [compression].default, then the built-in route default. If compression would not make either nonempty stream smaller, the expansion guard sets applied to false, returns empty compressed streams, and records "expansion-guard" in strategy. The raw stdout, stderr, ranges, totals, encoding, and log paths remain unchanged.
If you are integrating through MCP, see MCP Server for the same lifecycle as tools. To see the observation deadline and reconnect behavior end to end, run the one-minute demo.
GitHub Actions builds the Linux x86_64 archive for each v* release. Download its matching checksum, then verify and install:
ARCHIVE=agent-exec-v<VERSION>-x86_64-unknown-linux-gnu.tar.gz
shasum -a 256 -c "$ARCHIVE.sha256"
mkdir -p ~/.local/bin
tar -xzf "$ARCHIVE"
mv agent-exec ~/.local/bin/
agent-exec --version
agent-exec run -- echo "release-smoke"macOS artifacts are built locally on the trusted mini host after GitHub Actions creates the release. From a checkout at the release tag:
scripts/release-macos.sh --tag v<VERSION>
scripts/release-macos.sh --tag v<VERSION> --uploadThe first command builds, smoke-tests, packages, and checksums the native architecture archive without network mutation. The second uploads it to the existing GitHub Release. Install the resulting macOS archive with the checksum, extraction, version, and managed-command steps above.
Windows release binaries are not provided. ARM, 32-bit, musl Linux, Windows, and other unsupported targets can use crates.io or a source build.
A Rust toolchain can install the published package:
cargo install agent-exec --locked
agent-exec --version
agent-exec run -- echo "release-smoke"For development or unsupported targets, build from a repository checkout:
cargo install --path . --lockedagent-exec generates dynamic completion scripts for Bash, Zsh, Fish, and PowerShell.
All job ID candidates are limited to jobs whose persisted current working directory matches the caller's current working directory. Candidates come from the resolved jobs root. Entries with missing or mismatched current working directory metadata are excluded.
State filters depend on the command:
| Command | Candidate states |
|---|---|
status, tail, restart, tag set, notify set |
All known job IDs; unreadable states may still appear |
start |
created |
wait |
created, running |
kill |
running |
delete |
exited, killed, failed |
Completion is advisory. Command implementations still validate the selected job and may support behavior not offered by completion.
agent-exec completions bash >> ~/.bash_completion
source ~/.bash_completionmkdir -p ~/.zsh/completions
agent-exec completions zsh > ~/.zsh/completions/_agent-exec
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinitmkdir -p ~/.config/fish/completions
agent-exec completions fish > ~/.config/fish/completions/agent-exec.fishagent-exec completions powershell | Out-String | Invoke-ExpressionAfter registration, job ID completion is available at command arguments such as:
agent-exec tail <TAB>
agent-exec kill <TAB>The default run behavior observes the job for up to 10 seconds and returns inline output:
agent-exec run -- echo "hello world"The following is an illustrative complete response for a successful terminal job. Paths, timestamps, and timing values vary.
{
"schema_version": "0.1",
"ok": true,
"type": "run",
"job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
"state": "exited",
"tags": [],
"stdout_log_path": "/home/user/.local/share/agent-exec/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
"stderr_log_path": "/home/user/.local/share/agent-exec/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log",
"elapsed_ms": 8,
"waited_ms": 2,
"stdout": "hello world\n",
"stderr": "",
"stdout_range": [0, 12],
"stderr_range": [0, 0],
"stdout_total_bytes": 12,
"stderr_total_bytes": 0,
"encoding": "utf-8-lossy",
"exit_code": 0,
"finished_at": "2026-07-19T12:00:00Z",
"duration_ms": 1,
"compression": {
"mode": "route",
"applied": false,
"detected_kind": "summary",
"stdout": "",
"stderr": "",
"stdout_original_bytes": 12,
"stderr_original_bytes": 0,
"stdout_compressed_bytes": 0,
"stderr_compressed_bytes": 0,
"omitted": false,
"strategy": ["expansion-guard"]
}
}Generated job IDs are 32-character lowercase hexadecimal strings. Commands that accept a job ID also accept an unambiguous prefix and return the canonical full job ID.
Use --no-wait when the launch response must return immediately, then inspect the job separately:
JOB=$(agent-exec run --no-wait -- sleep 30 | jq -r .job_id)
agent-exec status "$JOB"
agent-exec tail "$JOB"
agent-exec wait "$JOB"Without --no-wait, run observes for up to 10 seconds before returning.
The following command sends SIGTERM after 5 seconds and SIGKILL 2 seconds later if necessary:
agent-exec run \
--timeout 5 \
--kill-after 2 \
-- sleep 60Pass ordinary commands as argv after --:
agent-exec run -- sleep 8
agent-exec run -- cargo test --all
agent-exec run -- npm run buildUse an explicit shell only when the workload requires shell syntax such as pipelines, redirects, expansion, or compound statements:
agent-exec run -- sh -lc 'sleep 8; echo done'create persists a job definition without starting a process. start launches it later.
JOB=$(agent-exec create -- echo "deferred hello" | jq -r .job_id)
agent-exec start "$JOB"createwrites the execution definition tometa.json, initializesstate.jsonwithstateset tocreated, and returnstypeset tocreate.startreads the persisted definition, launches the supervisor, and observes for up to 10 seconds by default.restartreuses the job ID and definition. It terminates a running process tree before launching the replacement run.runcombines definition and launch in one command.
Values passed through create --env KEY=VALUE are durable configuration. The real values are stored in meta.json for the later start, while the display-oriented env_vars metadata applies --mask. Use --env-file FILE when values should be read from a file at start time instead of being stored directly in the job definition.
--mask KEY only replaces the named --env value in display-oriented environment metadata and response fields. It does not redact child stdout, child stderr, persisted logs, notification payload content, or values loaded from --env-file. A child process that prints a secret will expose it in its output. Do not treat --mask as a general secret-filtering mechanism.
| State | Meaning |
|---|---|
created |
Definition persisted; no process started |
running |
Supervisor and child process active |
exited |
Process exited normally |
killed |
Process terminated by a signal |
failed |
Supervisor-level failure |
kill rejects created jobs because there is no process to signal. wait continues through created and running until a terminal state or its client-side deadline. list --state created selects jobs that have not started.
| Flag | Default | Description |
|---|---|---|
--root <PATH> |
Platform data directory | Override the jobs root. Precedence is --root, AGENT_EXEC_ROOT, $XDG_DATA_HOME/agent-exec/jobs, then the platform default. |
--yaml |
false |
Emit YAML instead of JSON for response-producing CLI commands. |
-v, -vv |
Warnings | Increase diagnostic verbosity on stderr. |
Place global options before the subcommand:
agent-exec --root /tmp/jobs run -- echo hello
agent-exec --root /tmp/jobs status <JOB_ID>
agent-exec --root /tmp/jobs list
agent-exec --root /tmp/jobs gc --dry-runFor backward compatibility, job-store commands also accept --root after the subcommand where defined:
agent-exec run --root /tmp/jobs -- echo hello
agent-exec status --root /tmp/jobs <JOB_ID>Use agent-exec --help and agent-exec <COMMAND> --help for the complete current CLI surface.
agent-exec create [OPTIONS] -- <COMMAND> [ARGS...]create persists execution-definition options for the command, effective working directory (--cwd, or the caller's current working directory), environment and inheritance, input, runtime limits, progress updates, tags, completion and output-match notifications, and shell configuration. Materialized input is limited by --stdin-max-bytes. It does not accept launch observation, compression, or automatic GC options. Use agent-exec create --help for the complete option list.
--stdin VALUE and --stdin-file PATH are mutually exclusive. Their contents are copied to <job-directory>/stdin.bin; later start reuses the persisted file reference.
Unlike run, create never captures piped stdin implicitly: a create definition stores input only when --stdin, --stdin -, or --stdin-file is given explicitly. Without one of those, the child of a later start receives null stdin.
The response includes job_id, state, stdout_log_path, and stderr_log_path.
agent-exec start [OPTIONS] <JOB_ID>Only a job in created state can start. By default, start observes for up to 10 seconds and returns the same inline stream fields as run. Observation controls include --wait [true|false], --until, --forever, --no-wait, --max-bytes, and compression selection. Automatic GC controls are also available.
agent-exec restart [OPTIONS] <JOB_ID>restart preserves the job ID and persisted definition. If the job is running, it sends the signal selected by --signal and confirms termination before relaunching. It clears prior-run stream logs, full.log, and stale completion_event.json so subsequent observation reflects the replacement run.
restart supports the same inline observation, compression, and automatic GC controls as start.
agent-exec run [OPTIONS] -- <COMMAND> [ARGS...]Common options:
| Flag | Default | Description |
|---|---|---|
--timeout <SECONDS> |
0 |
Stop the process after this runtime; 0 disables the limit. |
--kill-after <SECONDS> |
0 |
Delay between SIGTERM and SIGKILL after timeout. |
--cwd <PATH> |
Inherited | Set the child current working directory. |
--env KEY=VALUE |
None | Set an environment variable; repeatable. |
--env-file <FILE> |
None | Load environment variables from a file; repeatable. |
--no-inherit-env |
false |
Do not inherit the launcher environment. |
--mask <KEY> |
None | Mask the named --env value in display metadata; repeatable. |
--stdin <VALUE> |
None | Provide input directly; --stdin - reads noninteractive caller input. Omit both stdin flags to forward piped caller input automatically. |
--stdin-file <PATH> |
None | Copy file content to job-local input; takes precedence over automatic detection. |
--stdin-max-bytes <BYTES> |
64 MiB | Limit materialized input size, including automatically forwarded input. |
| `--wait [true | false]` | true |
--until <SECONDS> |
10 |
Bound inline observation. |
--forever |
false |
Observe until the job becomes terminal. |
--no-wait |
false |
Return without observation. |
--max-bytes <BYTES> |
65536 |
Limit the head excerpt from each stream. |
--tag <TAG> |
None | Assign a tag; repeatable and deduplicated. |
--notify-command <COMMAND> |
None | Run a shell command when the job finishes. |
--notify-file <PATH> |
None | Append a job.finished NDJSON event. |
--config <PATH> |
XDG default | Load a specific config.toml. |
--shell-wrapper <PROGRAM AND FLAGS> |
Config or platform default | Override the shell wrapper. |
--compress <MODE> |
Config or route |
Select inline compression. |
Input examples:
# Piped or redirected input is forwarded automatically; no stdin flag required.
printf 'abc' | agent-exec run -- cat
agent-exec run -- cat < ./input.txt
# The explicit forms remain available and behave exactly as before.
printf 'abc' | agent-exec run --stdin - -- cat
agent-exec run --stdin - -- cat <<'EOF'
line1
line2
EOF
agent-exec run --stdin "abc" -- cat
agent-exec run --stdin-file ./input.txt -- catWhen neither --stdin nor --stdin-file is given, run reads its own stdin to EOF only if that stdin is not a terminal. The bytes go through the same path as --stdin -: they are materialized into <job-directory>/stdin.bin, recorded in meta.json.stdin_file, and bounded by --stdin-max-bytes. Input above that limit fails before the job launches with error.code set to stdin_too_large.
--stdin and --stdin-file are authoritative: when either is present, caller stdin is not read implicitly. When stdin is a terminal and no stdin option is given, run does not read it, does not block, and the child receives null stdin. Automatic detection applies to run only — create, start, restart, and the MCP surfaces stay explicit-only.
If --stdin - receives a terminal instead of redirected input, the command fails with error.code set to stdin_required.
agent-exec status <JOB_ID>The response can report created, running, exited, killed, or failed. It always includes job_id, state, and created_at; it includes started_at, finished_at, and exit_code when available.
agent-exec tail [--tail-lines <N>] [--max-bytes <N>] [--compress <MODE>] <JOB_ID>The response includes bounded stdout and stderr tails, their raw byte ranges and totals, encoding, and both log paths. Defaults are 50 lines and 65,536 bytes per stream.
agent-exec wait [--until <SECONDS> | --forever] [--poll <SECONDS>] <JOB_ID>The default client-side deadline is 30 seconds. Every response includes bounded stdout and stderr, byte ranges and totals, and utf-8-lossy encoding; terminal responses also include the exit code. Reaching the deadline does not stop the job. Use tail for later or repeated log retrieval, and run --timeout to limit process runtime.
agent-exec kill [--signal <NAME>] [--no-wait] <JOB_ID>The default signal is TERM. By default, kill briefly observes the result; --no-wait skips that observation.
agent-exec list [--state <STATE>] [--limit <N>] [--cwd <PATH> | --all] [--tag <PATTERN>]...By default, list returns jobs whose persisted current working directory matches the caller's current working directory. --cwd selects another directory, and --all disables current working directory filtering. States are created, running, exited, killed, failed, and unknown.
Repeated --tag filters use logical AND. An exact pattern such as ci matches that tag only. A namespace pattern such as project.build.* matches tags below that namespace.
agent-exec list --all --tag ci
agent-exec list --all --tag project.build.*
agent-exec list --tag ci --tag releaseagent-exec ps [--limit <N>] [--cwd <PATH> | --all] [--tag <PATTERN>]...ps is equivalent to list --state running and returns the same type set to list.
agent-exec tag set <JOB_ID> [--tag <TAG>]...The command replaces all tags, preserving the first occurrence of each duplicate. Omit --tag to clear the list.
agent-exec run --tag project.build --tag ci -- make build
agent-exec tag set 7f3a9c1e4b2d8a605e7c9f0134ab6d82 --tag project.release --tag approved
agent-exec tag set 7f3a9c1e4b2d8a605e7c9f0134ab6d82Stored tags use dot-separated segments containing alphanumeric characters and hyphens, such as ci, project.build, and release.v2. The .* suffix is reserved for filter patterns.
agent-exec notify set <JOB_ID> [OPTIONS]This metadata-only command updates the persisted notification configuration. It never invokes a sink immediately, including for a terminal job. Unspecified fields are preserved. --command replaces notify_command, while notify_file remains unchanged.
Completion notification:
JOB=$(agent-exec run --no-wait -- sleep 5 | jq -r .job_id)
agent-exec notify set "$JOB" --command 'cat > /tmp/event.json'Output-match notification:
JOB=$(agent-exec run --no-wait -- sh -c 'sleep 1; echo ERROR foo' | jq -r .job_id)
agent-exec notify set "$JOB" \
--output-pattern 'ERROR' \
--output-command 'cat >> /tmp/matches.ndjson'
agent-exec notify set "$JOB" \
--output-pattern '^ERR' \
--output-match-type regex \
--output-stream stderr \
--output-file /tmp/stderr-matches.ndjsonOutput-match settings apply only to lines observed after the configuration becomes active. Use agent-exec notify set --help for all fields.
agent-exec gc [--older-than <DURATION>] [--max-jobs <N>] [--max-bytes <BYTES>] [--dry-run]gc scans the entire jobs root, regardless of the current working directory. In the current implementation, it first builds an oldest-first pool of terminal jobs older than --older-than; --max-jobs and --max-bytes apply only within that pool and never select newer terminal jobs. Jobs in created or running state and jobs with unreadable state are preserved.
Selection is order-dependent: age eligibility is established first, --max-jobs removes age-only selection from the newest N pool entries, and --max-bytes may then select oldest pool entries until their removal would bring pool storage within the limit.
| Flag | Default | Description |
|---|---|---|
--older-than <DURATION> |
30d |
Build the age-eligible terminal pool. Accepted suffixes include d, h, m, and s. |
--max-jobs <N> |
None | Apply count policy within the age-eligible pool. |
--max-bytes <BYTES> |
None | Apply oldest-first byte policy within the age-eligible pool. |
--dry-run |
false |
Report aggregate effects without deleting directories. |
The age timestamp is finished_at when present, otherwise updated_at.
agent-exec gc --dry-run
agent-exec gc --older-than 7d --dry-run
agent-exec gc --older-than 7d
agent-exec --root /tmp/jobs gc --older-than 7dThe gc response is aggregate-only and has no jobs array:
| Field | Description |
|---|---|
root |
Resolved jobs root. |
dry_run |
Whether deletion was disabled. |
older_than |
Effective retention duration. |
older_than_source |
default or flag. |
deleted |
Directories actually deleted; always 0 for a dry run. |
skipped |
Total skipped directories; equal to out_of_scope + failed. |
out_of_scope |
Entries excluded by state, age, timestamp, readability, or policy limits. |
failed |
Eligible entries that could not be deleted or remained after deletion. |
freed_bytes |
Bytes removed, or bytes that a dry run would remove. |
scanned_dirs |
Directories scanned. |
candidate_count |
Directories selected by policy before deletion limits. |
agent-exec delete <JOB_ID> [--dry-run]
agent-exec delete --all [--dry-run]rm is a visible alias and returns the same response shape.
Single-job deletion is not scoped by the current working directory. The implementation rejects a job whose readable state is running. It may delete created, terminal, or unknown jobs, including a directory whose state is missing or unreadable. Inspect the job or use --dry-run before deleting an explicit ID when state integrity is uncertain.
delete --all removes only terminal jobs whose persisted current working directory matches the caller's current working directory. It skips created, running, unreadable-state, and terminal jobs whose recorded PID is still alive. Jobs from other directories contribute to out_of_scope but are not listed individually.
agent-exec delete 7f3a9c1e4b2d8a605e7c9f0134ab6d82
agent-exec delete --all --dry-run
agent-exec delete --all
agent-exec --root /tmp/jobs delete --allThe response includes root, dry_run, deleted, skipped, out_of_scope, failed, and per-job jobs. cwd_scope is present only for --all. Each job result contains job_id, state, action, and reason; action is deleted, would_delete, or skipped. A reported deleted action means the path was confirmed absent after deletion.
agent-exec schemaThe response contains the JSON Schema document for CLI response types, its schema format, and generation timestamp.
After a successful launch, run, start, and restart perform bounded, best-effort automatic GC by default.
- Default retention is
30d. created,running, and unreadable-state jobs are preserved.- Cleanup failure does not fail the launch command.
- Scan and deletion limits bound launch-time work.
Per-invocation controls are --no-auto-gc, --auto-gc-older-than, --auto-gc-max-jobs, and --auto-gc-max-bytes.
[gc]
auto = true
older_than = "30d"
max_jobs = 200
max_bytes = 1073741824
scan_limit = 200
delete_limit = 20CLI values override configuration for that invocation.
agent-exec serve exposes job operations to HTTP clients.
agent-exec serve [--bind <HOST:PORT> | --port <PORT>] [--allow-origin <ORIGIN>]The default address is 127.0.0.1:19263. --port changes the loopback port. A non-loopback bind requires both --insecure and a nonempty AGENT_EXEC_SERVE_TOKEN.
AGENT_EXEC_SERVE_TOKEN enables bearer authentication only for the mutating endpoints POST /exec and POST /kill/{id}. It does not protect GET /health, GET /status/{id}, GET /tail/{id}, or GET /wait/{id}. Those read endpoints can expose job state and output.
Keep the default loopback bind unless remote access is required. For non-loopback access, restrict the port with a firewall, private network, or authenticating reverse proxy. Do not expose the server directly to the public internet. The required --insecure flag acknowledges that the built-in token does not secure read endpoints.
--allow-origin enables CORS for one explicit origin. The wildcard origin * is rejected.
| Method | Path | CLI equivalent | Behavior |
|---|---|---|---|
GET |
/health |
None | Returns schema_version, ok, and type set to health. |
POST |
/exec |
run |
Starts a job and returns RunData. |
GET |
/status/{id} |
status |
Returns job status. |
GET |
/tail/{id} |
tail |
Returns bounded stdout and stderr tails. |
GET |
/wait/{id} |
wait --forever |
Blocks until a terminal state and returns bounded stdout/stderr output metadata. |
POST |
/kill/{id} |
kill |
Sends TERM; ?no_wait=true skips observation. |
HTTP responses use the same schema_version, ok, and type envelope fields as CLI responses.
{
"command": ["bash", "-c", "echo hello"],
"cwd": "/tmp",
"env": {"FOO": "bar"},
"timeout": 30,
"wait": true,
"until": 10,
"max_bytes": 65536
}Only command is required. Pass timeout as a nonnegative number of seconds; it may be fractional. until must be a nonnegative integer number of seconds. wait defaults to true, until to 10, and max_bytes to 65536. The obsolete timeout_ms field is rejected.
Start the host server on a non-loopback address with a strong token:
export AGENT_EXEC_SERVE_TOKEN="$(openssl rand -hex 32)"
agent-exec serve --bind 0.0.0.0:19263 --insecureProvide the same token to the container through its secret or environment configuration. From a Docker container on a platform that supports host.docker.internal:
curl -sS \
-H "Authorization: Bearer $AGENT_EXEC_SERVE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":["my-agent-script"],"wait":false,"until":0,"max_bytes":65536}' \
http://host.docker.internal:19263/execUse the returned job ID with the read endpoints. Because read endpoints do not require the bearer token, network restrictions remain mandatory.
agent-exec mcp exposes the managed-job lifecycle over stdio. It uses the same jobs root, persisted metadata, detached supervisor, logs, and response envelopes as the CLI; it does not require the HTTP server.
For tested setup instructions for Claude Code, Codex CLI, OpenCode, and Hermes Agent, see AI agent integrations.
Configure an MCP client to launch:
command: agent-exec
args: ["mcp"]
Set observation limits in the MCP server process environment:
AGENT_EXEC_MCP_DEFAULT_UNTIL_SECONDS=10
AGENT_EXEC_MCP_MAX_UNTIL_SECONDS=55
AGENT_EXEC_MCP_DEFAULT_UNTIL_SECONDS supplies an omitted until for MCP run and wait. AGENT_EXEC_MCP_MAX_UNTIL_SECONDS caps every MCP observation duration. Set the maximum below the MCP client's request timeout. An over-limit request uses the cap; it does not fail or stop the job.
Use a nondefault jobs root only when needed:
command: agent-exec
args: ["--root", "/path/to/jobs", "mcp"]
mcp_servers:
agent-exec:
command: agent-exec
args: ["mcp"]
env:
AGENT_EXEC_MCP_DEFAULT_UNTIL_SECONDS: "10"
AGENT_EXEC_MCP_MAX_UNTIL_SECONDS: "55"When MCP is unavailable, use agent-exec run -- <command> with CLI observation commands.
| Tool | Parameters | Behavior |
|---|---|---|
run |
command: string[], cwd?: string, env?: object, timeout?: integer, until?: integer, stdin?: string, stdin_file?: string |
Starts a detached job. timeout and until are seconds; the legacy omitted until is 10 seconds unless configured. |
status |
job_id: string |
Returns canonical job status. |
tail |
job_id: string, lines?: integer, max_bytes?: integer |
Reads bounded tails; defaults are 50 lines and 65,536 bytes. |
wait |
job_id: string, until?: integer |
Observes for a bounded duration and returns bounded stdout/stderr output metadata; the legacy omitted until is 30 seconds unless configured. Indefinite MCP waits are not supported. |
kill |
job_id: string |
Sends TERM. |
Retain the job ID returned by run. Closing the MCP transport, reaching an observation deadline, receiving no output, or encountering a tool error does not stop the job. Use kill only for explicit cancellation.
MCP run accepts job stdin through the same materialization as the CLI. Supply stdin for inline UTF-8 bytes or stdin_file for a path readable by the MCP server process; the two are mutually exclusive and a conflicting call fails without creating a job. stdin_file is snapshotted into the job directory before launch, so later edits to the source file do not change a running job's input. Unlike CLI --stdin, stdin: "-" is one literal dash byte: the MCP stdio transport carries protocol frames and is never read as job stdin. When both are omitted the child receives null stdin. Input above the 64 MiB limit, or an unreadable stdin_file, fails before the child launches.
The MCP run tool intentionally omits CLI-only masking, notification, tag, compression, and shell-wrapper controls.
For an MCP host with a 60-second request deadline, a maximum of 55 seconds leaves time for the response to return. The default can remain shorter, such as 10 seconds.
agent-exec reads optional [shell], [gc], and [compression] sections from $XDG_CONFIG_HOME/agent-exec/config.toml, which normally resolves to ~/.config/agent-exec/config.toml.
[shell]
unix = ["sh", "-lc"]
windows = ["cmd", "/C"]
[gc]
auto = true
older_than = "30d"
max_jobs = 200
max_bytes = 1073741824
scan_limit = 200
delete_limit = 20
[compression]
default = "route"All keys are optional. Shell values fall back to sh -lc on Unix-like systems and cmd /C on Windows. GC and compression values fall back to their built-in defaults.
--shell-wrapper <PROGRAM AND FLAGS>- The file selected by
--config <PATH>, when specified - The default XDG configuration file, only when
--configis omitted - The built-in platform wrapper
The number of command arguments after -- selects the launch mode:
| Mode | Example | Behavior |
|---|---|---|
| Shell string | agent-exec run -- "echo hi && ls" |
A single argument is passed unchanged to the shell wrapper. |
argv |
agent-exec run -- cargo test |
Two or more arguments use an exec "$@" handoff after the wrapper initializes the shell environment. |
The argv handoff replaces the wrapper with the target workload, so PID and lifecycle tracking align with the command. Prefer argv for routine commands and shell-string mode for actual shell expressions.
The configured wrapper also runs --notify-command. Notification delivery always uses shell-string mode.
agent-exec run --shell-wrapper "bash -lc" -- ./my_script.sh
agent-exec run --config /path/to/config.toml -- ./my_script.sh--notify-command and --notify-file deliver a job.finished event after a launched job reaches a terminal state.
--notify-commandruns a shell command through the configured wrapper and writes event JSON to its standard input.--notify-fileappends one NDJSON line.completion_event.jsonstores the event and sink delivery results in the job directory.- Delivery is best effort; sink failure does not change job state.
- Inspect
completion_event.json.delivery_resultswhen delivery success matters.
JOB=$(agent-exec run --notify-file /tmp/agent-exec-events.ndjson -- echo hello | jq -r .job_id)
agent-exec wait "$JOB"
agent-exec tail "$JOB"
JOB=$(agent-exec run --notify-command 'cat > /tmp/agent-exec-event.json' -- echo hello | jq -r .job_id)
agent-exec wait "$JOB"Command sinks receive these environment variables:
AGENT_EXEC_EVENT_PATH: persistedcompletion_event.jsonornotification_events.ndjsonpathAGENT_EXEC_JOB_ID: canonical job IDAGENT_EXEC_EVENT_TYPE:job.finishedorjob.output.matched
Example payload:
{
"schema_version": "0.1",
"event_type": "job.finished",
"job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
"state": "exited",
"command": ["echo", "hello"],
"cwd": "/path/to/current-working-directory",
"started_at": "2026-07-19T12:00:00Z",
"finished_at": "2026-07-19T12:00:00Z",
"duration_ms": 12,
"exit_code": 0,
"stdout_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
"stderr_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log"
}For signal termination, state is killed, exit_code may be absent, and signal is present when available.
install-skills installs only the embedded agent-exec skill into .agents/skills/ or .claude/skills/ and updates the corresponding .skill-lock.json. It is not a general skill installer and does not accept external sources.
agent-exec install-skills
agent-exec install-skills --claude
agent-exec install-skills --claude --globalA completion callback can return the job ID and event path to the OpenClaw session that launched the work. The session can inspect the persisted event and logs before responding.
SESSION_ID="01bb09d5-6485-4a50-8d3b-3f6e80c61f9c"
REPLY_CHANNEL="telegram"
agent-exec run \
--notify-command "openclaw agent --deliver --reply-channel $REPLY_CHANNEL --session-id $SESSION_ID -m \"job_id=\$AGENT_EXEC_JOB_ID event_path=\$AGENT_EXEC_EVENT_PATH\"" \
-- ./scripts/run-heavy-task.shWhen both agents share a filesystem, sending job_id and event_path is more compact than embedding the full event JSON.
JOB=$(agent-exec run --no-wait -- ./scripts/run-heavy-task.sh | jq -r .job_id)
SESSION_ID="01bb09d5-6485-4a50-8d3b-3f6e80c61f9c"
REPLY_CHANNEL="telegram"
agent-exec notify set "$JOB" \
--command "openclaw agent --deliver --reply-channel $REPLY_CHANNEL --session-id $SESSION_ID -m \"job_id=\$AGENT_EXEC_JOB_ID event_path=\$AGENT_EXEC_EVENT_PATH\""notify set updates future delivery metadata and does not invoke the callback immediately.
agent-exec run \
--notify-file /var/lib/agent-exec/events.ndjson \
-- ./scripts/run-heavy-task.shA separate worker can process the NDJSON file, retry delivery, and route events without coupling that work to the supervisor.
Keep command sinks short, fast, and idempotent. Common failures include quoting errors, environment or PATH differences, nonzero downstream exits, and incorrect delivery targets. Use a checked-in helper or durable worker for substantial orchestration.
When output-match notification metadata is active, the supervisor evaluates newly observed lines from stdout, stderr, or either stream and emits job.output.matched for every match.
containsperforms substring matching.regexuses Rust regular-expression syntax.- Earlier output is not replayed after
notify set. - Sink failure is recorded in
notification_events.ndjsonand does not affect job state. completion_event.jsonremains reserved forjob.finisheddelivery results.
{
"schema_version": "0.1",
"event_type": "job.output.matched",
"job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
"pattern": "ERROR",
"match_type": "contains",
"stream": "stdout",
"line": "ERROR: connection refused",
"stdout_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
"stderr_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log"
}A Rust program that links agent-exec can manage jobs through a typed API
instead of spawning the agent-exec executable and reparsing JSON. See the
agent_exec::embedded module docs for the full contract.
Managed jobs stay managed because supervision runs in a detached process, never an in-process thread — the supervisor owns the workload's stdout/stderr, timeout escalation, notifications, state updates, and process-tree cleanup long after the launching call returns. So embedded launch re-executes a supervisor executable, which by default is the consumer's own binary. That is why the consumer has to install the startup delegation before its own argument parsing:
use agent_exec::embedded::{EmbeddedClient, KillRequest, ListRequest, RunRequest,
SupervisorDelegation, TailRequest};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Reserved supervisor invocations are claimed here and never reach the
// consumer's own CLI. Ordinary invocations fall through untouched.
if agent_exec::embedded::delegate_supervisor_startup()? == SupervisorDelegation::Supervised {
return Ok(());
}
// 2. The jobs root is always explicit for embedded callers: no
// AGENT_EXEC_ROOT / XDG fallback is applied.
let client = EmbeddedClient::new("/var/lib/my-app/agent-exec-jobs")?;
// 3. Typed calls only — no JSON, no stdout writes, no CLI subprocess.
let launched = client.run(RunRequest::new(vec!["echo hello".to_string()]))?;
let status = client.status(&launched.job_id)?;
let tail = client.tail(TailRequest::new(&launched.job_id))?;
let jobs = client.list(ListRequest::all())?;
let killed = client.kill(KillRequest::new(&launched.job_id))?;
println!("{} {} {} {}", status.state, tail.encoding, jobs.jobs.len(), killed.signal);
Ok(())
}Reserved invocation ownership. Delegation claims a process only when
argv[1] is exactly the reserved marker; anything else returns
NotSupervisor with the consumer's arguments unchanged. Once claimed, the
generated argument grammar is parsed strictly, and missing, duplicated,
malformed, or unexpected arguments fail closed instead of falling through to
consumer command handling. The marker is a private dispatch token, not an
authentication boundary and not a stable end-user CLI; delegated supervision
also validates the explicit root and job identity against the pre-created
metadata before acknowledging startup.
Supervisor executable override. EmbeddedClient::with_supervisor_exe
selects a different trusted executable for tests and unusual packaging. It must
install the same delegation.
Lifecycle guarantees. run returns only after the delegated supervisor
acknowledges startup within a fixed five-second deadline, so a running result
means supervision really claimed the job and will outlive the launching process.
A launch that never produced validated supervision — missing delegation,
malformed delegated arguments, an unusable supervisor executable, or an expired
deadline — persists terminal failed with no intermediate running
transition, launches no workload, and emits no completion notification. The
deadline is a launch-integrity check rather than workload observation, so it
also applies to no-wait launches, and the inline observation budget starts only
after acknowledgement.
API shape and errors. The API is synchronous, matching the jobstore. Every
operation returns JobError, whose kind() and is_retryable() let callers
distinguish job_not_found, ambiguous_job_id, invalid_input,
invalid_state, launch_failed, io_error, and internal_error without
parsing message text.
The standalone CLI, MCP server, and HTTP server are adapters over this same typed implementation, so their JSON envelopes, exit codes, and job semantics are unchanged.
Diagnostic logs use stderr only:
RUST_LOG=debug agent-exec run -- echo hello
agent-exec -v run -- echo hellocargo build
cargo test --all
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings