Amba

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:

// 1. Create a library
amba_content_libraries_create({
  project_id: '<proj>',
  name: 'Daily Tips',
});
 
// 2. Add items
amba_content_items_add({
  project_id: '<proj>',
  library_id: '<lib>',
  items: [{ body: 'Tip 1' }, { body: 'Tip 2' }, { body: 'Tip 3' }],
});
 
// 3. Schedule daily rotation
amba_content_schedules_create({
  project_id: '<proj>',
  library_id: '<lib>',
  name: 'Daily rotation',
  schedule_type: 'daily_rotation',
  config: { timezone: 'America/Los_Angeles' },
});

In-app, the loop reads today's selection through the SDK:

import { Amba } from '@layers/amba-react-native';
 
// Pass the schedule name — matches the `name` field used in amba_content_schedules_create.
const todayTip = await Amba.content.today('Daily rotation');
console.log(todayTip?.body); // "Tip 1" on day 1, "Tip 2" on day 2, ... (null before the first delivery)

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

TypeDefault cronWhat it does
daily_rotation0 9 * * *Rotates through items, one per day, at 09:00 in the chosen timezone
weekly0 9 * * MONDelivers one item per week on Mondays
randomrequiredPicks a random item each tick; each item comes up once per pass
sequentialrequiredDelivers 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:

FieldMeaning
items_remainingItems the schedule has not delivered yet in its current pass.
items_per_dayDeliveries per day at the schedule's cron × batch_size.
runway_daysitems_remaining ÷ items_per_day: days of new content left.
exhaustedtrue 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.

EventWhen it fires
content.library_running_lowThe 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_exhaustedA 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:

PATCH /v1/admin/projects/:projectId/content/schedules/:id
 
{ "runway_alarm_days": 60 }

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.

  • wrap starts a new pass from the first item. Players see content they have already seen.
  • stop delivers nothing. Players see a gap: today() returns null, 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 with batch_size above 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:

PATCH /v1/admin/projects/:projectId/content/schedules/:id
 
{ "on_exhausted": "wrap" }

Create a schedule

POST /v1/admin/projects/:projectId/content/schedules
 
{
  "library_id": "<uuid>",
  "name": "Morning Affirmations",
  "schedule_type": "daily_rotation",
  "config": {
    "timezone": "America/Los_Angeles"
  }
}

Override the default cron with your own:

{
  "library_id": "<uuid>",
  "name": "Evening Tips",
  "schedule_type": "daily_rotation",
  "config": {
    "cron": "0 19 * * *",
    "timezone": "UTC"
  }
}

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

// Create a library — one item per day in the rotation
amba_content_libraries_create({
  project_id: '<proj>',
  name: 'Daily Questions',
});
 
// Each item is a day bundle: a JSON array of questions in the body field
amba_content_items_add({
  project_id: '<proj>',
  library_id: '<lib>',
  items: [
    {
      body: JSON.stringify({
        questions: [
          { id: 1, text: 'What was your biggest win today?' },
          { id: 2, text: 'What challenged you most?' },
          // ... up to N questions for this day
        ],
      }),
    },
    // add one item per day of rotation content
  ],
});

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.

amba_content_schedules_create({
  project_id: '<proj>',
  library_id: '<lib>',
  name: 'Daily Questions',
  schedule_type: 'daily_rotation',
  config: {
    cron: '0 0 * * *',
    timezone: 'UTC',
  },
});

3. Read in the app

import { Amba } from '@layers/amba-react-native';
 
// "Daily Questions" matches the schedule name above.
const bundle = await Amba.content.today('Daily Questions');
if (bundle) {
  const { questions } = JSON.parse(bundle.body);
  // questions → array of question objects, ready to render
}

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 (in sort_order for 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_exhausted decides the rest: wrap fills the batch from the start of a new pass, stop delivers the items that are left.
POST /v1/admin/projects/:projectId/content/schedules
{
  "library_id": "<uuid>",
  "name": "Daily Questions",
  "schedule_type": "daily_rotation",
  "batch_size": 5,
  "config": { "cron": "0 0 * * *", "timezone": "UTC" }
}

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.

POST /v1/admin/projects/:projectId/content/schedules
 
{
  "library_id": "<soft-copy-library-uuid>",
  "name": "IRS install agreement — soft tone",
  "schedule_type": "daily_rotation",
  "config": { "timezone": "UTC" },
  "segment_id": "<tone-soft-segment-uuid>"
}

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.

PATCH /v1/admin/projects/:projectId/content/schedules/:id
 
{ "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.

PATCH /v1/admin/projects/:projectId/content/schedules/:id
 
{ "is_active": false }

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

{
  timezone?: string;     // Default "UTC"
  cron?: string;         // Override the default schedule cron
  // Additional schedule-type-specific fields may be added later.
}

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:

  1. Loads the schedule + library.
  2. Picks the next batch_size items in the schedule's pass:
    • daily_rotation / weekly / sequential: the items after the last one delivered, in sort_order.
    • random: a random draw from the items not delivered yet in this pass.
    • At the end of the pass, on_exhausted decides: wrap continues from the first item, stop delivers nothing.
  3. Records one delivery per item, then writes the item (or the array, for batch_size above 1) to the schedule's config key, which apps read as default_value through remote config.
  4. Raises content.library_exhausted if the delivery wrapped or stopped, and content.library_running_low (once a day) while the runway is below runway_alarm_days.

SDK consumption

// Channel defaults to "default" — single-channel apps can call this bare.
const today = await Amba.content.today();
// Pass the schedule's name for multi-channel projects:
const lessonsToday = await Amba.content.today('lessons');

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

ToolDescription
amba_content_schedules_createCreate a delivery schedule on a library (on_exhausted, runway_alarm_days)
amba_content_list_schedulesList schedules with items_remaining, runway_days and exhausted
amba_content_update_scheduleChange a schedule, including on_exhausted and runway_alarm_days
amba_content_delete_scheduleDelete a schedule and its recurring delivery

Routes reference

MethodPathDescription
POST/v1/admin/projects/:projectId/content/schedulesCreate schedule
GET/v1/admin/projects/:projectId/content/schedulesList schedules for the project
PATCH/v1/admin/projects/:projectId/content/schedules/:idUpdate schedule atomically
DELETE/v1/admin/projects/:projectId/content/schedules/:idTear 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:

  1. POST v2 (defaults to 0% rollout). Schedule still delivers v1.
  2. PATCH v2 to 50%. Schedule still delivers v1; client-fetch endpoints serve v2 to ~half of users for measurement.
  3. PATCH v2 to 100%. Schedule starts delivering v2 on the next tick.
  4. (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