Base URL: http://localhost:3000
All responses are JSON. Error responses have the shape { "error": "..." }.
Load a world and its full session state.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
worldId |
string | No | World ID. Omit to load the default world (neon-harbor). |
action |
string | No | Set to list to list all worlds instead. |
Response (200) — load world:
{
"world": {
"id": "neon-harbor",
"name": "Neon Harbor",
"genre": "cyberpunk",
"tagline": "...",
"opening": "...",
"scene": { "id": "...", "name": "...", "description": "..." },
"characters": [
{
"id": "mira",
"name": "Mira Voss",
"role": "street doctor",
"personality": ["guarded", "compassionate"],
"goals": ["protect patients"],
"speaking_style": "terse",
"relationship_notes": {}
}
],
"relationships": [],
"rules": []
},
"session": { "id": "session-neon-harbor", "worldId": "neon-harbor" },
"messages": [
{
"id": "uuid",
"sessionId": "session-neon-harbor",
"speakerType": "user",
"speakerId": null,
"speakerName": "Chen",
"content": "What happened?",
"createdAt": "2026-05-19T12:00:00.000Z"
},
{
"id": "uuid",
"sessionId": "session-neon-harbor",
"speakerType": "character",
"speakerId": "mira",
"speakerName": "Mira Voss",
"content": "Keep your voice down.",
"createdAt": "2026-05-19T12:00:01.000Z"
},
{
"id": "uuid",
"sessionId": "session-neon-harbor",
"speakerType": "narrator",
"speakerId": null,
"speakerName": "Narrator",
"content": "The rain drums on the plastic awning...",
"createdAt": "2026-05-19T12:00:02.000Z"
}
],
"events": [
{ "id": "uuid", "sessionId": "...", "turnIndex": 0, "summary": "...", "createdAt": "..." }
],
"worldFacts": [
{ "id": "uuid", "sessionId": "...", "content": "A courier is missing", "createdAt": "..." }
],
"characterMemories": [
{ "id": "uuid", "sessionId": "...", "characterId": "mira", "category": "impression", "content": "...", "createdAt": "..." }
],
"worldTime": { "sessionId": "...", "day": 1, "timeOfDay": "night", "turnCount": 3, "turnsThisPeriod": 3 },
"relationships": [
{ "sessionId": "...", "fromId": "mira", "toId": "ren", "trust": 40, "hostility": 20, "dependency": 10 }
],
"worldEvents": [
{ "id": "uuid", "sessionId": "...", "type": "revelation", "description": "...", "impact": "...", "createdAt": "..." }
],
"relationshipHistory": [
{ "sessionId": "...", "fromId": "mira", "toId": "ren", "trust": 40, "hostility": 20, "reason": "...", "turnIndex": 2, "createdAt": "..." }
],
"clues": [
{ "id": "uuid", "sessionId": "...", "name": "Missing Courier", "description": "...", "source": "ren", "relatedCharacterId": "ren", "createdAt": "..." }
],
"isMock": false,
"providerType": "openai"
}speakerType values: "user", "character", "narrator"
List all available worlds.
Response (200):
{
"worlds": [
{ "id": "neon-harbor", "name": "Neon Harbor", "genre": "cyberpunk" },
{ "id": "dark-fantasy", "name": "The Obsidian Tower", "genre": "dark-fantasy" }
]
}Create a new world.
Request Body: Full world object matching worldSchema (lib/world/types.ts).
Required fields:
{
"id": "my-world",
"name": "My World",
"genre": "mystery",
"tagline": "A short description",
"opening": "Opening narration text",
"scene": { "id": "scene1", "name": "Main Scene", "description": "..." },
"characters": [
{
"id": "char1",
"name": "Alice",
"role": "detective",
"personality": ["observant"],
"goals": ["solve the case"],
"speaking_style": "concise",
"relationship_notes": {}
}
],
"relationships": [],
"rules": ["No magic"]
}Validation:
- Every character must have non-empty
nameandrole(enforced by the route, not the schema). idis sanitized to[a-z0-9-].
Response (200):
{ "success": true, "worldId": "my-world" }Errors:
| Status | Cause |
|---|---|
| 400 | Schema validation failed, or character missing name/role |
Export a world as YAML with optional session data.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
worldId |
string | Yes | World ID to export |
Response (200):
{
"worldId": "neon-harbor",
"yaml": "id: neon-harbor\nname: Neon Harbor\n...",
"sessionData": {
"messages": [...],
"worldFacts": [...],
"characterMemories": [...],
"worldEvents": [...],
"worldTime": { "day": 1, "timeOfDay": "night", ... },
"relationships": [...],
"relationshipHistory": [...]
},
"exportedAt": "2026-05-19T12:00:00.000Z"
}Errors:
| Status | Cause |
|---|---|
| 400 | Missing worldId |
| 404 | World YAML not found |
Import a world from YAML.
Request Body:
{
"yaml": "id: my-world\nname: My World\n...",
"sessionData": { ... }
}yaml(required): Full world YAMLsessionData(optional): Previously exported session data
Response (200):
{ "success": true, "worldId": "my-world" }Errors:
| Status | Cause |
|---|---|
| 400 | Missing YAML, invalid YAML, or schema validation failed |
Test an LLM provider connection.
Request Body:
{
"llmConfig": {
"providerType": "openai",
"apiUrl": "https://api.openai.com/v1",
"apiKey": "your_openai_api_key_here",
"model": "gpt-4o-mini"
}
}| Field | Type | Required | Description |
|---|---|---|---|
providerType |
string | No | openai / anthropic / openrouter / ollama. Defaults to openai. |
apiUrl |
string | No | Provider base URL |
apiKey |
string | Conditional | Required for anthropic and openrouter. Optional for openai/ollama. |
model |
string | No | Model name |
Key behavior:
- Anthropic / OpenRouter:
apiKeyis required. Returns 400 if missing. - OpenAI-compatible (openai/ollama):
apiKeyis optional. If omitted, the request is sent without anAuthorizationheader (no fake token). - Mock Mode: Only when no provider is configured at all. Not triggered by an empty key.
Response (200):
{
"ok": true,
"providerType": "openai",
"model": "gpt-4o-mini",
"preview": "Parallel API settings are connected."
}Errors:
| Status | Cause |
|---|---|
| 400 | anthropic or openrouter without API key, or provider resolved to Mock |
| 502 | Connection failed, timeout, or provider returned an error |
Send a message and receive the world's response.
Request Body:
{
"sessionId": "session-neon-harbor",
"message": "What happened to the courier?",
"worldId": "neon-harbor",
"playerName": "Chen",
"language": "en",
"llmConfig": {
"providerType": "openai",
"apiUrl": "https://api.openai.com/v1",
"apiKey": "your_openai_api_key_here",
"model": "gpt-4o-mini"
}
}| Field | Type | Required | Description |
|---|---|---|---|
message |
string | Yes | Player's message |
sessionId |
string | No | Existing session ID. Omit to auto-create/load. |
worldId |
string | No | World ID. Defaults to neon-harbor. |
playerName |
string | No | Player's in-world name |
language |
string | No | "en" or "zh". Default "en". |
llmConfig |
object | No | Provider override. If omitted, uses server env or Mock. |
stream |
boolean | No | Set true for streaming response (NDJSON). Default false. |
llmConfig behavior:
- If provided with
apiKey: uses that key. - If provided without
apiKeybut withproviderType/apiUrl/model: uses those settings (for key-less providers like Ollama). - If omitted entirely: uses server env vars, or Mock Mode if none set.
Response (200) — TurnResult:
{
"userMessage": { "id": "uuid", "content": "What happened?" },
"narration": "The rain picks up...",
"characterMessages": [
{ "speakerId": "mira", "speakerName": "Mira Voss", "content": "Keep your voice down." }
],
"event": { "summary": "Player asked about the courier" },
"sceneUpdate": "Tension rises in the market",
"degraded": false,
"memoriesExtracted": {
"worldFacts": ["The player is investigating the courier"],
"characterMemories": [
{ "characterId": "mira", "category": "impression", "content": "The player asks too many questions" }
]
},
"worldTime": { "day": 1, "timeOfDay": "night", "label": "Day 1 · Night" },
"relationshipChanges": [
{ "fromId": "mira", "toId": "player", "trust": -5, "hostility": 5, "reason": "Asked about sensitive topic" }
],
"worldEvents": [
{ "type": "revelation", "description": "A shadow moves in the alley", "impact": "New lead" }
],
"clues": [
{ "name": "Suspicious Figure", "description": "Someone was watching from the alley", "source": "narrator", "relatedCharacterId": "ren" }
]
}All fields except userMessage and characterMessages are optional — they appear only when relevant.
degraded: true means the response used a fallback provider or fallback台词 (the real provider failed).
Errors:
| Status | Cause |
|---|---|
| 400 | Empty or missing message |
| 500 | Engine error |
When stream: true is set in the request body, the response uses Newline-Delimited JSON (application/x-ndjson). Each line is a self-contained JSON object. The Content-Type header is application/x-ndjson.
Event types:
| Event | Shape | When |
|---|---|---|
status |
{ type: "status", data: { phase: "character_started", characterId, characterName } } |
Before each character generates |
status |
{ type: "status", data: { phase: "extraction_started" } } |
Before post-turn extraction |
content |
{ type: "content", data: { kind: "narration_done", text } } |
Narration text ready |
content |
{ type: "content", data: { kind: "character_delta", characterId, characterName, text } } |
Token-level delta (when provider supports streaming) or full character message |
content |
{ type: "content", data: { kind: "character_reset", characterId } } |
Stream failed mid-output; frontend should clear accumulated text for this character |
done |
{ type: "done", data: TurnResult } |
Turn fully committed to store |
error |
{ type: "error", data: { message } } |
Fatal error |
Event ordering: status(narrator/content)* → [status → content*]* → status(extraction) → done|error
The done event always fires after all store writes succeed. If the store write fails, an error event is sent instead.
Token streaming support: Real token-level streaming (incremental character_delta events) currently works with Anthropic-compatible providers (including MiniMax). Other providers (OpenAI, Ollama, Mock) receive the full character message as a single character_delta after generation completes.
Performance note: Streaming reduces time to first text (the user sees narration and dialogue sooner) but does not reduce total wall time — the full turn still takes the same time to complete.
Client implementation: Use fetch with response.body.getReader() to consume the NDJSON stream. Parse each line as JSON, accumulate character_delta text per character, and handle character_reset by clearing accumulated text for that character.