Integrating a production workflow with Pouchy World

Written against WORLD_API_VERSION 1.34.0 and @pouchy_ai/world-sdk 0.31.0.

This document is for the team on the other side of the hand-off: the people whose system takes a finished script and turns it into something an audience watches. It describes what Pouchy gives you, what it deliberately does not, and the exact shape of the boundary between the two.

Everything here is read from the source at the commit that introduced it. Where the code cannot do something a reader might reasonably expect, this document says so in the same voice it uses for everything else — a gap you find in staging is more expensive than one you read about here.


1. The boundary, stated first

Pouchy delivers a reviewed script. That is the whole deliverable.

An interactive world runs, its beats commit to a ledger, a deterministic Evidence Draft joins every line to the turn it came from, an Editorial Draft is a model's reading of that evidence with every claim re-checked, a person approves it, and an approved script export is minted. The export is the end of Pouchy's responsibility.

Not in scope, and not planned here: video, voice, casting, editing, rendering, scheduling, distribution, publication, rights clearance, or any connector to a production system. Pouchy holds no production credentials, calls no external production API, and creates no task in anybody's pipeline. Your workflow owns all of it.

This is a boundary, not a roadmap gap. The pipeline's whole design — evidence before editorial, human review before export, "no automatic publish" enforced by layout rather than by a flag — exists so that what crosses this line has a person's name on it.

What you are receiving

Scenes and lines Each line carries the origin the SERVER verified — evidence with the turn it came from, or suggestion because nothing committed matched it, whatever the model claimed.
Lineage The whole chain: original story package (id, revision, content hash) → world instance → ledger range → evidence draft → editorial draft.
The approval Who approved it, when, and against which exact text.
A content id exportId is a hash of the content. Replaying an export returns the row you already have.

What you are NOT receiving

  • Not a shooting script. humanReviewRequired is a fact of the type in the layers upstream, and suggestionRatio on the export tells you what share of lines the server could not match to a committed one. Read it before reading the prose.
  • Not the script over a webhook. See §8.
  • Not rights, not clearance, not talent. Pouchy tracks provenance inside its own pipeline; it makes no claim about anything outside it.

2. Credentials: which door opens what

Pouchy has four credential types. Getting this wrong is the most common integration failure, so the table is exhaustive rather than convenient.

credential shape lives where opens
Owner token Firebase ID token, ~1 hour a browser sign-in the OWNER plane: authoring, drafts, the editorial loop, the export routes
Project admin key pchy_admin_… your server the /v1/admin MIRROR: a fixed list of world READS
Project secret key pchy_sk_… your server the MACHINE lane: sessions, turns, trusted events
Source signing key HMAC key pair your server proves the PROVIDER, alongside the secret key

The fact that shapes your integration

Creating an export is a human act. Reading one is not.

POST …/approved-export and POST …/approved-export/candidate require requireProjectAccess(request, projectId, 'admin') — a signed-in user's Firebase ID token. That is deliberate and permanent: an export is a reviewer's signature on a specific text, and a machine credential that could mint one could sign on their behalf.

Reading the body needs no browser. GET …/approved-export/{exportId} is mirrored on the project admin key at the matching /v1/admin/... path, so an unattended backend fetches an approved script with a long-lived pchy_admin_ credential. In the SDK that is getApprovedExport(...), which routes to the admin plane automatically when adminKey is set — the same mechanism as getProgress and getTurn.

The loop, with nobody in the middle:

  1. A person approves the editorial draft and creates the export in the dashboard.
  2. world.script_approved reaches your endpoint with identifiers and lineage — never the script (§8).
  3. Your backend calls getApprovedExport(...) with fields taken straight from that webhook and receives the body.

Everything step 3 needs is in the payload: exportId at the top level, and environmentId, worldInstanceId, evidenceDraftId and editorialDraftId inside lineage. No other source is required, and a test asserts exactly that rather than leaving it as a claim.

The collection GET does not carry bodies, and never has. It answers summaries — identity, provenance, a scene count — so a caller that wants a script asks for one by id. An earlier revision of this document said an operator could forward "the row" from that listing; that was wrong, and it is corrected here.

Which contract the dashboard produces

Corrected. This section used to say the export button posted {} and so always produced V1. It no longer does: the card offers a contract-version choice, and a V2 export has been produced from it against a live deployment.

V1 is still the default, and deliberately — omitting version is what keeps the request byte-identical to what every caller written before V2 has always sent. A reviewer who wants V2 picks it explicitly, in the UI or with version: 2 on the API or SDK. If your workflow needs V2, say so during onboarding, because nobody will send it by accident.


