Skip to main content

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 after npm 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. Return null only 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).

OperationSuccess shapeFailure shape
loadContext(dir?){ root, rawConfig, config, version }null
loadState(root, config)state object directlynull
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.

TierFileWhy it exists
1. Single-turn primitiveexamples/ci-runner-proof/run-one-turn.mjsSmallest proof that a second runner can initialize, assign, stage, and accept a governed turn through the declared boundary
2. Full lifecycle primitiveexamples/ci-runner-proof/run-to-completion.mjsProves phase gates, rejection/retry, and full pm → dev → qa progression without shelling out to the CLI
3. Composition libraryexamples/ci-runner-proof/run-with-run-loop.mjsProves 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-interface and agentxchain/adapter-interface. That is boundary violation, not engineering.
  • Shelling out to agentxchain step and 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 runLoop before 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