Content Scheduling
Deliver content library items on a schedule — daily rotation, weekly, random, or sequential.
Content schedules are reliable and recurring — Amba runs them on the cadence you configure, every tick lands a fresh delivery, and pausing or deleting a schedule keeps state consistent.
Worked example — schedule it daily, fetch with today
A complete pattern, agentic-end-to-end:
In-app, the loop reads today's selection through the SDK:
today(scheduleName) returns the item the named schedule delivered today. Every user who calls it gets the same item.
Three tips on a daily schedule is three days of content. The schedule's runway says so from the moment you create it (items_remaining: 3, runway_days: 3), and content.library_running_low fires that day; add items to the library to keep ahead of your players. See Content runway.
Schedule types
| Type | Default cron | What it does |
|---|---|---|
daily_rotation | 0 9 * * * | Rotates through items, one per day, at 09:00 in the chosen timezone |
weekly | 0 9 * * MON | Delivers one item per week on Mondays |
random | required | Picks a random item each tick; each item comes up once per pass |
sequential | required | Delivers items in sort_order order |
random and sequential require you to provide an explicit cron — the schedule type alone doesn't imply a cadence.
Every schedule walks its library in passes. A pass delivers each active item once. daily_rotation, weekly and sequential continue in sort_order from the last item they delivered, so items you add to the end of the library extend the current pass. random draws from the items it has not delivered yet in the current pass.
Content runway
A schedule hands every player the same item at each delivery, so your most active players use up the library at exactly the schedule's cadence. Every schedule read (create, list, update, and amba_content_list_schedules) reports how long the library lasts at that pace:
| Field | Meaning |
|---|---|
items_remaining | Items the schedule has not delivered yet in its current pass. |
items_per_day | Deliveries per day at the schedule's cron × batch_size. |
runway_days | items_remaining ÷ items_per_day: days of new content left. |
exhausted | true while a stop schedule has run out (see below). |
runway_days is null when the cron uses syntax the runway cannot read (six-field crons, L, W, #); the five-field form with lists, ranges, steps and day names, and @daily-style shortcuts, all read.
Alarms
Two events tell you when to add content. Subscribe to them like any other event, with amba_webhooks_create or POST /v1/admin/projects/:projectId/webhooks/subscriptions (see Outbound webhooks; content.* covers both, along with content.published). They also show up in amba_events_list.
| Event | When it fires |
|---|---|
content.library_running_low | The runway is below the schedule's runway_alarm_days (default 30). Fires once a day, in the schedule's timezone, while it stays below. |
content.library_exhausted | A delivery found every item already delivered and wrapped or stopped (see on_exhausted). Fires on every such delivery. |
Both payloads carry schedule_id, schedule_name, library_id, library_name, items_remaining, runway_days, on_exhausted, and cadence (cron, timezone, batch_size, items_per_day). library_exhausted adds outcome (wrapped or stopped); library_running_low adds runway_alarm_days.
Set the threshold per schedule:
Running out of content
on_exhausted decides what a delivery does once every item has been delivered. Both settings are last resorts. The running-low alarm is how a team stays ahead, so neither ever has to happen.
wrapstarts a new pass from the first item. Players see content they have already seen.stopdelivers nothing. Players see a gap:today()returnsnull, the HTTP read (GET /v1/client/content/today/:channel) returns{ "data": null, "exhausted": true }, and the schedule's config key holds{ "exhausted": true }(or[]for a schedule withbatch_sizeabove 1). Add items to the library and the next delivery picks them up.
New schedules default to stop. Schedules created before 2026-10-07 are set to wrap, the behavior they always had. Switch either way at any time:
Create a schedule
Override the default cron with your own:
Delivering a batch at midnight UTC
A common pattern for daily question sets, challenge prompts, or lesson content: publish each day's batch at exactly midnight UTC so it's available when users open the app.
The approach is to pack all N items for a given day into a single content item's body as a JSON array — a day bundle. The schedule rotates through bundles one per tick; today() returns the bundle; the app parses the array.
1. Create the library and add day bundles
2. Schedule at midnight UTC
Pass cron: "0 0 * * *" and timezone: "UTC" to fire at exactly 00:00 UTC every day. The daily_rotation type advances through bundles one per tick.
3. Read in the app
today() returns null if the schedule hasn't fired yet today — handle that with a fallback state in your UI.
Multiple items per day (batch_size)
The bundle pattern packs all N items into one item's body. If you'd rather deliver N distinct items per tick — each as its own content item — set batch_size on the schedule (default 1, max 100). The delivery shape changes with it:
batch_size: 1(default) — one item is delivered per tick and written as a single object under the config key.today()returns that item.batch_size > 1— the next N distinct items in the schedule's pass are picked (insort_orderfor rotation and sequential, at random for random), each recorded as its own delivery, and written as an array of item objects under the config key. Read the whole batch from that key through remote config (Amba.config.fetch(), see Config keys);today()returns the last item of the batch. A batch counts as N items against the runway, and when the pass has fewer than N left,on_exhausteddecides the rest:wrapfills the batch from the start of a new pass,stopdelivers the items that are left.
batch_size must be an integer in 1–100; out-of-range values are rejected 400 up front. Change it later with PATCH /v1/admin/projects/:projectId/content/schedules/:id { "batch_size": N }. Reading per-item state stays easy — track per-item completion in a Collection keyed by item id.
Targeting a segment
A schedule can carry the id of a user segment. Each tick then writes the rotated value into that segment's slot of the key's remote_configs.conditions, and signed-in members of the segment read it from GET /v1/client/config. Everyone else reads the key's default value.
Re-running the same schedule replaces its previous slot, so the conditions array stays bounded.
A schedule with no segment_id (or segment_id: null) is broadcast to all users — the existing behavior. To stop targeting a segment and revert to broadcast, PATCH with segment_id: null.
If segment_id references a segment that doesn't exist, the route returns 400 INVALID_SEGMENT_ID. Validation runs before the workflow starts so a bad id can't leak resources.
If the segment itself is deleted, the schedule's segment_id falls back to NULL (broadcast) via ON DELETE SET NULL. The schedule keeps running.
PATCH / DELETE
Updates apply atomically — if anything fails, the schedule reverts to its previous state so deliveries don't silently drift.
is_active: false pauses the schedule (no new deliveries fire) without deleting the row.
Deleting a schedule is also atomic — the recurring job is torn down first, then the row is removed.
Config keys
Each schedule gets a server-allocated config_key of the form content_<schedule_uuid>. Apps read it through remote config (Amba.config.fetch(), GET /v1/client/config), and it stays stable across renames of the schedule's name.
config shape
Empty or missing cron for random / sequential returns 400 INVALID_SCHEDULE_CONFIG up front — the row never commits without a working schedule spec.
Overlap policy
If a previous content delivery is still running when the next tick arrives, the next tick is skipped rather than queued. This is the right default for content rotation; queueing would deliver two items simultaneously.
Life of a tick
Each tick:
- Loads the schedule + library.
- Picks the next
batch_sizeitems in the schedule's pass:daily_rotation/weekly/sequential: the items after the last one delivered, insort_order.random: a random draw from the items not delivered yet in this pass.- At the end of the pass,
on_exhausteddecides:wrapcontinues from the first item,stopdelivers nothing.
- Records one delivery per item, then writes the item (or the array, for
batch_sizeabove 1) to the schedule's config key, which apps read asdefault_valuethrough remote config. - Raises
content.library_exhaustedif the delivery wrapped or stopped, andcontent.library_running_low(once a day) while the runway is belowrunway_alarm_days.
SDK consumption
today(channel?) returns the one item the active schedule with that name delivered today, in the schedule's timezone, so timezones matter — schedules default to UTC unless you set config.timezone. It returns null before the day's delivery and while a stop schedule is out of items.
MCP tools
| Tool | Description |
|---|---|
amba_content_schedules_create | Create a delivery schedule on a library (on_exhausted, runway_alarm_days) |
amba_content_list_schedules | List schedules with items_remaining, runway_days and exhausted |
amba_content_update_schedule | Change a schedule, including on_exhausted and runway_alarm_days |
amba_content_delete_schedule | Delete a schedule and its recurring delivery |
Routes reference
| Method | Path | Description |
|---|---|---|
POST | /v1/admin/projects/:projectId/content/schedules | Create schedule |
GET | /v1/admin/projects/:projectId/content/schedules | List schedules for the project |
PATCH | /v1/admin/projects/:projectId/content/schedules/:id | Update schedule atomically |
DELETE | /v1/admin/projects/:projectId/content/schedules/:id | Tear down recurring delivery + row |
Versioning + rollouts on schedules
Schedule deliveries are the same for every user: each delivery writes one remote_config key that every client reads. Per-user rollout bucketing happens at the client-fetch layer (GET /v1/client/content/libraries/:id/keys/:key).
For schedules, the activity collapses each (library_id, content_key) group to its stable baseline — the highest-version row with rollout_percent = 100. New versions ramping below 100% are invisible to schedules until they reach the baseline. The expected workflow when ramping a schedule-driven library:
- POST v2 (defaults to 0% rollout). Schedule still delivers v1.
- PATCH v2 to 50%. Schedule still delivers v1; client-fetch endpoints serve v2 to ~half of users for measurement.
- PATCH v2 to 100%. Schedule starts delivering v2 on the next tick.
- (Optional) PATCH v1 down to 0% — purely cosmetic at this point, since v2 is already canonical.
Items with content_key = NULL predate this feature and continue to participate in scheduled rotations as standalone rows.
See Versioning + rollouts on the main Content page for the detailed selection rule and curl examples.
Next
- Content libraries — manage items and libraries.