Pilot SDK documentation

Build from current truth

Connect one script to one ordinary Fenwild character, make typed decisions, and let server authority settle every result.

SDK guide01Beta 0.1
Get started

Your first bot in three steps

01

Create access

Create an account and character through the game entry page when signup is available. Sign in to Account → Bots, select that character, then create a named credential. Copy its secret once; it expires after 365 days.

02

Install

Once it is published, install @fenwild/pilot-sdk in an ESM project on Node.js 22+ or Bun.

03

Release control

Close game tabs controlling that character, export the token, then run the script below. The Account page can stay open.

mkdir my-fenwild-pilot && cd my-fenwild-pilot
npm init -y
npm pkg set type=module
npm install @fenwild/pilot-sdk
export FENWILD_AUTOMATION_TOKEN="the-one-time-secret"
One controller per character.

The browser and bot cannot issue competing actions. A controller on another credential is refused with character_in_use and is never reported as connected. A starting bot retries at most three times, then connectPilot and runPilot reject with that error. A running bot whose reconnect is refused keeps retrying, with backoff up to 5 seconds, until the character is free. A connection on the same credential replaces the one before it: a bot's reconnect takes over its own dropped stream at once, and a second process on that token takes the character from the first, which stops with control_superseded. All four account characters can play simultaneously, each manually or with its own bot or Pilot. Starting a bot on another character leaves this bot running.

Bots earn the same normal skill XP as manual play. Deaths, item loss, trade and chat are real; the account owner remains responsible for the bot.

Control loop

Start with runPilot

runPilot owns the normal connection, observation, submission, rejection and close loop. Your brain receives the current observation and may return one ordinary player action, synchronously or asynchronously.

Save this complete JavaScript example as pilot.mjs.

import { runPilot } from "@fenwild/pilot-sdk";

const automationToken = process.env.FENWILD_AUTOMATION_TOKEN;
if (!automationToken) throw new Error("Set FENWILD_AUTOMATION_TOKEN first");

const shutdown = new AbortController();
process.once("SIGINT", () => shutdown.abort());
process.once("SIGTERM", () => shutdown.abort());
let firstObservation = true;

await runPilot({
  baseUrl: "https://play.fenwild.com",
  automationToken,
  signal: shutdown.signal,
  onStatus: (status) => console.log("Pilot:", status),
  onError: (error) => console.warn(error.code, error.message)
}, (observation) => {
  const { player } = observation.snapshot;
  if (firstObservation) {
    console.log("Observing tick", observation.serverTick, "at", player.position);
    firstObservation = false;
  }
  if (!player.runEnabled && player.runEnergy > 0) {
    return { type: "toggleRun", enabled: true };
  }
  return undefined;
}, {
  onActionRejected: ({ action, error }) => {
    console.warn(action.type, error.code, error.message);
  }
});
node pilot.mjs

You should see Pilot: connected and the first observed tick and position. The script enables run when energy is available, then observes quietly; it does not walk or train. Stop with Ctrl+C. Bun users can run bun pilot.mjs.

This complete first brain enables run once authority reports energy, then observes without repeating the action. The runner waits for the action outcome before asking for another decision. A correlated rejection stops the runner unless you deliberately handle it; an unconfirmed action stops the runner by default and is never replayed. Advanced stateful brains can use onActionInterrupted to discard uncertain local work and reconsider a fresh observation; see the runner callbacks.

Use connectPilot for custom coordination

The lower-level client is useful when one process coordinates observations with market, history or social feeds. Always submit against the observation that produced the decision. A refused action rejects submit with PilotProtocolError, so catch it wherever refusal is possible.

import { PilotProtocolError, connectPilot } from "@fenwild/pilot-sdk";

const pilot = await connectPilot({
  baseUrl: "https://play.fenwild.com",
  automationToken: process.env.FENWILD_AUTOMATION_TOKEN!
});

try {
  for await (const observation of pilot.observations()) {
    const { player } = observation.snapshot;
    const maxHp = player.skills.find((skill) => skill.skillId === "hitpoints")?.level ?? 10;
    const carriesPotion = player.inventory.some((item) => item.itemId === "healing_potion" && item.quantity > 0);
    const canDrink = observation.serverTick >= player.vitals.foodCooldownUntilTick;
    if (player.vitals.hp > 0 && player.vitals.hp * 2 <= maxHp && carriesPotion && canDrink) {
      try {
        await pilot.submit({ type: "useItem", itemId: "healing_potion" }, observation);
      } catch (error) {
        if (!(error instanceof PilotProtocolError)) throw error;
        console.warn(error.code, error.message); // Refused: decide again from newer truth.
      }
    }
  }
} finally {
  pilot.close();
}
Actions and receipts

Authority makes the result explicit

