Quickstart: a short drama that the audience can change

No official app, no UI from us. This is the whole path from a screenplay you already have to a NEW screenplay draft that came out of people playing with it:

original screenplay (your JSON)
  → Story Package        (versioned narrative content)
  → World                (roles bound to agents, runtimeMode: coordinated)
  → world session        (one user, one role)
  → user turn            (the audience changes the plot)
  → coordinated beat     (several characters answer, one state commits)
  → trusted event        (your backend's own story events)
  → Story Ledger         (the committed record)
  → ScriptDraft          (structured material, every line traceable)
  → human review         (a writer reads it — the gate is not optional)
  → export               (JSON you can turn into the next screenplay)

Everything below runs on @pouchy_ai/world-sdk from a Node backend. The two credentials it holds — a project Secret Key and an event-source signing secret — never go near a browser or a phone.

0. What you need

  • a project, and a project Secret Key (pchy_sk_…);
  • an event source provisioned for the project, with its signing key (kid + secret). The source name is the world's providerRef;
  • one published agent per role;
  • an owner token (a signed-in project admin) for the authoring calls.
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!,     // authoring + reads
  secretKey: process.env.POUCHY_SECRET_KEY!,        // machine lane
  signing: {
    source: 'drama-backend',                        // == the world's providerRef
    keyId: process.env.POUCHY_SOURCE_KID!,
    secret: process.env.POUCHY_SOURCE_SECRET!
  }
});

1. The screenplay becomes a Story Package

A Story Package is versioned narrative CONTENT: canon, roles, scenes, plot nodes, established facts, constraints, branches, endings, a typed flag schema, who may see which fact, and what each role is allowed to change.

The original screenplay stays a reference{ title, version } — never stored bytes. Pouchy does not want your script; it wants the structure you are willing to let an audience move.

const pkg = await world.createStoryPackage({
  name: 'Harbor Drama S1',
  summary: 'A three-role harbor mystery.',
  source: { title: 'Harbor Drama (original screenplay)', version: 'v2.3' },
  canon: ['The lighthouse has been dark for ten years.'],
  roles: [
    { storyRoleId: 'heroine', worldRoleId: 'heroine',
      goals: ['find the truth'],
      effectsAllow: ['set_flag', 'reveal_fact', 'complete_objective'] },
    { storyRoleId: 'villain', worldRoleId: 'villain',
      secrets: ['he sank the ferry himself'],
      effectsAllow: ['set_flag', 'update_relationship'] },
    { storyRoleId: 'hero', worldRoleId: 'hero' }        // no grant = proposes nothing
  ],
  scenes: [{ sceneId: 'docks', title: 'Docks', description: 'Night, rain.' }],
  nodes: [{ nodeId: 'n1', sceneId: 'docks', title: 'The page surfaces',
            objective: 'the ledger page reaches someone who will act', prerequisites: [] }],
  establishedFacts: [{ factId: 'fact.ledger', text: 'The ledger names the harbormaster.' }],
  branches: [{ branchId: 'b1', fromNodeId: 'n1',
               condition: 'the page goes public', toNodeId: 'n1' }],
  stateSchema: { flags: [{ key: 'page_public', kind: 'boolean', initial: false }] },
  visibility: { factDefault: 'role-scoped', factAllow: { heroine: ['fact.ledger'] } }
});

effectsAllow is default deny: a role with no grant can speak but cannot change anything. That is a feature — most characters in most scenes should not be able to move the plot.

2. The World binds roles to agents

const created = await world.createWorld({
  name: 'Harbor Drama World',
  providerRef: 'drama-backend',
  runtimeMode: 'coordinated',                 // EXPLICIT — see the note below
  storyPackageRef: { packageId: pkg.packageId, revision: pkg.revision, contentHash: pkg.contentHash },
  capabilityAllow: { views: [], actions: [], events: ['story.beat'] },
  roles: [
    { roleId: 'heroine', agentId: 'agent_heroine', eventSubscriptions: ['story.beat'] },
    { roleId: 'villain', agentId: 'agent_villain', eventSubscriptions: ['story.beat'] },
    { roleId: 'hero',    agentId: 'agent_hero' }
  ]
});

