Build Your Own Runner
The runner interface page tells you what the boundary is. This page tells you how to use it without guessing.
If you are building a CI runner, a hosted orchestrator, or an internal execution service, the rule is simple: import governed execution operations only through agentxchain/runner-interface. If you shell out to agentxchain step, you did not build a second runner. You wrapped the CLI.
Start with Runner Interface for the declared boundary. Come back here when you need the actual build sequence.
Installation
Add agentxchain as a dependency in your runner project:
npm install agentxchain
The runner interface and run-loop are published as package exports. Import them directly:
import { loadContext, assignTurn } from 'agentxchain/runner-interface';
import { runLoop } from 'agentxchain/run-loop';
Node.js requirement: >=18.17.0 or >=20.5.0 (same as the CLI).
Repo proofs vs installed-package starter
Do not collapse these into one thing:
examples/ci-runner-proof/is the repo-native proof surface. Those scripts intentionally exercise the local source tree in CI so this repository can prove its own runner boundary continuously.examples/external-runner-starter/is the external-consumer starter surface. That is the example to copy afternpm install agentxchain.
They solve different problems. Treating the repo proof scripts as the canonical installed-package example would be lazy boundary design.
What a runner owns
A runner owns orchestration, not the protocol itself.
The runner decides:
- when to start or resume work
- how to dispatch work to agents or workers
- when to archive or upload artifacts before acceptance
- how to stop, retry, or escalate inside its own environment
The runner does not own:
- governed state transitions
- the canonical staging path
- history and decision-ledger writes
- gate-approval semantics
Those stay inside the shipped library boundary.
Step 1: Import the declared boundary
These are the operations a real runner starts with:
import {
loadContext,
loadState,
initRun,
reactivateRun,
assignTurn,
writeDispatchBundle,
getTurnStagingResultPath,
acceptTurn,
rejectTurn,
approvePhaseGate,
approveCompletionGate,
} from 'agentxchain/runner-interface';
This is a real package export, not a path hack. Do not import governed-state.js, turn-paths.js, or other internal helpers directly. That shortcut buys you nothing except future drift.
Step 2: Load context and state
Every runner starts by resolving the project root and current governed state:
const { root, config } = loadContext(targetDir);
const state = loadState(root, config);
At this point your runner knows whether it is looking at:
- an idle repo that needs
initRun(root, config) - a paused or blocked repo that may need
reactivateRun(root, state, details?) - an active repo ready for
assignTurn(root, config, roleId)
Step 3: Initialize or reactivate the run
Use the current state, not wishful assumptions.
const current = loadState(root, config);
if (current.status === 'idle') {
initRun(root, config);
} else if (current.status === 'paused' || current.status === 'blocked') {
reactivateRun(root, current, { source: 'my-runner' });
}
This matters because the runner boundary is not "always start a new run." Hosted or continuous runners have to resume truthfully.
Step 4: Assign a turn
Once the run is live, assign the next governed turn:
const assignResult = assignTurn(root, config, 'pm');
if (!assignResult.ok) throw new Error(assignResult.error);
const { turn, state: assignedState } = assignResult;
The top-level turn return is part of the contract. Do not rediscover it by walking state.active_turns.
Step 5: Dispatch work and stage the result
Now the runner does its own job.
Use writeDispatchBundle(root, assignedState, config, opts?) if your worker needs the repo-native prompt bundle. Or skip it if your hosted runner builds its own dispatch payload. Both are valid.
What is not optional is the staging path:
const resultPath = getTurnStagingResultPath(turn.turn_id);
The worker result must be written to .agentxchain/staging/<turn_id>/turn-result.json through that canonical helper. Hardcoding or inventing your own path is lazy and brittle.
Step 6: Accept or reject the staged result
Once the worker has produced a valid turn result, your runner chooses the protocol transition:
const accepted = acceptTurn(root, config);
or:
const rejected = rejectTurn(root, config, result, 'verification failed');
This is the trap people miss: acceptTurn() is destructive for transient turn artifacts. After acceptance commits, AgentXchain removes the accepted turn's dispatch bundle and staging directory. If your runner needs those files for upload, audit, or diagnostics, copy them before acceptance.
Step 7: Handle gates truthfully
A complete runner does not stop at acceptance. It also handles the pending gate state it created.
const nextState = loadState(root, config);
if (nextState.pending_phase_transition) {
approvePhaseGate(root, config);
}
if (nextState.pending_run_completion) {
approveCompletionGate(root, config);
}
If your environment requires human review instead of automatic approval, pause there. The point is to read and react to actual pending state, not to assume the run is finished because one turn succeeded.
Return value contracts
Every lifecycle operation uses the same { ok, error } pattern. Success returns ok: true plus operation-specific fields. Failure returns ok: false plus error (a human-readable string).
| Operation | Success shape | Failure shape |
|---|---|---|
loadContext(dir?) | { root, rawConfig, config, version } | null |
loadState(root, config) | state object directly | null |
initRun(root, config) | { ok: true, state } | { ok: false, error } |
reactivateRun(root, state, details?) | { ok: true, state } | { ok: false, error } |
assignTurn(root, config, roleId) | { ok: true, state, turn, warnings? } | { ok: false, error } |
acceptTurn(root, config, opts?) | { ok: true, state, validation, accepted, gateResult, completionResult, hookResults } | { ok: false, error, error_code?, validation? } |
rejectTurn(root, config, result, reason, opts?) | { ok: true, state, escalated, turn } | { ok: false, error } |
approvePhaseGate(root, config) | { ok: true, state, transition } | { ok: false, error, error_code?, state?, hookResults? } |
approveCompletionGate(root, config) | { ok: true, state, completion } | { ok: false, error, error_code?, state?, hookResults? } |
loadContext and loadState return null on failure (no project found or no state file). All governed operations return the { ok } envelope. Check ok before accessing other fields.
When rejectTurn exhausts retries, it returns { ok: true, escalated: true } — the rejection succeeded but the run is now blocked.
Shipped proof path
AgentXchain already ships the repo-native graduation path. Use it in order instead of jumping straight to abstractions.
| Tier | File | Why it exists |
|---|---|---|
| 1. Single-turn primitive | examples/ci-runner-proof/run-one-turn.mjs | Smallest proof that a second runner can initialize, assign, stage, and accept a governed turn through the declared boundary |
| 2. Full lifecycle primitive | examples/ci-runner-proof/run-to-completion.mjs | Proves phase gates, rejection/retry, and full pm → dev → qa progression without shelling out to the CLI |
| 3. Composition library | examples/ci-runner-proof/run-with-run-loop.mjs | Proves runLoop can compose the primitive operations once primitive correctness is already established |
Start with Tier 1. If Tier 1 is not stable, Tier 3 will only hide your defects behind nicer control flow.
External-consumer starter
If you are starting from a blank project instead of this repo, use the installed-package starter:
npm init -y
npm install agentxchain
curl -O https://raw.githubusercontent.com/shivamtiwari93/agentXchain.dev/main/examples/external-runner-starter/run-one-turn.mjs
node run-one-turn.mjs --json
The starter imports from agentxchain/runner-interface, scaffolds a tiny governed repo in a temp directory, and proves the installed package can initialize, assign, stage, and accept a turn without any CLI shell-out.
Common failure modes
- Importing internal helpers directly instead of
agentxchain/runner-interface. That is boundary violation, not engineering. - Shelling out to
agentxchain stepand calling it a hosted runner. That is still the CLI runner. - Writing staged results to a guessed path instead of
getTurnStagingResultPath(turn.turn_id). - Forgetting that
acceptTurn()removes transient dispatch and staging artifacts after commit. - Adopting
runLoopbefore the primitive assign/stage/accept path is already proven.
Minimal development loop
Run the shipped proofs in the same order you should build your own runner:
node examples/ci-runner-proof/run-one-turn.mjs
node examples/ci-runner-proof/run-to-completion.mjs
node examples/ci-runner-proof/run-with-run-loop.mjs
If your own runner cannot pass the same progression, you are still missing a boundary detail.
Where to go next
- Runner Interface for the declared boundary
- Protocol Implementor Guide if you are implementing the conformance surfaces too
examples/ci-runner-proof/README.mdfor the repo-native proof scriptsexamples/external-runner-starter/README.mdfor the installed-package starter