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 through agentxchain/runner-interface, and import first-party adapter dispatch through agentxchain/adapter-interface when you want the shipped adapters. 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 { dispatchLocalCli } from 'agentxchain/adapter-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 adapter implementation files 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 ctx = loadContext(targetDir);
if (!ctx) throw new Error('No governed project found');
const { root, config } = ctx;
const state = loadState(root, config);
if (!state) throw new Error('No governed state — run initRun(root, config) first');
Both return null on failure. loadContext fails when there is no agentxchain.json in the directory tree. loadState fails when there is no .agentxchain/state.json (the run has not started yet).
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.
If you want the shipped adapters instead of custom dispatch code, import them through:
import {
dispatchLocalCli,
dispatchApiProxy,
dispatchMcp,
printManualDispatchInstructions,
waitForStagedResult,
readStagedResult,
} from 'agentxchain/adapter-interface';
That is the public adapter boundary. Do not reach into cli/src/lib/adapters/*.
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.
Step 8: Graduate to runLoop only after the primitives are stable
runLoop is a composition helper, not a shortcut around the runner boundary. Use it after you have already proven your assign → stage → accept path.
import { runLoop } from 'agentxchain/run-loop';
const result = await runLoop(root, config, {
selectRole(state, config) {
return state.next_role || config.routing?.[state.phase]?.entry_role || null;
},
async dispatch({ turn, state, bundlePath, stagingPath, config, root }) {
const turnResult = await dispatchWorker({ turn, state, bundlePath, stagingPath, config, root });
if (!turnResult) {
return { accept: false, reason: 'worker returned no staged result' };
}
return { accept: true, turnResult };
},
async approveGate(gateType, pausedState) {
return gateType === 'phase_transition' || gateType === 'run_completion';
},
onEvent(event) {
auditLogger.info(event.type, { runId: event.state?.run_id, turnId: event.turn?.turn_id });
},
}, { maxTurns: 20 });
if (!result.ok) {
throw new Error(`runLoop stopped: ${result.stop_reason}`);
}
console.log(result.stop_reason); // completed
console.log(result.turns_executed);
console.log(result.state.status);
The callback contract is the important part:
selectRole(state, config)decides which governed role should run next. Returnnullonly when your runner is intentionally stopping.dispatch(context)owns real worker execution. It must return either{ accept: true, turnResult }or{ accept: false, reason }.approveGate(gateType, state)decides whether paused phase/completion gates should advance.onEvent(event)is optional, but useful for logs, metrics, and external observability.
If your runner cannot make the primitive operations pass first, runLoop will only hide the defect behind a cleaner API.
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?, state?, hookResults? } |
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 starters
If you are starting from a blank project instead of this repo, use the installed-package starters:
Manual staging (runner-interface only)
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
Imports from agentxchain/runner-interface. Scaffolds a tiny governed repo, initializes a run, assigns a turn, stages a result by hand, and accepts it. The starter uses a one-phase review-only config, so that accepted turn completes the run instead of leaving it active. No adapter, no subprocess.
Adapter-backed dispatch (runner-interface + adapter-interface)
npm init -y
npm install agentxchain
curl -O https://raw.githubusercontent.com/shivamtiwari93/agentXchain.dev/main/examples/external-runner-starter/run-adapter-turn.mjs
node run-adapter-turn.mjs --json
Imports from both agentxchain/runner-interface and agentxchain/adapter-interface. Uses dispatchLocalCli to dispatch a real local_cli turn through the shipped adapter — the same dispatch path the CLI uses. Proves the full dispatch → stage → accept lifecycle works from a clean consumer install.
Start with the manual starter to prove your import boundary, then graduate to the adapter starter when you want real dispatch.
Common failure modes
- Importing internal helpers directly instead of
agentxchain/runner-interfaceandagentxchain/adapter-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