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 getToday
A complete pattern, agentic-end-to-end:
In-app, the loop reads today's selection through the SDK:
getToday(libraryName) 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:
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
getToday()readscontent_deliveriespluscontent_items.
SDK consumption
getToday(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.