3. The hand-off, end to end

world beats commit to the ledger
  → Evidence Draft            deterministic; every line joined to its turn
  → Editorial Draft           a model's reading; every claim re-checked server-side
  → HUMAN REVIEW              scene by scene: accept / edit / reject
  → status: approved          the draft FREEZES; reviewer, time and content digest stamped
  → POST …/approved-export    version 1 (default) or version 2 (explicit) — HUMAN
  → world.script_approved     signed webhook: identifiers + lineage, never the script
  → GET …/approved-export/{exportId}
                              the body — owner token OR project admin key (§2)

The route, in full:

/v1/projects/{projectId}/environments/{envId}/instances/{worldInstanceId}
   /script-drafts/{draftId}/editorial/{editorialId}/approved-export

GET on the collection lists exports without their bodies (at most 50, newest first, each row carrying a scene count). POST mints one, owner plane only. GET …/{exportId} returns one with its body, and is the route mirrored on the admin key; it is served Cache-Control: no-store, because the body is the script and it should not sit in a shared cache.

Any miss on that item read — wrong project, environment, instance, draft, editorial, or simply no such id — answers the same 404. They are not told apart, so refusals cannot be used to map which objects exist.


4. Choosing a contract

POST …/approved-export takes an optional version.

{ "notify": true }              // → V1. The default, and byte-identical to
                                //   what every pre-V2 caller received.
{ "notify": true, "version": 2 } // → V2.

The default is 1. Omitting version sends no version at all. A caller written before V2 existed receives exactly what it always did.

An unrecognised value is a 400. 0, 3, -1, 1.5, "2", null, true — all refused, none coerced. "The default is V1" must not also mean "a typo gets V1": a caller who believes it asked for V2 and silently shipped against V1 is the failure this refusal exists to prevent.

Discriminating a received export

content.contractVersion is the discriminant, and it is a literal type on both sides of the union. Narrow before reading anything version-specific:

import type { ApprovedScriptExportRow } from '@pouchy_ai/world-sdk';

function ingest(row: ApprovedScriptExportRow) {
  const c = row.content;
  if (c.contractVersion === 2) {
    // c.episodes, c.keyMoments, c.characterNotes, c.relationChanges,
    // c.divergence, c.synopsis, c.review.approvalTimeSource
  } else {
    // V1: scenes, characters, stateChanges, lineage, review
  }
}

Reading c.synopsis without narrowing is a TypeScript error as of 0.18.0. If your build starts failing there on upgrade, that is the union doing its job.

The two ids are different, and that is correct

V1 hashes under the domain prefix approvedscript1, V2 under approvedscript2. One approval exported both ways yields two different exportIds, with different shapes: asx_… and asx2_…. Both are stable. They differ because they carry different content, not because anything is wrong.

To be precise about what domain separation is: it does not make a hash collision mathematically impossible. It means the same approval, read under the two contracts, never shares a preimage — so a V2 id can never be mistaken for the V1 id of the same approval.

V1 is frozen. Nothing is added to it, ever. Its exportId is a value integrators key on, and a new field would silently re-mint the id of every export produced from an approval that was already exported. Stored rows are never rewritten, re-digested or migrated: a row written as V1 stays V1 forever.


5. Accountability: who signed off on what

Four fields carry the responsibility boundary. Treat them as the record of a human decision, because that is what they are.

review.reviewer and review.approvedAt

Stamped when the draft ENTERS approvedapprovedBy and approvedAt on the editorial row, copied into the export. Not the last-edit time.

approved → changes_requested is the only legitimate way to reopen a draft, and it deletes all three approval fields. A re-approval is a new approval, with its own time, its own approver and its own digest. approved → exported keeps them, because that export IS the approval being spent.

review.approvalTimeSource — V2 only

  • stampedapprovedAt is the moment the draft entered approved.
  • legacy_updated_at — this approval predates the stamp, so the row's LAST-TOUCH time stood in. That is what the export always used to do, silently; V2 says it out loud.

If a legal or contractual decision turns on the approval time, stamped is the only value you may rely on. A legacy_updated_at time is the last time the row was written, which may be after the approval.

In V1 this claim lives on the storage row only, never in the content — adding it to V1 would have changed the digest of every export ever produced. Both versions carry it on the row; only V2 carries it in the content, and inside the digest.

approvedContentDigest — the binding