runtimeMode is never inferred. Omitted, a world runs the actor runtime (one wake per subscribed role, each answering independently). coordinated is what gives you a multi-role beat with one shared state — and it is a published decision, so an existing story never changes runtime underneath its players.

3. A session for one user in one role

const session = await world.createWorldSession({
  environment: created.environmentId,
  role: 'heroine',
  externalUserId: 'user-42'          // YOUR id for this person
});
// session.session_token → hand to YOUR frontend, which uses @pouchy_ai/companion-sdk
//                          (the field is session_token, NOT token)
// session.world.instance  → the world instance id you drive turns against

The minted token is scoped to that instance and that role. Your Secret Key stays on your server.

4. The audience changes the plot

const turnId = newTurnId();          // keep it: retrying means re-sending it
const beat = await world.runTurn({
  environmentId: created.environmentId,
  worldInstanceId: session.world.instance,
  turnId,
  text: 'Unlike the original script, I hand the ledger page to the whole crowd.'
});

const read = describeTurn(beat);
// read.worldMoved       — did the state commit
// read.audienceHeard    — did every line reach its session yet
// read.shouldRetrySameTurn / read.needsDifferentRequest

What happened server-side, in order: the coordinator pinned the world revision, the story revision and the state; selected the cast from the story's own role list; ran each role against ITS OWN projection (the villain's secret never enters the heroine's prompt); settled the roles' typed effect proposals; and committed once. Messages are delivered only after that commit — and the intent to deliver them was written inside it, so a crash costs latency and never a message.

beat.roleMessages[].fallback === true marks a neutral beat: that character's correction failed, so the platform delivered "they did not act" rather than letting them vanish from the scene.

5. Your own story events

await world.sendEvent({
  name: 'story.beat',
  environment: created.environmentId,
  worldInstance: session.world.instance,
  data: { playerId: 'user-42', beat: 'the harbormaster arrives' }
});

On a coordinated world this becomes ONE beat with the subscribed cast. The turn id is derived from your eventId, so re-delivering the same event runs no model, commits nothing and sends nothing twice.

6. Recovery, when your process dies mid-turn

try {
  await world.runTurn({ /* … */ turnId });
} catch (err) {
  // Lost the response? The turn either committed or it did not — ask.
  const committed = await world.getTurn(envId, instanceId, turnId).catch(() => null);
  if (!committed) { /* nothing happened; re-send the SAME turnId */ }
}

A 404 means the turn never committed: there is no "maybe" state to report. That is the whole reason state commits before anything is published.

7. Prove the world is what its history says

const report = await world.replayLedgerToEnd(envId, instanceId);
// verdict: 'consistent' | 'drift' | 'missing_entries' | 'illegal_patch' | …

Always a dry run. A drift verdict is a finding for a human — this endpoint has no repair mode and no flag that turns one on.

8. The new screenplay draft

const draft = await world.createScriptDraft(envId, instanceId);
// draft.content.beats[].turnId  — every line traces to the beat that produced it
// draft.content.humanReviewRequired === true

await world.reviewScriptDraft(envId, instanceId, draft.draftId, 'good bones, cut beat 2');
const exported = await world.exportScriptDraft(envId, instanceId, draft.draftId);

Before review, export refuses with 409. The draft reads only COMMITTED beats, never carries a role's private layer, and never claims an ending was reached — deciding that a story ended is exactly the judgement this pipeline reserves for a person.

What to check when something is refused

You see It means
403 on a turn wrong lane, bad/missing signature, or the world/provider is no longer live
409 runtimeMode the world runs actor; publish a revision with coordinated
completionStatus: 'conflict' someone else moved the world; re-send the SAME turnId
completionStatus: 'rejected' your proposed effects were refused; fix them and use a NEW turnId
deliveryStatus: 'pending' the state committed; delivery is queued and will retry
409 on export nobody has reviewed the draft yet

Run node conformance.mjs from the package to check all of this against your own project before you write any product code.