Skip to content

Latest commit

 

History

History
449 lines (321 loc) · 25.6 KB

HTTP_API.md

File metadata and controls

449 lines (321 loc) · 25.6 KB

Paracosm HTTP API Reference

The dashboard server (paracosm dashboard) exposes a versioned HTTP surface under /api/v1/*. The same routes power the React dashboard and any external client. This page documents every endpoint, its request shape, response shape, and status codes.

Source of truth: src/server/routes/platform-api.ts, src/server/routes/public-demo.ts, src/server/routes/bundle.ts, and src/server/routes/library-import.ts; POST /scenario/store, a dashboard route outside /api/v1, is in src/server/server-app.ts.

For the SDK (WorldModel.simulate(), WorldModel.batch(), WorldModel.intervene(), etc.) see the Cookbook. This page is the wire-level reference for the HTTP surface only.

Base URL and gating

  • Local: http://localhost:3456 (override with PORT, set in the environment or in a .env file in the directory you start the server from)
  • All /api/v1/runs* and /api/v1/bundles/* routes return 403 run_history_routes_disabled when the server is started with PARACOSM_ENABLE_RUN_HISTORY_ROUTES=false. Default is true except in hosted_demo mode.
  • Routes are CORS-permissive (Access-Control-Allow-Origin: *) for local dashboard development.

Endpoint index

Method Path Purpose
GET /api/v1/runs List runs with filters and pagination
GET /api/v1/runs/aggregate Rollup stats for the filtered set
GET /api/v1/runs/:runId Run record + full RunArtifact JSON
GET /api/v1/runs/:runId/swarm Final agent-swarm snapshot (lightweight)
POST /api/v1/runs/:runId/replay Re-run each between-turn step of the stored artifact and check the state saved after it
POST /api/v1/runs/:runId/replay-result Record a client-side replay outcome
GET /api/v1/bundles/:bundleId Bundle metadata + member RunRecords
GET /api/v1/bundles/:bundleId/aggregate Bundle-scoped rollup
POST /api/v1/library/import Import an externally-produced RunArtifact
GET /api/v1/demo/status Public-demo capability flags (always reachable)
POST /scenario/store Store a scenario in this visitor's memory; with saveToDisk, write it to the scenarios folder and the shared catalog instead (admin credential)
POST /compile Compile a scenario JSON into the shared catalog under an id that names nothing yet

GET /api/v1/runs

List runs newest-first with optional filters.

Query parameters

Param Type Notes
mode turn-loop | batch-trajectory | batch-point Filter by simulation mode
sourceMode local | hosted_demo | platform_api Server mode that produced the run
scenario string Filter by scenarioId
leader string Filter by actorConfigHash
bundleId string Scope to one Quickstart submission
q string Full-text match on actor name and scenario id
limit number Default 50, clamped to [1, 500]
offset number Default 0, clamped to [0, ∞)

200 response

{
  "runs": [
    {
      "runId": "run_a1b2c3d4-...",
      "createdAt": "2026-04-30T12:34:56.789Z",
      "scenarioId": "mars-genesis",
      "scenarioVersion": "1.2.0",
      "actorConfigHash": "leaders:9f2c...",
      "economicsProfile": "standard",
      "sourceMode": "local",
      "createdBy": "anonymous",
      "costUSD": 0.34,
      "durationMs": 217000,
      "mode": "turn-loop",
      "actorName": "Maya Patel",
      "actorArchetype": "industrialist",
      "bundleId": "8b1e...",
      "summaryTrajectory": [0.42, 0.46, 0.51, 0.48, 0.44, 0.39, 0.36, 0.33]
    }
  ],
  "total": 132,
  "hasMore": true
}

Note: artifactPath is stripped from the public projection of every record. Use GET /api/v1/runs/:runId to load the artifact JSON.

GET /api/v1/runs/aggregate

Rollup counters across the filtered set.

Query parameters: mode, sourceMode, scenario, leader (same semantics as /runs).

200 response

{
  "totalRuns": 132,
  "totalCostUSD": 47.62,
  "totalDurationMs": 28800000,
  "replaysAttempted": 18,
  "replaysMatched": 17
}

GET /api/v1/runs/:runId

Single run record plus the full RunArtifact loaded from disk.

200 response

{
  "record": {
    "runId": "run_a1b2...",
    "sessionId": "<uuid>",
    "...": "all fields from /runs response"
  },
  "artifact": { "metadata": { "...": "..." }, "trajectory": { "...": "..." } }
}

sessionId is the session-store id the run's events were saved under. Populated for runs whose broadcast autoSaveOnComplete pass succeeded; omitted on legacy rows (runs persisted before the link wire-up), runs whose save was skipped (below_min_turns / partial_completion), and runs persisted by code paths that bypass the broadcast handler (CLI batch runs, replay-only flows). Consumers can build a deep link to the live SSE replay endpoint with https://<host>/sim?replay=<sessionId>&tab=viz when this field is set.

Status codes

  • 404 not_found — runId not in the run-history store
  • 410 artifact_unavailable — record exists but artifactPath was never preserved (legacy run)
  • 410 artifact_unreadable — file was preserved but is missing or unreadable on disk
  • 410 artifact_outside_runs_dir — the record's artifact path resolves outside the server's runs directory (PARACOSM_OUTPUT_DIR, else <cwd>/output, links resolved); the file is not read. A record imported before imports wrote their own copy can carry such a path.

The runs directory is resolved from the environment the server was started with. The server's own runs (/setup, /simulate and digital-twin runs) write their output files there, as imports do, so every record the server stores points into it.

The server resolves the path, opens the resolved file once without following a link at its last component, checks that the path still resolves inside the runs directory to that same open file, and reads from it. On Linux it also reads the path the kernel holds for the open file (/proc/self/fd/<n>) and refuses the file when that path is outside the runs directory, so a parent directory swapped between the checks cannot point the read elsewhere. An entry that changes between the check and the read answers 410 artifact_unreadable, or 410 artifact_outside_runs_dir when it then resolves outside the runs directory.

HEAD /api/v1/runs/:runId answers 200, 404 or 410 with no body. It checks the record's path text against the runs directory and does not open the file or resolve links, so a 200 there can still be a 410 on GET.

GET /api/v1/runs/:runId/swarm

Final agent-swarm snapshot for the run — every agent's name, department, role, mood, family edges, and last short-term memory entries. Lighter payload than the full RunArtifact when the consumer (network viz, org-chart UI, family-tree renderer) only needs the roster.

200 response

{
  "runId": "run_a1b2c3d4-...",
  "swarm": {
    "turn": 6,
    "time": 6,
    "population": 98,
    "morale": 0.72,
    "births": 1,
    "deaths": 2,
    "agents": [
      {
        "agentId": "agent-001",
        "name": "Maria Chen",
        "department": "engineering",
        "role": "lead-engineer",
        "rank": "lead",
        "alive": true,
        "marsborn": false,
        "psychScore": 0.84,
        "age": 6,
        "generation": 0,
        "partnerId": "agent-014",
        "childrenIds": ["agent-072"],
        "featured": true,
        "mood": "focused",
        "shortTermMemory": [
          "Repaired the backup oxygen line.",
          "Argued with logistics over the rover schedule."
        ]
      }
    ]
  }
}

Status codes

  • 404 not_found — unknown runId
  • 404 swarm_not_captured — run exists but did not produce a swarm (e.g., batch-point mode that bypassed the turn loop)
  • 410 artifact_unavailable — record exists but the artifact file was never preserved
  • 410 artifact_unreadable — file is missing or unreadable on disk
  • 410 artifact_outside_runs_dir — same as /runs/:runId

Equivalent SDK access: WorldModel.swarm(artifact) (or artifact.finalSwarm directly). Derived helpers: WorldModel.swarmByDepartment(artifact), WorldModel.swarmFamilyTree(artifact).

POST /api/v1/runs/:runId/replay

Re-run each between-turn step the stored artifact recorded, from the state the run started it from, and report whether every fresh state equals the state the run saved right after that step (scenarioExtensions.kernelSnapshotsAfterProgression) under canonical JSON. A 200 result is persisted to the run-history store, so /runs/aggregate counters reflect every replay that compared.

No request body.

200 response

{ "matches": true,  "divergence": "" }
{ "matches": false, "divergence": "/scenarioExtensions/kernelSnapshotsAfterProgression/3/state/metrics/population (188 vs 186)" }

Status codes

  • 404 not_found — unknown runId
  • 410 artifact_unavailable / 410 artifact_unreadable / 410 artifact_outside_runs_dir — same as /runs/:runId
  • 410 scenario_unavailable — the scenario referenced by the artifact is not in the active catalog
  • 422 replay_preconditions_unmet — replay refused, with the underlying WorldModelReplayError message: the artifact holds no snapshots or no decisions, it was saved before the turn loop kept the state after each between-turn step, or it lacks the state a step started from or the state after a turn's step, which replay cannot verify a run without (#235). No result is recorded.

POST /api/v1/runs/:runId/replay-result

Record a client-side replay outcome (used by the dashboard's verification loop when replay runs in the browser instead of the server).

Request body

{ "matches": true }

Responses

  • 204 No Content on success
  • 400 invalid_json — body did not parse
  • 400 matches must be a boolean — matches field missing or wrong type
  • 404 not_found — unknown runId

GET /api/v1/bundles/:bundleId

Bundle metadata plus the member RunRecords, sorted by createdAt ascending. Used by the Compare modal as its entry-point fetch.

200 response

{
  "bundleId": "8b1e...",
  "scenarioId": "mars-genesis",
  "createdAt": "2026-04-30T12:34:56.789Z",
  "memberCount": 3,
  "members": [
    { "runId": "run_...", "actorName": "Maya Patel", "...": "RunRecord" }
  ]
}

Status codes

  • 404 — bundle id has no members
  • 501 — store implementation does not support bundle queries (only the noop in-memory store hits this)

GET /api/v1/bundles/:bundleId/aggregate

Bundle-scoped rollup. Used by the Compare modal's AggregateStrip.

200 response

{
  "bundleId": "8b1e...",
  "count": 3,
  "costTotalUSD": 1.07,
  "meanDurationMs": 218000,
  "outcomeBuckets": {}
}

outcomeBuckets is currently always {} — outcome classification is queued for the v2 fingerprint extraction pass. The dashboard treats an empty map as "data unavailable" gracefully.

POST /api/v1/library/import

Import an externally-produced RunArtifact (Studio JSON drop, shared export, replay clone) so it shows up in the Library tab. The artifact is enriched into a RunRecord and inserted into the active store.

The server keeps its own copy of each imported artifact: it writes the artifact to a new file, v3-imported-<hash of runId>-<random suffix>.json, in its runs directory (PARACOSM_OUTPUT_DIR, else <cwd>/output) and the record's artifact path points at that file. The file is created exclusively: an entry already at that name, a link included, is never followed or overwritten. No file path is taken from the request; a scenarioExtensions.outputPath in the imported artifact is dropped from the stored copy. GET /api/v1/runs/:runId then serves the imported artifact.

When two imports of one runId arrive together, the record stored first is kept with its file; the other import answers alreadyExisted: true and removes the file it wrote. An import whose record cannot be read back right after its insert keeps its file and answers alreadyExisted: false.

The output-retention sweep at boot (OUTPUT_RETENTION_DAYS, default 30, removes v3-*.json files older than that) and POST /admin/data/wipe with wipeOutput: true (removes v3-*.json files) work on the same runs directory, so they remove imported copies as they do run output. Neither removes any other file in the directory.

Request body — single

{ "artifact": { "metadata": { "...": "..." }, "trajectory": { "...": "..." } } }

Request body — bundle (1–50 artifacts share one bundleId)

{ "artifacts": [ { "...": "RunArtifact" }, { "...": "RunArtifact" } ] }

The route gives the bundle a new id, bundle_ and a random UUID. With addToBundle set to an id of that form, such as the bundleId an earlier request's response gave or a new one the caller makes, the artifacts go in that bundle instead, and the response answers that id:

{ "addToBundle": "bundle_8b1e...", "artifacts": [ { "...": "RunArtifact" } ] }

A bundle over the request body limit

The server refuses a request body over its limit, 5 MiB (5,242,880 bytes) unless MAX_REQUEST_BODY_BYTES sets another, with 413 and Request body too large. Maximum N bytes. A run saved with captureSnapshots carries three kernel snapshot sets in scenarioExtensions (kernelSnapshotsPerTurn, kernelSnapshotAtStart, kernelSnapshotsAfterProgression), which replay and fork read from the server's copy, so a bundle of such runs can pass the limit.

The Studio tab sends a bundle in one request when its JSON fits the limit, and otherwise in parts of whole runs, in the bundle's order, each part under the limit. The page names a new bundle in addToBundle on every part, the first included, so a part the server stored but whose reply was lost is in the bundle a later promote adds the rest to. Every run goes whole, its snapshots included. A part the server refuses with 413 and a lower limit is made again under that limit. A run whose JSON alone passes 5 MiB goes in one request with the rest of the bundle until the server has stated its limit, so a server started with a higher limit takes it; a run alone over the stated limit is refused with one message naming it, its size and the limit. The import stops at the first part the server does not take, and the page says which runs were imported, into which bundle, and which were not; promoting the same bundle again, from the same file dropped again included, adds the rest to that bundle while the Studio tab stays open.

Responses (single)

{ "runId": "run_a1b2...", "alreadyExisted": false }

Responses (bundle)

{
  "bundleId": "bundle_8b1e...",
  "runIds": ["run_a1b2...", "run_c3d4..."],
  "alreadyExisted": [false, false]
}

alreadyExisted is true for any artifact whose metadata.runId was already present in the store — re-imports collapse to the existing row instead of duplicating.

Status codes

  • 201 Created — single or full bundle inserted
  • 400 Invalid artifact body / 400 Bundle item N is not a valid RunArtifact — Zod validation failed (shape or content)
  • 400 Invalid bundle — bundle missing artifacts array, exceeds the 50-artifact cap (MAX_BUNDLE_SIZE), or has an addToBundle that is not bundle_ and a lowercase UUID
  • 413: the request body is over MAX_REQUEST_BODY_BYTES (5 MiB by default); the error states the limit
  • 500 — store insert raised; the response includes failedRunId and (for bundles) bundleId and insertedSoFar so the client can show partial-success state and retry the broken artifact

GET /api/v1/demo/status

Always reachable (not gated by paracosmRoutesEnabled). Lets a public-demo client probe what the running server allows.

200 response

{
  "mode": "local",
  "replayAvailable": true,
  "authenticatedApiAvailable": false
}

mode is one of local, hosted_demo, platform_api. replayAvailable is false only in platform_api mode (where replay needs an authenticated path that is not yet exposed). authenticatedApiAvailable is true only in platform_api mode.

POST /scenario/store

The dashboard's scenario editor route. Any visitor can store a scenario JSON in memory. The stored scenario is the visitor's own: the visitor is the paracosm_visitor cookie the dashboard gives a browser, and every caller that sends no such cookie is one visitor, the anonymous one. A paracosm_visitor cookie whose value is not a UUID counts as no cookie, so that caller is the anonymous visitor too. When the JSON is a runnable scenario package, that visitor's GET /scenarios lists it with source: "memory", and their GET /scenarios/:id, POST /scenario/switch, GET /scenario?id=, POST /setup and the Quickstart routes take its id. No other visitor lists, reads or runs it, and GET /admin-config lists each visitor's own stored ids under memoryScenarios.

A memory store never takes an id the shared catalog holds (a built-in such as mars-genesis, a scenario from the scenarios folder, or a compiled one), nor the id of a saved compiled draft the server is still re-compiling after a restart: the server answers 409 and the visitor gives their copy an id of its own.

The memory store holds a scenario of at most 256 KiB of JSON (MEMORY_SCENARIO_MAX_BYTES), at most 10 scenarios a visitor (MEMORY_SCENARIOS_PER_VISITOR), at most 20 for one address, every visitor id that address sends together (MEMORY_SCENARIOS_PER_ADDRESS; the address is the one the rate limiter keys on), and at most 100 in all (MEMORY_SCENARIOS_CAP). A visitor at any of those counts can still store over an id of their own. A stored scenario leaves the store:

  • when 24 hours pass in which it was not stored again, read by its id or run (MEMORY_SCENARIO_IDLE_MS), so a full store makes room by itself;
  • when its id enters the shared catalog, through an admin's disk save or its own visitor's POST /compile or Quickstart compile: from then on the id names the catalog's scenario;
  • when DELETE /admin/scenarios/:id names it, from every visitor's store;
  • when the server restarts.

With saveToDisk: true the server writes the scenario to <scenarios folder>/<id>.json, where the boot loader reads it after a restart, and, when it is runnable, puts it in the catalog every visitor reads; it does not also hold it in memory. The disk write takes the admin credential the /admin/* routes take: the server started with ADMIN_WRITE=true and ADMIN_TOKEN set, and the request's X-Admin-Token header equal to ADMIN_TOKEN (compared in constant time). Its id must be 1 to 128 letters, digits, hyphens or underscores, starting with a letter or digit, and must name one file directly in the scenarios folder. A refused request stores nothing, in memory or on disk.

The dashboard sends the header from the token an operator enters in Settings, in the Admin Token field. The field shows only when GET /admin-config answers adminWrite: true. It is a password field with Save and Clear; the token is kept in that browser's localStorage under paracosm:adminToken (in memory until the page reloads, where the browser cannot store it) and is sent as X-Admin-Token on this disk write, on POST /admin/data/wipe and on DELETE /admin/scenarios/:id only. A 401 from any of the three clears the token it refused and names the field in its message. With a token saved, the dashboard's Clear local data also sends POST /admin/data/wipe, and its question says so before anything is cleared.

Request body

{ "scenario": { "id": "deep-ocean-station", "...": "..." }, "saveToDisk": false }

200 response

{ "stored": true, "id": "deep-ocean-station", "savedToDisk": false, "adminWrite": false, "switchable": true }

adminWrite is the server's ADMIN_WRITE setting. switchable is true when the JSON is a runnable scenario package, which POST /scenario/switch can then pick.

Status codes

  • 200 — stored (and written, when savedToDisk is true)
  • 400 scenario with id required — no scenario or no scenario.id
  • 400 The scenario id must be a string. — a memory store whose id is not a string
  • 409 — a memory store under an id the shared catalog holds, or the id of a saved compiled draft still re-compiling after a restart
  • 400 Invalid scenario id for a disk write — saveToDisk with an id outside the pattern above (a path separator, a dot, a percent sign, a null byte, over 128 characters, or not a string)
  • 401 Invalid or missing X-Admin-Token header — saveToDisk without the header or with a wrong token
  • 403 ADMIN_WRITE not enabled on this server — saveToDisk on a server started without ADMIN_WRITE=true
  • 503 — saveToDisk on a server with ADMIN_WRITE=true and no ADMIN_TOKEN (fail closed)
  • 413 — the body is over MAX_REQUEST_BODY_BYTES (5 MiB by default), or a memory store's scenario is over 256 KiB of JSON
  • 507 — a memory store of a new id by a visitor who holds 10, from an address that holds 20, or when the store holds 100 in all

At boot the server re-compiles the drafts the compile-from-seed flow saved under <scenarios folder>/compiled/, newest first, at most 50 (COMPILED_SCENARIOS_CAP); a draft whose id is outside the pattern above is skipped with a log line naming its file, and the drafts past the cap are left unread, in one log line. A draft whose id the catalog already holds (a built-in or a scenario from the scenarios folder) is left out with a log line and is not compiled: the catalog's scenario stays.

POST /compile

The scenario editor's compile route. It compiles the scenario JSON in scenario and puts the result in the shared catalog under the JSON's id, with source: "compiled", where every visitor's GET /scenarios lists it. It becomes nobody's pick: the editor that compiled it posts /scenario/switch with the id.

A compile never replaces a scenario. The server answers 409 when the id is taken, which is any of:

  • the shared catalog holds it: a built-in, a scenario from the scenarios folder, or a compiled scenario, whoever compiled it, the caller included;
  • it is the id of a saved compiled draft the server is still re-compiling after a restart;
  • another visitor holds it in memory (POST /scenario/store).

The refusal comes before the stream starts, before the compiler runs and before the request counts toward the day's compiles. To change a compiled scenario, compile it under a new id. An id that only the caller stored in memory is free: the compiled scenario lands in the catalog and the stored copy leaves the memory store.

The id is checked again when the compile finishes. If something took it while the compile ran, the stream ends with an error event that carries the same message, and the catalog is unchanged.

The Quickstart compile (POST /api/quickstart/compile-from-seed) picks the scenario's id itself. When that id is taken, the scenario lands under the next free id (<id>-2, then <id>-3), with the id cut short where it must be so the whole is at most 64 characters, the longest id the Quickstart routes take, and the status answer's scenarioId and scenario.id report the id it landed under.

Request body

{ "scenario": { "id": "deep-ocean-station", "...": "..." }, "seedText": "optional grounding text", "webSearch": true }

Optional fields: provider, model, seedText, seedUrl, webSearch (default true), maxSearches, apiKey, anthropicKey.

200 response

A text/event-stream: status, one progress event per hook, a compile_validation_fallback event for each hook that fell back, compile_metrics, then complete ({ "id", "version", "departments", "hooks" }) or error ({ "error" }).

Status codes

  • 200: the stream above
  • 400 scenario JSON object required: no scenario object in the body
  • 409 "<id>" is taken on this server. Give your scenario an id of its own.: the id is taken, as above
  • 429: on the hosted demo, the address is past its compiles for the day and the request carries no API key of its own

Error envelope

Every error response is JSON-encoded with at minimum an error field. Most also include a contextual key (runId, scenarioId, path) to help clients build readable messages.

{ "error": "not_found", "runId": "run_does_not_exist" }

Unknown routes under /api/v1/ return 404 unknown_platform_route with the requested path echoed.

Related docs

  • Cookbook — SDK-level walkthrough with input + output JSON for every public method
  • Architecture — engine, runtime, CLI layering and replay semantics

Dashboard deep links

The dashboard at /sim also accepts a client-side query-param contract for sharing runs without server upload — useful for one-click Reddit posts and bug reports. Not an HTTP endpoint, but documented next to the rest of the wire-level surface for discoverability.

Param Required Behaviour
?load= yes URL of a remote .json save (http/https only, CORS-readable).
?tab= no Tab to land on after the load resolves: sim (default), viz, reports, chat, library, settings, studio.
?autoload= no 1 or true skips the F9 preview-confirm modal.
?replay= no Session ID from a server-stored run. Switches the SSE source to /sessions/:id/replay instead of fetching a remote file. Pairs with ?tab= the same way.

Full example walkthrough: Cookbook → Sharing a run via deep link.