An approval is a statement about a specific text, and the code enforces exactly that:

  • On entering approved, the server computes editorialApprovalDigest(content, sceneDecisions) — a domain-separated hash (editorialapproval1) over the scenes and the scene decisions, with decision keys sorted — and stores it.
  • While approved, the draft is closed to scene edits: decideEditorialScene answers 409. approved, rejected and exported are all closed.
  • At export time the digest is recomputed and compared. A mismatch is a 409: "this editorial draft changed after it was approved — re-approve it before exporting". This check is identical for version: 1 and version: 2; V2 gets no second, looser path to the same door.

Rows approved before the digest existed carry none and are exported on the old terms rather than refused. That is a deliberate compatibility choice, and the row's approvalTimeSource is how you tell such a row apart.

What this does and does not prove

It proves: this text is the text that person approved, and it has not changed since. It does not prove identity beyond your project's own auth, and it is not a digital signature you can verify independently of Pouchy. If you need non-repudiation, sign the export on receipt with your own key.


6. What V2's fields mean, exactly

Every V2 field is derived from material the pipeline already committed. No model is called to produce any of them — that is asserted in the test suite, not merely intended. Each has an edge worth knowing before you build on it.

synopsis

The surviving scene headings, in order, joined with /, capped at 2000 characters.

It is assembled, not written. It is not prose, and it will not read like prose. If your workflow needs an authored logline, that is a person's job or a generator designed and reviewed as one. Do not put this in front of an audience.

episodes

Scenes chunked at APPROVED_SCRIPT_V2_EPISODE_SCENES = 5, in order. episodeId is ep_1, ep_2, … ; title is the first scene's heading (falling back to its sceneId if the heading is empty); synopsis is that chunk's headings joined.

This is a MECHANICAL split, not an editorial one. Nobody decided that an episode ends after five scenes. It is a stable, reproducible starting point for a producer to move. Treat a boundary as a default to override, never as a creative judgement — and note that because episodes are inside the digest, re-chunking would produce a different exportId, which is deliberate.

keyMoments

One entry per committed beat that completed at least one story node — where the plot provably moved. Carries sourceTurnId, seq, completedNodeIds, and a summary joining that beat's state-change summaries with ; . Capped at 100.

"Key" means "completed a story node", not "dramatic". Which moments matter to an audience is a judgement, and this pipeline reserves judgements for people. A beat with wonderful dialogue that advanced no node does not appear here — it is still in scenes.

relationChanges

Committed set_relation state changes, carrying the Evidence layer's own formatted summary and the sourceTurnId. Capped at 100.

Deliberately NOT { between: [a, b], from, to }. The structured role pair is not present in the evidence draft — that layer stores a formatted "a ↔ b: descriptor" summary — and recovering it would mean parsing a string that can legitimately contain the separator, in either a role id or a descriptor. Inventing structure by parsing prose is the guessing this pipeline reserves for people. If your workflow needs the pair as data, parse it at your own risk and validate against characters, or ask for it as a product change to the Evidence layer.

characterNotes

One per character who speaks in the surviving scenes: roleId, storyRoleId where known, a line count, the sceneIds they appear in, and firstLine — their first evidence-origin line, capped at 200 characters.

A character who only ever spoke SUGGESTED lines gets a note with no firstLine. That is not missing data. A model's connective sentence must not be read as characterisation, and the absence is how you tell. A narrator the model invented for continuity will typically appear exactly this way.

Note also that this is structure, not prose: there is no written "note". With no model call there is nothing to write, and a mechanical sentence in a field called note would be worse than the counts and ids it stands on.

divergence

