a-gent
Task state for coding agents, stored as git objects under refs/a-gent/.
Task data travels with the repo, syncs over any git remote, works offline, and never
appears in the working tree — no .gitignore entry, no stray directory, nothing for an
agent to stumble into.
Status: in development. The storage layer is the current focus.
Install
Requires git. macOS and Linux, amd64 and arm64.
go install github.com/samsambutdifferent/a-gent/cmd/gent@latest
That drops the binary in $(go env GOPATH)/bin, which is not on PATH by default. If
gent comes back as command not found, add it:
echo 'export PATH="$(go env GOPATH)/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
Or download a release archive from Releases.
The Linux binaries are statically linked, so they run on any distribution regardless of
libc — including musl-based ones like Alpine.
Use
gent init # prepare the repo
gent create "Add rate limiting" # -> E5MVA1
gent set E5MV dispatched "to claude" # any unambiguous prefix will do
gent list
gent show E5MV
gent archive E5MV # off the list, still there
gent list --archived # or --all
gent unarchive E5MV
gent push # send task refs to origin
gent pull # fetch and reconcile
gent doctor # check the config
Task IDs are random, not sequential. Two agents working offline would both reach for
T-001, and because reconciliation keys events by ULID it would fuse the two chains into
one task holding two created events — silently. Random ids make that unreachable, and a
guard in reconciliation refuses the merge if it ever happens anyway.
Listings and messages show the first six characters; commands accept any unambiguous
prefix, the same bargain git makes with commit SHAs. --json always carries the full id.
Every read command takes --json, which is the query layer — pipe it wherever you like:
gent list --json | jq -r '.[] | select(.state=="blocked") | .id'
Working on a task in a container
gent gives a task its own container: a clone of the repository, the task's spec mounted, no
access to your home directory, your credentials, your object store, or your other tasks. What
gets committed comes back as a branch. Your working tree is never touched.
There are two ways in, and which one you want depends on who starts the work.
An agent — gent shell
git config --global a-gent.engine podman # docker, podman, or nerdctl
git config a-gent.image ghcr.io/acme/dev:1 # your toolchain
gent shell E5MV
# branch gent/E5MV/01J8XK (based on 4f2a1c9e8b3d)
# spec /gent/spec.md
# $ claude # you start it, at your own prompt
# $ exit
# -> 3 commits on gent/E5MV/01J8XK
You start the session, which matters for two reasons. An agent's permission system is built to
escalate to a human — auto mode ends a session rather than guessing — so with you there it works
as designed, and nothing has to be pre-approved or skipped. And starting an agent session from a
script is automating access to a service, which a consumer subscription does not cover; opening
a terminal isn't.
You are not tied to the terminal you started it in. ctrl-\ detaches and the session
keeps running; gent attach E5MV's run id brings you back, printing what happened while you
were gone before it reconnects — usually the part you need in order to answer whatever it is
waiting for.
gent attach 01J8XK4M # replay, then reconnect
gent logs 01J8XK4M -f # or just watch, without a keyboard attached
Which raises the question of when to come back. The container installs Claude Code hooks that
report a permission prompt or an idle prompt, and gent reads them:
gent runs
# RUN TASK STATUS ENGINE NEEDS YOU
# 01J8XK4M E5MV running docker (shell) permission: Bash(git push --force)
# 01J8XN2P A71Q running docker (shell) —
gent runs --watch # redraws, and pings you
git config a-gent.notify \
'osascript -e "display notification \"{{reason}}\" with title \"gent {{run}}\""'
a-gent.notify runs whatever you like — {{run}}, {{task}}, {{reason}} and {{branch}}
expand per argument, never through a shell. It fires once per distinct request, not once per
poll. Unset by default: the column is still the signal.
The detach key is ctrl-\ rather than the engine's own ctrl-p ctrl-q, because
ctrl-p is previous-history in readline and in Claude Code's input — you would fight it every
time you recalled a command. Change it with git config a-gent.detach-keys.
Deterministic work — gent run
gent run E5MV -- make test # dispatched; returns immediately
gent runs # live and finished
gent logs 01J8XK4M -f # tail the output
gent stop 01J8XK4M # one clean note, no orphans
gent clean # remove containers and clones, keep branches
For tests, builds, migrations, verifiers — anything nobody needs to watch. If you want an
agent dispatched this way, authenticate it with an ANTHROPIC_API_KEY (see below) and give it
--permission-mode dontAsk with an explicit allowlist, so its blast radius is declared rather
than removed.
What the container stops
The agent cannot reach your home directory, your credentials, your object store, or your other
tasks. Only committed content on one branch is present, and untracked files like .env never
enter, because the clone is made from the gitdir rather than the working tree. It cannot push,
and it cannot write to your repository. What it commits returns as a real branch via a git
bundle; gent lands it and stops, because merging is your decision.
Two things worth knowing. Only commits leave — the spec handed to the agent says so
explicitly, and uncommitted work is discarded when the clone is cleaned. And a container stops an
agent damaging your machine while it works; it says nothing about whether the code it produces
is safe to run, which is why the branch is landed rather than merged.
Keeping the credential out of the container
gent proxy holds the credential so the agent never does. The container gets a placeholder
and a base URL; the proxy swaps in the real value on the way out.
export REAL_KEY=...
gent proxy --upstream https://api.anthropic.com --inject 'x-api-key=${REAL_KEY}'
It prints the exact gent run flags to point a run at it. By default it binds an address
containers can reach and your network cannot — loopback under a VM-based engine, the bridge
gateway on native Linux — because a proxy holding a credential must not be offered to your
whole LAN.
A compromised agent can still use the API while the proxy runs. What it cannot do is walk
away with a credential that still works tomorrow, so a compromise ends when you stop the proxy.
The access log records every call it made.
For Claude Code specifically, the container needs CLAUDE_CODE_SIMPLE=1 and a placeholder
ANTHROPIC_API_KEY: without them the CLI looks for real OAuth credentials, finds none, and
refuses to start.
gent doctor reports the resolved engine, whether it is reachable, and whether it can actually
see your repository — that last one matters on macOS, where the engine runs in a VM and a bind
mount from an unshared path silently arrives empty.
There is no delete
Events are append-only, so nothing is ever removed. What you actually want when a
task is finished with is gent archive, which moves its ref from
refs/a-gent/tasks/ to refs/a-gent/archive/. Nothing is rewritten and no event is
appended: the chain keeps every commit, its state is still whatever its last real
event said, and gent unarchive puts it back exactly as it was. Archiving is a
filing decision, not a state.
An archived task stays addressable by prefix, stays readable, stays writable, and
still owns its id — so create can never hand that id to something else. It also
keeps syncing: gent push sends archived chains into the remote's tasks
namespace, because the wire format has no notion of an archive. That is deliberate.
Archiving is about your listing on this machine, so it does not propagate to other
clones, and a pull will not un-file something you filed away.
Why refs
Three properties fall out of storing task state under refs/a-gent/ rather than as files
in the tree:
- Invisible to agents. The working tree only ever reflects
HEAD. Task data exists in
the object database but is in no checkout.
- No merge conflicts. Nothing is in the tree, so nothing can conflict during a merge.
Concurrent writes are handled by appending events and sorting them, not by git's merge
machinery.
- Optional sharing. Solo: never push the namespace. Team: push it. Same tool, same
data, one config line apart.
A caveat worth knowing up front
A plain git push sends only refs/heads/* and refs/tags/*. Task refs stay local
unless you push them explicitly — that is what gent push is for. Run gent doctor if
you are unsure whether a repo is configured correctly.
GitHub and GitLab accept the namespace on push but do not render it in their web UI. Your
task data is there and invisible in the browser; that is expected, not a failed push.
License
MIT — see LICENSE.