ServicesAlmanac

Content Windows

A content window is the calendar-driven half of Almanac: a daily shop, a weekly playlist, a featured slot, a seasonal event. The window itself never ticks. Its instance is a pure function of the world's virtual clock - floor(virtual time / cycle length) - so every client computes the same open instance at the same moment, and a window never reads as half-flipped.

Creating and previewing windows requires a platform admin token; listing them and reading positions and transitions does not.

Define a window

cat > daily-shop.json <<'JSON'
{
  "name": "daily-shop",
  "kind": "rotation",
  "schedule": {
    "recurrence": "every_virtual_day",
    "anchor": "world_midnight"
  },
  "rotation": { "source": "lottery_table:shop_stock_t2", "slots": 3 },
  "hooks": { "pubsub_channel": "shop.flips" },
  "preview_horizon_windows": 7
}
JSON

snug almanac windows create --world-id GgMcwrwWABuCsQMFUQct --file ./daily-shop.json
snug almanac windows list   --world-id GgMcwrwWABuCsQMFUQct --page-size 25

The list endpoint also takes sort_by (created_at or name) and sort_order over HTTP - an unrecognised field is 400 invalid_request - though the CLI exposes only --page and --page-size.

kind is rotation, playlist, featured or seasonal_event - a label carried through to transition events, not a behaviour switch. recurrence drives the cycle length:

RecurrenceCycle
every_virtual_dayhours_per_day
every_virtual_weekhours_per_day * 7
season_boundthe named season's months, in virtual days

season_bound requires season_name; omitting it is 400 invalid_window. Naming a season the calendar does not define is not rejected - verified on a world whose LongWinter spans two months, the matching window got a 60-virtual-day cycle while a window naming NoSuchSeason was accepted with 201 and silently fell back to a one-month, 30-virtual-day cycle. Spell season names carefully.

Where every window is right now

windows positions resolves the world clock once and reports each window against it, which is the query to build a shop UI on:

snug --output json almanac windows positions --world-id GgMcwrwWABuCsQMFUQct
{
  "world_id": "GgMcwrwWABuCsQMFUQct",
  "now": {
    "real_instant": "2026-08-29T12:47:19.934897Z",
    "virtual_ms": 771240,
    "dilation": 24.0,
    "paused": false,
    "virtual_time": { "year": 0, "month": "Frostwane", "day": 1, "hour": 0, "phase": "night" }
  },
  "positions": [
    {
      "window_id": "yBdmQddLxZrHhdGmsSXM",
      "instance": 0,
      "cycle_virtual_ms": 86400000,
      "position_virtual_ms": 771240,
      "remaining_virtual_ms": 85628760,
      "remaining_real_ms": 3567865,
      "next_transition_real": "2026-08-29T13:46:47.799897Z"
    }
  ]
}

now.virtual_ms and every remaining_* come from a single clock read, so they never disagree with each other. The lighter almanac now snapshot carries the same information in active_windows and next_transitions alongside weather.

Because the instance is derived from world time rather than from creation time, a window added to a world that has already been running for 30 virtual days opens at instance 30, not 0.

Preview ahead

windows preview resolves future instances with their contents, so designers can see what next week's shop holds before it opens. It is admin-only.

snug --output json almanac windows preview \
  --world-id GgMcwrwWABuCsQMFUQct --window-id yBdmQddLxZrHhdGmsSXM --count 3
{
  "instances": [
    {
      "instance": 1,
      "starts_at_real": "2026-08-29T13:46:47.800Z",
      "ends_at_real": "2026-08-29T14:46:47.800Z",
      "contents": [
        "lottery_table:shop_stock_t2#662606",
        "lottery_table:shop_stock_t2#686087",
        "lottery_table:shop_stock_t2#972350"
      ]
    }
  ]
}

The preview is honest: the three slot tokens above came from a preview taken an hour before instance 1 opened, and the real transition event for instance 1 carried exactly the same three. count clamps silently at 100: asking for 500 returns 100 instances rather than an error.

One caveat on contents. A slot is a deterministic hash of (rotation.source, instance, slot index), rendered as {source}#{n}. The source string is an opaque label: nothing resolves it against a real Lottery table today, so treat the tokens as a stable per-slot seed you map to your own catalogue, not as item IDs.

Transition events

A background worker polls worlds roughly once a second and, for each window whose instance has advanced, appends a transition event to the world's capped stream. Read them newest-first:

snug --output json almanac transitions --world-id GgMcwrwWABuCsQMFUQct --limit 50
{
  "transitions": [
    {
      "world_id": "GgMcwrwWABuCsQMFUQct",
      "window_id": "yBdmQddLxZrHhdGmsSXM",
      "window_name": "daily-shop",
      "kind": "rotation",
      "previous_instance": 0,
      "instance": 1,
      "virtual_ms": 86493168,
      "season": "Winter",
      "phase": "night",
      "contents": ["lottery_table:shop_stock_t2#662606", "..."],
      "at_real": "2026-08-29T12:54:21.105191Z",
      "next_transition_real": "2026-08-29T13:00:20.717191Z",
      "stream_id": "1788008061108-0"
    }
  ]
}

Verified on a world dilated to 36000 (one virtual day every 2.4 real seconds): instances 30 through 34 each produced exactly one event, in order, with no gaps and no duplicates. Pausing the clock stops the fanout dead - the event count held steady across eight real seconds that would otherwise have produced three flips - and resuming picks it back up.

--limit defaults to 50 and clamps at 200; the stream itself keeps only the most recent 1000 events. Treat it as a recent-history tail, not an audit log.

GET /transitions is currently the only way an application learns about a flip. subscribe returns the names a realtime subscription would use:

snug --output json almanac subscribe --world-id GgMcwrwWABuCsQMFUQct --region northreach
{
  "world_id": "GgMcwrwWABuCsQMFUQct",
  "region": "northreach",
  "pubsub_channel": "world:GgMcwrwWABuCsQMFUQct:northreach",
  "websocket_topic": "world:GgMcwrwWABuCsQMFUQct:northreach"
}

Two things to know before you build on that. The returned name contains a colon, which PubSub channel validation rejects, so subscribing to it fails outright with 400 invalid_channel - it is also not the name the server publishes on, which is almanac.transitions.{world_id}. And transition events do not reach PubSub subscribers at all: with listeners attached to both that canonical channel and a window's hooks.pubsub_channel, a manual publish to each arrived immediately while five real transitions fired in the same span and none was delivered. Poll GET /transitions until that seam is fixed.

The other two hook fields, webhook_event and timeline_stream, round-trip through create and list but nothing reads them - no webhook is delivered and no timeline entry is written.

Reference

On this page