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:
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:opencodeorclaude(fakeis 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 examplehttp://BROKER:PORT.INFERENCE_AUDIENCE: the broker's configured GitHub OIDC audience.- Not, for now, a provider's API key: preflight refuses
INFERENCE_REGISTERapi-keyandINFERENCE_PROVIDERgithub(GitHub's billing, which setsapi-key), and the caller passes noINFERENCE_API_KEYon. The reusable workflow's agent job runs the agent as the runner's user, which could read the key fromrun, 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'shosted.tomlat the pinned commit; a scripted trial always uses that and ignoresAGENT_CONFIG. Verify sandbox probes on your own image rather than copying hosted-image socket exceptions.AGENT_CONFIG_REPO: optional publicOWNER/NAMErepository holding opencode's or Vibe's configuration;AGENT_CONFIG_PATHselects its directory (empty for the root). These are used only withAGENT=opencodeorAGENT=vibe, not in the scripted trial; a directory shared by both agents needs bothopencode.jsonandconfig.toml. They select agent configuration, not the runner TOML named byAGENT_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.