Skip to content
This text was generated by an LLM (how cgwalters uses LLMs). cgwalters has not reviewed it yet.

Dispatching an operator task

The shipped dispatch caller runs the scripted agent by default: no model, inference service or secret is needed when the target is your own public repository. Its sessions ignore task text; they test policy, sandbox, hand-back, check and apply, not agent quality.

Ten-minute scripted trial

Copy these files to the same paths in your repository:

The runner's configuration, hosted.toml, is read from this repository at the pinned commit; there is no copy to keep. A caller that kept an edited copy sets AGENT_CONFIG to its path.

In dispatch.yml, replace uses: ./.github/workflows/agentic-job.yml with uses: cgwalters-forge/agentic-job/.github/workflows/agentic-job.yml@COMMIT, where COMMIT is the full reviewed SHA of the source you copied, including the review-item and fake-profile inputs. A local reusable call here pins the workflow, binary and actions to this checkout's commit: it cannot silently run an older release. In dispatch.toml, replace repos with your exact public repository name. Commit the files to your protected default branch and allow the pinned actions and reusable workflow in Actions settings. implement also needs APPLY_ENVIRONMENT (below): apply opens its pull request from a fork owned by the account whose PAT that is (or, for a target that account owns and that accepts it, from a branch of the target).

Leave the other repository variables unset for this trial. Start with an existing issue:

Terminal window
gh workflow run dispatch.yml -f repo=OWNER/REPO -f item=1 \
-f kind=triage -f task='Test the dispatch wiring without a model'

triage and research post a canned comment on that issue and leave the tree clean. implement writes DISPATCH-TRIAL.md and proposes one draft PR from the applying account's fork. review takes an open pull request number targeting the configured base (main by default), not an issue number. Both same-repository and fork pull requests are accepted. It posts a clearly scripted verdict naming the pinned head. This is not an approval or permission to merge. Delete trial comments, and branches in the applying account's fork, when finished.

Dispatch review remains opt-in (kind=review), analysis-only and bounded to one verdict comment or noop on the selected PR. The resolver reads the PR from the configured target repository and pins its head SHA. The sandbox fetches that SHA from the target repository without forge credentials, never from an author-supplied fork URL or branch name. The base supplies trusted instructions; the head remains untrusted data. No job permissions or token access change. This does not enable fork end-to-end CI or make a verdict merge authorization. The real reviewer first tries the change as a user would, from the docs and --help alone, and reports what it tried, as in the verdict. It then reads the diff and surrounding code and reports findings; it does not run existing test suites, formatters or linters to confirm they pass, or look up or report CI results. It may build and run code to test whether a specific concern holds, such as a function failing under a condition no test covers, and reports what it ran and what it showed for that concern. It omits the Verification: paragraph. CI remains a separate required check, combined with review by the merge gate, not by the reviewer.

fix takes the number of an open pull request of the target, whoever opened it. Its run starts from that pull request's branch at the head it had when dispatched, and its change is pushed there, to the pull request's head repository, ordinarily as one commit on top without force. Optional bounded series replacement allows a rebase onto the policy snapshot or the base branch's current head resolved by trusted apply, using an exact head lease. A pull request whose head moved meanwhile is refused, and the run has to be dispatched again. Apply refuses, before it pushes, a head repository the applying account cannot push to (saying so), one that is neither a fork of the target nor the target, the head repository's default branch or a protected branch, and the .github exemption anywhere but that account's own fork, whose Actions it turns off. A branch of any other head repository, the target itself or someone else's fork, is refused unless that repository's default branch has the file .github/agentic-job/accept-owner-branches naming it: a push there runs its push and pull_request workflows on agent-written code with their secrets, write token and OIDC (#340, #430). Like implement it needs APPLY_ENVIRONMENT. It is off in the shipped bounds; to enable it for a target, add to dispatch.toml

push_to_pull_request_branch = { max = 1, branches = ["dispatch/**"] }

with globs of the branches a fix may push to; see pushing to a pull request's branch. The scripted session is implement's.

