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 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 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 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).

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? }
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 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 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