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.
humanReviewRequiredis a fact of the type in the layers upstream, andsuggestionRatioon 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:
- A person approves the editorial draft and creates the export in the dashboard.
world.script_approvedreaches your endpoint with identifiers and lineage — never the script (§8).- 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
GETdoes 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 approved — approvedBy 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
stamped—approvedAtis the moment the draft enteredapproved.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 computeseditorialApprovalDigest(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:decideEditorialSceneanswers 409.approved,rejectedandexportedare 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: 1andversion: 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 againstcharacters, 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,descriptionfrom the node's objective).branch_reached— a branch condition the run reached (branchId,descriptionfrom 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: falseand 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:
- Recompute nothing, but check the pair.
exportIdshould equalasx_${exportDigest}for V1 andasx2_${exportDigest}for V2. A mismatch means something between us rewrote the row. - Read
suggestionRatiobefore 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:
- Merge the API change to
main. - Deploy to the target environment (Vercel).
- Verify the deploy —
https://pouchy.ai/api/version?t=<random>, in a NEW tab or a fresh connection, and compareruntime.shato 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. - Conformance, if you have a deployed environment to point at:
node conformance.mjs --scenario=dramaand--scenario=npc. - Publish the SDK:
node release-check.mjsmust reportPREPARED — NOT PUBLISHEDwith 12/12, thennpm publish --access public. - 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:
- 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. - Union narrowing. Build a fixture for each
contractVersionand run your ingest path over both. The V1 fixture must not reach any V2 branch, and your handler must survivedivergencebeingundefinedrather than[].
G. Before you go live
- Your endpoint is HTTPS and answers within 4 seconds.
- You verify
X-Pouchy-Signatureover 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.contractVersionbefore 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
suggestionRatioandapprovalTimeSourcebefore treating a script as final.
11. Known gaps, named
These are limits of the code as it stands, not oversights in this document.
The export body is not machine-fetchable.Closed by theGET …/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.The dashboard cannot produce V2.Closed — the export card offers a contract-version choice and V1 remains the default. See §4.relationChangescarries prose, not a role pair. §6 explains why, and what would have to change.synopsisis assembled, not authored, andepisodesis 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.- There is no webhook dead-letter replay. Four attempts, then dropped.
- 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. - 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.