submit attaches the observation tick and resolves when authority returns a correlated snapshot or action-specific view. A receipt can acknowledge the start of travel or combat; it does not promise arrival, a kill, or task completion. Observe later snapshots for those outcomes. Accepted, rejected and explicitly unknown outcomes remain distinct. The SDK permits at most eight pending actions, and the default ten-second acknowledgement deadline prevents abandoned work from living forever.

Authority refuses an action whose observation is more than 10 ticks (6 s) old with stale_intent. runPilot always submits against the observation it gave your brain, so bound slow model calls with a timeout inside that window—or use connectPilot, re-check the decision against pilot.lastObservation(), and submit against that newer observation.

Use parsePilotAction for unknown JSON or model output. It returns a fresh exact public action and rejects unknown verbs, missing or mistyped fields, unsafe integers, invalid enums and undeclared authority-looking fields with PilotActionValidationError before a sequence is reserved or the socket is written.

import {
  PilotActionUnknownError,
  PilotActionValidationError,
  PilotProtocolError,
  parsePilotAction,
  type PlayerIntentAction
} from "@fenwild/pilot-sdk";

// pilot comes from connectPilot(); askModel is your own model call.
for await (const observation of pilot.observations()) {
  let action: PlayerIntentAction;
  try {
    action = parsePilotAction(await askModel(observation));
  } catch (error) {
    if (error instanceof PilotActionValidationError) continue; // Not an exact public action.
    throw error;
  }
  try {
    await pilot.submit(action, observation);
  } catch (error) {
    if (error instanceof PilotActionUnknownError) {
      break; // Inspect current truth after restart. Never retry blindly.
    }
    if (!(error instanceof PilotProtocolError)) throw error;
    console.warn(error.code, error.message);
  }
}
Unknown does not mean rejected.

If an acknowledgement is lost, inspect current authority after restart. Never replay the mutation blindly.

Lifecycle and shutdown

Bound every connection and every exit

The SDK first renews the credential through POST /auth/session/renew (a five-second request timeout), then opens the realtime WebSocket (a separate ten-second default deadline). Temporary renewal failures, retryable admission closes and a busy character (character_in_use) receive at most three startup retries with backoff. Other initial handshake failures stop startup; retry later with a bounded supervisor policy. reconnect: false disables these connection retries.

After the first connection opens, recovery uses bounded exponential backoff with equal jitter. Two server closes end the pilot instead of starting recovery: a revoked or expired credential reports PilotCredentialRevokedError, including when a reconnect presents it after the bot was offline, and a stream closed as control_superseded reports PilotControlSupersededError. Both reach onError, and a running runPilot then resolves rather than rejects, so a supervisor that restarts failures does not reconnect into the same close. A supervisor that also restarts resolved runs must first check onError for these errors, because an ordinary abort resolves too. Once connected, a session lookup that fails on Fenwild's side is retried like any drop; before the first connection, temporary authorization failures have the bounded startup retries described above. The delay resets only after a valid player snapshot—not a bare socket open—so repeated handshake-only failures retain their escalated cadence. Unread snapshots and market tapes coalesce to their newest state rather than building a stale backlog.

import { connectPilot } from "@fenwild/pilot-sdk";

const shutdown = new AbortController();
process.once("SIGTERM", () => shutdown.abort());
process.once("SIGINT", () => shutdown.abort());

const pilot = await connectPilot({
  baseUrl: "https://play.fenwild.com",
  automationToken: process.env.FENWILD_AUTOMATION_TOKEN!,
  signal: shutdown.signal
});

try {
  for await (const observation of pilot.observations()) {
    // Decide from current truth.
  }
} finally {
  pilot.close();
}

Abort releases runPilot immediately, including while an asynchronous brain or rejection callback is pending. Pass the same signal into model and HTTP calls if their work must also be cancelled. Slow code pauses only your character; it cannot stall Fenwild's authoritative 600 ms tick.

Public game reference

Plan with stable, legitimate facts

The optional @fenwild/pilot-sdk/game-data entry point provides typed items, equipment, combat styles, spells, prayers, exact public drops, recipes, shop stock, services, Wilds rules, the Ash Shrine, resource nodes, public movement and line-of-sight grids, and the advisory knownPathToward route helper.

import {
  FENWILD_GAME_DATA,
  itemDefinition,
  serviceDefinition
} from "@fenwild/pilot-sdk/game-data";

const bank = serviceDefinition("bank")!;
const potion = itemDefinition("greater_healing_potion")!;

console.log(bank.interactionTile, potion.detail.heal);
console.log(FENWILD_GAME_DATA.crafting.recipes);

It is a curated compiled reference, not the private repository. NPC automation tuning, provisioning, economy guardrails, audit machinery, hidden outcomes and server code remain private. Current observations and receipts still decide what exists now and whether an action is legal.

Record and replay

Reconstruct the context your brain had

A bounded local recorder captures owner-visible observations, Trading Post views, market tapes, submitted intents and resolved outcomes without credentials. Deterministic replay validates the document and reconstructs that context without contacting Fenwild.

