npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-sdk

Node ≥ 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

AuthoringcreateStoryPackage, publishStoryPackage, getStoryPackage, listStoryPackages, createWorld, publishWorld, getWorld, listWorlds.

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

Deliberatingdeliberate, 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.

ReadinggetWorldState, 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 repeat

Both 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 present

publicFlags (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 returncreateScriptDraft, getScriptDraft, listScriptDrafts, reviewScriptDraft, exportScriptDraft.

Delivery opslistDeliveries, 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 deliveriesrehydrateDelivery, 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.

OperationsgetWorldMetrics, 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-offcreateApprovedExport, 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 layercreateEditorialDraft, 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.

HelperssignSourceRequest (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.mjs

Checks 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

Everything else starts at the documentation index.

License

See LICENSE.