Where the run stands against the story it started from. Two kinds, both facts:

  • unresolved_objective — a story node never completed (nodeId, description from the node's objective).
  • branch_reached — a branch condition the run reached (branchId, description from the condition text).

Capped at 100 across both kinds.

It never says what "should" have happened. There is no counterfactual here and there will not be one.

The rule that ties them together: a rejected scene stays rejected

keyMoments and relationChanges are restricted to turns belonging to surviving scenes. If a reviewer rejects a scene, its beats do not walk back in under a different field name — otherwise the rejection would be cosmetic.

divergence and lineage are deliberately not filtered. They are facts about the run, and rejecting a scene does not un-complete a node or change where the material came from. If you present divergence next to scenes, expect it to reference nodes whose scenes a reviewer removed; that is correct.

An empty derived array is omitted, not empty

If a run reached no branch and left no objective open, divergence is absent rather than []. "This run reached nothing" and "nobody asked" would read the same as an empty array; an omitted key says the honest one. Handle undefined.


7. Idempotency, retries, validation, rollback

Idempotency

exportId is the content digest, and the store writes with create(). That makes the write itself the idempotency check:

  • Same approval exported twice → the second call returns the existing row with created: false and HTTP 200. A first create is 201.
  • There is no window in which a retry produces a second script. Retry the POST freely.
  • The id is derived from content, so an export you have seen before is an export you can recognise without storing a request id.

Retries and refusals

Every refusal happens before any write. On a 400 (bad version), a 409 (unapproved draft, drifted content, every scene rejected) or a 404, nothing was stored — fix the cause and retry the same request.

Validation on your side

Two things are worth validating on receipt, and both are cheap:

  1. Recompute nothing, but check the pair. exportId should equal asx_${exportDigest} for V1 and asx2_${exportDigest} for V2. A mismatch means something between us rewrote the row.
  2. Read suggestionRatio before the prose. It is the share of lines the server could NOT match to a committed line. A high ratio is not an error; it is a fact about how much of this script is a model's connective tissue.

Rollback

Every field V2 added is optional, and V1 rows are untouched. A deployment rolled back to a build that predates V2 reads rows the newer build wrote and ignores what it does not recognise. That property is why the addition was safe to make; preserve it on your side by ignoring unknown fields rather than rejecting them.

If you pin an API version, note that a newer world API always serves an older SDK — every world API change so far has been additive — but an SDK newer than the deployed API will 404 on routes that have not shipped yet. See §9.


8. Webhook security

world.script_approved fires when an export is created with notify: true.

The payload carries identifiers, never the script

{
  "id": "evt_…",
  "type": "world.script_approved",
  "created": 1756000000,
  "data": {
    "exportId": "asx_…",
    "exportDigest": "…",
    "contractVersion": 1,
    "title": "…",
    "lineage": { /* package, instance, ledger range, draft ids, model, prompt version */ },
    "reviewer": "…",
    "approvedAt": "…",
    "scenes": 12,
    "suggestionRatio": 0.18
  }
}

No dialogue, no scene text, no state changes. A webhook payload lands in logs, proxies and third-party queues, and a full script is not something to scatter across them. The body comes from the authenticated route instead.

contractVersion in the payload tells you which contract to expect before you fetch anything.

Verifying the signature

Header: X-Pouchy-Signature: t=<unix seconds>,v1=<hex>

v1 is HMAC-SHA256(endpoint_secret, "${t}.${rawBody}"). Verify over the raw bytes you received, before any JSON parse or re-serialisation.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=').map((s) => s.trim()))
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;   // replay window
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(String(parts.v1 ?? ''), 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Use a constant-time compare. Reject on a stale t — the signature alone does not stop a replay.

Delivery behaviour you must design for

Transport HTTPS only. A non-https endpoint is refused at registration, and a redirect down to plaintext is blocked per hop.
Timeout 4 seconds per attempt. Acknowledge fast and do the work asynchronously.
Attempts 4 total — one original plus three retries at 5 min → 30 min → 2 h.
Retry signing Re-signed with a FRESH timestamp over the ORIGINAL body. The event id stays stable across retries.
Endpoints At most 20 per project; the event fans out to every subscribed endpoint.

Deduplicate on the event id, not on receipt order. Because retries reuse the id, a retry of a delivery that actually succeeded (your 200 lost on the way back) arrives as the same event. And because exportId is a content hash, the export itself is idempotent even if you miss the dedupe.

After four failed attempts the event is dropped. There is no dead-letter replay for webhooks. If your endpoint was down for more than about two and a half hours, reconcile by listing exports rather than waiting for a redelivery.


9. Release order: deploy before publish

The rule, from the release runbook: publishing does not deploy. An npm version that names endpoints Production has not deployed yet is a broken package with a green publish.

The order, and it is not negotiable:

  1. Merge the API change to main.
  2. Deploy to the target environment (Vercel).
  3. Verify the deployhttps://pouchy.ai/api/version?t=<random>, in a NEW tab or a fresh connection, and compare runtime.sha to the merged commit. A keep-alive connection pinned to an old edge has read a stale deployment for an hour before now; the cache-buster and the fresh connection are the fix.
  4. Conformance, if you have a deployed environment to point at: node conformance.mjs --scenario=drama and --scenario=npc.
  5. Publish the SDK: node release-check.mjs must report PREPARED — NOT PUBLISHED with 12/12, then npm publish --access public.
  6. Verify the publish: npm view @pouchy_ai/world-sdk version.

For your side of the boundary, the same order inverted: do not upgrade your SDK pin until the API version you need is deployed. WORLD_API_VERSION is served at /v1 and in the OpenAPI document's info.version, and it is also returned as x-pouchy-world-api-version. Check it during onboarding and after any upgrade.

The compatibility matrix in docs/world-sdk-release-runbook.md maps every SDK version to the world API version it was built against. For this document: SDK 0.31.0 ↔ world API 1.34.0.


10. Integration checklist

Every command here runs locally against this repository. None of them contacts a production system, a real world, a model provider, or any external service. Run them from the repository root.

A. The contract you are building against

# The world API version this build serves.
grep -n "WORLD_API_VERSION = " src/lib/server/platform/world-openapi.ts

# The SDK version, in both places it is declared — they must agree.
grep -n '"version"' packages/world-sdk/package.json
grep -n "WORLD_SDK_VERSION = " packages/world-sdk/src/index.ts

B. The export contract is what this document says

# V1's frozen shape: golden vectors, canonical bytes, key sets.
npx vitest run src/lib/world/approved-script-export.v1.frozen.test.ts

# V2's semantics, isolation from V1, and the derived-field rules.
npx vitest run src/lib/world/approved-script-export.v2.test.ts

# The storage and hand-off side: default V1, explicit V2, idempotency,
# the approval-digest binding, and the webhook payload.
npx vitest run src/lib/server/world/approved-export-v2.test.ts

C. The credential model is what §2 says

# Which world reads are mirrored on an admin key, and which are withheld.
# The export routes appear in NEITHER list — they are owner-plane only.
npx vitest run src/lib/server/world/world-admin-mirror.drift.test.ts

# Every documented credential prefix matches what the code actually mints.
npx vitest run src/lib/server/world/credential-prefix.drift.test.ts

D. The webhook behaviour is what §8 says

# The signature canonical and the retry ladder (`signWebhookPayload`,
# `nextRetryDelayMin`).
npx vitest run src/lib/server/platform/platform.test.ts

# The retry queue's claim discipline — an attempt is claimed once, so a
# concurrent sweep cannot double-deliver.
npx vitest run src/lib/server/platform/webhook-retry-claim.test.ts

# The delivery guards: https re-pin per redirect hop, and the truncation
# case that would otherwise ship a mid-JSON payload under a valid signature.
npx vitest run src/lib/server/platform/audit235-webhooks.test.ts

E. The SDK artifact is sound

cd packages/world-sdk && node release-check.mjs

Expect "passed": 12, "failed": 0, "status": "PREPARED — NOT PUBLISHED". That means the artifact is sound. It does not mean anything was released.

F. Your own receiver, without touching Pouchy

Two things you can test end to end with no network at all:

  1. Signature verification. Take the verify() function in §8, feed it a body and a header you construct with the same HMAC, and assert it returns true — then flip one byte of the body and assert it returns false. If the second assertion passes trivially because your comparison throws, you have found a bug worth finding now.
  2. Union narrowing. Build a fixture for each contractVersion and run your ingest path over both. The V1 fixture must not reach any V2 branch, and your handler must survive divergence being undefined rather than [].

G. Before you go live

  • Your endpoint is HTTPS and answers within 4 seconds.
  • You verify X-Pouchy-Signature over raw bytes, in constant time, with a timestamp tolerance.
  • You deduplicate on the event id.
  • You reconcile by listing exports if your endpoint was down for more than ~2.5 hours — there is no webhook redelivery after four attempts.
  • You narrow on content.contractVersion before reading V2 fields.
  • You ignore unknown fields rather than rejecting them.
  • You have agreed with the operator which contract version you receive — the dashboard button produces V1 (§2).
  • You have agreed how the script body reaches you, given that it is not machine-fetchable (§2).
  • You read suggestionRatio and approvalTimeSource before treating a script as final.

11. Known gaps, named

These are limits of the code as it stands, not oversights in this document.

  1. The export body is not machine-fetchable. Closed by the GET …/approved-export/{exportId} item read and its admin mirror. Creating an export is still a human act on the owner plane, and always will be.
  2. The dashboard cannot produce V2. Closed — the export card offers a contract-version choice and V1 remains the default. See §4.
  3. relationChanges carries prose, not a role pair. §6 explains why, and what would have to change.
  4. synopsis is assembled, not authored, and episodes is a mechanical split. Neither is an editorial judgement, and this document would rather say so twice than have one of them reach an audience unexamined.
  5. There is no webhook dead-letter replay. Four attempts, then dropped.
  6. Browser acceptance is substantially done, not finished. The export card, runtime progress and the consistency scan all passed against a live deployment; the out-of-order validation guard is now proved at component level. What remains is a timeline case needing an instance of more than 200 beats. See docs/world-batch10-browser-acceptance.md. None of it affects the export contract, which is covered by the tests in §10.
  7. External Beta and Production are NO-GO. See docs/world-pilot-gates.md. This document describes how to integrate; it does not say the platform has been cleared for external traffic.