Skip to content

guettli/agentloop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

111 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AgentLoop

You create issues, agents do it.

Uses:

  • forge to access issue trackers (Github, Gitlab, Forgejo, ...)
  • ACPX to access agents (Claude, Codex, Gemini, ...)
  • Golang

We recommend to run AgentLoop in a separate user account.

Structure. Configuration and data are kept apart:

  • loop/ — configuration. Meant to be tracked in git (commit it in your deployment repo so the agents' instructions are versioned and reviewable).
  • loop-data/ — runtime state (git clones, per-issue metadata, the global lock). Not tracked.
loop/                                  # config (git-tracked)
  REPO-NAME/
    config.yaml
    acpx-config                        # selects the agent and its flags
    prompts/
      some-name.md
loop-data/                             # runtime state (not tracked)
  agentloop.lock                       # global lock
  REPO-NAME/
    issues/
      NNNN/                            # git clone for solving issue NNNN
      NNNN.meta/                       # per-issue metadata (survives clone cleanup)
        run-XXXX/                      # one dir per agent run

loop/REPO-NAME/config.yaml:

  • Configures the repo URL (like https://github.com/guettli/agentloop)
  • Configures a global issue selector. Required: Usernames, otherwise everybody could give instructions to agents by creating issues!
  • Global issue exclusion Labels. For example NeedSupervisor
  • Name of head-branch. Defaults to "main".
  • Per-issue rate-limit ceiling. rateLimitWindow (Go duration, default 1h) and rateLimitMax (int >= 1, default 10). See "Rate-Limit" below.

loop-data/REPO-NAME/issues:

  • Directory, for each issue there is a git clone in sub-directory. Example directory 1234 is the git clone for solving issue 1234

loop-data/REPO-NAME/issues/NNNN.meta/:

  • Sibling metadata directory for issue NNNN (NOT inside the clone, so it survives clone cleanup).
  • One file per key-value pair. Used to persist state across cron ticks (each tick is a fresh process), for example the rate-limit start timestamps and the per-issue agent lock.

loop-data/REPO-NAME/issues/NNNN.meta/run-XXXX/:

  • One sub-directory per agent run. XXXX is a timestamp (the run start time), so runs sort chronologically and never collide.
  • acpx is invoked with --format json; its output is written to run-XXXX/output.json.
  • Finish and success/failure are read from that JSON (not from a separate exit-code file).

loop/REPO-NAME/prompts/some-name.md:

  • Markdown prompt for agent.
  • Each prompt declares three labels explicitly, all in the loop/ namespace (agentloop never derives label names — agentloop init generates them for new prompts):
    • InputLabel: selects which issues get this prompt (e.g. loop/plan). Must not be empty. This is the label a human adds to an issue to request the prompt.
    • InProcessLabel: set while the agent runs (e.g. loop/plan-in-process).
    • OutputLabel: set when the agent finished successfully (e.g. loop/plan-done).
  • An issue carries at most ONE loop/* label at a time — that single label is the issue's state. Transitions are moves (see "Label state machine" below).
  • By default only open issues are selected. Can be overwritten.
  • Concurrency level can be defined. By default 1. This is per prompt: the maximum number of agents triggered by this prompt that may run at once. It is enforced by counting the issues currently in this prompt's InProcessLabel. Example: planning prompts (which create no PRs) can run concurrently (>1); coding prompts should stay at 1 so agents do not produce conflicting PRs / merge conflicts.
  • timeout (front-matter, Go duration string like 30m or 2h) bounds how long a single agent run may stay alive. Defaults to 30m. An agent older than its timeout is killed and the issue moved to NeedSupervisor (see for-each-0 below), so a hung-but-alive agent cannot wedge the per-prompt concurrency slot forever.
  • maxRestarts (front-matter, integer) caps how many times a crashed acpx process may be relaunched against the same acpx session before the issue is escalated to NeedSupervisor. Defaults to 3; set to 0 to disable restart entirely (a crash escalates immediately). See "Locking and crash recovery" below for what counts as a crash.
  • ciGateLabel (front-matter, optional loop/* label) holds a successful agent run pending CI on the PR it opened instead of advancing it to OutputLabel. See "CI gate" below.
  • maxCIFixes (front-matter, integer) caps how many times the same acpx session may be resumed to fix a CI failure on its PR before the issue is escalated to NeedSupervisor. Defaults to 3; set to 0 to escalate on the first CI failure. Only consulted when ciGateLabel is set.

Loop

Every N minutes a Cron Job (crontab -e on Linux) runs one agentloop tick. Use a prebuilt ./agentloop binary, not go run — see "Running" for why.

It checks if there is still one agentloop running (global lock in ./loop-data). If yes, then terminates.

for every loop/*/config.yaml file it starts a goroutine.

For each config:

for-each-0 (reconcile): The set of running agents and the set of issues carrying a InProcessLabel (loop/<prompt>-in-process) can drift out of sync (a crashed agent leaves a stale label; a label removed by hand leaves an orphan agent). agentloop reconciles this on every invocation before doing anything else:

  • An issue in InProcessLabel whose per-issue lock is stale (PID gone) is treated by for-each-2 (finished, or failed → NeedSupervisor).
  • A running agent whose issue no longer carries the matching InProcessLabel is flagged (NeedSupervisor) — state was tampered with.
  • A running agent whose run started longer than its prompt's timeout ago (default 30m) is treated as hung: agentloop sends SIGTERM and moves the issue to NeedSupervisor with an explanatory comment. This bounds how long a single agent may wedge the per-prompt concurrency slot. This makes the per-prompt concurrency count (number of issues in InProcessLabel) trustworthy.

for-each-1: Check if there are matching (open) issues (carrying the prompt's InputLabel). If yes, then check that the prompt's concurrency limit is not reached and that no agent is active for that issue (should not happen in the happy-path). then ensure clone in loop-data/REPO-NAME/issues/NNNN exists and is on the configured head-branch. The directory might already exist. Move the issue to the InProcessLabel (remove the InputLabel, add the InProcessLabel). Start acpx detached in its own session (like nohup, so the agent keeps running after the loop process exits):

  • The agent and its flags come from the per-repo acpx-config file. Because the agent runs non-interactively (no TTY), the acpx-config must auto-approve tool calls (globalArgs: [--approve-all]); otherwise permission prompts default to "deny" and the agent can do nothing.
  • The prompt comes from the prompt file (--file); the issue title, body, number and URL are passed to the agent in addition to the prompt.
  • --format json is used; output is written to loop-data/REPO-NAME/issues/NNNN.meta/run-XXXX/output.json.
  • acpx is invoked in its persistent-session form (<agent> -s <session>), not as one-shot <agent> exec, so a crashed process can be relaunched against the same session in a later tick (acpx transparently reconnects via session/resume or session/load). agentloop runs <agent> sessions ensure --name <session> synchronously before each launch so the session record exists when the prompt is dispatched; no extra setup is needed (acpx maintains ~/.acpx/sessions/ itself). The session name is agentloop-<repo>-issue-<NNNN> — stable per issue (so a restart resumes the same conversation) and unique per repo+issue.

After starting, watch the subprocess for 5 seconds. If it crashes within that window, remove the InProcessLabel and set the NeedSupervisor label so a human can look at it.

for-each-2: Check if agents are finished. An agent is finished when its per-issue lock is stale (the acpx PID is gone) — only then is the run-XXXX/output.json complete and safe to parse (reading it while the agent is still running could yield partial JSON). If finished and successful, add the agent's final output (markdown) as a comment to the issue and move it to the OutputLabel (remove the InProcessLabel, add the OutputLabel). If it failed, remove the InProcessLabel and add NeedSupervisor instead.

The agent works inside the clone and creates a Pull Request itself (via its own tools) when the prompt is a coding prompt; the PR references the issue. Planning prompts create no PR — their result is only the comment.

The agent only ever reads the issue description (title + body), never comments. This keeps the trust boundary at the issue author: a non-allowlisted user cannot inject instructions by commenting.

Locking and crash recovery

There are two kinds of lock, both implemented the same way: a lockfile that stores the PID of the owning process plus a match string expected in that process's command line (/proc/<pid>/cmdline).

  • Global lock (in ./loop-data): ensures only one agentloop runs at a time. Match = the agentloop executable name.
  • Per-issue agent lock (in loop-data/REPO-NAME/issues/NNNN.meta/): the "is an agent active for this issue" check. Match = the unique run prompt path, which appears in the agent's argv (acpx … --file <promptPath>).

A lock is considered stale (and may be reclaimed) when no process with that PID exists, or the process with that PID has a command line that no longer contains the match string (the PID was recycled by an unrelated process). A command-line match is used instead of the executable name because acpx is an interpreted CLI — the process is node, not acpx — and because the unique prompt path rules out a false "still alive" after PID recycling. This way a kill -9 or crash does not block future cron ticks forever.

Restart after a mid-run crash

A stale per-issue lock whose output.json looks truncated — at least one JSON-RPC message was emitted, but no terminal session/prompt result and no error — is the signature of the acpx process dying mid-run (host reboot, OOM, kill -9). Because every run dispatches to a stable per-issue acpx session, the loop runner relaunches a fresh acpx process against the same session in the next tick rather than escalating: acpx then respawns the agent and reconnects via session/resume (or session/load), and a short comment on the issue names the restart number. The issue stays in its InProcessLabel; the per-issue lock is rewritten with the new PID; a new run-XXXX/ directory captures the resumed run's output.

The restart is bounded by:

  • maxRestarts (per-prompt front-matter, default 3) — once exceeded, the issue is escalated to NeedSupervisor.
  • The per-issue rate-limit (10 starts/hour) — every restart counts as a start, so a flapping agent cannot loop forever within an hour.
  • The watchdog (timeout) — a session that never finishes is still killed and escalated.

A clean failure (the agent emitted an error message, or finished with a non-success stopReason) is not a crash: relaunching against the same session would just hit the same error, so those still escalate immediately. Runs started before this feature have no persisted session and also escalate on a crash (acpx has no session to resume).

CI gate: hold a successful run until CI passes

A coding prompt may declare a ciGateLabel in its front-matter (e.g. loop/code-ci-pending). When set, a successful agent run that would otherwise transition to OutputLabel is instead held in ciGateLabel. agentloop then polls CI on the agent's open PR each tick:

  • CI pass → the issue advances to OutputLabel and the run is fully cleaned up.
  • CI pending → the issue stays in ciGateLabel; the next tick re-checks.
  • CI failure → the same acpx session is resumed with a CI-fix prompt and the issue moves back to InProcessLabel for the normal lifecycle to handle the resumed run. The clone directory and session name survive the hold, so the resumed agent has its prior context.

The CI-fix loop is bounded by maxCIFixes (per-prompt front-matter, default 3). Once exceeded — or when CI fails on a legacy held issue with no recorded session — the issue is escalated to NeedSupervisor. The CI-fix counter is independent from maxRestarts (process-crash budget); the two failure modes have their own budgets and do not deplete each other. A non-default route (e.g. the agent emitting <!-- loop-route: escalate -->) bypasses the CI gate, since the agent has explicitly redirected the issue.

PR discovery uses the agent's branch naming convention (issue-<N>-…): agentloop looks for the first open PR whose head branch starts with issue-<N>-. If no PR has been opened yet, the gate keeps waiting. CI status is the combined commit-status conclusion across all contexts on the PR's head commit — any failure wins, then any pending, otherwise success.

Clone lifecycle

When (re)using a clone directory: remove unknown / untracked files and reset to the configured head-branch (default "main"), so every agent starts from a clean, known state.

After cloning, agentloop installs a per-clone credential.helper that calls back into agentloop (agentloop git-credential) to look up the forge token at push time — using the same resolution as forge (GITHUB_TOKEN / GITLAB_TOKEN / GITEA_TOKEN / BITBUCKET_TOKEN, then FORGE_TOKEN, then ~/.config/forge/config). This makes git push work non-interactively so a coding agent can open a PR. The literal token is never written to disk — only the helper invocation is stored in .git/config — so rotating the token needs no rewrite of any clone. If no token is found the helper emits nothing and the push fails fast rather than prompting (cron is non-interactive).

SSH override: operators who prefer SSH can set a global rewrite, e.g. git config --global url."git@github.com:".insteadOf https://github.com/, and provide a key; the credential helper is then dormant.

If the clone contains a .pre-commit-config.yaml, agentloop runs pre-commit install in it so the configured hooks fire on commits the agent makes — the agent runs the same checks a developer would see locally. The step is skipped when the file is missing; pre-commit must be on PATH when it is present (the nix dev shell includes it).

Cleanup (to bound disk usage) runs when an issue reaches a terminal state for a tick — a successful finish (move to the OutputLabel) or a failure (move to NeedSupervisor):

  • The per-issue clone directory loop-data/REPO/issues/NNNN/ is removed. It is not removed while the agent is still running (per-issue lock live). A re-trigger (moving the issue back to the InputLabel) recreates the clone on the next tick via clone.Ensure.
  • The per-issue meta directory NNNN.meta/ is kept (small files: lock, k/v state, run history).
  • Old run directories NNNN.meta/run-XXXX/ are pruned: a run is kept iff it started within the rate-limit window (plus a margin) or it is among the most recent few runs (for post-mortem debugging). This bounds disk on a frequently re-triggered issue.

Cleanup failures are logged and never flip a finished run's outcome.

Concurrency and merge conflicts

agentloop does not try to prevent merge conflicts for you — it gives you the knobs and you decide.

Per-prompt concurrency only serializes agents within one prompt. Two different coding prompts (or the same prompt across repos) can still run agents at the same time and open PRs against the same head-branch, which can conflict. Also, while a coding agent works in its clone the head-branch may move (another PR merges), so the clone can be behind by the time the PR is opened.

It is up to the user to configure this sensibly, for example:

  • Keep coding prompts at concurrency 1, and avoid having several coding prompts active at once if they touch overlapping code.
  • Accept that the agent's PR may need a rebase, and let normal PR review / CI catch conflicts.

Label state machine

Every issue handled by agentloop carries at most ONE loop/* label at a time. That single label is the issue's state. agentloop moves it through the states:

loop/plan              (InputLabel — issue is selected, waiting for an agent)
   │  agent starts → move
loop/plan-in-process  (InProcessLabel — an agent is working)
   │  success → move                     │  failure → move
loop/plan-done         (OutputLabel)     │  (remove loop/*, add NeedSupervisor)

A move = remove the current loop/* label, then add the next one.

When an issue reaches the OutputLabel it no longer carries the InputLabel, so it stops matching and is not picked up again. This is the terminal state.

Re-run: to run an issue again, an allowlisted user moves it back to the InputLabel (loop/plan). That is the only intended re-trigger.

These label names are the recommended default; they are configurable per prompt.

Example: plan, code and merge prompts

Three prompts in one repo, each with its own label triple:

Prompt InputLabel InProcessLabel OutputLabel Concurrency
plan loop/plan loop/plan-in-process loop/plan-done >1 (no PRs)
code loop/code loop/code-in-process loop/code-done 1 (one PR at a time)
merge loop/merge loop/merge-in-process loop/merge-done 1 (one merge at a time)

A human adds loop/plan to an issue. agentloop runs the plan agent → posts the plan as a comment → moves the issue to loop/plan-done. A human reviews the plan, then adds loop/code to the same issue (moving it out of loop/plan-done). agentloop runs the code agent, which opens a PR. The code prompt declares routes: { merge: loop/merge } and the default code prompt instructs the agent to emit <!-- loop-route: merge -->, so the issue moves to loop/merge. The merge agent then rebases the PR branch onto the head branch (typically main) if it is behind, waits for CI, fixes failures on the PR branch until CI is green, and merges the PR — moving the issue to loop/merge-done. If CI cannot be made green it emits <!-- loop-route: escalate --> and the issue is moved to NeedSupervisor for a human to look at. Because each prompt's InputLabel is distinct, the issue maps to exactly one prompt at each step.

Auto-merge is opt-in per repo

Auto-merge is not a separate config flag — it is configured by which prompts the operator commits. There is no second source of truth to keep in sync.

  • Auto-merge on (default scaffold): keep merge.md in prompts/ and the routes: { merge: loop/merge } line in code.md. The code agent hands the PR off to the merge agent, which rebases, fixes CI and merges.
  • Auto-merge off: delete merge.md and drop the routes: { merge: ... } entry from code.md. The code prompt then terminates at loop/code-done and a human reviews / merges the PR by hand.

Fork merge.md if you want a different strategy. Common variants:

  • Strict rebase (default in the scaffold): linear history, force-push the PR branch when main moves.
  • Merge-commit style: replace the rebase step with "merge the head branch into the PR branch" so a merge commit is created instead of rewriting history.
  • Forge-side auto-merge (e.g. gh pr merge --auto): hand off to the forge's own auto-merge feature instead of polling. This frees the per-prompt concurrency slot while CI runs but is forge-specific (GitHub-only on gh) and shifts CI-failure recovery off the agent — so the default keeps the agent in the loop. Concurrency for merge stays at 1 either way.

Routing: let a prompt pick the next label itself

A prompt can declare a routes: map in its front-matter to skip the default OutputLabel and send the issue to a different label based on a token the agent emits. The token is parsed from a trailing HTML-comment marker in the agent's final message:

<the agent's normal markdown reply>

<!-- loop-route: code-ready -->

The marker is invisible when the comment is rendered on the issue. The <token> (letters, digits, -, _) is looked up in the prompt's routes::

---
inputLabel: loop/plan
inProcessLabel: loop/plan-in-process
outputLabel: loop/plan-done          # default when no marker is emitted
routes:
  code-ready: loop/code              # small/scoped → hand off to the code prompt
  needs-design: loop/plan            # ambiguous → run plan again
  escalate: NeedSupervisor           # send to a human
---

Semantics:

  • No marker (or empty token) → the issue moves to OutputLabel (today's behaviour, unchanged).
  • Marker token matches a key in routes: → the issue moves to that target.
  • Marker token does not match any key → treated as a failure (NeedSupervisor), because the agent emitted intent the prompt does not declare. agentloop never silently demotes an unknown route to the default.

Validation at loop load:

  • Each routes: value must be either NeedSupervisor or a label in the loop/ namespace.
  • Each routes: value must be a label managed by some prompt in the same loop (i.e. an inputLabel or outputLabel) — agentloop only creates labels it knows; an unknown label would not exist on the tracker.
  • A routes: value must not be another prompt's inProcessLabel: routing into a "processing" state with no live agent would jam the issue.

A routed handoff is just a label move — the next tick picks the issue up under its new label like any other transition. (Within one tick, if the target prompt is iterated after the source prompt in prompts/ order, the handoff may also fire immediately.) The per-issue rate-limit (10 starts/hour by default) still throttles tight A→B→A loops.

Uniqueness is enforced by agentloop, not the tracker

loop/FOO must be unique per issue: an issue must not carry loop/a and loop/b at once. Issue trackers do not reliably enforce this — GitHub has no mutually-exclusive label groups at all, and GitLab only enforces it for scoped labels (loop::a), with differing behaviour across Gitea/Forgejo. Therefore agentloop enforces it itself: every transition removes any other loop/* label before adding the target one.

If an issue is found carrying more than one loop/* label (e.g. set by hand), agentloop tries to resolve the conflict before falling back to NeedSupervisor:

  • An OutputLabel (e.g. loop/plan-done) is a terminal marker for a past prompt run, not a state intent. When the only ambiguity is leftover OutputLabel(s), agentloop drops them and keeps the live label (the InputLabel or InProcessLabel that names the issue's actual state). This handles the common case of a human adding the next prompt's InputLabel (e.g. loop/code) without first removing the previous prompt's OutputLabel (e.g. loop/plan-done). agentloop posts a short comment naming the labels it removed.
  • Anything else (two InputLabels, InputLabel + InProcessLabel, etc.) is genuinely ambiguous and the issue is moved to NeedSupervisor.

Validation

For one loop/REPO-NAME directory, all InputLabels must be unique across prompts (so one issue maps to exactly one prompt). InputLabel, InProcessLabel and OutputLabel are three distinct labels per prompt, so a processing issue can never match its own (or another prompt's) InputLabel.

Because the default names are derived (loop/<prompt> plus reserved suffixes -in-process and -done), agentloop validates at startup that no prompt's InputLabel collides with another prompt's InProcessLabel or OutputLabel (e.g. a prompt named plan-done would collide with plan's OutputLabel). agentloop knows the configured label names, so it matches issue labels against those exactly rather than parsing the suffix blindly.

Rate-Limit

Avoid continuous starting of agents. For one issue, do not start an agent more often than 10 times per hour by default.

The limit is configurable per loop in config.yaml:

  • rateLimitWindow (Go duration string, e.g. 1h, 30m, 2h) — defaults to 1h.
  • rateLimitMax (integer >= 1) — defaults to 10.

Both keys are optional; omitting them keeps the historical 10 starts/hour ceiling. Run-directory cleanup (see "Clone lifecycle") prunes with a margin larger than the configured window, so rate-limit accounting stays exact after a prune.

The start timestamps are persisted in loop-data/REPO-NAME/issues/NNNN.meta/ (one file per key-value pair), so the limit is enforced even though cron starts a fresh process on every tick.

Failure handling

If an agent fails (crashes early, errors, or produces unusable output), remove its loop/* label and set the NeedSupervisor label. Issues with NeedSupervisor are excluded globally (see config.yaml exclusion labels), so no new agent is started for them until a human intervenes. A human re-triggers by moving the issue back to the InputLabel.

How to create the labels in the forge?

agentloop creates the required issue labels in the forge if they are missing: each prompt's InputLabel, InProcessLabel and OutputLabel (all loop/*), plus NeedSupervisor. This happens per repo (e.g. on first run / startup for that config), so the operator does not have to create them by hand.

This relies on forge being able to create labels, set labels on an issue, and post comments. Confirmed: forge supports all of these via its Go interface (Labels().Create, Issues().Update{Labels}, Issues().CreateComment) across GitHub/GitLab/Gitea/Bitbucket — no fork or CLI fallback needed.

The loop/* label "move" uses Issues().Update with the full label slice (forge replaces the whole set in one call). To avoid clobbering other labels, agentloop reads the issue's current labels first and changes only the loop/* one.

Note: the Usernames allowlist must be filtered client-side (on the issue author) — only GitLab filters by author server-side; GitHub and Gitea ignore it.

Running

Development uses a nix flake dev shell and Task:

./run task check          # gofmt + go vet + golangci-lint + go test
./run task build          # build ./...
./run task install-acpx   # pnpm install --frozen-lockfile (materializes node_modules/.bin/acpx)

(./run loads the flake via direnv; inside the shell you can call task directly.)

acpx is pinned in package.json and pnpm-lock.yaml. task install-acpx (or pnpm install --frozen-lockfile) materializes it at node_modules/.bin/acpx, which is what agentloop launches — no global npm install -g acpx needed. To bump acpx, edit package.json and run ./run pnpm install to refresh the lockfile, then commit both files.

Run agentloop init <repo-url> to scaffold a new loop. It creates the git-tracked loop/ config (with plan and code prompts) and the loop-data/ runtime directory. A complete example is in example/loop/:

loop/                        # commit this in your deployment repo
  agentloop/
    config.yaml              # repoURL, usernames, exclusionLabels, headBranch
    acpx-config              # agent + globalArgs: [--approve-all] (required)
    prompts/
      plan.md                # front-matter: inputLabel, inProcessLabel, outputLabel, ...
      code.md
loop-data/                   # runtime: per-issue clones + NNNN.meta/, global lock

agentloop authenticates to the forge the same way the forge CLI does: a token from a domain-specific environment variable (GITHUB_TOKEN/GH_TOKEN, GITLAB_TOKEN, GITEA_TOKEN, BITBUCKET_TOKEN), a FORGE_TOKEN fallback, or forge's ~/.config/forge/config. The token must be able to create labels, set labels on issues and post comments. For coding prompts that open PRs the token additionally needs push scope (repo / write_repository / equivalent); read-only access is enough for prompts that only plan or comment. The merge prompt also force-pushes the PR branch when it rebases onto the head branch — the same push scope is sufficient.

The agent (the one named in acpx-config) authenticates with its own credentials, stored by the agent CLI under the invoking user's HOME (the exact path depends on the agent). agentloop does not manage these — log the agent in once as the agentloop user before the first cron tick, so its config exists in $HOME when cron fires. The cron wrapper preserves HOME, so the detached agent finds the same credentials it would in an interactive shell. If you install your own crontab line instead of using the wrapper, make sure HOME is set to the agentloop user's home directory; cron's default environment does not guarantee it.

Run it periodically from cron; each invocation processes one tick and exits. Use a prebuilt binary, not go run: a coding agent is launched detached and calls back into agentloop git-credential to authenticate its push after the tick has exited, so the binary must persist at a fixed path (a go run temp binary is deleted on exit).

* * * * * GITHUB_TOKEN=... cd /home/agentloop && ./agentloop   # build once: go build -o agentloop .

(-loop and -loop-data default to ./loop and ./loop-data.) The global lock in loop-data/agentloop.lock ensures overlapping invocations exit immediately, so a short cron interval is safe.

When agentloop runs from a nix flake dev shell (as here), cron's minimal environment lacks the flake tools and direnv. contrib/agentloop-cron.sh is a ready-made wrapper that reconstructs PATH, loads the flake and .env via ./run, runs pnpm install --frozen-lockfile so acpx matches the lockfile, prepends ./node_modules/.bin to PATH, and logs each tick to loop-data/cron.log. Install it for a five-minute interval:

*/5 * * * * /path/to/agentloop/contrib/agentloop-cron.sh

Tick summary

Each loop emits a one-line structured summary at the end of every tick — a heartbeat that makes cron.log greppable:

tick loop=agentloop input.loop/plan=2 input.loop/code=0 started=1 finished_ok=1 finished_fail=0 orphans=0 hung=0 quarantined=0 auto_cleaned=0

Keys:

  • loop=<name>: the loop directory name.
  • input.<InputLabel>=<n>: open issues seen carrying this prompt's InputLabel.
  • started=<n>: agents successfully launched this tick.
  • finished_ok / finished_fail: agents whose run completed this tick, by outcome.
  • orphans: issues moved to NeedSupervisor because their InProcessLabel was removed externally.
  • hung: issues moved to NeedSupervisor by the watchdog (run exceeded the prompt's timeout).
  • quarantined: multi-loop/*-label issues moved to NeedSupervisor (genuinely ambiguous state).
  • auto_cleaned: multi-loop/*-label issues whose only ambiguity was a stale OutputLabel, fixed in place.

Inspecting state: agentloop status

agentloop status prints a read-only snapshot of every loop, derived entirely from loop-data/. It does not contact the forge, so it works offline and never modifies anything — useful for operators and for debugging stuck agents. For each issue with a meta directory it reports:

  • Whether the per-issue lock is live or stale (PID + match string).
  • Which prompt owns the live lock (if recorded).
  • The latest run directory and how long ago it started.
  • The parsed result of that run (success / route / stopReason / error).
  • The per-issue rate-limit count over the last hour (N/10).

The global lock state is reported once at the top.

Web UI: agentloop serve + agentloop passkey

agentloop serve runs a small HTTP server that renders the same view the status command prints, plus per-issue run details. It is independent of the cron tick: it only reads loop/ and loop-data/, never moves labels.

agentloop serve -addr 127.0.0.1:8080 -rp-id localhost

Pages use Bootstrap and htmx loaded from public CDNs (no JS build step). The loop view auto-refreshes its issues table every 15 seconds via an htmx partial.

Authentication: passkeys, approved out-of-band

Sign-in uses WebAuthn passkeys (FIDO2). Registration is split across two steps to keep an attacker from creating an account just because they can reach the server:

  1. The user opens /register, picks a username, and creates a passkey in their browser. The server stores it under loop-data/webui/pending/<username>.json — it cannot log in yet.

  2. The operator approves the registration on the same machine:

    agentloop passkey list
    agentloop passkey approve <username>
    

    That moves the record to loop-data/webui/users/<username>.json.

  3. The user opens /login and signs in with the passkey.

Sub-commands:

Command Effect
agentloop passkey list Show pending registrations and approved users
agentloop passkey approve <name> Promote a pending registration to an approved user
agentloop passkey reject <name> Discard a pending registration
agentloop passkey delete <name> Remove an approved user (rotate credentials)

-loop-data defaults to ./loop-data, matching the cron command.

Flags

Flag Default Notes
-addr 127.0.0.1:8080 TCP listen address
-rp-id localhost WebAuthn Relying Party ID (effective domain, no scheme)
-rp-name AgentLoop Shown by the platform's passkey UI
-origins derived from -addr Comma-separated list of allowed browser origins
-loop loop Config directory
-loop-data loop-data Runtime data directory

For anything other than localhost, run the server behind a TLS-terminating reverse proxy: WebAuthn refuses non-HTTPS origins (other than localhost). Set -rp-id to the public hostname and -origins to the HTTPS URL.

About

Agent Loop: Run AI Agents in Kubernetes Pods

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors