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) resolves to the current scheduled item for the calling user — segment-aware where the schedule is targeted, broadcast otherwise.
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 |
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.
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 object.batch_size > 1— up to N distinct items are picked for the schedule's mode (rotation / random / sequential, wrapping at the library boundary), each recorded as its own delivery, and written as an array of item objects under the config key.today()returns the array.
batch_size must be an integer in 1–100; out-of-range values are rejected 400 up front. Change it later with PATCH /admin/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 be scoped to a user segment so the same content key returns different copy for different audiences. Two schedules can share the same SDK-facing config key (admin-supplied) but write to it under different segment slots — the SDK resolves the right value at read time based on the requesting user's segment memberships.
A common pattern is tone-of-voice variants: the same mission.script.irs_installment_agreement returns gentler copy for a tone:soft segment and direct copy for tone:direct.
Both schedules can write to the same logical key. Each tick writes the rotated value to a per-segment slot in remote_configs.conditions; the existing SDK condition evaluator picks the right entry per user. Re-running the same schedule replaces its previous slot rather than appending — 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 a user is a member of multiple segments and you have schedules targeting each of those segments — both pointing at the same key — the SDK picks the first matching entry in remote_configs.conditions. The order is the order schedules wrote to the key (insertion order); admins can re-run a schedule to bring its segment to the front.
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>. This key is what the SDK sees on GET /client/content/today — it's 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 item based on
schedule_type:daily_rotation/sequential: compute position from (count(deliveries)modcount(items)).random:ORDER BY random() LIMIT 1.
- Writes a row to
content_deliverieswith the chosen item id and timestamp. - The SDK's
today()readscontent_deliveriespluscontent_items.
SDK consumption
today(channel?) returns the one item selected by the channel's active schedule for the current day window. Filters by occurred_at::date = CURRENT_DATE server-side, so timezones matter — schedules default to UTC unless you set config.timezone.
MCP tools
| Tool | Description |
|---|---|
amba_content_schedules_create | Create a delivery schedule on a library |
Routes reference
| Method | Path | Description |
|---|---|---|
POST | /admin/content/schedules | Create schedule |
GET | /admin/content/schedules | List schedules for the project |
PATCH | /admin/content/schedules/:id | Update schedule atomically |
DELETE | /admin/content/schedules/:id | Tear down recurring delivery + row |
Versioning + rollouts on schedules
Schedule deliveries are not user-specific — the activity writes one remote_config key that every client reads. Per-user rollout bucketing happens at the client-fetch layer (GET /client/content/libraries/:id/keys/:key), not at the schedule layer.
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.