A target with issues turned off can take its item from another repository: -f item-repo=OWNER/TRACKER with an issue number there, for implement, triage and research. The issue's text, the status comment and a triage or research comment are that repository's; a pull request is the target's. Policy refuses an item repository the bounds' repos do not list, and it needs APPLY_ENVIRONMENT like any other repository.

Apply refuses a target the applying account owns, as it has no fork of it, unless the target's default branch has the file .github/agentic-job/accept-owner-branches naming it; its CI then runs the agent's branch with its secrets. A fix to such a pull request is pushed to its branch under the same file.

Every run's result can be found from the issue it was dispatched for. A draft PR gets a Closes #N line for an issue in the output repository, or Refs OWNER/REPO#N for an item elsewhere. Apply derives the line from the repository and item in policy (its issue input, which a copy of dispatch.yml has to keep) and appends it only if absent; cross-repository references close nothing on merge. Fix runs leave existing PR bodies alone. Its title is the first line of the agent's summary, cut between words to 100 characters. For non-review runs in the calling repository, notify and conclude upsert one status comment there, linking the current run and its result across runs and retries. Cross-repository targets receive no job-token notification. The result's URL is the result-url output of dispatch.yml (and of agentic-job.yml and apply.yml) and is in the run summary. A run that hands back only noop, missing_tool or missing_data has no result. If these are missing_data, with at most noop and no change, the run is blocked: it stays green with a notice, and its blocked output is true. If they include missing_data beside anything else, apply fails. Either way conclude posts the sanitized reason and run link in the bounded status comment even with notifications off (cross-repository items need the caller's SAFE_OUTPUTS_PAT). Otherwise the run stays green and status remains opt-in. The run summary also holds the reason. Reviews normally have no status comment, except for missing-data runs.

An agent whose change does only part of the issue starts a line of the PR body with Part of #N (or Refs #N), and apply then adds no closing line. Partial implement results without one still get the primary closing reference, but remain drafts: review must resolve the unfinished work before merging, or remove the closing line if merging only an incremental part.

For a batched implement run, put the primary issue in item and name the small sibling issues and their requested work explicitly in task. The primary item routes admission, status and the generated reference line; it is not an allowlist of issues the PR may address. No extra input is needed: create_pull_request bounds count requests, not issue references in their bodies. One PR may therefore retain several agent-supplied Closes #N lines for completed siblings (use Refs OWNER/REPO#N outside the target). Check accepts those lines and apply preserves them while ensuring the primary reference is present; that reference does not narrow the batch. Review covers the whole PR diff, including the sibling work, rather than only the primary item. The batch still shares one run's existing output, patch and budget limits; this does not authorize extra comments or issue actions. Closing references are agent-authored text, not trusted evidence of scope or permission, and take effect only when the draft PR is merged.

Runs of one kind for one item run one at a time, and only one waits: a dispatch made while another for the same kind and item is pending cancels the pending one. To run several of one kind on one item, start each after the last has started. Runs of any kind on one item also share their status jobs, one at a time with one waiting, so more than two at once can cancel a waiting one and fail its run. CI's reusable call (scripted: true, not the trial above) is also grouped by the caller's run and posts no status comment, so separate CI runs do not cancel each other.

All targets must use main and fit the committed bounds. Do not use globs. Protected documentation is refused by default; to deliberately permit a file, use [unprotected_files] in the bounds file as shown in its commented example. See output validation.

Switch to a real agent