import {
  PilotRecorder,
  connectPilot,
  replayPilotRecording
} from "@fenwild/pilot-sdk";

const recorder = new PilotRecorder({ capacity: 10_000 });
const pilot = await connectPilot({
  baseUrl: "https://play.fenwild.com",
  automationToken: process.env.FENWILD_AUTOMATION_TOKEN!,
  recordingSink: recorder
});

let observed = 0;
for await (const _observation of pilot.observations()) {
  // Decide and submit as usual; the recorder captures each step.
  if (++observed === 100) break;
}
pilot.close();

// Save the document with your runtime's file API, then replay it offline.
const document = JSON.stringify(recorder.snapshot());
for await (const frame of replayPilotRecording(document)) {
  console.log(frame.observation.serverTick, frame.recentActionOutcomes);
}

Recordings are still player data. Protect them like save data, and use a custom PilotRecordingSink when an owner-controlled store should receive entries instead of memory.

Offline brain training

Turn confirmed journeys into a learned bot

The @fenwild/pilot-sdk/training subpath and fenwild-pilot-training command cover collection, recording conversion, journey-level split merging, deterministic local training, held-out evaluation, public benchmarks, and live play.

npx --no-install fenwild-pilot-training benchmark
npx --no-install fenwild-pilot-training collect \
  --out train.json --id mossmere-train --split train
npx --no-install fenwild-pilot-training collect \
  --out test.json --id wilds-test --split test
npx --no-install fenwild-pilot-training merge \
  --inputs train.json,test.json --out my-corpus.json --id my-corpus
npx --no-install fenwild-pilot-training train \
  --input my-corpus.json --out my-brain.json
npx --no-install fenwild-pilot-training evaluate \
  --model my-brain.json --data my-corpus.json --split test
npx --no-install fenwild-pilot-training play --model my-brain.json

Only collection and play connect to Fenwild. Every training operation runs on local files and local CPU; no model, corpus, replay request, or training query reaches the game server.

Enter the brain forge
Feeds and player history

Combine current truth with bounded context

Market

marketTapes() streams public offers and bounded anonymous sales. Trading remains an ordinary place-validated action.

History

historyUpdates() emits changed owner profiles. lastHistory() retains the newest sparse snapshot.

Outcomes

actionOutcomes() records correlated accepted, rejected and unknown commands without parsing log text.

Social

Friends, mutes, parties and chat use typed ordinary-player views and actions rather than a private account surface.

For a richer brain, combine the current observation with pilot.lastMarketTape(), summarizeHistory(pilot.lastHistory()) and pilot.recentActionOutcomes(20). Combat impacts, projectiles, healing, chat, the killfeed and nearby deaths arrive inside each snapshot as combatImpacts, pendingProjectiles, healingEvents, chat, killfeed and nearbyDeaths. The separate events() feed is reserved: the current server sends no event batches, so it stays empty.

Credential and runtime safety

A scoped gameplay credential

  • Keep automation tokens in an environment variable or secret store, never source control or chat.
  • The token authorizes realtime control and its own HTTP session renewal. It cannot manage tokens, sign into Account, or authorize HTTP gameplay, admin or database operations.
  • baseUrl accepts an HTTP service origin and optional path prefix. URL userinfo and legacy query credentials fail before custom fetch or socket code receives them.
  • Malformed or oversized tokens fail locally without echoing the credential.
  • Recognized realtime responses are runtime-checked before acknowledging actions or entering typed feeds. Malformed messages report bad_message and preserve the last valid state.
  • A token expires 365 days after creation. Revocation or expiry closes the live stream, reports PilotCredentialRevokedError, and prevents reconnection with the dead token, even when the bot was offline at the time.
  • The realtime server accepts browser connections only from play.fenwild.com pages. Run independent bots in Node.js 22+ or Bun.
  • Credential-redacted traces can still describe private player activity; handle exports carefully.
Reference brains

Run, copy, replace the policy

The package exports two complete strategies from @fenwild/pilot-sdk/brains as survivalGuardian and yardTrainer, with compiled commands and short readable TypeScript runners for both. The runners use runPilot, keeping credential, receipt, rejection, ambiguity, shutdown and close plumbing out of the policy.

npx --no-install fenwild-survival-guardian
npx --no-install fenwild-yard-trainer

Survival guardian

Reads HP, supplies and cooldowns, heals below its threshold when legal, and observes while defeated.

Yard trainer

Stays on its hometown's side of the Wilds, collects eligible owned drops, chooses a free trainer and preserves its healing floor.

Copy either runner and replace the brain it passes to runPilot with a behavior tree, planner, learned policy, local LLM or your own model endpoint that decides within 10 ticks (6 s).

Public surface

Look up exact signatures

The SDK follows semantic versioning. Beta 0.x releases may refine APIs; the matching contracts dependency pins the compatible protocol surface.

Open the API reference