@reactor-models/happy-oyster
v1.0.0
Published
Strongly-typed SDK for the HappyOyster model on Reactor
Maintainers
Readme
@reactor-models/happy-oyster
Typed JavaScript + React SDK for the HappyOyster model on Reactor. Tracks model release v3.0.1, and requires
@reactor-team/js-sdk3.x.
Install
npm install @reactor-models/happy-oysterpnpm add @reactor-models/happy-oysterThe Reactor session this SDK runs on is @reactor-team/js-sdk 3.x, which the
package depends on directly and installs for you. Import it yourself only if
your app also drives Reactor at that level; pin the same major if you do.
The package exports a plain-JavaScript client and a set of React bindings. Import whichever you need from @reactor-models/happy-oyster:
import { HappyOysterModel } from "@reactor-models/happy-oyster";import {
HappyOysterProvider,
useHappyOyster,
} from "@reactor-models/happy-oyster";React 18 or later is required when using the provider and hooks. The token-loading examples below use React 19's use(); on React 18, fetch the JWT in a useEffect and pass it to the provider once it resolves.
HappyOyster comes in two experiences, chosen with the mode option and fixed for the life of the session:
"adventure"— a playable world you drive with movement controls (move,look,interact)."directing"— a story world you steer with text instructions (instruct,pause,resume,rewind).
Authenticate
Reactor uses short-lived JWTs for session auth. You hold your API key on your server, mint a token on demand, and the client never sees the raw key. Tokens are valid for 6 hours — if one leaks, it expires on its own.
Mint a JWT with POST https://api.reactor.inc/tokens and the Reactor-API-Key header; the response JSON is { "jwt": "..." }.
JavaScript (Next.js route handler)
// app/api/reactor/token/route.ts
import { NextResponse } from "next/server";
export async function POST() {
const res = await fetch("https://api.reactor.inc/tokens", {
method: "POST",
headers: { "Reactor-API-Key": process.env.REACTOR_API_KEY! },
});
const { jwt } = await res.json();
return NextResponse.json({ jwt });
}React (provider)
Call the /api/reactor/token route above from a client component and pass the result to the provider:
"use client";
import { use } from "react";
import {
HappyOysterProvider,
HappyOysterVideo,
} from "@reactor-models/happy-oyster";
async function getToken() {
const r = await fetch("/api/reactor/token", { method: "POST" });
const { jwt } = await r.json();
return jwt;
}
const tokenPromise = getToken();
export default function App() {
const token = use(tokenPromise);
return (
<HappyOysterProvider mode="adventure" jwt={token} autoConnect>
<HappyOysterVideo className="w-full aspect-video" />
</HappyOysterProvider>
);
}Connect
Opening a session connects to the experience you picked with mode. Connecting only opens the session — create or attach a world next, then start streaming.
JavaScript
import { HappyOysterModel } from "@reactor-models/happy-oyster";
const happyOyster = new HappyOysterModel({ mode: "adventure", videoElement });
await happyOyster.connect(jwt);React
The provider takes the mode and the JWT as props; fetch the token from the same /api/reactor/token route the Authenticate example mints:
"use client";
import { use } from "react";
import {
HappyOysterProvider,
useHappyOyster,
} from "@reactor-models/happy-oyster";
async function getToken() {
const r = await fetch("/api/reactor/token", { method: "POST" });
const { jwt } = await r.json();
return jwt;
}
const tokenPromise = getToken();
function Controller() {
const { phase } = useHappyOyster();
return <span>Status: {phase}</span>;
}
export default function App() {
const token = use(tokenPromise);
return (
<HappyOysterProvider mode="adventure" jwt={token}>
<Controller />
</HappyOysterProvider>
);
}Attach to an existing session
By default connect() opens a fresh session. To adopt a session your backend
already created — for example one handed out by an admission queue — pass its
sessionId (and, if the backend pre-registered one, its connectionId)
through connectOptions. connect() then attaches to that session instead of
calling POST /sessions.
Pair it with a jwt resolver: the SDK calls it before every authenticated
Reactor API request, so a short-lived token is never presented after it has
aged out. The resolver must return a token that is valid for this session —
a session-scoped Reactor JWT may only operate the sessions it created, so a
backend handing out an attachable session must hand out the token bound to it
(or mint a replacement bound to the same session id). A resolver that returns a
freshly minted, unbound token will 403 the first upload or clip call.
"use client";
import { HappyOysterProvider } from "@reactor-models/happy-oyster";
export default function App({
sessionId,
connectionId,
getToken,
}: {
sessionId: string;
connectionId?: number;
getToken: () => Promise<string>;
}) {
return (
<HappyOysterProvider
mode="adventure"
jwt={getToken}
connectOptions={{ sessionId, connectionId }}
autoConnect
>
{/* … */}
</HappyOysterProvider>
);
}The plain-JS client takes the same options as the second argument to
connect():
await happyOyster.connect(getToken, { sessionId, connectionId });Worlds
A world is a permanent asset on your account. A session has one current world at a time; create a new one or reopen an existing one, then startTravel to stream it.
createWorld
Create a new world for this session's experience and make it the current one. Resolves once the world is ready to enter. The parameters depend on the mode the session was opened with.
Save encrypted_world_id from the returned snapshot — it is the only way to reopen this world in a later session with attachWorld.
| Parameter | Type | Required | Description |
| -------------------- | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| prompt | string | ✅ | Natural-language description of the world to create. Up to 2000 characters. |
| firstFrameImageUrl | string | | Publicly reachable image URL to use as the world's opening frame. Must be landscape with a width/height ratio between 1.5 and 2.0. Mutually exclusive with firstFrameImage. |
| firstFrameImage | File \| Blob | | Local image to use as the world's opening frame, for images without a public URL. At most 2 MB, landscape with a width/height ratio between 1.5 and 2.0. Mutually exclusive with firstFrameImageUrl. |
| perspective | "first_person" \| "third_person" | | Adventure only. Camera perspective. Defaults to "third_person". |
| resolution | "480p" \| "720p" | | Directing only. Video resolution. Defaults to "720p". |
| layout | "Stable" \| "Fast" | | Directing only. Camera movement style. |
| narrative | "Calm" \| "Dramatic" \| "Normal" | | Directing only. Narrative style. |
JavaScript
import { HappyOysterModel } from "@reactor-models/happy-oyster";
const happyOyster = new HappyOysterModel({ mode: "adventure" });
await happyOyster.connect(jwt);
const world = await happyOyster.createWorld({
prompt: "A misty forest at dawn",
});
// Save world.encrypted_world_id to reopen this world later.React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { createWorld } = useHappyOyster();
return (
<button onClick={() => createWorld({ prompt: "A misty forest at dawn" })}>
createWorld
</button>
);
}attachWorld
Reopen a world you created earlier by its encrypted_world_id and make it the current one. Resolves once the world is ready to enter (immediately for one that is already built). The world must belong to the same experience the session was opened with.
JavaScript
import { HappyOysterModel } from "@reactor-models/happy-oyster";
const happyOyster = new HappyOysterModel({ mode: "adventure" });
await happyOyster.connect(jwt);
const world = await happyOyster.attachWorld(savedWorldId);React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example({ savedWorldId }: { savedWorldId: string }) {
const { attachWorld } = useHappyOyster();
return <button onClick={() => attachWorld(savedWorldId)}>attachWorld</button>;
}Travel
Traveling is the live session: the current world streams into your <video> element and accepts controls. While traveling, the world API is locked — end the travel before switching worlds.
startTravel
Enter the current world and begin streaming its live video. Call once a world is ready (createWorld or attachWorld has resolved). A <video> element must be attached first — pass videoElement to the constructor, call attachVideo(), or mount <HappyOysterVideo />.
JavaScript
import { HappyOysterModel } from "@reactor-models/happy-oyster";
const happyOyster = new HappyOysterModel({ mode: "adventure", videoElement });
await happyOyster.connect(jwt);
await happyOyster.createWorld({ prompt: "A misty forest at dawn" });
await happyOyster.startTravel();React
"use client";
import { useHappyOyster, HappyOysterVideo } from "@reactor-models/happy-oyster";
function Example() {
const { startTravel } = useHappyOyster();
return (
<>
<HappyOysterVideo className="w-full aspect-video" />
<button onClick={() => startTravel()}>startTravel</button>
</>
);
}Session length
HappyOyster ends a travel itself when its time runs out. Adventure travels may run for up to 2 minutes, and startTravel() always asks for that ceiling — a travel that does not ask for one gets 1 minute. Directing travels are not capped this way; their length comes from the experience.
Drive your countdown from maxExperienceTimeSec, the budget HappyOyster granted the live travel, rather than a hardcoded number. It is null while nothing is streaming and for Directing travels; ADVENTURE_MAX_EXPERIENCE_SEC is what to size the clock with before a travel opens.
JavaScript
const { session } = await happyOyster.startTravel();
console.log(`this travel gets ${session?.maxExperienceTimeSec} seconds`);
// The same value, readable at any point during the travel:
happyOyster.maxExperienceTimeSec;React
"use client";
import { ADVENTURE_MAX_EXPERIENCE_SEC } from "@reactor-models/happy-oyster";
import { useHappyOyster } from "@reactor-models/happy-oyster/react";
function TravelClock() {
const { streaming, maxExperienceTimeSec } = useHappyOyster();
const budget = maxExperienceTimeSec ?? ADVENTURE_MAX_EXPERIENCE_SEC;
return <span>{streaming ? `${budget}s of travel` : "not traveling"}</span>;
}disconnect
End the session: stop the live stream and close the connection. Worlds are permanent — the current world stays on your account and can be reopened later with attachWorld.
JavaScript
await happyOyster.disconnect();React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { disconnect } = useHappyOyster();
return <button onClick={() => disconnect()}>disconnect</button>;
}Adventure controls
Available while traveling an Adventure world. Controls are held: a direction keeps applying until you release it or call stop, and axes compose (move Front while you look Mouse_Left).
move
Hold a movement direction until it is released.
| Parameter | Type | Required | Description |
| ----------- | -------------------------------------------------------------------------------------------------------- | -------- | ------------------------- |
| direction | "Front" \| "Back" \| "Left" \| "Right" \| "Front_Left" \| "Front_Right" \| "Back_Left" \| "Back_Right" | ✅ | The direction to move in. |
JavaScript
await happyOyster.move("Front");React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { move } = useHappyOyster();
return <button onClick={() => move("Front")}>move</button>;
}look
Hold a view rotation until it is released.
| Parameter | Type | Required | Description |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------- |
| direction | "Mouse_Up" \| "Mouse_Down" \| "Mouse_Left" \| "Mouse_Right" \| "Mouse_Up_Left" \| "Mouse_Up_Right" \| "Mouse_Down_Left" \| "Mouse_Down_Right" | ✅ | The direction to turn the view. |
JavaScript
await happyOyster.look("Mouse_Left");React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { look } = useHappyOyster();
return <button onClick={() => look("Mouse_Left")}>look</button>;
}interact
Hold an interaction until it is released. The built-in verbs are Jump, Attack, Crouch, and Sprint; a world may advertise its own verbs, and any verb string is accepted.
| Parameter | Type | Required | Description |
| --------- | ------------------------------------------------------ | -------- | --------------------------- |
| action | "Jump" \| "Attack" \| "Crouch" \| "Sprint" \| string | ✅ | The interaction to perform. |
JavaScript
await happyOyster.interact("Jump");React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { interact } = useHappyOyster();
return <button onClick={() => interact("Jump")}>interact</button>;
}stop
Release every held control back to neutral. To release a single axis instead, call release({ translation: true }), release({ rotation: true }), or release({ interaction: true }).
JavaScript
await happyOyster.stop();React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { stop } = useHappyOyster();
return <button onClick={() => stop()}>stop</button>;
}Directing controls
Available while traveling a Directing world.
instruct
Steer the unfolding story with a text instruction. Resolves with whether the instruction was accepted; accepted instructions appear on the timeline in travelState.
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | -------------------------------------- |
| content | string | ✅ | The instruction to apply to the story. |
JavaScript
await happyOyster.instruct("A storm rolls in");React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { instruct } = useHappyOyster();
return <button onClick={() => instruct("A storm rolls in")}>instruct</button>;
}pause
Pause the story. Resume it with resume, or step back in time with rewind.
JavaScript
await happyOyster.pause();React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { pause } = useHappyOyster();
return <button onClick={() => pause()}>pause</button>;
}resume
Resume a paused story.
JavaScript
await happyOyster.resume();React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { resume } = useHappyOyster();
return <button onClick={() => resume()}>resume</button>;
}rewind
Rewind a paused story to an earlier moment. Resolves with the second playback actually resumed at; playback continues automatically.
| Parameter | Type | Required | Description |
| ------------- | -------- | -------- | -------------------------------------------------------------------- |
| rewindToSec | number | ✅ | The point to rewind to, in seconds. Rounded down to a multiple of 4. |
JavaScript
const { resumedAtSec } = await happyOyster.rewind(8);React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { rewind } = useHappyOyster();
return <button onClick={() => rewind(8)}>rewind</button>;
}State
The model owns all world state and broadcasts an authoritative snapshot on every change. Mirror it — treat these snapshots as the single source of truth for your UI, rather than tracking each command's outcome yourself.
worldState
The current world's snapshot: its lifecycle phase and details. In React it is reactive on useHappyOyster(); in plain JS, read model.worldState or subscribe with model.onWorldState(...).
| Field | Type | Description |
| -------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| phase | "no_world" \| "creating" \| "building" \| "ready" \| "traveling" \| "failed" | Where the current world is in its lifecycle. |
| encrypted_world_id | string \| null | The id to save and reopen this world later with attachWorld. |
| prompt | string \| null | The prompt the world was created from. |
| first_frame | string \| null | URL of the world's opening frame. |
| mode | number \| null | The experience this world belongs to (1 Adventure, 2 Directing). |
JavaScript
import { HappyOysterModel } from "@reactor-models/happy-oyster";
const happyOyster = new HappyOysterModel({ mode: "adventure" });
happyOyster.onWorldState((state) => {
console.log("world_state", state.phase, state.encrypted_world_id);
});
await happyOyster.connect(jwt);React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { worldState } = useHappyOyster();
return <span>Phase: {worldState?.phase}</span>;
}travelState
The live travel's snapshot, updated as the world plays. Directing worlds populate the instruction timeline and chapters; Adventure worlds populate the available action verbs. null while not traveling.
| Field | Type | Description |
| --------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| status | "init" \| "pending" \| "running" \| "failed" \| "completed" | The travel's lifecycle status. |
| user_instructions | TravelInstruction[] | Directing: the instructions applied so far, each with its scheduled window on the timeline. |
| chapters | TravelChapter[] | Directing: auto-detected chapters of the story. |
| character_actions | string[] | Adventure: interaction verbs this world advertises for interact. |
| environment_actions | string[] | Adventure: environment verbs this world advertises for interact. |
JavaScript
happyOyster.onTravelState((state) => {
console.log("travel_state", state.status, state.chapters);
});React
"use client";
import { useHappyOyster } from "@reactor-models/happy-oyster";
function Example() {
const { travelState } = useHappyOyster();
return <span>Travel: {travelState?.status ?? "idle"}</span>;
}Video
The live world renders into a <video> element you provide.
JavaScript
Pass the element to the constructor, or attach it later with attachVideo(), before calling startTravel:
import { HappyOysterModel } from "@reactor-models/happy-oyster";
const happyOyster = new HappyOysterModel({ mode: "adventure" });
happyOyster.attachVideo(videoElement);
await happyOyster.connect(jwt);React
Mount <HappyOysterVideo /> anywhere under the provider; it wires itself up automatically. <HappyOysterStateVideo /> renders the model's status frame for an optional at-a-glance overlay.
"use client";
import { HappyOysterVideo } from "@reactor-models/happy-oyster";
export function Example() {
return <HappyOysterVideo className="w-full aspect-video" />;
}Upgrading to 1.0.0
1.0.0 moves the SDK onto @reactor-team/js-sdk 3.x. Most apps use the
HappyOysterModel facade — connect, createWorld, attachWorld,
startTravel, the controls, worldState, travelState — and that surface is
unchanged, so those apps upgrade by bumping the version.
Three things changed for code that drives the low-level layer
(HappyOysterBase, useHappyOysterBase) directly.
A command's answer belongs to the call. The model answers
get_credentials, end_travel and get_state with a message correlated to the
command that asked, addressed to that one connection — and those three methods
now resolve with it.
The answer still reaches that connection's message event as well, so a
subscription for it is not dead. It is unusable, which is why
onTravelCredentials / onTravelEnded and the
useHappyOysterBaseTravelCredentials / useHappyOysterBaseTravelEnded hooks
are removed: a subscription cannot say which getCredentials() call an answer
belongs to, and it never fires at all for a second client in the same session.
The awaited call settles both questions. Read the answer instead:
// before
base.onTravelCredentials((credentials) => open(credentials));
await base.getCredentials();
// after
const credentials = await base.getCredentials();
if (credentials) open(credentials);requestState() is the exception worth knowing: its snapshot answers the call
and is published to onWorldState subscribers, because a snapshot is
authoritative however it travelled. Asking for state still refreshes every
mirror.
Broadcasts are unchanged. onWorldState, onTravelState and
onActionError carry what the model sends to the whole session — every
snapshot, and every refusal — exactly as before.
useHappyOysterBaseTravelState is new, and rounds out the set.
sessionExpiration is gone from useHappyOysterBase(). It never carried a
value in the 2.x SDK either; it was dead surface, so nothing replaces it.
