@pouchy_ai/world-sdk
v0.32.1
Published
Server-side TypeScript client for Pouchy World — story packages, world definitions, world sessions, coordinated turns, trusted events, replay verification and script drafts. Node only: it holds a project Secret Key and a source signing key, which never be
Maintainers
Readme
@pouchy_ai/world-sdk
Server-side TypeScript client for Pouchy World — build an interactive short drama, a game's NPC cast, or an interactive novel on top of Pouchy, without an official app.
npm i @pouchy_ai/world-sdkNode ≥ 18, zero dependencies.
Node only. Really.
This client holds a project Secret Key (pchy_sk_…) and an event-source
signing secret (pesk_…). They never belong in a browser or a mobile app.
The world's machine lane requires BOTH — a key proves the project and its
test/live axis, a signature proves the provider — and a leaked pair is a world
anyone can drive.
What your users' devices get is the session token this client mints for
them, which is scoped to one world instance and one role and carries no project
credential. Drive that with @pouchy_ai/companion-sdk.
The two lanes
| Lane | Credential | What it does |
|---|---|---|
| owner | a signed-in project admin's ID token | author story packages and worlds; read state, turns, drafts; run replay |
| machine | Secret Key and X-Pouchy-Source-Signature | mint sessions, drive turns, send trusted events |
| keyed | Secret Key alone, no signature | generate and read script drafts and editorial readings (0.32.0) — nothing on that plane is authored, so there is nothing for a signature to prove |
import { PouchyWorldClient, newTurnId, describeTurn } from '@pouchy_ai/world-sdk';
const world = new PouchyWorldClient({
projectId: process.env.POUCHY_PROJECT_ID!,
adminToken: process.env.POUCHY_ADMIN_TOKEN!,
secretKey: process.env.POUCHY_SECRET_KEY!,
signing: { source: 'drama-backend', keyId: process.env.KID!, secret: process.env.SECRET! }
});What it covers
Authoring — createStoryPackage, publishStoryPackage, getStoryPackage,
listStoryPackages, createWorld, publishWorld, getWorld, listWorlds.
Running — createWorldSession, runTurn, sendEvent,
startNextEpisode (machine lane since 0.24.0 — an unattended serial backend
can advance its own player's worldline to the next episode and receive the
carryover).
Deliberating — deliberate, selectCandidate. Ask for a couple of public
directions a beat could take, then commit the one the player picked. Off unless
the world's published revision declares it; the envelope is server-side only.
Reading — getWorldState, getProgress, getTurn, listTurns,
listTurnsSince, replayLedger, replayLedgerToEnd, scanConsistency,
getWorldOverview, getWorldCost, listWorldVersions,
listStoryPackageVersions, setWorldDisabled, setStoryPackageDisabled,
preflightWorld.
Which credential does what. This matters more than it looks: one of them expires within the hour.
| you hold | you can | you cannot |
|---|---|---|
| secretKey + signing | mint sessions, drive turns, send events; generate and read script drafts and editorial readings (0.32.0 — the signature is not used for those) | read the world back: state, timeline, metrics; decide anything in the content loop |
| adminKey (pchy_admin_…, long-lived) | author story packages and worlds; read the world: overview, state, timeline, turn read-back, metrics, delivery queue, cost | drive a turn, act on the delivery queue, run the content loop |
| adminToken (Firebase ID token, ~1h) | everything above plus the content loop's DECISIONS | outlive the hour |
A server holds secretKey + signing + adminKey and needs no browser login
for the runtime loop: author a story package and a world, mint sessions, drive
turns, and read everything back. adminToken is for a person, and the content
loop's review step is a human decision — the point of it rather than an obstacle.
Which calls have no /admin mirror, and therefore need adminToken: the
content loop's DECISIONS (below), the delivery-queue ACTIONS (drainDeliveries,
requeueDelivery, rehydrateDelivery, resolveDeliveryGap), replayLedger,
archiveLedger, evaluateWorld, and preflightWorld. Reading an approved
export back IS mirrored (getApprovedExport) — that is how a machine collects
what a person approved, with the id arriving on the world.script_approved
webhook.
The content loop splits at the signature (0.32.0 / world API 1.38.0):
generate and read by machine, decide by human. A project secretKey alone —
no signature — now works on listScriptDrafts, createScriptDraft,
getScriptDraft, exportScriptDraft, listEditorialDrafts,
createEditorialDraft, getEditorialDraft, and
exportEditorialDraft({ preview: true }). Every one of those DERIVES material
from beats already committed, and none of them writes a name that later gates
something. adminToken is still required for the ones that do:
reviewScriptDraft (its reviewer is what the export gate reads),
decideEditorialScene (its verdicts are hashed into the approval digest),
setEditorialStatus (approved is what lets an export exist),
exportEditorialDraft WITHOUT preview (it stamps the draft exported),
createApprovedExport / deriveStoryPackageCandidate, and the draft DELETE
(which this client does not expose at all) — that last one because it is a recursive delete reaching approved exports
through their parent, so the machine lane stops at a human signature in both
directions: it may not create one and it may not erase one.
So an overnight job can run the whole derivation and a person still signs what
leaves. The methods fall back to adminToken when no secretKey is
configured, so a client that had only the owner token behaves exactly as
before.
conformance.mjs follows the same line: on an admin key alone it runs every
runtime gate and SKIPS the two whose routes are owner-plane, printing why.
Setting POUCHY_ADMIN_TOKEN as well runs all of them.
const world = new PouchyWorldClient({
projectId: process.env.POUCHY_PROJECT_ID!,
secretKey: process.env.POUCHY_SECRET_KEY!, // drive
adminKey: process.env.POUCHY_ADMIN_KEY!, // read back — does not expire
signing: { source: 'drama-backend', keyId: …, secret: … }
});An admin key proves the PROJECT, never the Provider, so it cannot drive a turn —
the turn door requires a Secret Key and a signature over the exact bytes. And
the delivery queue ACTIONS stay on adminToken on purpose: requeue, rehydrate
and resolve-gap each decide what happens to a reader who is missing a beat, and
resolve-gap tells them it is never coming.
Telling the world a fact you already hold. Your system knows things the model can only guess at. Send them as patches and the world records them, instead of hoping a character proposes the right effect:
await world.runTurn({
environmentId, worldInstanceId,
text: 'I hand over the coin pouch.',
proposedPatches: [{ op: 'set_flag', key: 'paid', value: true }]
});sendEvent takes the same field. Coordinated worlds only — an actor world has
no single commit for a deterministic write to ride, and sending patches to one
answers 422 rather than dropping them quietly.
The ops, in full. A closed union of eight, published in the world OpenAPI from 1.39.0 and listed here because an integrator looking for "a way to move the story without depending on a model's judgement" had no way to find it:
| op | fields | notes |
|---|---|---|
| set_scene | sceneId | must be a declared scene |
| advance_clock | by | on top of the coordinator's own tick of 1 |
| set_location | location | free text |
| set_flag | key, value | the value must match the flag's declared kind |
| reveal_fact | factId | must be a declared fact; append-only |
| complete_node | nodeId | refused by name while a prerequisite is unmet |
| set_relation | between: [a, b], descriptor | symmetric; both must be declared roles |
| set_entity | entityId, descriptor | |
note_private exists in the union but is not available to a provider — it
needs an acting role, and a provider batch has none.
Ordered within the array, so a complete_node can satisfy a later op's
prerequisite in the same call. All-or-nothing: one refused op commits nothing and
the beat answers rejected with a per-op reason in rejectedEffects. At most 20
ops. This is the PROVIDER acting as itself — it widens nothing a role may
propose, and a role's own grant is untouched by it.
Focusing a beat on some of the cast. By default every bound role answers a
beat (up to the server cap). For an interview or a one-on-one scene, pass
focusRoles and only those roles run — the rest are not billed and are
recorded in skippedRoles with code: 'not_focused':
const beat = await world.runTurn({
environmentId, worldInstanceId,
text: 'Amara, what did you see?',
focusRoles: ['amara']
});Shrink-only: the server intersects the focus with the bound story cast, so it
can never widen a beat, and a focus matching no bound role is refused as
no selectable role without spending anything.
Why a role was passed over. Each skippedRoles entry carries a reason
(the human line, which carries the sub-cause and is free to be reworded) and a
code — a closed WorldSkipCode. Key off the code, never the reason.
Reading an error string to decide what happened is how a message edit silently
reclassifies a whole class; this SDK ships the enum precisely so you do not
have to pattern-match prose.
import { skipSpentNoModelCall } from '@pouchy_ai/world-sdk';
for (const s of beat.skippedRoles) {
if (s.code === undefined) audit.unknown(s.roleId, s.reason); // old server
else if (skipSpentNoModelCall(s.code)) cost.free(s.roleId, s.code);
else cost.mayHaveSpent(s.roleId, s.code);
}skipSpentNoModelCall answers false for turn_error even though it often is
free: context assembly failing and a provider dying mid-stream both land there
and cannot be told apart afterwards, so a cost model that counted it free would
under-count silently. It answers false for an unknown code for the same reason.
To read a beat, subtract: a role in selectedRoles and absent from
skippedRoles chose silence; a role listed here never ran.
Resuming after a crash. getTurn returns the same fields the live result
did — nextOptions included — so a recovered session can offer the audience
the choices it was about to. To catch up on beats you missed entirely, store
the last seq you processed and call listTurnsSince:
const missed = await world.listTurnsSince(envId, instanceId, lastSeqIHandled);
for (const beat of missed) render(beat); // in order, no gap, no repeatBoth are additive: a turn committed before world API 1.6 carries none of the
four turn-time facts (selectedRoles, skippedRoles, repairs,
nextOptions). Absent means UNKNOWN, never "none".
Reopening a story. getProgress answers the question a returning reader's
client has — where was I, and can I go on? — without replaying anything.
const p = await world.getProgress(envId, instanceId);
if (p.status !== 'ready') return renderStartFresh(); // narrow FIRST
renderScene(p.checkpoint.currentScene); // may be null
renderCounts(p.checkpoint.completedNodeCount, p.checkpoint.declaredNodeCount);
renderDirections(p.checkpoint.nextOptions);
renderPresence(p.checkpoint.publicFlags); // 0.27.0 — always presentpublicFlags (0.27.0, Story Contract v4) carries the flags the author
declared public: true, sorted by key, with the value the state this
checkpoint reports — the declared initial when never set, null when there is
neither. It is always present (an empty array for a story that publishes
nothing), so a client can gate a character's presence on a runtime exit the
story sets, rather than on a static scene list. Every other flag stays out of
the checkpoint: a flag's name can itself be a spoiler.
Narrowing on status is not politeness: the unavailable arm has no
checkpoint key, so skipping the check reads undefined rather than telling a
reader they never started a story they are halfway through.
Two shapes it does not have. declaredNodeCount is not a denominator — a
branching story never visits every node the author declared, so show the two
counts, never a percentage. And recentProgressRecords is not a summary:
{ seq, kind, at } says a beat happened, not what happened in it; the lines
live behind getTurn / listTurns.
Content return — createScriptDraft, getScriptDraft, listScriptDrafts,
reviewScriptDraft, exportScriptDraft.
Delivery ops — listDeliveries, getDelivery, drainDeliveries,
requeueDelivery, describeDelivery. Deliveries land in ledger order per
session, so a stuck line holds the ones behind it in that session (and only
that session) — blocking on a row is a reader who has stopped receiving the
story. requeueDelivery is the way out; it takes no payload, because the beat
was committed by the coordinator and re-writing it here would make the delivery
plane a second authoring path. There is no discard.
Stuck deliveries — rehydrateDelivery, resolveDeliveryGap,
describeDeliveryResolution. Three verbs in the order to try them: re-send what
is still there, rebuild it from the committed record, or tell the reader the
beat is gone. None of them accepts replacement text, and none of them skips a
beat silently — resolved_gap means the reader was told, and it is the only
non-delivered state a session moves past.
Operations — getWorldMetrics, evaluateWorld, archiveLedger,
replayLedger / replayLedgerToEnd. Metrics keep delivery and turn health as
separate families; the eval suites are deterministic, so a score can be
regressed. archiveLedger defaults to a dry-run plan, execute only ever
copies, and prune — the one call here that deletes — refuses without
confirm, and again unless the archive verifies and has outlived retention.
Production hand-off — createApprovedExport, listApprovedExports,
getApprovedExport, deriveStoryPackageCandidate. An approved editorial draft becomes a versioned
approved script export carrying the whole chain: original story package → world
instance → ledger range → evidence draft → editorial draft → reviewer.
Idempotent on content, so replaying an export is a no-op.
version picks the contract and defaults to 1. ApprovedScriptExportV1 is
frozen: its exportId is a content hash integrators key on, so nothing is added
to it, ever. version: 2 adds what a production workflow needs beyond the
scenes — a synopsis assembled from scene headings, character notes quoting only
committed lines, a mechanical episode split, the beats that completed a story
node, committed relation changes, and where the run diverged from its story. All
of it derived from committed material; no model is called for any of it. The
two contracts hash under separate domains, so one approval exported both ways
yields two different, individually stable ids. content is a union — narrow on
contractVersion before reading version-specific fields, and expect an
unrecognised version to be a 400 rather than a quiet fall back to 1.
listApprovedExports returns summaries — identity, provenance, a scene
count, and no script. getApprovedExport returns one export with its body,
and it is the call an unattended backend makes: with adminKey set it routes to
the /admin mirror, so no browser is involved anywhere in your deployment.
Everything it needs arrives in the world.script_approved webhook, so the loop
closes without a person in the middle. Creating an export is deliberately NOT on
that lane — an export is a reviewer's signature on a specific text.
The Story Package candidate is validated and returned; publishing it is a separate act by a person, and only evidence-origin material becomes canon. It takes version 1 exports only and refuses a V2 export with 422 — it is the one path from an export into published canon, and which V2 fields may become canon has not been decided.
Editorial layer — createEditorialDraft, getEditorialDraft,
listEditorialDrafts, setEditorialStatus, decideEditorialScene,
exportEditorialDraft. The script draft is deterministic: it is assembled from
committed ledger entries and nothing else, and nothing rewrites it. The
editorial draft is a model's reading of that draft — scenes, ordering,
connective prose — stored underneath it. Every line the model presents as
something a character said is re-checked against the committed record on the
server; anything that does not match is stored as origin: "suggestion",
whatever the model labelled it. Suggestions never reach world canon, and only
an approved editorial can be exported.
Helpers — signSourceRequest (the exact canonical the server verifies),
newTurnId / isReservedTurnId (idempotency), describeTurn (read a result
without guessing), WorldApiError with typed codes, .retryable,
serverCode (the server's own code on a refusal — which 409, since code
maps the status), errorId (the server's err_… lookup reference on a
persisted 5xx — quote it in a support request), and rejectedEffects (which of YOUR proposed ops the world
refused, and why — validation is all-or-nothing, so one bad op moves nothing).
When a signed door refuses you
All three signed doors answer one uniform 403 on a bad signature — they will never tell you which of the four things is wrong, because an endpoint that names the failing credential is an oracle. The reason lives in the project's own audit trail instead:
GET https://pouchy.ai/v1/projects/{projectId}/environments/{envId}/preflight
Authorization: Bearer <OwnerToken>That is an OwnerToken — a signed-in project admin's Firebase ID token, not the
Secret Key this client holds, and not the Admin key either (preflight is
deliberately absent from the /admin mirror). There is no SDK method for it for
the same reason: it answers a question a developer asks once while looking at
the dashboard, not one a backend asks in a loop.
It returns the recent refusals with a closed reason vocabulary — missing,
malformed, unknown_key, stale, bad_signature, no_keys — plus how many
audit rows it scanned, because an empty feed is not a clean bill of health.
World SDK — errors and refusals maps
every reason and every WorldApiError code to what to change.
The three things integrators get wrong
1. Turn ids are the idempotency key. Mint one per BEAT and re-send the same
one to retry. A new id is a new beat: it will run the models again and commit
again. newTurnId() exists so this is a deliberate choice rather than a habit.
2. Sign the bytes you send, with the door's own id slot. signSourceRequest
hashes the exact body string. Serializing twice — once to sign, once to send —
signs bytes you did not send, and the server will (correctly) refuse them. This
client always signs the string it is about to write. The fourth canonical line
is the ID SLOT, and it differs per door: turnId on the turns door, eventId
on /events, and the body's own world.request_id on /sessions — a session
mint has neither a turn nor an event. createWorldSession passes it for you; if you
are signing by hand there, an invented or empty id verifies locally and comes
back bad_signature, which reads exactly like a wrong secret.
3. Execution and delivery are different questions. executionStatus says
whether the world moved; deliveryStatus says whether the audience has heard
about it yet. A pending delivery is not a failed turn — the intent was
written inside the commit and a durable outbox is retrying it.
const beat = await world.runTurn({ environmentId, worldInstanceId, text: '…' });
const read = describeTurn(beat);
if (read.shouldRetrySameTurn) { /* conflict — re-send the SAME turnId */ }
if (read.needsDifferentRequest) { /* rejected/refused — fix it, use a NEW id */ }Conformance
node node_modules/@pouchy_ai/world-sdk/conformance.mjsChecks your credentials, signing, world resolution, role bindings, a real turn, turn recovery, state read, replay and draft generation against your OWN project — before you write product code. It prints a pass/fail line per check and exits non-zero on the first structural failure.
Quickstarts
- An interactive drama — screenplay → world → new screenplay draft
- NPCs that share one world — one town, three NPCs, one shared state
- Inside your Next.js app — one route handler, one page, no browser login anywhere in the deployment
Everything else starts at the documentation index.
License
See LICENSE.
