Use the Agent Runner SDK
nax-agent-runner-sdk is the shared Node.js lifecycle client for Netlify Agent
Runner. Use it when a service, worker, CLI, or GitHub Action needs typed
submission, exact-session polling, durable recovery, bounded retry, and safe
landing.
The package lives in the nax repository at packages/agent-runner-sdk but is
versioned and published independently.
Install and authenticate
npm install nax-agent-runner-sdkimport { createAgentRunnerSdk } from 'nax-agent-runner-sdk'
const sdk = createAgentRunnerSdk({
token: process.env.NETLIFY_AUTH_TOKEN,
})Token precedence is:
- Per-operation
options.token - Constructor
token NETLIFY_AUTH_TOKEN- Netlify CLI config discovery
NETLIFY_AGENT_RUNNER_TOKEN is not an alias. NETLIFY_AUTH_TOKEN is the sole
environment-token contract. Long-running services should pass their token
explicitly.
A separate optional githubToken is used only for GitHub pull request merges.
PR creation itself uses the Netlify Agent Runner backend and does not need a
GitHub token in the SDK.
Configure the client
const sdk = createAgentRunnerSdk({
transport: 'http',
apiStyle: 'v1',
token: netlifyToken,
githubToken,
defaultDeadlineMs: 25 * 60 * 1_000,
pollIntervalMs: 15_000,
retryAttempts: 3,
clockSkewAllowanceMs: 5_000,
onLandingCheckpoint: persistHandle,
onRetryCheckpoint: persistHandle,
onTelemetry(event) {
metrics.increment(event.kind)
},
})HTTP is the default built-in transport. apiStyle: 'v1' uses the verified
api.netlify.com/api/v1/agent_runners endpoints. The opt-in 'bb-api' style
uses the legacy access-control base and camel-case aliases. Both normalize to
the same Runner and Session types.
You may inject a custom object implementing Transport. Tests can also inject
fetch, sleep, now, random, and generateRequestId.
There is no built-in Netlify CLI transport. CLI versions 24.8.1 and 27.0.2 do not expose complete machine-readable follow-up, session cancellation, pagination, reconciliation, or landing operations. HTTP remains the safe built-in transport; the SDK does not infer session identity from CLI prose or timestamps.
onTelemetry receives value-free request metadata and API-drift events.
Tokens, prompts, response bodies, and private request markers are redacted.
Start inputs and follow-ups
StartInput requires siteId and exactly one of prompt or promptRef:
const handle = await sdk.start({
siteId,
prompt: 'Fix the failing tests and open a pull request.',
agent: 'claude',
model: 'claude-opus-4-8',
effort: 'high',
branch: 'fix/runner-tests',
mode: 'normal',
fileKeys: ['requirements.md'],
land: 'pr',
deadlineMs: 25 * 60 * 1_000,
retryBudget: { capacity: 1 },
})The defaults are:
| Field | Default |
|---|---|
agent | claude |
land | none |
deadlineMs | 25 minutes |
retryBudget.capacity | 0 |
deployId and requestId are also supported. A supplied requestId must be a
UUID unique to one logical create attempt.
Follow-ups accept a new prompt or promptRef plus optional agent, model,
effort, mode, fileKeys, and requestId:
const sessionHandle = await sdk.followUp(handle, {
prompt: 'Address the review feedback without changing the public API.',
agent: 'opencode',
model: 'z-ai/glm-5.2',
effort: 'xhigh',
})
await save(sdk.serializeHandle(sessionHandle))agent, model, and effort are open protocol strings. The SDK forwards
them unchanged and deliberately has no provider/model catalog or effort enum.
Catalogs and compatibility validation belong to the consuming product. For
backend-selected Auto behavior, omit model and effort; do not pass an
auto sentinel.
The returned SessionHandle retains the original site, start input, policy,
deadline, retry count, and landing progress. Its
sessionId === currentSessionId, so later reads and commits are attributed to
the exact follow-up.
Choose in-process or out-of-band execution
For one process, use run:
const outcome = await sdk.run({
siteId,
prompt,
land: 'pr',
}, {
onProgress(event) {
console.log(event.kind)
},
})
await save(sdk.serializeHandle(outcome.handle))
if (outcome.result.status === 'succeeded') {
console.log(outcome.result.resultText)
}
if (outcome.landing?.kind === 'prOpen') {
console.log(outcome.landing.prUrl)
}run performs start, waitFor, any bounded retry-safe replacement attempts,
then land only after successful execution. Automatic replacement is limited
to classified capacity, rate-limit, and platform-server failures within the
persisted budget and original deadline.
For Lambda, EventBridge, queues, or any process boundary:
- Call
start. - Persist its complete serialized handle.
- Parse the handle on each tick.
- Call
getSnapshot. - Enforce
handle.policy.deadlineAtwithstop. - On terminal success, call
landand persist its returned handle. - On a retryable failure, call
shouldRetry, thenretry, and persist its returned handle.
let handle = sdk.parseHandle(await loadHandle(jobId))
const snapshot = await sdk.getSnapshot(handle)
if (snapshot.kind === 'running') {
if (Date.now() >= handle.policy.deadlineAt) {
handle = await sdk.stop(handle)
await saveHandle(jobId, sdk.serializeHandle(handle))
}
await scheduleNextTick(jobId)
} else if (snapshot.result.status === 'succeeded') {
console.log(snapshot.result.resultText)
const landed = await sdk.land(handle)
handle = landed.handle
await saveHandle(jobId, sdk.serializeHandle(handle))
}The SDK has no hidden durable state. Persist the handle returned by every mutating method, including start, followUp, retry, stop, land, reconciliation, and run. Persist every handle delivered to onLandingCheckpoint and onRetryCheckpoint before continuing.
See the package’s
eventbridge-resume.ts
for a complete typechecked worker.
Method reference
| Method | Return | Behavior |
|---|---|---|
start(input, options?) | RunHandle | Creates a runner and resolves the exact initial session. |
run(input, options?) | RunOutcome<RunHandle> | Starts, waits, applies bounded safe retries, and lands after success. |
getSnapshot(handle, options?) | RunSnapshot | Returns running state or a terminal result. |
getResult(handle, options?) | RunResult | Rejects unless the exact session is terminal. |
waitFor(handle, options?) | RunResult | Polls and enforces the absolute deadline. |
land(handle, options?) | { handle, landing } | Resumes origin-specific landing. |
stop(handle, options?) | same handle kind | Cancels the exact runner/session. |
followUp(handle, input, options?) | SessionHandle | Creates and scopes a follow-up session. |
classifyFailure(error) | FailureClassification | Produces a safe category/code/retryability result. |
shouldRetry(handle, failure) | boolean | Checks retryability and persisted capacity. |
retry(handle, { failure, ...options }?) | same handle kind | Backs off, checkpoints, and creates a replacement with a new request ID. |
reconcileCreate(input, window, options?) | reconciliation union | Resolves an uncertain runner create. |
reconcileSession(handle, input, window, options?) | reconciliation union | Resolves an uncertain or conflicting session create. |
serializeHandle(handle) | string | Validates and serializes the current handle schema. |
parseHandle(value) | Handle | Parses, validates, and rejects unknown schema versions. |
The normalized low-level transport remains available as sdk.transport. Use
sdk.transport.member(runnerId, action, input) only when the high-level engine
does not cover the operation.
Handles and deadline semantics
Handles contain:
- Schema version and kind
- Runner, site, and exact current-session identities
- Effective semantic input, including provider, model, effort, and the private request ID
- Original landing and retry policy
- Absolute
deadlineAt - Consumed retry capacity plus the last safe reason and backoff timing
- Resumable landing checkpoints
- Effective follow-up input for session handles
Use serializeHandle and parseHandle at every storage boundary. Do not
persist only IDs or construct a partial handle.
deadlineMs is converted once when the original attempt is sent.
Reconciliation reconstructs deadlineAt from that attempt’s sentAt.
Follow-ups and retries preserve it. Resuming work never restarts the clock.
waitFor cancels when an in-process deadline elapses. Out-of-band workers must
check deadlineAt themselves and call stop.
Results and usage
RunResult has four terminal variants:
switch (result.status) {
case 'succeeded':
console.log(result.resultText)
console.log(result.changes)
break
case 'failed':
console.error(result.failure.code)
break
case 'cancelled':
break
case 'timedOut':
console.error(result.cancelledRunner)
break
}
await persistUsage(result.usage)usage: Usage | null is present on every result variant. Record it for failed,
cancelled, and timed-out attempts as well as successful ones.
Successful changes is tri-state:
changed: the service reported a diff.unchanged: the service explicitly reported no diff.unknown: the API did not provide enough evidence.
unknown must not be treated as unchanged.
RunOutcome always contains { result, handle } and may contain landing.
Execution and landing are separate: a successful result can have a failed,
unsupported, or still-open landing.
Landing outcomes
The LandingOutcome union includes:
| Kind | Meaning |
|---|---|
merged | GitHub PR merged at the recorded mergeSha. |
prOpen | PR exists and merged is exactly false. |
published | Netlify Git production publish completed. |
unsupported | The origin has no applicable landing strategy. |
failed | commit, pr, merge, or publish failed with a classification. |
skipped | Landing policy was none. |
The default handler supports GitHub pr, merge, and auto. For follow-ups
it commits the exact current session; stale runner-level commit state from an
earlier session cannot short-circuit the current one. Merge persists the live
PR head and sends it as a compare-and-swap guard. Head drift fails closed.
merge requires githubToken. GitHub auto merges only when that token is
configured; otherwise it returns prOpen.
For Netlify Git, publish and auto call publish_to_production. The handle
checkpoints an accepted/in-progress request before polling and checkpoints
completion only when the exact current session reports is_published: true.
A stale runner-level merge SHA or successful execution is not publication
proof. Restarts resume polling without duplicating an accepted action. The
published deploy URL is included when the session exposes one.
Zip/drop and incompatible origin/mode combinations return unsupported. An
injected custom landingHandler may return any valid outcome.
Landing failures are values, not thrown exceptions. Always inspect them separately:
const landed = await sdk.land(handle)
await save(sdk.serializeHandle(landed.handle))
if (landed.landing.kind === 'failed') {
console.error(landed.landing.step, landed.landing.failure.code)
}Reconcile uncertain creates
Each create receives a UUID request ID before transmission. The SDK appends a reserved server-visible marker to the submitted inline prompt or blob wrapper, while preserving the unmarked semantic input. The exact marker is stripped from normalized result text and redacted from telemetry and emitted error messages. Typed ambiguity errors intentionally retain the effective input in memory so a later worker can reconcile it.
An uncertain runner write throws create-ambiguous; an uncertain session
write throws session-create-ambiguous; a session 409 throws
session-already-active. These errors carry the effective input and bounded
{ sentAt, failedAt } request window.
start and followUp make one automatic reconciliation attempt. If a unique
match cannot be proven, catch the typed error and reconcile later:
import { isAgentRunnerSdkError } from 'nax-agent-runner-sdk'
try {
const handle = await sdk.start(input)
await save(sdk.serializeHandle(handle))
} catch (error: unknown) {
if (!isAgentRunnerSdkError(error, 'create-ambiguous')) throw error
const resolution = await sdk.reconcileCreate(
error.effectiveInput,
error.window,
)
switch (resolution.kind) {
case 'matched':
await save(sdk.serializeHandle(resolution.handle))
break
case 'none':
await requireManualReview('No matching runner exists.', [])
break
case 'ambiguous':
await requireManualReview(
'More than one exact candidate exists.',
resolution.candidates,
)
break
}
}Reconciliation searches only the bounded time window plus clock-skew allowance, traverses all result pages, requires the exact marker, and verifies the prompt/agent/model/branch fingerprint. It never adopts a later same-prompt run.
For a session conflict, pass the error as conflict:
if (isAgentRunnerSdkError(error, 'session-already-active')) {
const resolution = await sdk.reconcileSession(
handle,
error.effectiveInput,
error.window,
{ conflict: error },
)
}That permits adoption only when the reported active session also matches the
exact marker and fingerprint. none and ambiguous require explicit review;
never blind-replay the create.
Retry safely
Safe GET/DELETE operations have transport-level retry. A new runner/session is a separate logical attempt and is explicit:
const failure = sdk.classifyFailure(error)
if (sdk.shouldRetry(handle, failure)) {
handle = handle.kind === 'run'
? await sdk.retry(handle, { failure })
: await sdk.retry(handle, { failure })
await save(sdk.serializeHandle(handle))
}retry uses the same injected exponential-jitter policy as transport retry,
rotates the request ID, increments consumed capacity, preserves the semantic
input, landing policy, and original absolute deadline, then returns a new
handle that must be persisted. Configure onRetryCheckpoint to durably save
the incremented count and safe category/code/timing before replacement I/O.
Network/request timeouts, auth and validation failures, argv-too-long, arbitrary terminal failures, prompt/blob failures, malformed API shapes, ambiguous creates, unmatched session conflicts, and GitHub head drift never create automatic replacement attempts. Transport keeps its own operation-specific retry rules.
Prompt delivery and BlobStore
StartInput and FollowUpInput accept a BlobRef, and the package exports:
interface BlobRef {
store: string
key: string
tenant: string
expiresAt: number
}
interface BlobStore {
put(
key: string,
bytes: Uint8Array,
options: { ttlSeconds: number; tenant: string },
): Promise<BlobRef>
delete(ref: BlobRef): Promise<void>
runnerFetchInstruction(ref: BlobRef): {
shell: string
sentinel: string
}
}Use the built-in Netlify adapter and delivery policy:
import {
compactPromptByBytes,
createAgentRunnerSdk,
createNetlifyBlobStore,
} from 'nax-agent-runner-sdk'
const blobStore = createNetlifyBlobStore({
siteId,
token: netlifyToken,
})
const sdk = createAgentRunnerSdk({
token: netlifyToken,
blobStore,
promptDelivery: {
safeBytes: 16 * 1_024,
tenant: ({ siteId }) => `${siteId}/${artifactId}`,
key: `artifact-${artifactId}`,
compact: compactPromptByBytes,
},
})The SDK measures the final UTF-8 wire prompt after adding the request marker.
It sends inline when safe, optionally compacts and remeasures, then offloads
the original semantic prompt when a BlobStore is available. The final
delivery mode and byte counts are serialized under handle.promptDelivery.
The Netlify adapter uses tenant-scoped collision-resistant keys, a one-day
default logical expiry, a seven-day lifetime ceiling, and a 5 MiB size ceiling.
Runner fetches use site-scoped netlify blobs:get; the caller token never
enters the instruction. Success, cancellation, and timeout delete the ref.
Failed runs retain it until logical expiry for exact retry reuse. Cleanup
faults are best-effort and emit only a value-free onBlobCleanupError event.
expiresAt is enforced by the SDK and recorded in blob metadata; Netlify Blobs
does not automatically remove the stored object at that timestamp. A consumer
that can abandon failed handles must run its own expired-entry sweep for the
configured store.
NAX_SAFE_PROMPT_BYTES changes the default 16 KiB final-wire budget.
requestMarkerOverheadBytes remains exported for compatibility adapters.
Expired refs fail with prompt-ref-expired; they are never silently replaced.
Use classifySentinelEvidence to normalize fetch proof to confirmed,
failed, probable, or suspect.
Migrate a direct Agent Runner client
Map existing operations to the engine:
| Existing client behavior | SDK behavior |
|---|---|
| Direct runner POST | sdk.start |
| Runner plus latest-session reads | sdk.getSnapshot with a persisted handle |
| Poll loop | sdk.waitFor, or out-of-band snapshot ticks |
| Direct session POST | sdk.followUp |
| DELETE cancel | sdk.stop |
| PR/commit/merge calls | sdk.land |
| Blind create retry | Reconciliation, then explicit shouldRetry/retry |
| Raw member call | sdk.transport.member |
During migration:
- Replace stored runner/session IDs with the full serialized handle.
- Read and land the exact
currentSessionId, not a runner’s stale latest session or runner-level merge SHA. - Preserve the handle’s original absolute deadline.
- Remove duplicate auth, retry, response normalization, and create replay.
- Use only
NETLIFY_AUTH_TOKENor an explicitly passed token. - Configure SDK
blobStore/promptDeliveryand remove duplicate consumer sizing, offload, sentinel, and cleanup logic. - Persist usage from every terminal result.
Errors
Use isAgentRunnerSdkError(error, code?) to narrow typed SDK errors. Stable
failure profiles cover authentication, permission, not-found, validation,
rate-limit, transport, capacity, argv-too-long, terminal failure,
timeout/cancel, prompt/blob delivery, API drift, ambiguity, landing, platform,
and unknown failures. GitHub profiles are exported separately.
classifyFailure converts a thrown value into a safe
FailureClassification with title, message, remediation, severity,
retryability, user-action, and stage metadata. It deliberately does not expose
raw prompt, token, or backend error text.
Recover and present GitHub failures
recommendRecovery accepts either an ambiguous create/session request or a
serialized handle with fresh runner, session, and pull-request evidence:
import {
recommendRecovery,
upsertGithubFailureComment,
} from 'nax-agent-runner-sdk'
const recovery = recommendRecovery({
kind: 'live',
serializedHandle,
runner,
session,
pullRequest,
failure,
})
await upsertGithubFailureComment({
serializedHandle,
failure,
links: { runnerUrl, sessionUrl, prUrl },
recovery,
}, commentAdapter)The typed recovery action can refresh the exact result, reconcile an uncertain create with its bounded window and private request marker, resume a persisted landing step, stop the deadline target, or escalate a changed PR head. It never recommends prompt-similarity adoption, a forbidden generic retry, or merging a newer head.
The comment presenter creates or updates one bot-owned comment identified by
GITHUB_FAILURE_COMMENT_MARKER. It renders safe category/code/stage, sanitized
links, handle version, and generated recovery/retry guidance. Prompts, token
assignments, request markers/IDs, and blob-delivery values are redacted.
Check runs and labels remain isolated opt-in capabilities:
upsertGithubFailureCheck uses a stable external ID, while
ensureGithubFailureLabel applies a bounded category label. Neither runs as a
side effect of the comment presenter or core execution.
Package compatibility and releases
- Node.js 18, 20, and 22
- ESM and CommonJS entry points
- Strict
.d.tsand.d.ctsdeclarations - Package-specific Git tags:
nax-agent-runner-sdk-vX.Y.Z - Prerelease distribution tag:
next - Stable distribution tag:
latest
The release gate builds and typechecks the package, runs the deterministic
suite, compiles public examples, creates the exact npm tarball, and installs
that tarball into clean ESM and CommonJS consumers. After an authorized
publication, npm run published:smoke repeats those consumer checks against
nax-agent-runner-sdk@next for a prerelease or the exact stable version from
the package manifest. Publication remains a manual, explicitly authorized
step.
Roll back consumers by restoring their prior exact version pins and
redeploying them. If npm’s default must also be rolled back, restore latest
to the prior stable version with npm dist-tag add; do not delete the failed
version or the retained next artifacts.
See also
- Run NAX in CI for the GitHub Action consumer.
- Transports for nax transport selection.
- Artifacts for nax’s persisted run/session files.