This document defines every Chronicle REST API endpoint and WebSocket message that the Foundry module depends on. A new AI working on this module MUST understand this contract to avoid breaking changes.
All REST requests include a Bearer token:
Authorization: Bearer <api-key>
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)
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
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)
A single game system may expose multiple entity presets, each with its own
foundry_actor_type and field mappings. For example, Draw Steel has both a
hero preset (drawsteel-character, actor type "hero") and a creature
preset (drawsteel-creature, actor type "npc").
Expected creature preset response:
{
"system_id": "drawsteel",
"preset_slug": "drawsteel-creature",
"preset_name": "Draw Steel Creature",
"foundry_system_id": "draw-steel",
"foundry_actor_type": "npc",
"fields": [
{ "key": "stamina_max", "label": "Stamina (Max)", "type": "number", "foundry_path": "system.stamina.max", "foundry_writable": false },
{ "key": "stamina_value", "label": "Stamina (Current)", "type": "number", "foundry_path": "system.stamina.value", "foundry_writable": true },
{ "key": "might", "label": "Might", "type": "number", "foundry_path": "system.characteristics.might.value", "foundry_writable": true },
{ "key": "agility", "label": "Agility", "type": "number", "foundry_path": "system.characteristics.agility.value", "foundry_writable": true },
{ "key": "reason", "label": "Reason", "type": "number", "foundry_path": "system.characteristics.reason.value", "foundry_writable": true },
{ "key": "intuition", "label": "Intuition", "type": "number", "foundry_path": "system.characteristics.intuition.value", "foundry_writable": true },
{ "key": "presence", "label": "Presence", "type": "number", "foundry_path": "system.characteristics.presence.value", "foundry_writable": true },
{ "key": "speed", "label": "Speed", "type": "number", "foundry_path": "system.speed.value", "foundry_writable": true },
{ "key": "stability", "label": "Stability", "type": "number", "foundry_path": "system.stability.value", "foundry_writable": true },
{ "key": "level", "label": "Level", "type": "number", "foundry_path": "system.level", "foundry_writable": false },
{ "key": "ev", "label": "EV", "type": "number", "foundry_path": "system.ev", "foundry_writable": false }
]
}Current limitation: The Foundry module's generic adapter (
generic-adapter.mjs) and actor sync (actor-sync.mjs) currently support only one preset per system. If the primary preset isdrawsteel-character, creature entities with slugdrawsteel-creaturewill not sync. Multi-preset support requires extending the adapter architecture to load multiple presets and route entities bytype_slug.
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.
Request: Same shape as POST (partial updates supported).
Deletes an entity.
Updates only the fields_data on an entity.
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.
Request:
{
"visibility": "public"
}Toggles entity reveal state (NPC reveal to players). Body is exactly
{ "is_private": <bool> } (a *bool); an explicit value matching the current
state is a no-op. This is the ONLY correct way to flip visibility from the
module — a bare PUT /entities/:id with just {is_private} 400s because
UpdateEntity requires a name.
Request:
{ "is_private": true }Used by: actor-sync.mjs; sync-dashboard.mjs (single + bulk visibility
toggles, routed here as of FM-SYNC-WIRE-FIX-R1)
Erratum (FM-SYNC-WIRE-FIX-R1): the dashboard visibility toggles previously PUT
/entities/:idwith only{is_private}→ 400 (swallowed), so visibility never changed. Now routed toPOST /entities/:id/reveal.
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 8 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 on an entity. Uses the forward label string to identify
the relation type (not a numeric ID). The write body binds snake_case
(target_entity_id / relation_type / reverse_relation_type) and
target_entity_id is required (empty → 400). Note the read/WS payload is
camelCase — the shapes are deliberately asymmetric.
Request:
{
"target_entity_id": "uuid",
"relation_type": "parent of",
"reverse_relation_type": "child of",
"metadata": {}
}Erratum (FM-SYNC-WIRE-FIX-R1):
item-sync.mjspreviously sent this body camelCase (targetEntityId/relationType) with a null target, so every create 400'd. Now snake_case, and it SKIPS the create when the item has no linked Chronicle target entity (a custom Foundry item has nothing to relate to). Metadata is sent as a raw object (bound asjson.RawMessage), not a JSON-encoded string.
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.
Used by: item-sync.mjs → remove item from actor inventory
Erratum (FM-SYNC-WIRE-FIX-R1): the route is FLAT. Earlier revisions of this doc (and
item-sync.mjs) used a nestedDELETE /entities/:entityId/relations/:relationId, which Chronicle does not serve (404). Chronicle's real route isDELETE /relations/:relationId(syncapi/routes.go).
Update relation metadata (e.g., item quantity, equipped state). Body is
{ "metadata": { ... } } only (bound as json.RawMessage); relation type and
target are immutable via this route.
Used by: item-sync.mjs → update inventory item metadata
Erratum (FM-SYNC-WIRE-FIX-R1): the route is FLAT and takes no
/metadatasuffix. Earlier revisions usedPUT /entities/:entityId/relations/:relationId/metadata(404). Chronicle's real route isPUT /relations/:relationId(syncapi/routes.go).
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:
{
"data": [
{ "id": "uuid", "display_name": "Alice", "role": "player" }
]
}Lists all sync mappings for the campaign.
Used by: sync-manager.mjs → initial sync setup
Response:
{
"data": [
{
"id": "uuid",
"chronicle_id": "entity-uuid",
"foundry_id": "foundry-doc-id",
"type": "entity",
"last_synced": "2026-01-15T12:00:00Z"
}
]
}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": [ ]
}Generic sync endpoint for batch operations.
Lists all maps in the campaign.
Note: Drawing, token, fog, and layer endpoints exist on the Chronicle API but are consumed by Chronicle's web map editor only — they are not synced to Foundry. Only markers are synced as Foundry Scene Map Notes.
Lists map markers (pins/notes on the map).
Creates a map marker.
Updates a map marker.
Deletes a map marker.
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: calendar-sync.mjs → poll current state; _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 (RC-4, FM-REALTIME-DATE-SIGNAL): the composed
UsesRealTime() predicate (mode == reallife AND the real-time flag), so the
wire signal can never disagree with the write-guard below. Read it
defensively — payload?.tracks_real_time === true — never assume the field
is present (older Chronicle deployments and GET /calendar, the structure
endpoint, never carry it). When true, the module treats dates as
read-only: it skips its own PUT /calendar/date pushes (fetch-before-push
check via scripts/_realtime-date-guard.mjs, re-probed on every push attempt
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 }Optional — Chronicle ≥ the applied-beacon release (C-SYNC-APPLIED-BEACON).
Confirms that Foundry successfully applied a date pulled from Chronicle to
the active local calendar module (Calendaria or SimpleCalendar), as opposed
to merely having fetched/seen it. Chronicle's sync chip uses this to
distinguish "Foundry SAW this date" (the GET /calendar/date beacon) from
"Foundry APPLIED this date."
Used by: calendar-sync.mjs → _confirmAppliedDate via
scripts/_applied-date-confirm.mjs, called from both the poll apply path
(onInitialSync) and the WebSocket-driven apply path
(_onChronicaleDateAdvanced, calendar.date.advanced) — only after the
local calendar-module setter (_setLocalDate) reports it actually ran
without throwing. Never sent on a bare fetch/pull, and never on an apply
failure. An already-current date that still round-trips through a real
setter call counts as applied and is confirmed.
Request:
{ "year": 1492, "month": 3, "day": 1 }Response: 204 No Content
Graceful degradation: a Chronicle deployment that predates this endpoint
returns 404 (route doesn't exist) or 405 (method not recognized). The module
tolerates both silently — one console.debug per session, no retries — so
it keeps working unchanged against pre-upgrade Chronicle servers. Any other
failure (network, 5xx) is debug-logged and swallowed; a missed confirmation
only leaves Chronicle's applied-beacon stale, it never blocks or retries
sync. See scripts/_applied-date-confirm.mjs::isConfirmNotSupported.
Advances the calendar by N days (1-3650).
Request: { "days": 7 }
Advances time by hours/minutes (rolls over into days).
Request: { "hours": 2, "minutes": 30 }
Returns all season definitions.
Replaces all season definitions (bulk replace).
Returns all moon definitions.
Replaces all moon definitions.
Returns all era definitions.
Replaces all era definitions.
Returns all event category definitions.
Replaces all event categories.
Returns zodiac/elemental cycle definitions with entries.
Replaces all cycle definitions (including entries).
Returns fixed calendar festival entries.
Replaces all festival definitions.
Lists events for a month. Query: ?year=1492&month=3 or ?entity_id=uuid.
Creates a calendar event.
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
}New fields (Calendaria parity):
color— Hex color for calendar displayicon— Icon identifier (FontAwesome or custom)all_day— Whether event spans entire day(s) vs. specific timesrecurrence_interval— How many periods between recurrences (e.g., every 2 years)recurrence_end_year/month/day— When recurrence stopsrecurrence_max_occurrences— Maximum number of recurrences
Updates a calendar event. Same fields as POST.
Deletes a calendar event.
Returns a single event by ID.
Updates calendar name, time system, leap year, current date/time.
Replaces all month definitions.
Replaces all weekday definitions.
Returns calendar structure in Calendaria-compatible format.
Returns current weather state, or {} if none set.
Sets current weather state (GM override).
Exports the full calendar as Chronicle JSON. Add ?events=true to include events.
Imports a calendar from JSON (Chronicle, Simple Calendar, Calendaria, Fantasy-Calendar formats).
Uploads a media file (image, etc.).
Used by: api-client.mjs for image sync
Request: Multipart form data with file field.
Response:
{
"id": "media-uuid",
"url": "/media/media-uuid.png",
"filename": "map-background.png",
"content_type": "image/png",
"size": 1048576
}Returns media metadata.
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": {} }
}
]
}This section documents the install/update contract — the URLs Foundry hits to
fetch the module's manifest and zip from Chronicle (rather than GitHub).
Settled by FM-CONSOLIDATE-R1 D1 and codified in chronicle-package.json at
this repo's root (serving.manifestEndpoint, serving.downloadEndpoint).
Unlike the REST endpoints above, these endpoints are hit by Foundry itself
(both at install and on every update check), not by this module's runtime
code. The Update Source diagnostic dialog (scripts/update-info.mjs) also
hits the manifest endpoint manually so the operator can confirm reachability.
For the Foundry-side narrative (how Foundry stores the install-time URL, how rotation affects already-installed instances, how update-info.mjs classifies errors), see
.ai.md→ "Chronicle Integration — Install & Updates".
Per-campaign signed token in the query string — not the Bearer-token API key. Each token is tied to a specific campaign; Chronicle rotates them on request from the campaign owner. The token is generated when the owner first opens the Foundry VTT disclosure in their campaign settings.
?token=<signed>
The campaign owner can rotate their token at any time from the Foundry VTT
disclosure in campaign settings (the rotate button hits the token/rotate
endpoint documented below). After rotation:
- The old token is immediately invalidated. Any Foundry instance that
installed before the rotation will start getting
403with{ "error": "invalid_token", "category": "auth", ... }on its next update check. - The new token is the one embedded in the per-campaign URLs Chronicle emits going forward. Players who reinstall via the freshly-displayed URL get a working install again.
- Recovery for existing installs is reinstall, not in-place repair —
Foundry stores the install-time URL on its own and we don't have a
supported way to swap it out from within the module. The Update Source
diagnostic dialog detects this case (
authcategory inupdate-info.mjs) and surfaces a "reinstall using the fresh install URL" action message.
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— machine-readable code from the catalog below. Opaque identifier, used for logs / debugging. Foundry consumers MUST NOT branch on this field; treating it as a public enum couples Foundry to Chronicle's internal naming. Branch oncategoryinstead.category— snake_case bucket from the five-value enum below (auth,config,not_found,validation,internal). Pinned by cordinatordecisions/2026-05-17-error-catalog-wire-contract.md. This is the canonical wire field name (Chronicle marshals it asjson:"category"); thechronicleCategoryidentifier seen insidescripts/update-info.mjsis a local function-parameter rename, not a wire field.message— human-readable, operator-actionable string. Render verbatim; do not re-construct on the client side.
Authoritative catalog. The live source of truth is error-catalog.json
at the Chronicle repo, pinned by the wire-contract decision:
https://raw.githubusercontent.com/keyxmakerx/Chronicle/main/internal/plugins/foundry_vtt/error-catalog.json
The artifact is treated as implicit schema v1 — it does not currently
carry an explicit schema_version field, and the decision to add one is
deferred to the FM-DRIFT-GUARD dispatch (which will pin both the field
shape and the CI mismatch behavior). FM-DRIFT-GUARD CI (queued) will
fetch this URL on every Foundry PR and assert the table below matches.
error code |
category |
Description |
|---|---|---|
campaign_not_found |
not_found |
Campaign id in the URL does not exist (deleted by the owner, or never existed). HTTP 404. |
descriptor_invalid |
validation |
The release zip's chronicle-package.json failed schema validation on Chronicle's PostInstallHook. HTTP 422. |
invalid_token |
auth |
Token signature does not match (rotated, forged, or truncated). HTTP 403. |
module_json_missing |
internal |
The resolved release zip is missing its module.json at the descriptor's moduleJsonPath. Chronicle-side packaging bug. HTTP 500. |
no_package_registered |
config |
Chronicle has no package registered for this campaign's Foundry serving slot. Owner needs to install / re-pin a release. HTTP 503. |
no_version_available |
config |
Campaign has no pinned version and no auto-latest is available (e.g., catalog is empty). HTTP 503. |
pinned_version_not_installed |
config |
Campaign is pinned to a version that isn't in Chronicle's installed catalog (race after admin-side unpublish, or stale pin). HTTP 503. |
token_not_initialized |
config |
Owner has never opened the Foundry VTT disclosure for this campaign, so no token has been generated yet. HTTP 503. |
error-catalog.json also lists Chronicle's catch-all ErrInternal
constructor with wildcard: true, category internal, HTTP 500. In the
catalog file this entry's code is the literal placeholder <dynamic>;
on the wire, the runtime error value is the underlying Go error
message, not the literal text <dynamic>. Consumer rules per
cordinator decisions/2026-05-17-error-catalog-wire-contract.md:
- Treat wildcard codes as valid but opaque. Do not enumerate them in
any code→category map; do not branch on the runtime
errorvalue. - The
categoryfield remains authoritative for routing (here,internal). Rendermessageverbatim as with any other error. - Documentation lists the wildcard as a placeholder row, not an
enumerable code. FM-DRIFT-GUARD CI will treat any
wildcard: trueentry as "any code value matches the placeholder" so the doc keeps passing even though the wire payload differs from the literal<dynamic>string.
Fallback when category is missing or unrecognized.
scripts/update-info.mjs's categorize() (file:line
scripts/update-info.mjs:104-110) trusts body.category if it appears
in the local CHRONICLE_CATEGORIES set; otherwise it derives a category
from HTTP status: 401 / 403 → auth, 404 → not_found, 5xx → internal,
anything else → internal. There is intentionally no code-to-category
lookup table on the Foundry side — branching on error would re-create
the cross-repo drift the wire contract was written to prevent. New
Chronicle categories therefore need a paired Foundry-side update; until
that update lands, an unrecognized category falls through to HTTP-status
classification (less precise, but never wrong).
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 inside the
zip carries Chronicle URLs (not the GitHub URLs in the source zip), so
Foundry's subsequent update checks go to Chronicle. This rewrite happens at
download time per-campaign, so two campaigns hitting the same on-disk source
zip get two zips whose embedded module.json carries different
campaign-specific URLs. Settled by C-FMC-7.
Error response: Same JSON error shape as the manifest endpoint (same
error / message / category triple, same code catalog).
These endpoints are part of Chronicle's web UI, not called by Foundry or by anything in this module. They are documented here because they affect the contract Foundry depends on — token rotation invalidates installs, pin changes change the version served to Foundry — and a future contributor reading this file should know they exist.
Owner-only. Rotates the per-campaign signed token. After rotation, all
existing Foundry installs of this campaign's module will 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: Already-installed instances will see the new version
on their next module.json update check. Foundry will offer an update if
the new pin's version is greater than the installed version, a downgrade
prompt if it's lower (Foundry's native behavior — not module-specific), or
do nothing if it's 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 (C-FMC-5b);
absent or invalid descriptor falls back to hardcoded defaults matching the
schema above. CI validates the descriptor on every push via
tools/check-package-descriptor.mjs.
If Chronicle's URL shape ever changes, three places update together:
chronicle-package.json(serving.manifestEndpoint/downloadEndpoint)scripts/update-info.mjs(CHRONICLE_MANIFEST_REclassifier)- This section of
API-CONTRACT.md
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 |
{ id } |
Map marker deleted |
note.created |
Full note object | Note created |
note.updated |
Full note object | Note modified |
note.deleted |
{ id } |
Note deleted |
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 — see the gap note below; does not currently reach the wire |
sync.status |
{ connected: bool } |
Connection state change |
sync.error |
{ message } |
Synchronization error |
sync.conflict |
Conflict details | Data conflict detected |
Wired by FM-SYNC-SUBRESOURCES-P1 (scripts/calendar-sync.mjs onMessage +
scripts/_calendar-subresources.mjs). Before that dispatch only the first four
rows were handled and the switch had no default:, so every other type was
dropped with no trace.
P1 is display-level and non-destructive: no branch below writes a Chronicle value into the Foundry calendar's stored structure, and none creates a note. Chat announcements are GM whispers only — never public chat, so a payload Chronicle gated to the DM is not laundered into a player-visible one.
| Type | Module behavior | Calendaria | Simple Calendar |
|---|---|---|---|
calendar.date.advanced |
Applies the date, confirms it back | CALENDARIA.api.setDateTime |
SimpleCalendar.api date set |
calendar.event.created/updated/deleted |
Mirrors to a calendar note | Full (notes API) | Full (journal-flag notes) |
calendar.weather.changed |
Updates the dashboard world-state panel; applies to the calendar module if it exposes a weather setter, else whispers a GM chat line. A null payload triggers one GET /calendar/weather refetch. |
Reads only on shipped builds — the module probes setWeather / setCurrentWeather / setWeatherForDate and falls back to chat when absent (the probe result is reported in the diagnostics bundle) |
No weather surface → chat fallback |
calendar.season.changed |
Panel + GM chat line (calendarAnnounceSeasonEra, default on) |
Display only | Display only |
calendar.era.changed |
Panel + GM chat line (calendarAnnounceSeasonEra, default on) |
Display only | Display only |
calendar.moon.phase_changed |
Panel + GM chat line (calendarAnnounceMoon, default off — moons change phase every few in-world days) |
Display only | Display only |
calendar.worldstate.changed |
Panel + GM chat line (calendarAnnounceWorldstate, default on). Handler is wired and tested but currently unreachable — see the gap note. |
Display only | Display only |
calendar.structure.updated, calendar.cycle.changed, calendar.festival.changed |
Refetches GET /calendar, re-runs the structure comparison, and sets the badge: pause if now incompatible, clear a prior mismatch pause if now compatible, otherwise raise the advisory structure-changed state. Never auto-applies the structure — rewriting months/weekdays would silently re-date every existing note. Processed even while sync is paused (the only recovery path). |
Both | Both |
any other calendar.* |
default: branch logs one console.debug line per type per session — no more silent drops |
— | — |
Verified against Chronicle main on 2026-07-25 (FM-SYNC-SUBRESOURCES-P1
Step 0). Three independent blockers, all Chronicle-side:
- Not published.
worldstate_service.go:265callsPublishCalendarEvent("calendar.worldstate.changed", …), but the adapter that translates internal event names tows.MessageType(calendarEventPublisherAdapter.PublishCalendarEvent,internal/app/routes.go) has nocasefor it and hitsdefault: return. The event is dropped before the bus.calendar.weather.zones.changedis dropped the same way. - No celestial detail in the payload. Even once published, the payload is
{date, moodTint}— the meteor/eclipse rows incalendar_celestial_eventsthat motivated the dispatch are not in it. - No syncapi read path.
GET /calendar/world-stateis registered on the web plugin group, not the Bearer-tokensyncapigroup, so the module cannot fetch the detail either (internal/plugins/calendar/routes.go:157vsinternal/plugins/syncapi/routes.go).
The module-side handler ships wired and tested so the feature lights up the moment Chronicle closes (1); until then it is dormant. Closing (2) and (3) is Chronicle-side work.
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": "" } }