agent-exec
Non-interactive agent job runner. Runs commands as background jobs and returns structured JSON on stdout.
Core Concept
agent-exec is designed for agent harnesses, not just humans typing shell commands.
The defaults are intentionally optimized so an agent can run uncertain or noisy
commands without hand-tuning flags every time.
- Start with plain
agent-exec run -- <command> [args...]. - Do not add custom wait flags unless there is a concrete reason.
- Do not manually wrap ordinary commands in
sh -lc. - Expect inline stdout/stderr to be partial; the full logs are persisted and the response tells you where they are.
Why this matters:
runwaits up to 10 seconds by default, so the harness reliably gets control back before a long or stuck command consumes the whole turn.- The default wait also catches many startup failures immediately, which often
removes an extra
run -> status/tailround trip. - Large output does not need to fit in context because
stdout_log_pathandstderr_log_pathalways point to the persisted logs.
In other words, agent-exec is not a launch-only wrapper. The default run
behavior is the main product.
Output Contract
- stdout: JSON by default — every command prints exactly one JSON object; pass
--yamlto get YAML instead - stderr: Diagnostic logs (controlled by
RUST_LOGor-v/-vvflags)
This separation lets agents parse stdout reliably without filtering log noise.
Installation
Shell Completions
agent-exec supports dynamic shell completion for job IDs.
statusandtailcomplete all known job IDswaitcompletes only non-terminal jobs (created,running)killcompletes only running jobsdeletecompletes only terminal jobs
The completion candidates are generated dynamically from the jobs root, so you need to register the completion script in your shell first.
Bash
Zsh
fpath=(/.zsh/completions )
&&
Fish
Example:
Quick Start
Short-lived job (run だけで結果確認)
既定では run が最大 10 秒待機し、inline output を返します。
通常はこのデフォルトのまま使ってください。--no-wait や --forever
は例外用途です。
# 1. Start job and read inline output
Example output of run:
Long-running job (run → status → tail)
Start a background job, poll its status, then read its output:
# 1. Start the job (returns immediately with a job_id)
JOB=
# 2. Check status
# 3. Stream output tail
# 4. Wait for completion
Timeout and force-kill
Run a job with a timeout; SIGTERM after 5 s, SIGKILL after 2 s more:
Argv-first usage
For ordinary commands, pass the workload as argv after --:
Do not prepend sh -lc for ordinary commands. Reserve shell wrapping for
cases that actually need shell parsing such as pipes, redirects, variable
expansion, or compound commands:
# Needed because this uses shell syntax
Two-step job lifecycle (create / start)
In addition to the immediate run path, agent-exec supports a two-step
lifecycle where you define a job first and start it later.
# Step 1 — define the job (no process is spawned)
JOB=
# Step 2 — launch the job when ready
createpersists the command, environment, timeouts, and notification settings tometa.jsonand writesstate.jsonwithstate="created". It returnstype="create"and thejob_id.startreads the persisted definition and spawns the supervisor. 既定では最大 10 秒待機し、inline output(head 範囲)を返します。runは即時実行の convenience path で、同じ inline output 契約を返します(--no-waitで待機無効化可能)。
Persisted environment
--env KEY=VALUE values provided to create are stored in meta.json as
durable (non-secret) configuration and applied when start is called.
--env-file FILE stores the file path; the file is re-read at start time.
State transitions
| State | Meaning |
|---|---|
created |
Job definition persisted, no process running |
running |
Supervisor and child process active |
exited |
Process exited normally |
killed |
Process terminated by signal |
failed |
Supervisor-level failure |
kill rejects created jobs (no process to signal).
wait polls through created and running until a terminal state.
list --state created filters to not-yet-started jobs.
Global Options
| Flag | Default | Description |
|---|---|---|
--root <PATH> |
XDG default | Override the jobs root directory for all subcommands. Precedence: --root > AGENT_EXEC_ROOT > $XDG_DATA_HOME/agent-exec/jobs > platform default. |
--yaml |
false | Output responses as YAML instead of JSON (applies to all subcommands). |
-v / -vv |
warn | Increase log verbosity (logs go to stderr). |
The --root flag is a global option that applies to all job-store subcommands (run, status, tail, wait, kill, list, gc). The preferred placement is before the subcommand name:
For backward compatibility, --root is also accepted after the subcommand name (both forms are equivalent):
Commands
create — define a job without starting it
Persists the job definition. Accepts the same definition-time options as run
(command, --cwd, --env, --env-file, --mask, --stdin, --stdin-file,
--timeout, --kill-after, --progress-every, --notify-command,
--notify-file, --shell-wrapper).
Does not accept observation options (--tail-lines, --max-bytes, --wait).
--stdin / --stdin-file are materialized into <job-dir>/stdin.bin during
create. Later start reuses the persisted meta.json.stdin_file value and
does not require additional stdin flags.
Returns type="create", state="created", job_id, stdout_log_path,
and stderr_log_path.
start — launch a previously created job
Launches the job whose definition was persisted by create.
start accepts wait controls (--wait, --until, --forever, --no-wait) and --max-bytes for head extraction.
既定では bare --wait(--wait true と同義)と --until 10 相当で inline output を返し、--no-wait(--wait false --until 0 相当)で待機をスキップできます。
Returns type="start" with inline output fields (stdout, stderr, stdout_range, stderr_range, stdout_total_bytes, stderr_total_bytes, encoding).
Only jobs in created state can be started; any other state returns error.code="invalid_state".
run — start a background job
Key options:
| Flag | Default | Description |
|---|---|---|
--timeout <seconds> |
0 (none) | Kill job after N seconds |
--kill-after <seconds> |
0 | Seconds after SIGTERM to send SIGKILL |
--cwd <dir> |
inherited | Working directory |
--env KEY=VALUE |
— | Set environment variable (repeatable) |
--mask KEY |
— | Redact secret values from JSON output (repeatable) |
--stdin <VALUE> |
— | Provide job stdin content directly. Use --stdin - for pipe/heredoc/redirect input. |
--stdin-file <PATH> |
— | Copy file contents into job-local stdin.bin and use it as child stdin. |
| `--wait [true | false]` | true |
--until <seconds> |
10 | Maximum wait time for inline observation. |
--forever |
false | Wait indefinitely for terminal/observation. |
--no-wait |
false | Alias to skip waiting (--until 0). |
--max-bytes <bytes> |
65536 | Max head bytes per stream in inline output. |
--tag <TAG> |
— | Assign a user-defined tag to the job (repeatable; duplicates deduplicated) |
--notify-command <COMMAND> |
— | Run a shell command when the job finishes; event JSON is sent on stdin |
--notify-file <PATH> |
— | Append a job.finished event as NDJSON |
--config <PATH> |
XDG default | Load shell wrapper config from a specific config.toml |
--shell-wrapper <PROG FLAGS> |
platform default | Override shell wrapper for this invocation (e.g. "bash -lc") |
--stdin and --stdin-file are mutually exclusive. When --stdin - is used,
agent-exec requires non-interactive stdin; if caller stdin is a tty it fails
fast with error.code = "stdin_required".
For ordinary commands, prefer argv-style invocation after -- and let
agent-exec handle the launch normally. Do not add sh -lc unless shell
syntax is required by the workload itself.
# Pipe stdin into the job
|
# Heredoc stdin
# Inline stdin (no implicit newline added)
# File-backed stdin (materialized to <job-dir>/stdin.bin)
status — get job state
Returns running, exited, killed, or failed, plus exit_code when finished.
tail — read output
Returns tail output as stdout / stderr with stdout_range / stderr_range and total byte metrics.
wait — block until done
Polls until the job reaches a terminal state or the wait deadline elapses.
--until is a client-side wait deadline and does not stop the underlying job.
Use run --timeout when you need a process runtime limit.
kill — send signal
list — list jobs
By default only jobs from the current working directory are shown. Use --all to show jobs from all directories.
Tag filtering with --tag applies logical AND across all patterns. Two pattern forms are supported:
- Exact:
--tag aaamatches only jobs that have the tagaaa. - Namespace prefix:
--tag hoge.*matches jobs with any tag in thehogenamespace (e.g.hoge.sub,hoge.sub.deep).
# Show jobs tagged with "ci"
# Show jobs in the "project.build" namespace across all directories
# Combine: jobs tagged with both "ci" AND "release" in the current cwd
ps — shorthand for list --state running
ps returns only jobs in state running. It accepts the same filtering
knobs as list except for --state, which is fixed to running. Any
agent-exec ps [FLAGS] invocation is equivalent to
agent-exec list --state running [FLAGS] with the same JSON shape
(type="list").
tag set — replace job tags
Replaces all tags on an existing job with the specified list. Duplicates are deduplicated preserving first-seen order. Omit all --tag flags to clear tags.
# Assign tags at creation time
# Replace tags on an existing job
# Clear all tags
Tag format: dot-separated segments of alphanumeric characters and hyphens (e.g. ci, project.build, hoge-fuga.v2). The .* suffix is reserved for list filter patterns and cannot be used as a stored tag.
notify set — update notification configuration
Updates the persisted notification configuration for an existing job. This is a metadata-only operation: it rewrites meta.json and never executes sinks immediately, even when the target job is already in a terminal state.
Completion notification flags:
| Flag | Description |
|---|---|
--command <COMMAND> |
Shell command string for the job.finished command sink. |
--root <PATH> |
Override the jobs root directory. |
Output-match notification flags:
| Flag | Default | Description |
|---|---|---|
--output-pattern <PATTERN> |
— | Pattern to match against newly observed stdout/stderr lines. Required to enable output-match notifications. |
--output-match-type <TYPE> |
contains |
contains for substring matching; regex for Rust regex syntax. |
--output-stream <STREAM> |
either |
stdout, stderr, or either — which stream is eligible for matching. |
--output-command <COMMAND> |
— | Shell command string executed on every match; event JSON is sent on stdin. |
--output-file <PATH> |
— | File that receives one NDJSON job.output.matched event per match. |
Behavior
- All flags are optional; unspecified fields are preserved from the existing configuration.
--commandreplaces the existingnotify_command;notify_fileis always preserved.- Output-match configuration is stored under
meta.json.notification.on_output_match. - Once saved, output-match settings apply only to future lines observed by the running supervisor — prior output is never replayed.
- Calling
notify seton a terminal job succeeds without executing any sink. - A missing job returns a JSON error with
error.code = "job_not_found".
Example — completion notification
JOB=
Example — output-match notification
# Run a job that may print error lines.
JOB=
# Configure output-match: fire on every line containing "ERROR".
# Or use a regex pattern targeting only stderr:
gc — garbage collect old job data
Deletes job directories under the root using terminal-only safety rules. Candidates are selected by age and optional pressure policies. Active jobs (running / created) are never touched.
| Flag | Default | Description |
|---|---|---|
--older-than <DURATION> |
30d |
Retention window: terminal jobs older than this are eligible. Supports 30d, 24h, 60m, 3600s. |
--max-jobs <N> |
unset | Keep newest N terminal jobs; older terminal jobs become candidates. |
--max-bytes <BYTES> |
unset | Apply byte-pressure cleanup for terminal jobs when total terminal bytes exceed this target. |
--dry-run |
false | Report candidates without deleting anything. |
Automatic cleanup (auto-GC)
run / start perform best-effort bounded auto-GC after successful launch by default.
- Default retention:
30d - Same safety rules as manual
gc(skiprunning/created/ unreadable) - Failures never fail parent
run/start - Auto-GC is bounded (scan/delete budgets) to avoid dominating launch latency
Per-invocation controls:
--no-auto-gc--auto-gc-older-than <DURATION>--auto-gc-max-jobs <N>--auto-gc-max-bytes <BYTES>
Config (config.toml) controls (optional):
[]
= true
= "30d"
= 200
= 1073741824
= 200
= 20
CLI overrides config for each invocation.
Retention semantics
- The GC timestamp used for age evaluation is
finished_atwhen present, falling back toupdated_at. - Jobs where both timestamps are absent are skipped safely.
runningjobs are never deleted regardless of age.
Examples
# Preview what would be deleted (30-day default window).
# Preview with a custom 7-day window.
# Delete jobs older than 7 days.
# Operate on a specific jobs root directory.
JSON response fields
| Field | Type | Description |
|---|---|---|
root |
string | Resolved jobs root path (gc evaluates the entire root, regardless of cwd) |
dry_run |
bool | Whether this was a preview-only run |
older_than |
string | Effective retention window (e.g. "30d") |
older_than_source |
string | "default" or "flag" |
deleted |
number | Count of directories actually deleted |
skipped |
number | Count of directories skipped (sum of out_of_scope + failed for the per-job results) |
out_of_scope |
number | Count of jobs that were not candidates for deletion (e.g. running, non-terminal, missing timestamp, retention not satisfied) |
failed |
number | Count of jobs that were eligible candidates but could not be removed (delete syscall failed or post-delete existence check still saw the path) |
freed_bytes |
number | Bytes freed (or would be freed in dry-run) |
scanned_dirs |
number | Number of directories scanned during this GC run |
candidate_count |
number | Number of directories selected as deletion candidates by policy |
jobs |
array | Per-job details: job_id, state, action, reason, bytes |
The action field in each jobs entry is one of:
"deleted"— directory was removed AND the post-delete existence check confirmed the path is gone at command completion"would_delete"— would be removed in a real run (dry-run only)"skipped"— preserved with an explanation inreason
Use out_of_scope vs failed to tell "this job was never a deletion target" apart from "this job should have been deleted but wasn't". A job appearing as deleted always implies the path is absent on disk by the time the response is emitted.
delete
Explicitly remove one or all finished job directories. Unlike gc, which uses
age-based retention across the whole jobs root, delete is operator-driven:
remove one known job immediately, or clear finished jobs belonging to the
current working directory.
agent-exec delete <JOB_ID>
agent-exec delete --all [--dry-run]
rm is a visible alias of delete: agent-exec rm <JOB_ID> and
agent-exec rm --all [--dry-run] behave identically to the corresponding
delete invocations and emit the same JSON shape (type="delete").
State rules
delete <JOB_ID>— removes jobs in statecreated,exited,killed, orfailed. Returns an error forrunningjobs (the job directory is preserved).delete --all— removes only terminal jobs (exited,killed,failed) whose persistedmeta.json.cwdmatches the caller's current working directory. Jobs increatedorrunningstate are skipped and reported in the response.
Examples
# Remove a specific finished job.
# Preview which jobs would be removed from the current directory.
# Remove all terminal jobs from the current directory.
# Operate on a specific jobs root.
JSON response fields
| Field | Type | Description |
|---|---|---|
root |
string | Resolved jobs root path |
dry_run |
bool | Whether this was a preview-only run |
cwd_scope |
string | Effective cwd used by --all to evaluate which jobs to delete. Present only for --all; absent for single-job delete <JOB_ID>. |
deleted |
number | Count of directories actually deleted (0 when dry_run=true) |
skipped |
number | Count of directories that were not deleted (sum of out_of_scope + failed for the per-job results) |
out_of_scope |
number | Count of jobs filtered out before any deletion was attempted: cwd-mismatched jobs (only for --all) and in-scope jobs that were not deletion targets (e.g. running, created, pid_alive, state_unreadable) |
failed |
number | Count of jobs that were targeted for deletion but the deletion did not take effect (delete syscall failed or post-delete existence check still saw the path) |
jobs |
array | Per-job details: job_id, state, action, reason. cwd-mismatched jobs are aggregated into out_of_scope and not listed individually. |
The action field in each jobs entry is one of:
"deleted"— directory was removed AND the post-delete existence check confirmed the path is gone at command completion"would_delete"— would be removed in a real run (dry-run only)"skipped"— preserved with an explanation inreason(e.g."running","created","pid_alive","state_unreadable","post_delete_check_failed", or"delete_failed: ...")
Use cwd_scope, out_of_scope, and failed together to disambiguate three operator concerns:
- "did the bulk delete actually evaluate the directory I expected?" → check
cwd_scope - "is this job missing because it was filtered out, or because deletion failed?" → compare
out_of_scopevsfailed - "if I see
deleted, can I trust the directory is gone?" → yes;deletedis reported only after the post-delete existence check confirms the path is absent
Difference between delete and gc
delete |
gc |
|
|---|---|---|
| Scope | Single job or cwd-scoped finished jobs (cwd_scope reports the effective cwd) |
Entire jobs root, regardless of cwd |
| Trigger | Explicit operator action | Age-based retention policy |
| Running jobs | Always rejected / skipped | Always skipped |
| Dry-run | --dry-run flag |
--dry-run flag |
| Post-delete check | deleted ⇒ path confirmed absent |
deleted ⇒ path confirmed absent |
serve — HTTP API server
agent-exec serve starts a REST API server that exposes job operations over HTTP.
This allows Flowise, curl, or any HTTP client to launch and monitor jobs without
needing direct access to the CLI.
Default bind address: 127.0.0.1:19263 (localhost only, not exposed externally).
Network security note
The server performs no authentication. Access is controlled by the bind address:
127.0.0.1(default): only reachable from the same host — safe for local use.0.0.0.0: reachable from all network interfaces — requires a firewall or reverse proxy to restrict access.
Endpoints
| Method | Path | CLI equivalent | Description |
|---|---|---|---|
| GET | /health | — | Health check. Returns {"ok":true} |
| POST | /exec | run |
Launch a job; returns job_id |
| GET | /status/{id} | status |
Job status |
| GET | /tail/{id} | tail |
stdout/stderr log tail |
| GET | /wait/{id} | wait |
Block until job reaches a terminal state |
| POST | /kill/{id} | kill |
Send SIGTERM to the job |
All responses include schema_version, ok, and type fields matching the CLI schema.
POST /exec request body
Only command is required. Returns the same RunData as the run CLI command.
Flowise / Docker example
From a Flowise container, use host.docker.internal to reach the agent-exec server
running on the host:
POST http://host.docker.internal:19263/exec
{"command": ["my-agent-script"]}
Then poll GET http://host.docker.internal:19263/wait/{job_id} until the job finishes.
To allow container access, start the server with --bind 0.0.0.0:19263 and ensure
your firewall does not expose port 19263 to the public internet.
Configuration
agent-exec reads an optional config.toml to configure the shell wrapper used for command-string execution.
Config file location
$XDG_CONFIG_HOME/agent-exec/config.toml(defaults to~/.config/agent-exec/config.toml)
config.toml format
[]
= ["sh", "-lc"] # used on Unix-like platforms
= ["cmd", "/C"] # used on Windows
Both keys are optional. Absent values fall back to the built-in platform default (sh -lc / cmd /C).
Shell wrapper precedence
--shell-wrapper <PROG FLAGS>CLI flag (highest priority)--config <PATH>explicit config file- Default XDG config file (
~/.config/agent-exec/config.toml) - Built-in platform default (lowest priority)
Command launch modes (Unix)
agent-exec run supports two launch modes, selected by the number of arguments after --:
| Mode | Example | Behaviour |
|---|---|---|
| Shell-string | agent-exec run -- "echo hi && ls" |
Single argument is passed as-is to the shell wrapper. Shell operators (&&, pipes, etc.) are preserved. The wrapper process is the workload boundary. |
| Argv | agent-exec run -- cflx run |
Two or more arguments trigger an exec "$@" handoff. The shell wrapper runs briefly for login-shell environment initialisation, then replaces itself with the target workload. The observed child PID and lifecycle align with the intended command, not the shell. |
The exec handoff means that for argv-mode invocations, completion tracking aligns with the target workload rather than the shell wrapper, which resolves lingering-shell issues when the target replaces the wrapper process.
For agent-authored commands, argv mode should be treated as the default. Shell-string mode is for actual shell expressions, not for routine single-binary launches.
The configured wrapper applies to both run command-string execution and --notify-command delivery. Notify delivery always uses shell-string mode regardless of how the job was launched.
Override per invocation
Use a custom config file
Job Finished Events
When run is called with --notify-command or --notify-file, agent-exec emits a job.finished event after the job reaches a terminal state.
--notify-commandaccepts a shell command string, executes it via the configured shell wrapper (default:sh -lcon Unix,cmd /Con Windows), and writes the event JSON to stdin.--notify-fileappends the event as a single NDJSON line.completion_event.jsonis also written in the job directory with the event plus sink delivery results.- Notification delivery is best effort; sink failures do not change the main job state.
- When delivery success matters, inspect
completion_event.json.delivery_results.
Choose the sink based on the next consumer:
- Use
--notify-commandfor small, direct reactions such as forwarding the event back to the launching OpenClaw session withopenclaw agent --deliver --reply-channel ... --session-id ... -m .... - Use
--notify-filewhen you want a durable queue-like handoff to a separate worker that can retry or fan out. - Prefer a compact one-liner for agent-authored OpenClaw callbacks, and prefer
AGENT_EXEC_EVENT_PATHover parsing stdin when the downstream command accepts a file.
Example:
JOB=
Command sink example:
JOB=
install-skills
install-skills is intentionally narrow. It installs only the built-in
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 or local skill sources.
OpenClaw examples
Return the event to the launching OpenClaw session
This pattern is often more flexible than sending a final user message directly from the notify command. The launching session can inspect logs, decide whether the result is meaningful, and summarize it in context. In same-host agent-to-agent flows, job_id plus event_path is a good default.
Call openclaw agent --deliver with the reply channel and session id directly:
SESSION_ID="01bb09d5-6485-4a50-8d3b-3f6e80c61f9c"
REPLY_CHANNEL="telegram"
With this pattern, the receiving OpenClaw session can open the persisted event file immediately and still keep the job id for follow-up commands.
Prefer sending job_id and event_path instead of the full JSON blob when the receiver can access the same filesystem.
Attach or replace the callback later with notify set
Use notify set when the job is already running and you only learn the OpenClaw destination afterward.
JOB=
SESSION_ID="01bb09d5-6485-4a50-8d3b-3f6e80c61f9c"
REPLY_CHANNEL="telegram"
notify set is metadata-only: it updates the stored callback for future completion delivery and does not execute the sink immediately.
Durable file-based worker
Use --notify-file when you want retries or fanout outside the main job lifecycle:
A separate worker can tail or batch-process the NDJSON file, retry failed downstream sends, and route events to chat, webhooks, or OpenClaw sessions without coupling that logic to the main job completion path.
Operational guidance
--notify-commandaccepts a plain shell command string; no JSON encoding is needed.- Keep notify commands small, fast, and idempotent.
- Prefer
AGENT_EXEC_EVENT_PATHwhen the downstream command already knows how to read a file. - Common sink failures include quoting mistakes, PATH or env mismatches, downstream non-zero exits, and wrong chat, session, or delivery-mode targets.
- If you need heavier orchestration, let the notify sink hand off to a checked-in helper or durable worker.
For command sinks, the event JSON is written to stdin and these environment variables are set:
AGENT_EXEC_EVENT_PATH: path to the persisted event file (completion_event.jsonforjob.finished,notification_events.ndjsonforjob.output.matched)AGENT_EXEC_JOB_ID: job idAGENT_EXEC_EVENT_TYPE:job.finishedorjob.output.matched
Example job.finished payload:
If the job is killed by a signal, state becomes killed, exit_code may be absent, and signal is populated when available.
Output-Match Events
When a job has output-match notification configuration (set via notify set --output-pattern), the running supervisor evaluates each newly observed stdout/stderr line and emits a job.output.matched event for every line that matches.
Key properties:
- Delivery fires on every matching line, not once per job.
- Only future lines are eligible — output produced before
notify setwas called is never replayed. - Sink failures are recorded in
notification_events.ndjsonand do not affect the job lifecycle state. - Matching uses either
contains(substring) orregex(Rust regex syntax) as configured by--output-match-type. - Stream selection (
--output-stream) restricts matching tostdout,stderr, oreither.
Example job.output.matched payload:
Delivery records for output-match events are appended to notification_events.ndjson in the job directory (one JSON object per line). The completion_event.json file retains only job.finished delivery results.
Logging
Logs go to stderr only. Use -v / -vv or RUST_LOG:
RUST_LOG=debug
Development