Set operator-controlled repository variables (not dispatch inputs):

  • AGENT: opencode or claude (fake is the default).
  • AGENT_MODEL: the model name the selected agent and broker support.
  • AGENT_RUNNER: JSON runner labels, for example ["self-hosted","agentic-job"], for a disposable proxy-reachable image.
  • INFERENCE_URL: the reachable broker URL, for example http://BROKER:PORT.
  • INFERENCE_AUDIENCE: the broker's configured GitHub OIDC audience.
  • Not, for now, a provider's API key: preflight refuses INFERENCE_REGISTER api-key and INFERENCE_PROVIDER github (GitHub's billing, which sets api-key), and the caller passes no INFERENCE_API_KEY on. The reusable workflow's agent job runs the agent as the runner's user, which could read the key from run, so it would refuse the run anyway, after policy and a build (inference).
  • AGENT_CONFIG: the caller's runner configuration path if not using hosted Ubuntu 26.04 or giving your targets a toolchain. The default is this repository's hosted.toml at the pinned commit; a scripted trial always uses that and ignores AGENT_CONFIG. Verify sandbox probes on your own image rather than copying hosted-image socket exceptions.
  • AGENT_CONFIG_REPO: optional public OWNER/NAME repository holding opencode's or Vibe's configuration; AGENT_CONFIG_PATH selects its directory (empty for the root). These are used only with AGENT=opencode or AGENT=vibe, not in the scripted trial; a directory shared by both agents needs both opencode.json and config.toml. They select agent configuration, not the runner TOML named by AGENT_CONFIG.

Preflight names all missing model, runner, URL and audience variables at once, before policy/build or agent startup. It rejects unknown agents, registrations other than github-oidc, and the provider github. With claude, opencode or vibe, implement installs the skills and its task names the skill ("Use the implement skill") in place of the procedure. Real agents have a 150-request cap; scripted sessions have no inference and max-requests: 0. Real agents do not install scripted sessions. Hosted Ubuntu alone cannot reach a private broker. The shipped dispatch caller calls the complete reusable workflow, which has no network-join inputs. To join a private network on a hosted runner, adapt the caller to the composed form: call policy.yml, write your own agent job, then call check.yml and apply.yml, all from the same reviewed commit. In the agent job, run prepare, your chosen network-join step, secure-host, then run, as example-compose.yml marks. The join action and its credentials are yours, not a built-in vendor integration. Pin that action by commit, leave no credential readable by the sandbox user, and keep the agent job's repository permissions read only. For a tailnet join, disable its DNS: sandbox setup rejects tailnet resolvers when the egress proxy is enabled. This composed form changes what the broker has to admit (inference admission). Or provide disposable runners with passwordless sudo initially and systemd 257+, Node and apt-get or dnf; see runner requirements.

Configure the praxis-credential-broker to verify GitHub identity tokens with the chosen audience and admit the caller repository. Confirm registration and per-run budgets before real tasks. No model API key goes on the runner. Narrowing broker admission to a specific caller remains optional hardening tracked in tracker#452.

