Skip to Content
GuidesAgent Runner SDK

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-sdk
import { createAgentRunnerSdk } from 'nax-agent-runner-sdk' const sdk = createAgentRunnerSdk({ token: process.env.NETLIFY_AUTH_TOKEN, })

Token precedence is:

  1. Per-operation options.token
  2. Constructor token
  3. NETLIFY_AUTH_TOKEN
  4. 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:

FieldDefault
agentclaude
landnone
deadlineMs25 minutes
retryBudget.capacity0

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:

  1. Call start.
  2. Persist its complete serialized handle.
  3. Parse the handle on each tick.
  4. Call getSnapshot.
  5. Enforce handle.policy.deadlineAt with stop.
  6. On terminal success, call land and persist its returned handle.
  7. On a retryable failure, call shouldRetry, then retry, 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

MethodReturnBehavior
start(input, options?)RunHandleCreates 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?)RunSnapshotReturns running state or a terminal result.
getResult(handle, options?)RunResultRejects unless the exact session is terminal.
waitFor(handle, options?)RunResultPolls and enforces the absolute deadline.
land(handle, options?){ handle, landing }Resumes origin-specific landing.
stop(handle, options?)same handle kindCancels the exact runner/session.
followUp(handle, input, options?)SessionHandleCreates and scopes a follow-up session.
classifyFailure(error)FailureClassificationProduces a safe category/code/retryability result.
shouldRetry(handle, failure)booleanChecks retryability and persisted capacity.
retry(handle, { failure, ...options }?)same handle kindBacks off, checkpoints, and creates a replacement with a new request ID.
reconcileCreate(input, window, options?)reconciliation unionResolves an uncertain runner create.
reconcileSession(handle, input, window, options?)reconciliation unionResolves an uncertain or conflicting session create.
serializeHandle(handle)stringValidates and serializes the current handle schema.
parseHandle(value)HandleParses, 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:

KindMeaning
mergedGitHub PR merged at the recorded mergeSha.
prOpenPR exists and merged is exactly false.
publishedNetlify Git production publish completed.
unsupportedThe origin has no applicable landing strategy.
failedcommit, pr, merge, or publish failed with a classification.
skippedLanding 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 behaviorSDK behavior
Direct runner POSTsdk.start
Runner plus latest-session readssdk.getSnapshot with a persisted handle
Poll loopsdk.waitFor, or out-of-band snapshot ticks
Direct session POSTsdk.followUp
DELETE cancelsdk.stop
PR/commit/merge callssdk.land
Blind create retryReconciliation, then explicit shouldRetry/retry
Raw member callsdk.transport.member

During migration:

  1. Replace stored runner/session IDs with the full serialized handle.
  2. Read and land the exact currentSessionId, not a runner’s stale latest session or runner-level merge SHA.
  3. Preserve the handle’s original absolute deadline.
  4. Remove duplicate auth, retry, response normalization, and create replay.
  5. Use only NETLIFY_AUTH_TOKEN or an explicitly passed token.
  6. Configure SDK blobStore/promptDelivery and remove duplicate consumer sizing, offload, sentinel, and cleanup logic.
  7. 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.ts and .d.cts declarations
  • 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

Last updated on