CLI guide¶
Everything the TUI does is also scriptable. The af sessions and af tasks command groups output JSON to stdout and errors to stderr, so they compose with jq and shell scripts. Pass --json to wrap output in a {data, error} envelope for structured error handling. The TUI and the CLI share the same state — you can mix them freely.
Run af <command> --help for the authoritative flag list of any command.
Project scoping¶
Like the TUI and the web UI, every af sessions and af tasks command is scoped to one project. The rules are the same for all of them:
--repo <path>names the project explicitly, and always wins.- Otherwise the current directory's git repository is the project. A linked worktree resolves to its main repository, so a command run from inside a session's worktree still acts on the real project.
- Outside a git repository there is no project context:
sessions createandtasks addrequire--repo— a new binding is never guessed.- Listing spans every project.
- A
<title>or task<id>resolves across projects, but a title held by several projects is ambiguous and errors rather than picking one.
Acting on another project always takes an explicit --repo. The read-only list
commands also accept --all as an explicit opt-in to span every project.
af sessions list # this project's sessions
af sessions list --all # every project's sessions
af sessions list --repo /repos/beta
af tasks list # this project's tasks
af tasks list --all # every project's tasks
af tasks list --repo /repos/beta
These defaults changed (#1893)
af tasks list used to list every project's tasks; from inside a repository it now lists only that project's. Pass --all for the old behavior.
af tasks get, update, remove, and trigger used to accept --repo and silently ignore it, so an id reached any project's task from anywhere. They now refuse an id owned by a different project and name the --repo that would reach it.
Remote daemons¶
For the session reads that follow --daemon-url/AF_DAEMON_URL (sessions list, get, watch, preview, and attach), rule 2 does not apply: your current directory names a repository on this machine, which says nothing about the daemon's projects, so only an explicit --repo scopes a remote lookup.
af tasks is not remote-targeted at all. Every task command talks to the local daemon over its control socket, whatever --daemon-url/AF_DAEMON_URL is set to — so af tasks list scopes by your local current directory and lists local tasks, and af tasks remove removes a local one. There is no way to manage a remote daemon's tasks from here today.
af — the TUI¶
| Flag | Description |
|---|---|
-p, --program |
Agent to run in new sessions, one of claude, codex, aider, gemini, amp, opencode, devin. To pass custom paths or flags, use program_overrides in the config instead — see configuration.md. |
af sessions¶
All subcommands honor --repo <path> to target a project other than the current directory's (see Project scoping), and --json to wrap output in the shared envelope.
Session titles are unique within a project, not across projects — the same name may exist in several repos at once. Every command that takes a <title> resolves it inside the repo named by --repo, falling back to the current directory's repo. With no repo context (no --repo, cwd outside a repo) a title held by exactly one session anywhere still resolves; a title held by sessions in several projects is ambiguous and reports an error naming them rather than picking one:
Against a remote daemon (--daemon-url/AF_DAEMON_URL), the split follows the transport:
- Served by the targeted daemon —
list,get,watch,preview,attach— ignore the current directory rather than sending it as a scope, since it names a repo on your machine, not the daemon's. A bare title resolves across the remote's projects. - Everything else —
kill,archive,restore,retry-limit,send-prompt, tab create/delete — reaches the local daemon regardless of--daemon-url, so it stays scoped to the current directory.
Caveat for the reads: --repo becomes an id by hashing the path as given on this machine, so it only disambiguates when the daemon has that project checked out at the same absolute path. Prefer a bare title against a remote and let the ambiguity error tell you when to narrow it.
One exception to per-project titles: remote hook sessions share a global name namespace, because the slug reaches launch_cmd/delete_cmd verbatim and external provisioners key real sandboxes on it — see remote-hooks.md.
af sessions list [--all] # list this repo, or every repo explicitly
af sessions get <title> # fetch one session
af sessions create <title> [--prompt "..."] [--program <agent>] [--here]
# --name <title> remains an alias for existing scripts
af sessions send-prompt <title> "..." # append a prompt to a session
af sessions send-prompt <title> "..." --create # send-or-create
af sessions tab-create <title> --kind shell # spawn the same bare $SHELL Terminal tab the TUI/web opens
af sessions tab-create <title> --command "<cmd>" # spawn a process tab in the session's worktree
af sessions tab-create <title> --kind web --port 5173 # web/iframe tab pointing at a localhost dev server
af sessions tab-create <title> --kind vscode # VS Code editor tab on the session's worktree
af sessions tab-delete <title> --name <tab> # delete a single tab (the daemon won't respawn it)
af sessions tabs create <title> --command "<cmd>" # alias for tab-create (hyphen verb still works)
af sessions tabs create <title> --kind web --url <url> # alias for tab-create (hyphen verb still works)
af sessions tabs delete <title> --name <tab> # alias for tab-delete
af sessions preview <title> # snapshot the session's pane
af sessions attach <title> # attach interactively (foreground)
af sessions whoami # report the session this shell is inside
af sessions archive <title> # default done action: restorable later
af sessions archive --self # archive the current session (resolved via whoami)
af sessions handoff <title> --to <agent> # continue under a different agent, same worktree/branch
af sessions retry-limit <title> # retry a session parked at a usage-limit wall
af sessions kill <title> # permanently destroy + prune owned branch
af sessions restore <title> # restore an archived/lost/dead session
Flags:
list:--allspans every project's sessions explicitly; it is mutually exclusive with--repo.create: takes<title>positionally;--name <title>remains an alias for existing scripts.--promptsends an initial prompt, and--programselects the agent enum (defaulting to the configureddefault_program).--here(alias--in-place) attaches the session to the repo's existing working tree at its current branch instead of cutting a new worktree+branch: the agent runs in the repo root, no branch is created, and killing the session never removes the working tree or branch. Requires a git repository (the current directory, or--repo); incompatible with remote sessions. The titleroot(any casing) is reserved for the daemon-managed root agent — see theroot_agentskey in configuration.md.send-prompt:--createauto-creates the session if it doesn't exist;--programpicks the agent when creating.tab-create: creates a new tab in the session's worktree.--kind shellstarts the user's bare$SHELLthrough the same daemon operation the TUI and web use. Its canonical kind/base name isshell;Terminalis the presentation-only UI label, and--name/--command/--url/--portare rejected for this kind rather than ignored. By default,--commandis run in the worktree as a process tab;--namesets the tab's name — the handle the other tab verbs address it by, not the label the TUI renders (defaults to the command's basename; sanitized to[A-Za-z0-9_-], then auto-suffixed-2,-3, … on collision, so the name you pass is not always the name you get). With--kind web, creates an iframe tab targeting--url(or--portas a localhost:convenience) instead. With --kind vscode, creates a VS Code editor tab on the session's own worktree — it takes no target, so--url/--port/--commandare rejected, and it needscode-server(oropenvscode-server) installed, which af detects rather than bundles. Both are browser panes with no PTY — see web.md. The resolved tab name is printed as{"name": "..."}so scripts/agents can address it. The tab persists and reconnects across a daemon/afrestart like every other tab. Local sessions only: an off-box session (docker/ssh/hook) has no daemon-side worktree to spawn a tab in, so the daemon rejects the request — its tab list is fixed at the single agent tab (see remote-hooks.md).tab-delete: the counterpart oftab-create—--name(required) selects the tab to delete. Pass the tab's name (as reported byaf sessions get), not the label the TUI tab bar shows: agent and shell tabs render a fixedAgent/Terminalthat is not their name (agent/shell). A miss lists the tabs that exist with both spellings, so a wrong name is a next step rather than a dead end. The tab is removed from the daemon's session state and its tmux window is killed; the removal is persistent (the daemon won't respawn it, and it doesn't return on restart). The deleted tab's name is printed as{"name": "..."}. The agent tab can't be deleted — useaf sessions killto tear down the whole session. An archived session's tabs can't be deleted either (its preserved web-tab URLs must still be there forrestore) — restore it first. Targeting a missing tab or session is an error. Local sessions only (an off-box session has no daemon-side worktree and no user-managed tabs to delete).tabs {create,delete}: additive noun-subcommand aliases —af sessions tabs create==af sessions tab-createandaf sessions tabs delete==af sessions tab-delete(same flags and output). The hyphen verbs are kept for existing scripts; nothing is renamed. There is notabs list— list a session's tabs viaaf sessions get.archive: the default way to finish with a session. It tears down the session's tmux and moves its git worktree out to the global archive directory (<AGENT_FACTORY_HOME>/archived/<repoID>/<title>/), preserving the branch and any uncommitted changes. The session is not deleted — it becomes a quiescent archived row that survives restarts and is never auto-restored. Shell/process tabs do not survive the archive (their processes are torn down), but web tabs do — their target URLs are preserved and render again onrestore. While archived they are inert: the tab shows a placeholder rather than loading, and it can't be deleted. Prints{"ok": true, "title": "...", "archived_path": "..."}. An off-box session (docker/ssh/hook) archives differently — it pushes its branch tooriginand tears the sandbox down (durability lives in GitHub, not the sandbox), and restore re-provisions from the pushed branch. Not available for in-place (--here) sessions (they don't own a relocatable worktree). Bring it back withrestore. Pass--selfinstead of a<title>to archive the current session — it resolves the caller's own session the waywhoamidoes, so an agent can archive itself once its work is done;--selfand a<title>are mutually exclusive, and it errors with an actionable message when not run from inside a session.handoff: continues a session under a different agent, in place — the alternative to waiting out a provider usage limit.--to <agent>(required) names the incoming agent; any supported agent is a valid target. The session keeps its identity, worktree, branch, tabs, and task binding — only the agent process is replaced, so uncommitted work is untouched. Agent conversations are not portable between providers, so the incoming agent starts a fresh conversation and is delivered a mission brief: the session's goal, the fact that it is continuing someone else's work, and where to read it (git log/git diffon the branch). Use--briefto supply a mission that replaces the session's stored prompt when that prompt is stale or too broad. The swap is recorded along with the branch tip at that moment, which is the attribution boundary between the two agents' work. Prints{"ok": true, "title": "...", "from": "...", "to": "...", "head_sha": "..."}. Local-worktree sessions only (an off-box session runs its agent inside a provisioned sandbox, a different lifecycle). Refuses a handoff to the agent already running. See usage-limits.md.retry-limit: resumes a session currently parked at a provider usage-limit wall. It calls the same daemon recovery action as the TUI'sckey and the web's Retry button: an exited agent is re-spawned when necessary, the pending prompt is re-delivered (orcontinuewhen none is stored), and the limit state clears only after delivery succeeds. Prints{"ok": true, "title": "..."}and refuses a session that is not currently limit-blocked. See usage-limits.md.kill: permanently destroys a session: tmux is torn down, the worktree is removed, the stored row is deleted, and the branch is pruned when Agent Factory owns it. It always destroys the session, including any uncommitted or unmerged work on its branch — there is no undo. Useaf sessions archive <title>to keep it restorable instead.--forceis accepted but has no effect (kept for backward compatibility).restore: restores an archived, Lost, or Dead session. Archived sessions move their worktree back next to the repo, re-register it, re-spawn the agent (shell/process tabs are not restored — their processes were torn down at archive time; web tabs come back with their target URLs, since a web tab is just a URL and has no process), and mark the session running. Lost/Dead sessions recover in place, rebuilding a missing worktree when possible and resuming the recorded agent conversation when required. Prints{"ok": true, "title": "...", "worktree_path": "..."}. Fails if the session is not restorable, or if its origin repo is gone (an archived worktree is left intact for manual recovery). Honors--repolikekill.
af tasks¶
Tasks deliver a prompt to an agent automatically — on a cron schedule or whenever a long-running watch script emits a stdout line. Full semantics (trigger × delivery matrix, watch-script contract, status model) live in tasks.md. All subcommands honor --repo <path> and --json, and follow the shared project scoping rules.
af tasks list [--all]
af tasks add --name <n> --prompt <p> --cron "0 9 * * *" [--target-session <title>] [--program <agent>]
af tasks add --name <n> --watch-cmd <cmd> [--prompt "... {{line}} ..."] [--target-session <title>]
af tasks get <id>
af tasks update <id> [--cron ...|--watch-cmd ...] [--prompt ...] [--target-session ...] [--project-path <repo>] [--program <agent>] [--enabled true|false]
af tasks restart <id> # reload an edited watch script (watch tasks only)
af tasks trigger <id> # run a cron task immediately (cron tasks only)
af tasks remove <id>
Exactly one of --cron / --watch-cmd per task. On update, setting one trigger clears the other. --target-session "" explicitly reverts to create-a-session-per-run; omitting the flag leaves it untouched. --project-path moves the task to that repository; --repo still names the task's current project for authorization. --program accepts the same agent enum as tasks add; omitting it keeps the task's current program.
Project binding¶
A task is bound to exactly one project when it is created, and every run's worktree is created inside it. The binding comes from --repo, or from the current directory's project. The two flags on update do different jobs: --repo scopes which task may be edited and never re-binds one, while --project-path <repo> moves the task to another existing git repository — that path becomes both its new working directory and its project binding. So af tasks update <id> --repo /repos/alpha --project-path /repos/beta authorizes the task in alpha and moves it to beta.
The project a task belongs to is recorded as an id resolved when the task is bound, not re-derived from its path on each read. This is why deleting a directory a task points at — a subdirectory or a linked worktree — never hides the task from its own project.
If another client re-binds a task to a different project while a command is acting on it, the command is refused rather than applied to the moved task:
$ af tasks remove a1b2c3d4
{"error":"task \"a1b2c3d4\" was re-bound to a different project while this command was running
(expected /repos/alpha, now /repos/beta) — nothing was changed; re-run the command to act on it
in its current project"}
Nothing is changed when this happens, and re-running acts on the task where it now lives.
tasks add reports the resolved binding, so you can check it is the project you meant:
$ af tasks add --name nightly --prompt "sweep" --cron "0 3 * * *"
{
"id": "a1b2c3d4",
"project_path": "/repos/alpha"
}
Because the binding is inherited from the current directory, running tasks add inside a throwaway clone of a repository binds the automation to the clone rather than to the real project — the task's sessions then appear under a project nobody looks at. Creating a task from a clone that lives inside AGENT_FACTORY_HOME is refused for that reason; pass --repo to name the intended project. Session worktrees under AGENT_FACTORY_HOME are unaffected: a linked worktree resolves to its main repository, so an agent adding a task from inside its own session binds to the real project.
af daemon¶
The background daemon hosts task cron schedules, watch-task scripts, session monitoring, and the web UI. It starts on demand whenever af runs and an enabled task exists; installing it as a user-level autostart unit (systemd user service on Linux, launchd agent on macOS) keeps scheduled tasks firing after reboots. See tasks.md.
af daemon install # register autostart at login
af daemon restart # restart a running daemon and re-adopt live sessions
af daemon adopt # hand a detached daemon back to the installed autostart unit
af daemon uninstall # remove the autostart unit (the daemon still starts on demand)
af daemon status # read-only health, supervision, and config freshness (+ --json)
af daemon status uses the same no-spawn Ping as af doctor, plus bounded read-only service-manager inspection. It reports the responder PID separately from the recorded PID, whether the installed unit owns that responder, and whether the listener/auth config on disk matches what the daemon booted with. Manager failures and older daemons render as unknown; they are never guessed into “unsupervised” or “current.” It never starts, stops, or reloads a daemon or unit.
af daemon restart is safe to run after replacing the af binary. It asks a
running daemon to shut down cleanly, restarts the autostart unit when one is
installed, otherwise starts an ad-hoc daemon from the current binary. If no
daemon is running, it exits successfully without starting one.
af daemon adopt reclaims supervision when a daemon is running detached from
its unit — an ad-hoc child a live af spawned, which the service manager can no
longer restart. It stops that daemon, starts the installed unit in its place,
and verifies the unit now owns the process answering the control socket. Live
sessions keep running; the new supervised daemon re-adopts persisted state on
startup, exactly as af daemon restart does. It needs an installed unit that
serves this home; if that unit already owns the running daemon, adopt reports so
and changes nothing.
af config¶
Read the current repository's effective config (or global config outside Git), inspect another repository, and write the global config (~/.agent-factory/config.toml) from the CLI. Config is a hand-editable file read by the daemon and TUI at startup (not daemon-owned state). --json wraps output in the shared envelope (success and error).
af config list # current repository's effective values
af config get <key> # one current-repository value
af config get <key> --explain # candidates and why one won
af config list --repo ../another-project # inspect another repository
af config get program_overrides.codex --repo . --explain
af config set <key> <value> # global write, preserving comments/ordering
Bare get/list report the current repository's effective values (defaults plus global, legacy per-repo, checked-in, and personal layers); outside Git they report global values. --repo <repository-path> inspects another repository, while the deprecated --project read alias remains accepted for compatibility. The path is a selector only and does not register or persist a project. --explain reports the on-disk effective value, merge policy, precedence, every candidate's path/presence/result/reason, and per-leaf origins for merged maps/tables. Displayed source locations keep the selected/configured path spelling; symlinks are resolved for identity comparison, never for display. The output also says explicitly that the running daemon value was not checked. JSON mode carries the same data structurally.
set edits only the target value's bytes — every comment, blank line, and key ordering is preserved (the file is not regenerated) — and validates the value with the loader's own rules before writing, so it can never produce a config that fails to load. Without --project it writes the global config; with --project <id-or-path> it writes a personal per-project override instead, and af config unset <key> --project <id-or-path> clears one (only the preference keys default_program, program_overrides.<agent>, branch_prefix, on_archive_command admit a per-project override). Only scalar tunables are settable — run af config set --help, or see the full key reference in configuration.md, for the current list rather than one hand-copied here. cors_allowed_origins is settable as a comma-separated list that replaces the whole allow-list (each entry validated as a scheme://host[:port] origin; "" clears it). The structural tables (root_agents, [theme], the [keys] rebind table) and the session_env_passthrough list have no single-scalar shape, so they are not settable here — ask the config assistant to change them (it edits the file and validates), or edit config.toml directly and run af config validate. A global write is applied to the running daemon in place (#2480), so most keys take effect at once; branch_prefix waits for the next daemon start, and the keys af itself reads (auto_update, update_channel, detach_keys) wait for the next af launch. set prints which of those applies to the key you just wrote. A raw hand-edit is not applied for you and takes effect on the next af/daemon start.
Maintenance commands¶
af version # print the version and the release URL
af debug # print the resolved config and its path
af keys # print the effective TUI key bindings (defaults + [keys] rebinds)
af upgrade # self-upgrade to the latest GitHub release (Linux/macOS)
af doctor --setup # verify first-run prerequisites and writable storage
af doctor # diagnose setup, leaked resources, and daemon health
af doctor --fix # also apply the safe remediations
af bug-report # bundle logs + versions + tasks + redacted state into one file to attach
af bug-report --json # emit the structured manifest to stdout instead of writing a file
af reset # nuclear option — see below
af bug-report collects one shareable diagnostics file: the daemon log tail
(bounded to the last ~2MiB / 5000 lines), versions (af, Go, OS/arch, the daemon
snapshot), the configured tasks, the session state from instances.json, the
af daemon status health snapshot, and the global config. It writes a single
text file (default ~/af-bug-report-<ts>.txt, mode 0600; override with
-o/--output) so you can read the whole thing in one scroll before attaching
it. Redaction is best-effort: free-text and secret-bearing fields (session
titles, session prompts, task prompts, tab commands, remote metadata) are dropped, $HOME and
your username are collapsed to ~ / [user], and known credential shapes are
scrubbed everywhere — but perfect redaction is impossible, so review the file
before sharing it publicly. It is read-only and local (like af doctor): it
never dials the daemon or the network, and is not part of the HTTP af api
surface.
By default it also opens a pre-filled GitHub issue draft in your browser,
always against the agent-factory project — never whatever repo you happen to be
in, since the report is about af itself. The draft body carries a bounded,
redacted excerpt of the key diagnostics (versions, daemon status, counts, and the
newest log lines) so the report is useful as filed; the complete bundle is far
too large for a URL, so it stays on disk for you to attach. Nothing is submitted
for you — review the draft, drag the bundle onto it, and click Submit yourself.
Use -o/--output or --file to skip GitHub and only write the file.
af doctor --setup is the first-run profile: it checks AF home/config/state/log
writability, git and git identity, tmux, configured agent commands, daemon
health, and remote-hook setup for the current repo when configured.
af doctor is read-only by default: it reports orphaned processes left behind
by dead sessions, processes pegging a CPU core inside live sessions, af_ tmux
sessions with no backing record, abandoned temp agent-factory homes, and daemon
problems (stale socket, stale pid file, a daemon still running a replaced
binary). With --fix it kills orphans whose ancestry markers prove they came
from a dead Agent Factory session and removes stale temp homes, logging each
action; anything it cannot verify is reported, never touched, and stays
advisory rather than failing the run. Exits 1 only when an actionable
condition remains — advisory warnings do not.
When the repository you run it in configures a remote-hook backend,
af doctor also validates that setup, so a misconfigured remote surfaces as a
diagnosable problem instead of a cryptic failure at session-launch time:
- remote-config — the required
remote_hookscommands (one ofprovision_cmd/launch_cmd, plusdelete_cmd) are present, naming the missing field and the in-repo config file when one is not. It also rejects a config still carrying the removed pre-PR7 keys (list_cmd/attach_cmd/terminal_cmd), naming the stale key and pointing at the migration recipe. - remote-hook-script — every configured hook command resolves to something
runnable: a path that exists and carries the execute bit (with the exact
chmod +xfix otherwise), or a bare name found on$PATH.
There is no connectivity round-trip probe: the provision-and-expose contract has
no read-only verb (launch_cmd provisions real infrastructure and starts an
af agent-server; delete_cmd tears it down), so the live wire round-trip is
exercised by actually creating a session, not by a doctor dry-run.
These checks run only for a repo that configures remote_hooks. Run outside a
git repo, or in a repo with no remote backend — the common local-only case —
they collapse to a single n/a — no remote backend configured line and add no
findings, so local users see no new noise. The remote checks are validated
against the current working directory's repository; run af doctor from inside
the repo whose remote setup you want to check.
af reset stops every af daemon running for this AF home (the managed one plus any orphan left behind by an upgrade or a source-built agent-factory --daemon), removes the daemon sockets so a stale daemon or socket cannot serve the next af, kills all Agent Factory tmux sessions, removes every linked git worktree (and its branch) from each repo that has stored sessions — including worktrees you created by hand — wipes the durable project registry (registered bindings and their reachable checkout identity markers for this AF home), and deletes all stored session records. Only daemons owned by you AND using this AGENT_FACTORY_HOME are touched; a daemon or autostart unit for a different AF home is left alone. Use it to recover from a corrupted state, not for day-to-day cleanup — af sessions archive <title> (or a in the TUI) finishes with one session while keeping it restorable.