Every Chronicle REST endpoint and WebSocket message the Foundry module depends on.
All REST requests include a Bearer token:
Authorization: Bearer <api-key>
REST requests through the API client also carry the module's manifest version, so Chronicle can tell an owner when the module is out of date:
X-Chronicle-Module-Version: <module.json version>
The header is omitted when the version cannot be read. A Chronicle that
records it lists it in its CORS Access-Control-Allow-Headers; an older one
doesn't, so the browser refuses the preflight. The module then retries that
request once without the header and stops sending it for the session
(scripts/_module-version.mjs), so an older server keeps syncing. WebSocket
connections do not send it.
WebSocket connections authenticate via query parameter at connection time:
wss://chronicle.example.com/ws?token=<api-key>
API keys are scoped to a single campaign. The key determines:
- Which campaign's data is accessible
- Permission level:
read(GET),write(POST/PUT/DELETE),sync(sync endpoints) - A
sync-level key covers read + write + sync - Rate limit: 60 requests/minute (default)
Chronicle shows campaign owners a one-paste line that carries everything the module needs:
chronicle://chronicle.example.net/c/<campaignId>?key=<apiKey>
chronicle+http://192.168.1.5:8080/sub/c/<campaignId>?key=<apiKey>
- Scheme
chronicle:meanshttps://<host[:port]><path>;chronicle+http:meanshttp://<host[:port]><path>(plain-http instances). <path>is everything before the final/c/<campaignId>segment; it is empty unless Chronicle is served under a sub-path. The result is theapiUrlsetting.keyis the URL-decoded query parameter and becomes the API key.- Any other scheme, a missing key or campaign id, or userinfo in the line is invalid and changes nothing.
The module parses it in scripts/_connect-line.mjs (parseConnectLine); a GM
pastes it into the "Connect line" module setting, which fills the URL,
campaign ID and client-scoped API key and then clears itself.
All REST endpoints are prefixed with:
{chronicleUrl}/api/v1/campaigns/{campaignId}
The module's api-client.mjs constructs this from settings:
const baseUrl = getSetting('apiUrl'); // e.g., "https://chronicle.example.com"
const campaignId = getSetting('campaignId'); // UUID
// Requests go to: baseUrl + "/api/v1/campaigns/" + campaignId + pathAll error responses follow:
{
"error": "Human-readable error message"
}HTTP status codes:
400— Bad request (invalid input)401— Unauthorized (missing/invalid API key)403— Forbidden (insufficient permissions)404— Not found409— Conflict (optimistic concurrency viaexpected_updated_at)429— Rate limited500— Server error
Some syncapi endpoints invert the field roles above: error is a
machine-readable code, message is the human sentence.
HTTP 503
{"error":"calendar_rebuilding","message":"Chronicle's calendar is being rebuilt and is temporarily unavailable. Calendar sync is paused; maps, actors, items and notes are unaffected."}
503— a subsystem is deliberately unavailable, distinct from500(fault) and404(route absent on this build). Never read it as an empty resource.scripts/api-client.mjsattacheserr.status,err.code,err.serverMessageto the thrown error; callers key on the code. Message format stays stable (Chronicle API error <status>: <body>) as a regex fallback for transports that lose the status.
Re-verify by: when calendar V5 ships.
Lists all available game systems for this campaign.
Used by: sync-manager.mjs → _detectSystem()
Response:
{
"data": [
{ "id": "dnd5e", "name": "D&D 5th Edition", "status": "available",
"enabled": true, "has_character_fields": true, "has_item_fields": true,
"foundry_system_id": "dnd5e" }
]
}Returns character preset field definitions with Foundry annotations.
Used by: adapters/generic-adapter.mjs → createGenericAdapter()
Response:
{
"system_id": "drawsteel", "preset_slug": "drawsteel-character",
"preset_name": "Draw Steel Hero", "foundry_system_id": "draw-steel",
"foundry_actor_type": "hero",
"fields": [
{ "key": "might", "label": "Might", "type": "number",
"foundry_path": "system.characteristics.might.value", "foundry_writable": true },
{ "key": "stamina_max", "label": "Stamina (Max)", "type": "number",
"foundry_path": "system.stamina.max", "foundry_writable": false }
]
}Key fields:
foundry_actor_type— Actor type to create/filter (e.g., "character", "hero")foundry_path— Dot-notation path onactor.system(e.g., "system.abilities.str.value")foundry_writable— Whether this field can be written back to Foundry (false = read-only from Foundry)
Returns the system's creature preset (the first preset with category
creature) in the character-fields shape, plus foundry_pull_fields. A
system with no creature preset answers { "system_id", "fields": [] }; an
older Chronicle has no route (404). Both leave creature sync off.
Used by: creature-sync.mjs (with _creature-fields.mjs)
Response (Draw Steel, trimmed):
{
"system_id": "drawsteel", "preset_slug": "drawsteel-creature",
"preset_name": "Draw Steel Creature", "foundry_system_id": "draw-steel",
"foundry_actor_type": "npc",
"foundry_pull_fields": ["stamina_current", "conditions_json"],
"fields": [
{ "key": "organization", "label": "Organization", "type": "string",
"foundry_path": "system.monster.organization",
"foundry_choices": "DRAW_STEEL.monsters.organizations" },
{ "key": "keywords", "label": "Keywords", "type": "string",
"foundry_path": "system.monster.keywords", "foundry_format": "set",
"foundry_choices": "DRAW_STEEL.monsters.keywords" },
{ "key": "size", "label": "Size", "type": "string",
"foundry_path": "system.combat.size", "foundry_format": "size" },
{ "key": "free_strike", "label": "Free Strike", "type": "string",
"foundry_path": "system.monster.freeStrike", "foundry_format": "integer" },
{ "key": "stamina_current", "label": "Current Stamina", "type": "number",
"foundry_path": "system.stamina.value" },
{ "key": "conditions_json", "label": "Conditions Data", "type": "string",
"foundry_path": "statuses" }
]
}Creature-only keys:
foundry_choices— a path in Foundry'sCONFIG; the page's value (a key or a label) is stored as that list's key. No match leaves the field unset.foundry_format—set(comma list to a Set, each entry throughfoundry_choices),integer(first whole number: "5 damage" is 5),size("1S" is{value: 1, letter: "S"}). An unknown format is skipped.foundry_pull_fields— the only fields Foundry owns. They are written to Foundry only when the actor is made and when someone edits them in Chronicle, and they are the only ones sent back (just the ones that changed), throughPUT /entities/:id/fields, and only while the page'sfoundrysystem state hasgm.sendBack === true(see "What the module does withsystem_state.updated").
Re-verify by: 2026-11-11 (Chronicle internal/systems/manifest.go CreatureFieldsForAPI, internal/plugins/syncapi/api_handler.go GetCreatureFields; keyxmakerx/Chronicle#1311)
Returns item preset field definitions. Same shape as character-fields.
Used by: item-sync.mjs
Lists entities in the campaign. Supports pagination and filtering.
Used by: journal-sync.mjs → initial sync
Query params: ?page=1&per_page=50&type_id=X&updated_since=ISO
Response:
{
"data": [
{
"id": "uuid", "name": "Entity Name", "content": "<p>HTML content</p>",
"summary": "Short text", "entity_type_id": 1,
"fields_data": { "hp_current": 45, "str": 18 },
"tags": ["npc", "villain"], "visibility": "public",
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-15T12:00:00Z"
}
],
"pagination": { "page": 1, "per_page": 50, "total": 120 }
}Creates a new entity.
Used by: journal-sync.mjs → push new journal to Chronicle
Request:
{ "name": "Entity Name", "content": "<p>HTML content</p>", "entity_type_id": 1, "visibility": "public" }Response: The created entity object (same shape as GET).
Returns a single entity with full content.
Updates an entity. PARTIAL update — see the contract below.
Every JSON update endpoint in Chronicle reads a request body three ways:
| what the body does with a key | what happens to the stored value |
|---|---|
| absent | preserved |
| present with a value | replaced |
present and explicitly null |
cleared (nullable columns only) |
The distinction is real on the server: request structs bind patch.Field[T],
which records presence during JSON decoding, so absent and null differ.
Non-nullable columns have no cleared state, so an explicit null there
preserves rather than writing a zero.
Before this contract, value-typed fields wrote their zero when absent. Two
consequences hit this module's own traffic: actor-sync.mjs's {name}-only
rename bound is_private = false and published a hidden character entity
to every player in the campaign, and parent_id was not on the request
struct at all, so every update detached the entity from the hierarchy.
Both are fixed on the server. Send only the fields you mean to change.
Send only the fields you mean to change.
Request: any subset of the POST shape, plus parent_id:
{ "name": "Renamed Character" }{ "parent_id": null } ↑ explicitly unparents. Omitting parent_id leaves the parent alone.
Version skew. A pre-partial-update Chronicle does whole-replace instead.
ChronicleMarkerConfigDialog.#onSavestill spreads the stored marker under edited fields for this reason: harmless on a merging server, load-bearing on an older one.
Deletes an entity.
Updates only the fields_data on an entity. Chronicle with keyxmakerx/Chronicle#927 merges the sent keys into the stored set (null removes a key); older Chronicle replaces the whole set, so the module always sends the stored set with its changes laid over it.
Used by: actor-sync.mjs → push character stats to Chronicle
Request:
{ "fields_data": { "hp_current": 45, "str": 18, "level": 5 } }Returns entity permission/visibility settings.
Updates entity permissions. Owner only.
Request:
{ "visibility": "default", "is_private": false, "permissions": [ { "subject_type": "role", "subject_id": "1", "permission": "view" } ] }Answers {"status":"ok"}. The save stamps a new updated_at it does not
return, and its entity.updated broadcast can carry the previous one. So the
module pushes only when the body differs from the last one sent for that
journal, reads the page back afterwards, and never lets a journal's recorded
version move backwards.
Re-verify by: 2026-11-03 (Chronicle entities/service.go SetEntityPermissions)
Toggles entity reveal state (NPC reveal to players). Body is exactly
{ "is_private": <bool> } (a *bool); a matching value is a no-op. Use this
route, not a bare PUT /entities/:id, to flip visibility — it works against
every Chronicle version this module supports.
Request:
{ "is_private": true }Used by: actor-sync.mjs; sync-dashboard.mjs (single + bulk visibility toggles)
Lists all entity types in the campaign.
Response:
{
"data": [
{ "id": 1, "name": "Character", "slug": "dnd5e-character", "icon": "fa-user", "color": "#7C3AED" }
]
}Returns a single entity type with field definitions.
Create a new entity type in the campaign.
Used by: import-wizard.mjs → "Create new type" in Step 3
Request (all 4 fields required):
{ "name": "Quest", "name_plural": "Quests", "icon": "fa-solid fa-scroll", "color": "#fbbf24" }Response: The created entity type object (same shape as GET /entity-types items).
Lists addons with their enabled/disabled state for the campaign.
Used by: import-wizard.mjs → Step 1 addon discovery
Response:
{
"data": [
{ "slug": "calendar", "name": "Calendar", "category": "worldbuilding", "enabled": true },
{ "slug": "maps", "name": "Maps", "category": "worldbuilding", "enabled": true },
{ "slug": "bestiary", "name": "Bestiary", "category": "worldbuilding", "enabled": false }
]
}Lists all tags in the campaign.
Used by: import-wizard.mjs → Step 4 tag detection
Response:
{
"data": [
{ "id": 1, "name": "Important", "color": "#ef4444", "dm_only": false }
]
}Create a new tag.
Used by: import-wizard.mjs → Step 6 (Review) tag creation during import
Request:
{ "name": "NPCs", "color": "#60a5fa", "dm_only": false }Response: The created tag object.
Bulk assign or remove tags on multiple entities. Maximum 200 entities per request — the Foundry module auto-batches larger sets.
Used by: import-wizard.mjs → bulk tag assignment after import
Request:
{ "entity_ids": ["uuid1", "uuid2"], "tag_ids": [1, 2], "action": "add" }action must be "add", "remove", or "set" (replace all tags).
Response:
{
"status": "ok", "processed": 2,
"results": [ { "entity_id": "uuid1", "status": "ok" }, { "entity_id": "uuid2", "status": "ok" } ]
}Bulk update entity type for multiple entities.
Used by: sync-dashboard.mjs → bulk Change Type action
Response: { "status": "ok", "updated": 5 }
Request:
{ "entity_ids": ["uuid1", "uuid2"], "entity_type_id": 5 }Lists predefined relation types for the campaign. Types are immutable forward/reverse string pairs (17 built-in pairs like "parent of" / "child of").
Used by: import-wizard.mjs → future relation creation support
Response:
{
"data": [
{ "forward": "parent of", "reverse": "child of" },
{ "forward": "has item", "reverse": "owned by" },
{ "forward": "member of", "reverse": "has member" }
]
}Create a relation. Identifies the type by forward label string, not a
numeric ID. Write body is snake_case (target_entity_id /
relation_type / reverse_relation_type), target_entity_id required
(empty → 400). Read/WS payload is camelCase — deliberately asymmetric.
metadata is a raw object (json.RawMessage), not a JSON-encoded string.
Request:
{ "target_entity_id": "uuid", "relation_type": "parent of", "reverse_relation_type": "child of", "metadata": {} }item-sync.mjs skips the create when the item has no linked Chronicle target
entity (a custom Foundry item has nothing to relate to).
List all relations on an entity.
Used by: item-sync.mjs → pull inventory relations for actors
Delete a relation (and its reverse). relationId is the numeric relation id.
The route is flat — DELETE /relations/:relationId (syncapi/routes.go),
never nested under /entities.
Used by: item-sync.mjs → remove item from actor inventory
Update relation metadata (e.g., item quantity, equipped state). Body is
{ "metadata": { ... } } only (json.RawMessage); relation type and target
are immutable via this route. Flat route, no /metadata suffix
(syncapi/routes.go).
Used by: item-sync.mjs → update inventory item metadata
Lists campaign members with their display names and roles.
Used by: sync-manager.mjs → auto-match Chronicle users to Foundry users by display name
Response: a bare array. The sync API sends no email address.
[
{
"campaign_id": "uuid", "user_id": "uuid", "role": 1, "display_name": "Alice",
"joined_at": "2026-01-01T00:00:00Z",
"avatar_url": "/media/<uuid>/thumb/300?expires=...&sig=...&campaign=<campaign id>"
}
]avatar_url is a signed 300px thumbnail path of the member's profile picture,
valid for 15 minutes and only while that member belongs to the key's campaign;
absent when the member has no picture. The path holds the picture's media id,
which changes whenever the picture does. Fetch it cookieless, without
redirects, on the apiUrl host.
Used by (avatar): avatar-sync.mjs → copies the picture into the world's
files and sets it as the matched Foundry user's avatar.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/api_handler.go ListMembers, plugins/media/handler.go avatarLinkAllowed)
Lists all sync mappings for the campaign.
Used by: sync-manager.mjs → initial sync setup
Query: ?limit= (max 1000) and ?offset=.
Response: full SyncMapping objects (same shape as /sync/lookup below)
plus paging fields.
{
"data": [
{ "id": "mapping-uuid", "campaign_id": "campaign-uuid",
"chronicle_type": "entity", "chronicle_id": "entity-uuid",
"external_system": "foundry", "external_id": "foundry-doc-id",
"sync_version": 1, "last_synced_at": "2026-01-15T12:00:00Z",
"sync_direction": "both", "sync_metadata": {},
"created_at": "2026-01-15T12:00:00Z", "updated_at": "2026-01-15T12:00:00Z" }
],
"total": 1, "limit": 50, "offset": 0
}Creates a new sync mapping. Body shape is CreateSyncMappingInput.
Request:
{
"chronicle_type": "entity", "chronicle_id": "entity-uuid",
"external_system": "foundry", "external_id": "foundry-doc-id",
"sync_direction": "both", "sync_metadata": {}
}chronicle_type— one ofentity, map, calendar_event, marker, drawing, tokenexternal_system—foundrysync_direction—both | push | pull(defaults toboth)sync_metadata— optional object
Returns 409 Conflict (message: "sync mapping already exists for this object")
if a mapping already exists for the object.
Removes a sync mapping.
Looks up a mapping by Chronicle identity OR external (Foundry) identity.
Used by: all sync modules (sync-manager.mjs → findMapping /
findMappingByExternal) to find existing mappings before create.
Query: ?chronicle_type=entity&chronicle_id=<uuid>
— or — ?external_system=foundry&external_id=<foundry-doc-id>
Response: the full SyncMapping
{
"id": "mapping-uuid", "campaign_id": "campaign-uuid",
"chronicle_type": "entity", "chronicle_id": "entity-uuid",
"external_system": "foundry", "external_id": "foundry-doc-id",
"sync_version": 1, "last_synced_at": "2026-01-01T00:00:00Z",
"sync_direction": "both", "sync_metadata": {},
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"
}Returns 404 ({"error":"Not Found","message":"sync mapping not found"}) when no
mapping exists. This is the normal "not yet mapped" signal callers use to decide to
create one — not an error (the client scrubs it from the diagnostics error log).
Pulls all changes since a timestamp.
Used by: sync-manager.mjs → initial sync
Query: ?since=2026-01-01T00:00:00Z
Response:
{ "entities": [ ], "deleted_entities": [ "uuid1", "uuid2" ], "drawings": [ ], "tokens": [ ], "calendar_events": [ ] }Change feed: ids of what changed after a cursor. Keys of the owner or of members the owner has given DM access only (403 otherwise); absent on older Chronicle (404).
Used by: sync-manager.mjs → initial sync (_readChangeFeed): journals, characters and inventories
Query: ?since=<seq>&limit=1000 (default 500, max 1000)
Response:
{ "changes": [ { "seq": 12, "type": "entity", "resourceId": "uuid", "op": "created" } ], "next": 12, "hasMore": false, "resetRequired": false, "types": ["entity", "relation", "..."] }opiscreated,updatedordeleted; content is refetched through the normal reads, so visibility filtering still applies.resetRequired: truemeanssinceis older than the feed keeps (30 days): do a full rescan and resume fromnext.- Rows younger than 2 s are held back, so a cursor never passes a change still committing; the module reads at least 2.5 s after its socket opened.
- A
relationchange'sresourceIdis the relation's source entity: "this entity's relations changed". Item sync refetchesGET /entities/:id/relations. typeslists the resource types the server records (absent on an older Chronicle). The module saves it with the cursor; inventories use the delta only when the cursor was saved whilerelationwas recorded, else every linked character is reconciled.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/sync_changes_handler.go, sync_change_repository.go)
The campaign's sync history, both directions, newest first. Owner keys and keys of members with DM access only (403 otherwise); absent on older Chronicle (404).
Used by: sync-history-tab.mjs → the dashboard's History pane, under Diagnostics
Query: ?limit=50&before=<id>&direction=to_chronicle|to_foundry|link&failed=1&q=<text> (limit default 50, max 200; a filter matches a run when any of its steps does)
Response:
{ "data": [ { "id": 42, "at": "2026-10-03T19:41:40.017Z", "direction": "to_chronicle", "reportedBy": "chronicle", "kind": "page", "resourceId": "uuid", "name": "Port Ashwick", "action": "page updated", "call": "PUT /entities/:entityID", "status": "200", "ok": true, "durationMs": 38, "who": "Ren", "children": [] } ], "nextBefore": 41 }reportedBy: "chronicle"rows are every write a sync key made, recorded by Chronicle with its result;"client"rows are what the module reported.nextBeforeis present when an older page exists.
The module reports what only it sees: changes it applied in Foundry
(to_foundry), its connects and problems (link). Same access as the read.
Used by: sync-manager.mjs → _history (scripts/_history-report.mjs), every 15 s and on connect
Body: { "events": [ { "at", "direction", "kind", "resourceId", "name", "action", "call", "status", "ok", "durationMs", "message", "children" } ] }, at most 50 events of 200 steps. Returns { "stored": n }.
- A time more than 5 min ahead or 7 days back is replaced by the server's.
- Chronicle names a page the module sent by id only, and for a
to_foundrypage row names who last changed it in Chronicle. - 403 or 404 stops reporting for the session; other failures retry on the next flush.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/sync_history_handler.go, sync_history_recorder.go)
The GM's client reports the world's Foundry users. Chronicle sees only the GM's key, so this is how it learns which Foundry player is linked to which member, who is online, and who last changed or failed. The campaign owner's Foundry page shows it as "Players in Foundry".
Used by: sync-manager.mjs → _players (scripts/_player-report.mjs), sent from the GM client only, with the GM's own key
Body: { "players": [ { "foundryUserId", "name", "memberId", "online", "lastChangeAt", "lastFailedAt", "lastFailure", "failedCount" } ] }
- At most 100 players, GMs first; a full snapshot, so each report replaces the server's list.
nameat most 100 chars;memberIdis the Chronicle user id fromuserMappings,""when unlinked.lastChangeAt/lastFailedAtare RFC 3339 ornull;lastFailureis a short status line (e.g.HTTP 500 Internal Server Error, at most 200 chars), never page text;failedCountcounts failures over the last 7 days.- No emails or other account details are sent.
Response: { "stored": n }
Errors: 403 when the key is not the owner's or a DM-access key; 404 on an older Chronicle; both stop reporting for the session. 400 is a bad body: logged once, reporting continues.
When: on connect, shortly after a Foundry user logs in or out or is edited, or the mappings change, and otherwise at most every 5 min while connected, only when the snapshot changed.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi, the players handler)
Generic sync endpoint for batch operations.
Lists all maps in the campaign.
Note: the module renders a Chronicle map as a JournalEntry image page with an SVG overlay (
MapViewerSheet), not a Foundry Scene. Drawing, token, fog and layer endpoints below are read-only for that overlay; only markers are editable and pushed back.
player_image_url is set on every map row (list and single) whose map has a
picture and at least one shadow area, whatever the key's role:
/api/v1/campaigns/:id/maps/:mapId/player-image?v=<version>. That route
returns the picture as JPEG with the shadowed areas smudged in; the version
changes whenever the picture or a shadow does. The owner's key still gets the
original in image_id/image_url, so the module fetches the player copy,
stores it in Foundry, and points the page every player reads at that file
only (scripts/_map-player-image.mjs). A missing field means no shadows
(also what an older Chronicle sends). drawing_type also includes shadow
(exactly two corner points).
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/map_api_handler.go PlayerImage, playerImageAPIURL)
Each map row carries display_settings (nullable object): the map's own
look. The module reads frame.style/frame.tint and pins.style
(drop|seal|flag|dot), pins.size (s|m|l), pins.labels
(always|hover|never); absent means "follow the campaign" / the default.
Resolved by scripts/_map-look.mjs resolveMapLook into the page meta
(chronicleMapMeta.look) so players never need campaign settings.
The campaign map look: {campaign_frame, frames[], kinds[{id,label,color}], icons[{id,label,category}], default_icon}. campaign_frame is one of
atlas|arcane|old|modern|futuristic|gilded and is what a map without its
own frame wears. Fetched once per full sync; a 404 (older Chronicle) keeps
the Atlas default. Marker icon is a Font Awesome class from icons; the
viewer draws any well-formed fa- class and falls back to default_icon.
The marker dialog's icon picker offers icons grouped by category and
sends only an id from that list; without the list (older Chronicle) the
picker is hidden and a marker keeps its icon.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/map_api_look.go, keyxmakerx/Chronicle#1017)
All coordinates are percentage-based (0–100), not pixels. drawing_type is
one of freehand, rectangle, ellipse, polygon, text.
{
"id": "drw_001", "map_id": "map_001", "layer_id": null, "drawing_type": "rectangle", "points": [],
"stroke_color": "#ff0000", "stroke_width": 2, "fill_color": "#00ff00", "fill_alpha": 0.5,
"text_content": null, "font_size": null, "rotation": 0, "visibility": "everyone", "visibility_rules": null,
"created_by": "user_001", "foundry_id": null
}Coordinates are percentage-based (0–100); width/height are grid units.
{
"id": "tok_001", "map_id": "map_001", "layer_id": null, "entity_id": "ent_goblin01",
"name": "Goblin Archer", "image_path": "/uploads/tokens/goblin.png",
"x": 45.2, "y": 67.8, "width": 5.0, "height": 5.0, "rotation": 0, "scale": 1.0,
"is_hidden": false, "is_locked": false,
"bar1_value": 15, "bar1_max": 15, "bar2_value": null, "bar2_max": null,
"aura_radius": null, "aura_color": null,
"light_radius": null, "light_dim_radius": null, "light_color": null,
"vision_enabled": false, "vision_range": null, "elevation": 0, "sort_order": 0,
"status_effects": null, "flags": null, "foundry_id": null
}Fog regions use percentage coordinates; points is a JSON string of
[{x, y}, ...]. is_explored: true renders semi-transparent, false fully
opaque.
{ "id": "fog_001", "map_id": "map_001",
"points": "[{\"x\":10,\"y\":10},{\"x\":30,\"y\":10},{\"x\":30,\"y\":30}]",
"is_explored": false }layer_type is one of background, drawing, token, gm, fog.
{ "id": "lyr_001", "map_id": "map_001", "name": "Tokens",
"layer_type": "token", "sort_order": 1,
"is_visible": true, "opacity": 1.0, "is_locked": false }Lists map markers (pins/notes on the map).
Creates a map marker.
Updates a map marker. PARTIAL update — absent preserves, an explicit
null clears, a present value replaces (see "The partial-update contract"
under PUT /entities/:entityId).
foundry_id is this module's pairing key. It stays clearable HERE — send
{"foundry_id": null} to unpair — while Chronicle's web marker form, which
never sends the key, can no longer NULL it by omission.
Deletes a map marker.
Chronicle's date and event routes are live. Current Chronicle answers the retired routes (old structure, settings, import, export, advance) with
410 {"error":"calendar_route_retired"}; the module calls none of them. An older Chronicle, mid-rebuild, answers calendar routes with503 {"error":"calendar_rebuilding"}, which arms the module's push pause. See CLAUDE.md → "Calendar blackout and date-push pauses". A route marked Retired below answers 410.Re-verify by: when calendar V5 ships.
All calendar endpoints require the calendar addon to be enabled.
Returns the full calendar with all sub-resources eager-loaded: months, weekdays, moons, seasons, eras, event_categories, cycles, festivals.
Response:
{
"id": "uuid", "campaign_id": "uuid", "mode": "fantasy",
"name": "Calendar of Harptos", "description": "...", "epoch_name": "DR",
"current_year": 1492, "current_month": 1, "current_day": 15,
"current_hour": 14, "current_minute": 30,
"hours_per_day": 24, "minutes_per_hour": 60, "seconds_per_minute": 60,
"leap_year_every": 4, "leap_year_offset": 0,
"months": [{ "id": 1, "name": "Hammer", "days": 30, "sort_order": 0, "is_intercalary": false, "leap_year_days": 0 }],
"weekdays": [{ "id": 1, "name": "First Day", "sort_order": 0, "is_rest_day": false }],
"moons": [{ "id": 1, "name": "Selûne", "cycle_days": 30.0, "phase_offset": 0.0, "color": "#c0c0ff" }],
"seasons": [{ "id": 1, "name": "Winter", "start_month": 11, "start_day": 1, "end_month": 2, "end_day": 28, "color": "#a0c4ff" }],
"eras": [{ "id": 1, "name": "Dale Reckoning", "start_year": 1, "end_year": null, "color": "#6366f1", "sort_order": 0 }],
"event_categories": [{ "id": 1, "slug": "holiday", "name": "Holiday", "icon": "⭐", "color": "#f59e0b", "sort_order": 0 }],
"cycles": [{ "id": 1, "name": "Zodiac", "cycle_length": 12, "type": "yearly", "sort_order": 0, "entries": [] }],
"festivals": [{ "id": 1, "name": "Midsummer", "month": 7, "day": null, "after_month": 7, "sort_order": 0 }]
}Returns current date/time with computed state: current season, moon phases, era, weather.
Used by: _realtime-date-guard.mjs →
fetch-before-push real-time check (see below)
Response:
{
"mode": "fantasy", "year": 1492, "month": 1, "day": 15, "hour": 14, "minute": 30,
"tracks_real_time": false,
"current_season": { "id": 1, "name": "Winter", "color": "#a0c4ff" },
"current_moon_phases": [
{ "moon_id": 1, "moon_name": "Selûne", "phase_name": "Full Moon", "phase_position": 0.5, "phase_icon": "moon" }
],
"current_era": { "id": 1, "name": "Dale Reckoning", "start_year": 1, "color": "#6366f1" },
"current_weather": {
"preset_id": "rain", "preset_label": "Rain", "icon": "cloud-rain", "color": "#6b9bd2",
"temperature_celsius": 12.0,
"wind": { "speed_kph": 25.0, "speed_tier": "moderate", "direction": "NW", "direction_degrees": 315 },
"precipitation": { "type": "rain", "intensity": 0.6 },
"zone_id": "temperate", "zone_name": "Temperate", "description": "Steady rainfall"
}
}Key: current_season, current_moon_phases, current_era, and current_weather are
computed server-side. They may be null/absent if no data is configured.
tracks_real_time: the composed UsesRealTime() predicate (mode == reallife AND the real-time flag).
Read defensively — payload?.tracks_real_time === true — never assume it's
present (older deployments and GET /calendar never carry it). When true,
dates are read-only: the module skips PUT /calendar/date pushes
(fetch-before-push check via scripts/_realtime-date-guard.mjs, re-probed
each push so a mid-session enable self-heals) and shows one GM notice per
session. Pull and event sync are unaffected.
Sets current calendar date/time to an absolute value. Rejected with 422
(Chronicle's W3 guard) when the target calendar's tracks_real_time is true —
the module treats a 422 here identically to a pre-emptive tracks_real_time
read (sets the same session guard, shows the same one-time notice), never as
a retryable sync error.
Request:
{ "year": 1492, "month": 3, "day": 1, "hour": 8, "minute": 0 }Creates the campaign's calendar. Answers 201 {"created": …, "warnings": […]};
409 when the campaign already has a calendar. The module does not call it.
Optional (newer Chronicle deployments only). Confirms Foundry applied a
date pulled from Chronicle to a local calendar, not merely fetched it — drives the sync chip's "SAW" vs.
"APPLIED" distinction (SAW = the GET /calendar/date beacon).
Used by: scripts/_applied-date-confirm.mjs, for a caller that applied
a date to a Foundry calendar; never sent on a bare fetch or an apply failure.
No Foundry calendar is integrated, so nothing calls it yet (TODO(#95)).
Request:
{ "year": 1492, "month": 3, "day": 1 }Response: 204 No Content
Graceful degradation: a Chronicle predating this endpoint returns 404 or
405; the module tolerates both silently (one console.debug, no retries).
Any other failure is debug-logged and swallowed — a missed confirmation only
leaves the applied-beacon stale, never blocks sync. See
scripts/_applied-date-confirm.mjs::isConfirmNotSupported.
Answers 410 {"error":"calendar_route_retired"}.
Answers 410 {"error":"calendar_route_retired"}.
Each resource has a GET (returns all definitions) and a PUT (bulk-replaces all definitions):
| Resource | Routes | Notes |
|---|---|---|
| Seasons | GET/PUT /calendar/seasons |
|
| Moons | GET/PUT /calendar/moons |
|
| Eras | GET/PUT /calendar/eras |
|
| Event categories | GET/PUT /calendar/event-categories |
|
| Cycles | GET/PUT /calendar/cycles |
zodiac/elemental cycles, PUT includes entries |
| Festivals | GET/PUT /calendar/festivals |
fixed calendar entries |
Lists events for a month. Query: ?year=1492&month=3 or ?entity_id=uuid.
Used by: calendar-sync.mjs → fetchEvents, one request per month across the
current year ±1, and the built-in calendar's month window (current month ±1).
Day weather. GET /calendar/weather/days?year=&month= returns {data,total} of
that month's day readings (year, month, day, icon, preset_label, …); below
the Director only days up to today. Used by: the built-in calendar's month view.
Players' view. GET /calendar, GET /calendar/date, GET /calendar/events and
GET /calendar/weather/days take ?audience=players: Chronicle filters the read for an anonymous player
(hidden moons, secret eras, GM-only, per-player and unannounced events left out)
whatever the key's role, and the date, events and day-weather answers carry
"audience": "players". An older Chronicle ignores the parameter and answers
with the GM's view; without the echo the module leaves moons, era, events and
day weather out of the players' snapshot. Used by: CalendarSync.publishPlayerSnapshot.
Re-verify by: 2026-11-04 (Chronicle internal/plugins/syncapi/calendar_api_handler.go readViewer; lands with Chronicle #1084)
Creates a calendar event. Used by: the built-in calendar's Add event (GM), sending name, year, month, day, all_day, start_hour/start_minute when timed, and visibility (everyone or gm-only).
Request:
{
"name": "Festival of the Moon",
"description": "ProseMirror JSON or plain text",
"description_html": "<p>Rendered HTML</p>",
"entity_id": "optional-entity-uuid",
"year": 1492, "month": 11, "day": 30, "start_hour": 8, "start_minute": 0,
"end_year": 1492, "end_month": 12, "end_day": 1, "end_hour": 23, "end_minute": 59,
"is_recurring": true, "recurrence_type": "yearly", "recurrence_interval": 1,
"recurrence_end_year": null, "recurrence_end_month": null, "recurrence_end_day": null,
"recurrence_max_occurrences": null,
"visibility": "everyone", "category": "festival",
"color": "#ffd700", "icon": "star", "all_day": true
}Fields: color (hex), icon (FontAwesome/custom id),
all_day, recurrence_interval (periods between recurrences),
recurrence_end_year/month/day, recurrence_max_occurrences.
Updates a calendar event. Same fields as POST; PARTIAL update — absent
preserves, an explicit null clears, a present value replaces (see "The
partial-update contract" under PUT /entities/:entityId).
The module does not call it. A future push of a Foundry note edit must keep
the body narrow: the name, the date and the body, nothing else
(tools/test-partial-put-contract.mjs).
Deletes a calendar event.
Returns a single event by ID.
| Route | Behavior |
|---|---|
PUT /calendar/settings |
Updates calendar name, time system, leap year, current date/time |
PUT /calendar/months |
Replaces all month definitions |
PUT /calendar/weekdays |
Replaces all weekday definitions |
GET /calendar/structure |
Returns the calendar structure |
GET /calendar/weather |
Returns current weather state, or {} if none set |
PUT /calendar/weather |
Sets current weather state (GM override) |
GET /calendar/export |
Exports the full calendar as Chronicle JSON; ?events=true includes events |
POST /calendar/import |
Retired: answers 410 {"error":"calendar_route_retired"}; use POST /calendar |
Uploads a media file (image, etc.). Full path
/api/v1/campaigns/:id/media.
Used by: api-client.mjs (uploadMedia)
Request: Multipart form data with file field.
Response: 201 with the same media object GET /media/:mediaId returns.
Returns media metadata: mime_type, file_size, and url, a signed
/media/<id>?expires=…&sig=… link valid for about 15 minutes. Journal sync
uses it to copy pictures inside page text into the world's files
(scripts/picture-store.mjs); a saved signed link would expire.
Deletes a media file.
Lists relations for an entity (used for shop inventory).
Response:
{
"data": [
{ "id": "relation-uuid", "source_id": "shop-entity-uuid", "target_id": "item-entity-uuid",
"relation_type_id": 1, "metadata": { "quantity": 5, "equipped": false },
"target": { "id": "item-uuid", "name": "Longsword", "fields_data": {} } }
]
}The shop room window's read (scripts/shop-room-window.mjs). Needs the armory
addon. 404 when the shop is missing, not a shop, or hidden from the key.
Response:
{ "layout": { "...": "saved room, or null to generate one" },
"goods": [ { "id": 7, "relationType": "sells", "targetEntityId": "item-uuid",
"targetEntityName": "Rope", "metadata": { "price": 1, "currency": "gp", "quantity": 3 } } ] }goods are the shop's sells relations a plain player can see (no dmOnly
rows, no items hidden from players), so the room can be shown to every player.
The shop room's buying calls (scripts/shop-room-window.mjs), made by the
GM's client: for the GM, or for a player as the Chronicle member the player
is matched to (actingUserId, a query parameter on GET and a body field on
POST). Only an Owner key, or the key of a member the owner has given DM
access, may name someone else (403); the name must
be a current member (404); the call then has that member's rights only. Needs
the armory addon.
Buyers response: {"downtimeOpen": true, "canBuyNow": true, "buyers": [{"id": "char-uuid", "name": "Brin", "moneyKey": "gp", "money": 50}]}
Buy body: {"actingUserId": "member-uuid", "buyerEntityId": "char-uuid", "items": [{"relationId": 7, "quantity": 2}]}
(at most 50 lines, quantity 1–99; prices come from the listing, never the body)
Buy response: {"status": "bought", "spent": 6, "currency": "gp", "moneyLeft": 44}.
A player while downtime is closed gets {"status": "requested"} instead: the
basket is stored as a request the GM approves on Chronicle's Stashes page, and
nothing is charged yet (canBuyNow is false in the buyers response then).
Refusals are {"message": "..."}: 400 (empty basket, no coin field, a Wealth
sheet, not enough coin, mixed currencies), 403 (not their character), 404
(shop or good hidden), 409 (prices changed, or the item could not be added).
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/shop_api_handler.go)
The GM's DM Screen window (dm-screen.mjs, parsed by _dm-screen-view.mjs).
The body is Chronicle's dmscreen.View, the same data its own panel draws.
A Chronicle without these routes answers 404; the window then says to update
Chronicle. Players are refused (403) and the downtime switch is owner-only;
the GM's sync key counts as the owner.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/dm_screen_api.go, internal/plugins/dmscreen/model.go; added in Chronicle PR #1021)
Used by: dm-screen.mjs. Scope: read.
Response: sections Chronicle can't fill are left out (downtime, world, night) or empty.
{
"campaign_id": "uuid",
"downtime": { "open": false, "can_toggle": true, "pending": 2 },
"world": { "calendar_id": "uuid", "date_label": "3 Frostfall 1204", "time_label": "14:00", "weather": "Light snow" },
"night": { "name": "Session 12", "when": "Fri 3 Oct, 7pm", "going": 3, "maybe": 1, "cant": 0, "no_answer": 2 },
"foundry": { "connected": true, "never_seen": false, "last_seen": "2026-10-03T15:00:00Z" },
"system_name": "Draw Steel",
"party_filled": true,
"party": [
{ "id": "uuid", "name": "Vex", "player_name": "Sam", "subtitle": "Shadow", "conditions": ["Bleeding"],
"meters": [ { "label": "Stamina", "current": "12", "max": "30", "has_max": true, "percent": 40, "low": true } ] }
],
"hidden": [ { "id": "uuid", "name": "The Baron", "type_name": "Character", "revealed": false } ],
"conditions": [ { "name": "Bleeding", "text": "Plain rule text." } ]
}Used by: dm-screen.mjs. Scope: write. Body {}. Makes one hidden character visible to players.
Response: { "id": "uuid", "name": "The Baron", "revealed": true }
Used by: dm-screen.mjs. Scope: write. Body { "open": true } or { "open": false }; a missing open is a 400.
404 when the campaign has no Armory.
Response: { "open": true, "applied": 2, "failed": 0 } (waiting requests that went through or failed on opening)
The install/update contract: the URLs Foundry hits to fetch the module's
manifest and zip from Chronicle (not GitHub), codified in
chronicle-package.json at this repo's root (serving.manifestEndpoint,
serving.downloadEndpoint). Hit by Foundry itself (install, every update
check) and by the Update Source diagnostic dialog
(scripts/update-info.mjs), which also hits the manifest endpoint manually
so the operator can confirm reachability.
Install-time URL storage, rotation effects, and
update-info.mjserror classification:.ai.md→ "Chronicle Integration — Install & Updates".
Per-campaign signed token in the query string — not the Bearer-token API key. Chronicle rotates it on request from the campaign owner. Generated when the owner first opens the Foundry VTT disclosure in campaign settings.
?token=<signed>
The owner rotates their token any time from the Foundry VTT disclosure in
campaign settings (rotate button → token/rotate below). After rotation:
- The old token is immediately invalidated: a pre-rotation install gets
403with{ "error": "invalid_token", "category": "auth", ... }on its next update check. - The new token is embedded in URLs Chronicle emits going forward; reinstalling via the freshly-displayed URL works again.
- Recovery is reinstall, not repair — Foundry stores the install-time URL
with no supported way to swap it from the module. The Update Source dialog
detects this (
authcategory) and suggests reinstalling.
Returns the resolved module.json for whichever version the campaign is
pinned to (or the auto-latest version if no pin is set), with manifest and
download fields rewritten to per-campaign Chronicle URLs.
Used by:
- Foundry's Install Module dialog (operator pastes this URL).
- Foundry's native Setup → Modules → Update All on every check.
scripts/update-info.mjsfor the manual "Check Chronicle for updates".
Success response (200): A standard Foundry module.json body. The fields
named in the descriptor's serving.rewriteFields array (currently manifest
and download) carry Chronicle URLs:
{
"id": "chronicle-sync", "version": "0.1.11",
"manifest": "https://chronicle.example.com/api/v1/campaigns/<cid>/foundry-vtt/module.json?token=<signed>",
"download": "https://chronicle.example.com/api/v1/campaigns/<cid>/foundry-vtt/module.zip?token=<signed>",
"...": "all other fields unchanged from the on-disk module.json"
}Error response shape: Structured JSON body so clients (including
update-info.mjs) can surface actionable messages. Three fields, all
strings:
{ "error": "invalid_token", "category": "auth", "message": "The install-time token was rotated by the campaign owner..." }error— code from the catalog below. Opaque, logs/debugging only. Foundry MUST NOT branch on it — that couples Foundry to Chronicle's internal naming. Branch oncategoryinstead.category— snake_case bucket, one ofauth,config,not_found,validation,internal. Pinned by cordinatordecisions/2026-05-17-error-catalog-wire-contract.md; canonical wire field (json:"category") —update-info.mjs'schronicleCategoryis a local rename, not the wire field.message— human-readable, operator-actionable. Render verbatim.
Authoritative catalog: error-catalog.json at the Chronicle repo, pinned
by the wire-contract decision above:
https://raw.githubusercontent.com/keyxmakerx/Chronicle/main/internal/plugins/foundry_vtt/error-catalog.json
The catalog carries "schemaVersion": 1. CI (tools/check-error-catalog.mjs,
.github/workflows/check-error-catalog.yml) fetches it on every PR, push to
main and weekly, and fails when the table below or update-info.mjs's
CHRONICLE_CATEGORIES no longer match it, or when schemaVersion changes.
error code |
category |
Description | HTTP |
|---|---|---|---|
campaign_not_found |
not_found |
Campaign id in the URL doesn't exist (deleted, or never existed) | 404 |
descriptor_invalid |
validation |
Release zip's chronicle-package.json failed schema validation (PostInstallHook) |
422 |
invalid_token |
auth |
Token signature doesn't match (rotated, forged, truncated) | 403 |
module_json_missing |
internal |
Release zip is missing module.json at the descriptor's moduleJsonPath (Chronicle packaging bug) |
500 |
no_package_registered |
config |
No package registered for this campaign's Foundry serving slot — install / re-pin a release | 503 |
no_version_available |
config |
No pinned version and no auto-latest available (catalog empty) | 503 |
pinned_version_not_installed |
config |
Pinned version isn't in Chronicle's installed catalog (race after unpublish, or stale pin) | 503 |
token_not_initialized |
config |
Owner has never opened the Foundry VTT disclosure for this campaign | 503 |
error-catalog.json also lists a catch-all ErrInternal constructor:
wildcard: true, category internal, HTTP 500. Its catalog code is the
placeholder <dynamic>; the wire error value is the actual Go error
message. Treat wildcard codes as valid but opaque — never enumerate or
branch on the runtime error value; category (internal) is authoritative
for routing, message renders verbatim.
Fallback when category is missing or unrecognized:
categorize() in scripts/update-info.mjs trusts body.category if it's in
the local CHRONICLE_CATEGORIES set, else derives one from HTTP status
(401/403 → auth, 404 → not_found, else → internal). No
code-to-category lookup exists by design — branching on error would
reintroduce cross-repo drift; a new Chronicle category needs a paired
Foundry-side update first.
Streams the release zip for the resolved pinned version. Same token auth as the manifest endpoint.
Used by: Foundry's install/update flow, after reading the download URL
from the manifest response.
Response: application/zip body. The embedded module.json carries
Chronicle URLs (not the source zip's GitHub URLs), so subsequent update
checks go to Chronicle. Rewritten at download time per-campaign, so two
campaigns hitting the same source zip get differently-addressed zips.
Error response: Same JSON error shape as the manifest endpoint (same
error / message / category triple, same code catalog).
Part of Chronicle's web UI, not called by Foundry or this module. Documented because they affect the contract: token rotation invalidates installs, pin changes change the version served to Foundry.
Owner-only. Rotates the per-campaign signed token. After rotation, all
existing Foundry installs of this campaign's module get invalid_token
errors on their next update check (see "Token rotation behavior" above).
Used by: Chronicle's owner-side Foundry VTT disclosure (rotate button).
Response (200): The new signed token. Owner-side UI re-renders the per-campaign install URL with the new token embedded.
Owner-only. Sets or clears the campaign's pinned module version. An empty /
absent pin means "track latest". A specific version (e.g., 0.1.11) means
"serve exactly this version regardless of newer releases".
Used by: Chronicle's owner-side Foundry VTT disclosure (pin selector).
Effect on Foundry: installed instances see the new version on their next
module.json check — an update offer if the pin's version is greater, a
downgrade prompt if lower (Foundry's native behavior), nothing if equal.
chronicle-package.json at this repo's root tells Chronicle how to serve
this module. Schema v1:
This is the contract between this repo and Chronicle's packages plugin.
Chronicle reads it from the extracted zip via PostInstallHook; an absent or
invalid descriptor falls back to hardcoded defaults matching the schema
above. tools/check-package-descriptor.mjs validates it on every push.
If Chronicle's URL shape changes, update all three together:
chronicle-package.json(serving.manifestEndpoint/downloadEndpoint)scripts/update-info.mjs(CHRONICLE_MANIFEST_REclassifier)- This section of
API-CONTRACT.md
Move items and money between characters and stashes. All routes need the
Armory addon; an older Chronicle, or a campaign with the addon off, answers
404 and the module simply does not offer Stashes (it probes
GET /stashes/downtime once per session, caches a definitive answer, and keeps
that 404 out of the error log). JSON is camelCase, money is a number in whole
currency units, ids are strings (a stash id may arrive as a number).
Used by: scripts/stash-sync.mjs (GM client only), reached by players
through the module socket (scripts/stash-client.mjs).
Acting member. Every call may carry actingUserId (query on GET, body on
POST/PUT): the Chronicle user id of a current member. The call then runs under
THAT member's role and rules. Only an Owner key, or the key of a member the owner has given DM
access, may name someone else (403 otherwise), so naming a player only narrows what the call may do.
The module sends it only when relaying for a player, and takes the member from
the Foundry user id the socket layer attached to the request, never from the
request body.
| Method | Path | Notes |
|---|---|---|
| GET | /stashes/view?characterId=&actingUserId= |
{downtimeOpen, canApprove, character:{id,name,moneyKey,money,items:[{itemId,name,quantity}]}, stashes:[{id,name,money,items}], destinations:[{kind,id,name}]}. characterId is the Chronicle entity id (the actor's entityId flag). moneyKey is empty when the system has no money field. |
| POST | /stashes/moves |
Body {actingUserId, kind:"item"|"money", itemId, quantity, amount, from:{kind,id}, to:{kind,id}}, endpoint kinds character|stash. Items send itemId+integer quantity; money sends a number amount (at most two decimals). Answer {status:"applied"|"pending", move}. 403 not your character, 404 unseen stash, 409 not enough / not applicable. |
| GET | /stashes/history?characterId=&actingUserId= |
{history:[line]}, newest first, at most 50. A line has id, kind, status, quantity, amount, itemName, fromName, toName, requesterName, summary, createdAt; a sheet money edit has both ends the same character and reads e.g. "Wealth changed 2 → 3 · in Foundry". |
| GET | /stashes/requests |
{requests:[line]}, pending only; the actor must be able to approve. |
| POST | /stashes/requests/:moveId/approve, /decline |
Body {actingUserId} optional; the actor must be able to approve. Answer {status, move} (applied, declined or failed). |
| GET | /stashes/downtime |
{open}. |
| PUT | /stashes/downtime |
{open, actingUserId}; approver only. The module does not call it. |
Known limits: only the active GM client answers players, posts request cards and refreshes actors; the Stashes button treats any Chronicle-linked actor as a character. Replies to players travel encrypted on the module socket; a refresh never deletes a Foundry item except the one an applied move took from a character.
Lists are unwrapped defensively ({history}/{requests}/{data} or a bare
array): scripts/_stash-model.mjs.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/syncapi/stash_api_handler.go, internal/plugins/armory/stash_api.go, docs/api/openapi.yaml Stashes tag; the routes are on Chronicle PR #1022 and unreleased as of 2026-10-03)
Chronicle's quest boards and quest sheets. Every route needs a key of the
campaign owner or a co-DM (403 otherwise). Players have no key: the active GM's
client reads for them and always adds ?audience=players, which answers as a
plain player would see it. Pay and give also need the Armory addon (404 when
it is off).
| Method | Path | Notes |
|---|---|---|
| GET | /quests/homes |
[{kind:"category"|"page", id, name?}], the places with boards; categories first. id is the entity type id (as a string) for a category, the page id for a page; only pages carry name. With ?audience=players, pages a player may not open are left out. |
| GET | /quests/boards?category=<typeId> or ?page=<entityId> |
{canManage, me, looks:{board, ledger}, boards:[{id, name, who, canChange, items:[…]}], mapsOn}. An item has id, kind (notice|note|page|map|string), x, y, w, r (percentages and degrees) plus, by kind: notice questId, title, kicker, blurb, reward, status, daysLeft?; note text, ownerName; page or map entityId|mapId, name, typeName, concealed, imageUrl (a Chronicle-relative link, signed for the API key so Foundry can load it without a session); string from, to. hidden appears only in the DM view. Exactly one of category, page (400 otherwise). |
| GET | /quests/:entityID |
DM view {version, notice, status, handedOut, steps:[{id,text,done,shown}], rewards:[{id,kind,text,amount,entityId,name}], foes, links, layout, looks, due?}; players' view {notice (null when hidden), status, handedOut, steps (shown ones), hiddenSteps, due?:{label, daysLeft}, …}. status is not_started, active, done or failed. |
| PUT | /quests/:entityID |
Partial: {version, …changed fields}; steps is sent whole. 409 when version is stale (the module reloads). Answers the DM view. Body at most 256 KiB. |
| GET | /quests/party |
[{id, name, player?, imageUrl?}], the characters rewards can go to. DM only: ?audience=players is refused (403), and the module never relays it. |
| POST | /quests/pay |
{characterId, amount, reason}, amount in the sheet's main unit (two decimals). One call per character; the module splits coin rewards itself. |
| POST | /quests/give |
{characterId, itemId}, one reward item to one character. Answers {itemName, characterName}. |
The module sends hand-out steps one at a time and remembers the ones that
worked, so a retry after a failure never pays or gives twice; the quest is then
saved with handedOut: true (and status: "done" when asked).
Re-verify by: 2026-11-09 (Chronicle internal/plugins/syncapi/quest_api_handler.go, internal/app/quests_api_adapter.go, internal/plugins/quests/model.go)
Not part of the REST API: the notebook (scripts/player-notebook.mjs,
checks in scripts/_notes-grant.mjs) frames Chronicle web pages and never
uses the GM's sync key. Everything crosses postMessage, and each side checks
the other's origin (Chronicle's, taken from apiUrl) before acting.
| Page | Address | Purpose |
|---|---|---|
| Allow window | /campaigns/:id/notes/allow-app?origin=<Foundry origin> |
A pop-up where the player presses Allow. Replies {type:"chronicle:notes-grant", token, userId, campaignId} (token starts cnt_) or {type:"chronicle:notes-grant-declined"}. |
| Notebook frame | /embed/campaigns/:id/notes/journal |
The player's Journal. |
| Jot frame | /embed/campaigns/:id/notes/jots |
Jot notes for the page in view. |
| Calendar frame | /embed/campaigns/:id/notes/calendar |
The campaign's default calendar, Chronicle's own page, as this player sees it (same grant; the frame reads /api/notes-app/campaigns/:id/calendars/…). An older Chronicle answers 404. |
The module keeps a grant only when campaignId matches and the GM has matched
the returned userId to this Foundry login (Chronicle settings → Players); an unmatched or
mismatched account is refused. Frame to module: chronicle:embed-ready (the
module answers chronicle:notes-token with the token and current entityId),
chronicle:grant-rejected (the stored grant is dropped), chronicle:open-note
with noteId. Module to frame: chronicle:notes-token, chronicle:jots-page
with entityId, chronicle:open-note.
Re-verify by: 2026-11-04 (Chronicle internal/widgets/notes/app_grants_handler.go, allow_app.templ, static/js/notes_embed.js, internal/plugins/calendar/routes.go RegisterAppRoutes)
Every
calendar.*message type below is DORMANT. Chronicle's calendar event publisher was deleted with the plugin, so none reach the wire. A structure-mismatch pause from before the blackout can't be cleared by its recovery path (calendar.structure.updated) until V5 — reload the world. Re-verify by: when calendar V5 ships.
GET /ws?token=<api-key>
Upgrade: websocket
Authentication happens at connection time via the token query parameter.
If the token is invalid, the server rejects the upgrade.
{
"type": "entity.updated",
"data": { }
}| Type | Data Payload | Description |
|---|---|---|
entity.created |
Full entity object | New entity created |
entity.updated |
Full entity object | Entity modified |
entity.deleted |
{ id: "uuid" } |
Entity deleted |
entity_type.created |
Full entity type object | Entity type created |
entity_type.updated |
Full entity type object | Entity type modified |
entity_type.deleted |
{ id: "uuid" } |
Entity type deleted |
marker.created |
Full marker object | Map marker created |
marker.updated |
Full marker object | Map marker modified |
marker.deleted |
The deleted marker (with map_id) |
Map marker deleted |
drawing.created / .updated / .deleted |
Full drawing (map_id, visibility, …) |
Map drawing changed |
token.created / .updated / .deleted |
Full token (map_id, x, y, is_hidden, …) |
Map token changed |
token.moved |
{ x, y }; resourceId is the token id (no map_id); GM-only for a hidden token |
Token dragged |
layer.created / .updated / .deleted |
Full layer (map_id, …) |
Map layer changed |
fog.created / .updated / .deleted |
{ event, map_id, region? }; resourceId is the map; GM-only. A fog reset arrives as fog.updated |
Fog changed |
note.created |
{ noteId, entityId } — ids only; the module fetches the note. An older Chronicle sends the full note object instead |
Note created |
note.updated |
{ noteId, entityId } — ids only; the module fetches the note. An older Chronicle sends the full note object instead |
Note modified |
note.deleted |
{ noteId, entityId } (an older Chronicle sends the full note object; only the id is read) |
Note deleted |
relation.created |
Full relation row (id, sourceEntityId, targetEntityId, relationType, metadata, …); resourceId is the source entity |
Relation row created (one message per direction) |
relation.deleted |
Same | Relation row deleted |
relation.metadata_updated |
Same | Relation metadata changed (quantity, equipped, stash moves) |
calendar.event.created |
Full event object | Calendar event created |
calendar.event.updated |
Full event object | Calendar event modified |
calendar.event.deleted |
{ id } |
Calendar event deleted |
calendar.date.advanced |
{ year, month, day, hour, minute } |
Date/time changed |
calendar.season.changed |
{ id, name, color } — or null when the date left a season without entering another |
Season boundary crossed |
calendar.moon.phase_changed |
{ moon_id, moon_name, phase_name, phase_position } |
Moon phase changed |
calendar.weather.changed |
merged WeatherInput (FLAT snake_case) — or null from the weather-zone paths, where it is a "refetch me" ping |
Weather set or generated |
calendar.structure.updated |
null |
Calendar structure modified |
calendar.cycle.changed |
null |
Cycle edited (always fires alongside structure.updated) |
calendar.festival.changed |
null |
Festival edited (always fires alongside structure.updated) |
calendar.era.changed |
{ id, name, color } |
Era boundary crossed |
calendar.worldstate.changed |
{ date: {year, month, day}, moodTint: {color, intensity} } |
World state changed — dormant along with every other calendar.* type during the blackout |
stash.requested |
{ moveId, requestedBy, summary } — ids and a size summary only |
A player asked to move something; the GM client fetches GET /stashes/requests and posts a card |
stash.settled |
{ moveId, status, decidedBy } |
A request was answered (here or on the website); the card is rewritten |
stash.moved |
{ moveId, status, characterIds, stashIds } |
A move ran; the touched linked actors are re-pulled |
stash.money_changed |
{ characterId, moveId } |
A character's money changed (including a sheet edit); the actor is re-pulled |
downtime.changed |
{ open } |
Downtime was opened or closed; relayed to players' open windows |
npc.spotlight |
none; resourceId is the NPC's entity id |
"Show in Foundry" pressed on a Chronicle NPC page |
system_state.updated |
{ systemId, key }; resourceId is the page's entity id; DM-equivalent sockets only |
A system widget saved a page's per-system state (Draw Steel negotiation tracker: systemId drawsteel, key negotiation) |
quest.updated |
{ version }; resourceId is the quest page's id. Sent only to DM sockets when the change is DM-only |
A quest sheet was saved; open quest windows refetch |
notice_boards.updated |
{ home } (page or category); resourceId is the page id or the type id |
A home's boards changed; open board windows refetch |
sync.status |
{ connected: bool } |
Connection state change |
sync.error |
{ message } |
Synchronization error |
sync.conflict |
Conflict details | Data conflict detected |
Every calendar.* message goes to DM-equivalent sockets only (the GM's key). An event Chronicle stores as dm_only goes out with visibility: "gm-only"; other values pass through as stored, and the module treats anything but "gm-only" as public. Players get the calendar from the GM client's republished snapshot.
relation.* messages go to the owner's socket and sockets of members the owner has
given DM access only (a relation can
name a private entity). Item sync treats any of them for a linked character
as "reconcile this character's inventory" (scripts/_inventory-plan.mjs),
so a missed or repeated message cannot leave it wrong. A removed relation
only unlinks its Foundry item; the module deletes it only when a stash
move the GM just applied took it off that character. Re-verify by:
2026-11-03 (Chronicle internal/widgets/relations/service.go,
internal/app/routes.go relationEventPublisherAdapter; sent since
keyxmakerx/Chronicle#1025).
These reach DM-equivalent sockets only, with ids and statuses, never names.
Handled in scripts/stash-sync.mjs onMessage, by the active GM client
only (game.users.activeGM), and only once the Stashes probe said yes. The
module's WebSocket allowlist includes the stash. and downtime. prefixes.
Re-verify by: 2026-11-03 (Chronicle internal/websocket/.ai.md, internal/plugins/armory/stash_events.go)
Every map item change arrives as one of the messages above, so the module
never polls. marker.*, drawing.*, token.*, layer.* and fog.*
refetch that map's items (GET /maps/:id/{markers,drawings,tokens,layers,fog})
and rewrite the player-safe copy on the map page. token.moved is
patched into the GM's cached token instead, many per drag, and the stored
token positions are rewritten once the drag settles. Chronicle answers an
empty item list as null, which the module reads as an empty list; only a
failed fetch leaves the drawings, and so the shadows, unknown.
Chronicle sends no message for a map row itself (map.* is not published),
so every connect still reads GET /maps. On connect the change feed also
names map items changed while Foundry was closed: fog entries name their
map, other entries name the item, which the module places from what it has
stored, refreshing every map once when it cannot. Re-verify by: 2026-11-03
(Chronicle internal/app/routes.go mapEventPublisherAdapter,
internal/plugins/syncapi/map_api_handler.go).
Sent to DM-equivalent sockets only, with the page id and nothing else, when
the owner or a member given DM access presses "Show in Foundry" on an NPC
page. It is not in the change feed, so a missed one is simply gone. The
module's allowlist includes the npc. prefix; scripts/npc-presence.mjs
npcSpotlightRelay acts on the active GM client only: it finds a token
on the GM's current scene linked to that page (shown before hidden) and runs
the same spotlight as the token HUD star. No such token, or a hidden one,
only tells the GM. tools/test-npc-presence.mjs.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/foundry_vtt/npc_spotlight.go, internal/app/npc_spotlight_adapters.go; keyxmakerx/Chronicle#1039)
Sent to DM-equivalent sockets only, with ids and nothing of the state
itself. The module's allowlist includes the system_state. prefix;
scripts/negotiation-mirror.mjs acts on the active GM client only, on
worlds where game.system.id is draw-steel, and only for systemId
drawsteel with key negotiation. It reads
GET /entities/:entityId/system-state/:system/:key →
{ "systemId", "key", "public": {...}, "gm": {...}, "isGm": true, "updatedAt" }
(the gm half is present only for an owner-level key; a 404 means an older
Chronicle or no such page and is ignored quietly) and copies interest and
patience (clamped 0 to 5), motivations, pitfalls (higher-authority becomes
the system's authority; unknown slugs are dropped) and impression (only
when the tracker has one) onto system.negotiation of the NPC actors linked
to that page (actor flag npcEntityId, else a unique NPC name match; heroes
never). It runs once more when a GM links a page by dropping its journal on a
token. Nothing is written back: edits on the Foundry sheet don't sync to
Chronicle yet. tools/test-negotiation-mirror.mjs.
scripts/creature-sync.mjs acts on key foundry for any system: it
forgets its cached answer for that page, and reads
GET /entities/:entityId/system-state/<detectedSystem>/foundry again before
the next send-back. Only gm.sendBack === true lets a creature's
foundry_pull_fields go back to Chronicle; no state (404) means off.
Re-verify by: 2026-11-03 (Chronicle internal/plugins/systemstate, internal/plugins/syncapi/system_state_api.go; keyxmakerx/Chronicle#1051)
Nothing. The player notebook shows Chronicle's own Journal and Jot pages in
frames, so notes never become Foundry journals and no sync module reads
note.*. The old "Chronicle Notes" folder from the retired note sync is set
aside on the GM's world load (scripts/_notes-folder.mjs).
Handled in scripts/calendar-sync.mjs onMessage + scripts/_calendar-subresources.mjs.
Display-level and non-destructive: no Foundry calendar module is
integrated, and no branch writes into Foundry. Chat announcements are GM
whispers only — never public, so a DM-gated payload is never laundered into
a player-visible one.
| Type | Module behavior |
|---|---|
calendar.date.advanced |
Updates the cached date the dashboard shows and re-reads the month window. TODO(#95): apply it to the built-in calendar and confirm it back |
calendar.event.created/updated/deleted |
Re-reads the month window (scheduleRefresh); nothing is written into Foundry documents |
calendar.weather.changed |
Updates the dashboard world-state panel and whispers a GM chat line (calendarAnnounceWeather). null payload → one GET /calendar/weather refetch |
calendar.season.changed |
Panel + GM chat line (calendarAnnounceSeasonEra, default on) |
calendar.era.changed |
Panel + GM chat line (calendarAnnounceSeasonEra, default on) |
calendar.moon.phase_changed |
Panel + GM chat line (calendarAnnounceMoon, default off — moons change phase every few in-world days) |
calendar.worldstate.changed |
Panel + GM chat line (calendarAnnounceWorldstate, default on). Wired and tested, unreachable during blackout |
calendar.structure.updated, calendar.cycle.changed, calendar.festival.changed |
Refetches GET /calendar into the cache |
any other calendar.* |
default: logs one console.debug line per type per session — no silent drops |
Every cross-repo claim here carries a Re-verify by: line.
The API client automatically reconnects on WebSocket disconnection:
- Initial retry delay: 2 seconds
- Max retry delay: 30 seconds (exponential backoff)
- Infinite retries (never gives up)
- Queued messages are replayed on reconnection
Chronicle must whitelist the Foundry VTT server's origin in its CORS configuration.
The module makes cross-origin requests from the Foundry server (typically
http://localhost:30000 or a custom domain) to the Chronicle server.
CORS origins are managed in Chronicle's admin panel: Admin > API Settings > CORS Origin Whitelist
Required CORS headers from Chronicle:
Access-Control-Allow-Origin: <foundry-origin>
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
{ "schemaVersion": 1, "package": { "id": "chronicle-sync", // must match module.json#/id "kind": "foundry-module", "moduleJsonPath": "module.json" }, "serving": { "rewriteFields": ["manifest", "download"], "manifestEndpoint": "/api/v1/campaigns/{campaign_id}/foundry-vtt/module.json?token={token}", "downloadEndpoint": "/api/v1/campaigns/{campaign_id}/foundry-vtt/module.zip?token={token}", "perCampaignSignedToken": true, "zipContentRoot": "" } }