Quickstart: a Pouchy Experience inside your own Next.js app
One route handler and one page. No official Pouchy app, no project credential in the browser, and no browser login anywhere in your deployment.
Everything below runs on credentials a server can hold. That was not true before world API 1.7: the reads required a Firebase ID token that expires within the hour, so a backend could drive a turn and then needed a human at a browser to find out what it did.
0. What you need
Four values, all minted once from the dashboard:
| env var | where | note |
|---|---|---|
POUCHY_PROJECT_ID |
any dashboard URL | |
POUCHY_SECRET_KEY |
Settings → Keys | pchy_sk_… — carries the test/live axis |
POUCHY_ADMIN_KEY |
Settings → Admin keys | pchy_admin_… — long-lived, this is what makes the app unattended |
POUCHY_SOURCE_KID / POUCHY_SOURCE_SECRET |
Capabilities → Event source | the source name must equal the world's providerRef |
None of these belongs in the browser. They live in server-side environment variables and are read only inside route handlers. The value your page receives is a session token scoped to one instance and one role, which carries no project credential at all.
1. One client, server-side only
lib/pouchy.ts:
import 'server-only';
import { PouchyWorldClient } from '@pouchy_ai/world-sdk';
export const world = new PouchyWorldClient({
projectId: process.env.POUCHY_PROJECT_ID!,
secretKey: process.env.POUCHY_SECRET_KEY!, // drive: sessions, turns, events
adminKey: process.env.POUCHY_ADMIN_KEY!, // read back: state, timeline, turns
signing: {
source: process.env.POUCHY_SOURCE!,
keyId: process.env.POUCHY_SOURCE_KID!,
secret: process.env.POUCHY_SOURCE_SECRET!
}
});
import 'server-only' is not decoration. It makes a stray client import a build
error rather than a leaked key.
2. One route handler
app/api/story/route.ts:
import { world } from '@/lib/pouchy';
import { describeTurn } from '@pouchy_ai/world-sdk';
const ENV = process.env.POUCHY_ENVIRONMENT_ID!;
export async function POST(req: Request) {
const { userId, text, turnId } = await req.json();
// Deterministic per (project, environment, user): the same user returns to
// the same story, and you do not have to store the instance id yourself.
const session = await world.createWorldSession({
environment: ENV,
role: 'player',
externalUserId: userId
});
const instanceId = (session.world as { instance: string }).instance;
// turnId is the idempotency key. Mint it on the CLIENT for a new beat and
// re-send the same one to retry — a retry re-runs no model and cannot commit
// twice.
const beat = await world.runTurn({
environmentId: ENV,
worldInstanceId: instanceId,
turnId,
text
});
const read = describeTurn(beat);
return Response.json({
lines: beat.roleMessages,
options: beat.nextOptions,
stateRevision: beat.afterStateRevision,
// Tell the client what to DO, rather than making it interpret a status.
retryable: read.shouldRetrySameTurn,
needsEdit: read.needsDifferentRequest
});
}
3. Resuming — the part most integrations skip
Your process will restart mid-story. Store the last seq you rendered per
instance, then ask for exactly what you missed:
export async function GET(req: Request) {
const url = new URL(req.url);
const instanceId = url.searchParams.get('instance')!;
const lastSeq = Number(url.searchParams.get('since') ?? 0);
const missed = await world.listTurnsSince(ENV, instanceId, lastSeq);
const state = await world.getWorldState(ENV, instanceId);
return Response.json({ missed, state });
}
listTurnsSince follows the cursor to the end: in order, no gap at a page
boundary, no beat twice. Reading "the latest N and diff" instead works until the
gap is wider than N, which is precisely when you needed it.
If you lost a single response rather than a stretch of them, getTurn(env, instance, turnId) returns that beat in the same shape the live call did,
nextOptions included, so the page can offer the choices it was about to.
4. One page
app/story/page.tsx:
'use client';
import { useState } from 'react';
export default function Story() {
const [lines, setLines] = useState<{ roleId: string; message: string }[]>([]);
const [options, setOptions] = useState<{ branchId: string; condition: string }[]>([]);
const [busy, setBusy] = useState(false);
async function say(text: string) {
setBusy(true);
// Minted here so a retry of a failed request re-sends the SAME id.
const turnId = `turn_${crypto.randomUUID()}`;
const res = await fetch('/api/story', {
method: 'POST',
body: JSON.stringify({ userId: 'user_1', text, turnId })
});
const beat = await res.json();
setLines((prev) => [...prev, ...beat.lines]);
setOptions(beat.options ?? []);
setBusy(false);
}
return (
<main>
{lines.map((l, i) => <p key={i}><b>{l.roleId}:</b> {l.message}</p>)}
{options.map((o) => (
<button key={o.branchId} disabled={busy} onClick={() => say(o.condition)}>
{o.condition}
</button>
))}
</main>
);
}
The page holds no Pouchy credential. It talks to your route handler, which talks to Pouchy.
5. Telling the world something you already know
When your own system settles a fact, commit it with the beat rather than hoping a character narrates it correctly:
await world.sendEvent({
name: 'payment.settled',
environment: ENV,
worldInstance: instanceId,
data: { amount: 1200 },
proposedPatches: [{ op: 'set_flag', key: 'paid', value: true }]
});
Coordinated worlds only. On an actor world this answers 422 and says so — it does not accept the patches and drop them.
6. What to check when something is refused
| you see | it means |
|---|---|
| 401 on a read | the admin key is wrong, or you sent the Firebase token instead |
| 403 on a turn | the source signature — check source equals the world's providerRef, and that you signed the exact bytes you sent |
| 409 | the world pins a story package that is gone, revoked, or unpublished |
| 422 with "actor mode" | proposedPatches on a world that has no coordinator |
completionStatus: 'conflict' |
the world moved underneath you; re-send the SAME turnId |
completionStatus: 'rejected' |
the request itself needs changing; read rejectedEffects[].code — only narrative_conflict is story, every other code is a defect |
A 403 on a SIGNED door never says which of the four things is wrong — that is a
deliberate refusal to be an oracle, not a gap. The reason is in the project's
own audit trail: GET …/environments/{envId}/preflight, which needs an
OwnerToken (a signed-in admin's Firebase ID token), not the Secret Key you
are already holding and not the Admin key either. docs/world-sdk-errors.md
maps every reason code — and note that the id slot signed into the canonical
string differs per door: world.request_id on /sessions, turnId on the
turns door, eventId on /events.
Not covered here
The content loop (script drafts, editorial review) still needs adminToken, a
signed-in project admin's Firebase ID token. That one is deliberate: the human
review step is the point of the loop rather than an obstacle to automate.
Authoring — story packages and world definitions — used to be on that list and no longer is. As of SDK 0.12.0 it goes through the machine lane like everything else, so nothing in this quickstart needs a browser login.