For implement, and for cross-repository targets, set APPLY_ENVIRONMENT to a caller environment restricted to the protected default branch, and store its sole secret SAFE_OUTPUTS_PAT there, as described in credential setup. This is gh-aw's safe-outputs.github-token for cross-repository safe outputs; see the mapping and job-token limits. Use a classic PAT with repo and workflow scopes of a dedicated bot account that does not own the target and has write access to nothing but its own forks: such a PAT writes to every repository its account can, and still comments, opens issues and forks on any public repository, so the account is what bounds it. Apply pushes an implement branch only to that account's fork of the target (unless the target is the account's own and accepts its branches), which it makes if missing, and turns off Actions there before it pushes (pull requests come from a fork). Preflight names a missing APPLY_ENVIRONMENT too. Only apply enters the environment; an empty secret falls back to the job token, which cannot write cross-repository, fork or open pull requests. The shipped caller explicitly forwards SAFE_OUTPUTS_PAT whether or not an environment is configured; scripted CI forwards no credential value. If you call dispatch.yml as a reusable workflow, explicitly pass SAFE_OUTPUTS_PAT: ${{ secrets.SAFE_OUTPUTS_PAT }} to that wrapper too. A repository/organization secret also works; environment protection rules still gate apply, and the environment's same-name secret takes precedence. Never use secrets: inherit. For same-repository triage, research and review the apply job uses its job token instead; no environment is needed.

Giving runs a toolchain

A sandbox has only what the runner image and the runner configuration (AGENT_CONFIG, by default this repository's hosted.toml, which gives only this repository a toolchain) put there. A caller whose targets need one keeps a copy with its own entries and names it in AGENT_CONFIG. [setup] packages is installed for every run; [setup.repo-packages] adds a list for the target repository, or the default list for a target with none:

[setup.repo-packages]
default = []
"OWNER/REPO" = ["cargo"]
"OWNER/OTHER" = ["cargo", "golang-go"]

The policy job picks the entry (agentic-job config --repo) and prints the resulting setup.packages, held to the same checks as every package name; sandbox setup installs them with apt or dnf, as root and from the distribution's signed archive, before it locks the host. Names ignore case, as in the bounds. The list applies to every profile of a run on that target, so triage and research pay its install time too, and so do this repository's own end-to-end jobs, which share hosted.toml. The secure-host action has no target and gets only the default list.

The list comes only from the runner configuration, the caller's file or this repository's at the pinned commit, never from the target repository: its contents, a pull request's head included, are untrusted, and a package list read from them would let anyone who can open a pull request choose what is installed as root. For the same reason rust-toolchain.toml, .tool-versions and the like are not read. A toolchain the distribution lacks needs a setup script of the caller's (the reusable workflow's input; the shipped dispatch caller passes none), which runs unprivileged, never as root: in the reusable workflow as the runner's user, in an agent job of your own as the sandbox user.

The reusable workflow refuses [setup.tools] for now: its agent job has no sandbox user to run the recipe as, until the host keeps the runner's processes from reading each other (workflow.md). What follows is what the binary does for a caller that composes its own agent job with a sandbox user.

For an agenthome tools manifest, opt in through that same caller-owned configuration on the default branch, not through dispatcher workflow inputs:

[setup.tools]
repository = "https://HOST/OWNER/AGENTHOME.git"
revision = "FULL_40_DIGIT_COMMIT_SHA"
recipe = "install-tools"

Absent this table, nothing is fetched or run. All three fields are required; the revision must be a full commit hash, not a branch or tag. This prevents a moving ref from replacing setup code without a caller configuration change. The caller must review that commit and its manifest: pinning does not make its code or dependencies trustworthy. The agenthome install-tools interface is a Justfile recipe using Node, installing into $HOME/.local/bin and $HOME/.local/share/agenthome; its manifest detects existing tools and pins release checksums and direct package versions. Transitive PyPI dependencies remain resolver-trusted.

This code and its dependencies have pre-lockdown network access, including metadata services and the tailnet, and are outside the filesystem view. Treat them as trusted host setup, not as untrusted task code.

The runner image or setup.packages must provide Git, just, Node, curl, tar and unzip. After package installation and privilege denial, setup fetches the pinned repository and runs the recipe as the sandbox user with a clean environment, outside the sandbox command entry and before network rules. It can fetch PyPI and GitHub releases without changing sandbox egress policy. Each Git phase has a 120-second timeout; the recipe has 600 seconds, with a 10-second forced-kill grace. Any failure stops setup before agent inference. Installer transcripts are discarded to bound memory and prevent workflow-command injection; setup logs phase starts and the failing exit status instead. The checkout stays in $HOME/.agenthome-tools; the sandbox PATH prepends $HOME/.local/bin when this table is present (including before a later setup.script). Neither host/root PATH nor other runs change. A setup script still runs later inside the hardened sandbox; it is not a second pre-lockdown hook. Real installations and privileged ordering need fresh-runner CI; local tests do not exercise host hardening.

Rust. The shipped configuration gives this repository Ubuntu 26.04's cargo (1.93.1, pulling in rustc and gcc as its linker), which is newer than its rust-version of 1.88. That is preferred to rustup: nothing is fetched by a script, the packages are signed, and the run starts with the toolchain installed. A target that pins a newer Rust in its own rust-toolchain file can be given rustup from a caller setup script instead. rustup then follows the target's file, but as the sandbox user: the target picks a toolchain version, not what root installs.

A cargo build --locked needs no egress rule: the proxy lets every GET through, and Cargo only reads, from index.crates.io (the sparse index), static.crates.io (the crates) and, for git dependencies, a forge whose git fetch is already allowed. The shipped configuration lists those hosts in a comment; no write is opened for them. Allowing crates.io means the run executes code from it: every crate is checked against the checksum in Cargo.lock, but build scripts and procedural macros of the target's dependencies run as the sandbox user, as the target's own tests do. That is the same reach as the agent's own: the sandbox user's read-only GitHub token and inference access, not the host's root, the runner's user or a write credential.

In a container on Ubuntu 26.04 through the same egress proxy, installing cargo took 24 seconds (70 packages, most of them gcc and LLVM, which the hosted image partly has already), and a cold cargo test --workspace --locked --no-run of this repository 68 seconds. The hosted runner's Sandbox setup stage packages line in the agent job's log is the authoritative figure.

The scripted review session checks it: when the checkout has a Cargo.lock, it runs cargo --version and cargo metadata --locked (which downloads every dependency) and names the version on a Toolchain: line of its verdict, or none. CI's dispatch verifier requires a cargo version there for this repository.

Sessions and boundaries

The built-in patch, comment and review session installers come from the pinned reusable source: you need no setup script. They run as the sandbox user and write $HOME/.config/fake-agent/demo.json. That JSON is an array of scripted steps: write takes title, path and content; execute takes title and command; say, read and cost are also supported. {home} and {cwd} expand to the sandbox home and checkout. An executed command's failure is reported to the session, not automatically a failing run: make your session check the result if needed. The current fake-agent upload gate requires at least one redaction: retain the shipped session's first execute step when customizing. Its dynamically constructed canary is deliberately not a real credential; the transcript must redact it before publication. This control is specific to scripted runs, not a requirement of real agents. To customize, omit fake-profile and use your own setup script writing the same JSON path. The shipped sessions show the two hand-back files: {home}/out/safe-outputs.jsonl (one safe-output JSON object per line) and {home}/out/outcome.json (summary, tests, questions and stopped_early). The implement session needs only the patch and outcome: apply constructs its draft request from that summary. Safe outputs defines the fields and limits. Editing a session changes the trial, not the real agent.

Preflight has contents read only and fetches issue/PR title and body without checking out target code. Text reaches the task as fenced JSON data, with angle brackets escaped so it cannot close the fence. Dispatch inputs are never pasted into executable code. Default-branch checks apply to manual dispatch; the workflow-call interface lets same-repository CI exercise the exact caller. Its caller must be trusted like any other workflow with write permissions.

The reusable policy and check jobs downscope to contents read; the agent job has read access and runner-only OIDC registration. The sandbox has neither forge nor OIDC credentials. Only apply receives the configured environment token; apply and refusal reporting can use the caller's write-capable job token, and neither executes agent-produced code. Review resolves the open PR's exact head through the read-only policy API, refuses forks and wrong bases, and enforces one verdict comment or noop in the clean check job. Real reviewers start in the base checkout with the pinned head beside it. Other profiles allow one each of noop, missing_tool and missing_data, three outputs total; implement permits one draft PR, fix one push to the dispatched pull request's branch, triage/research one comment. Comment routing is fixed to the dispatched item, not agent-supplied fields. Triage may also close up to 30 issues of the target repo (not the item repository) that it finds finished or overtaken, each close_issue carrying its closing comment, within the bounds' 32 outputs; check refuses one naming another repository. A misled run's closes are undone by reopening, and each says why on the issue.

Verification

node --test workflow/dispatch.test.cjs workflow/review.test.cjs executes the actual preflight and all shipped sessions; cargo test -p agentic-job review covers hostile review routing and output cases. CI calls the same dispatch.yml, running research wherever it runs its end-to-end jobs, plus review on PRs; triage takes the same path with other profile text. Not implement: CI holds no PAT to fork with. Its separate verifier checks the issue comments and the review verdict, then existing CI cleanup removes the comments. Fork PRs skip these jobs. These tests do not prove real-model quality or connectivity to a private broker.