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 getToday

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';
 
const todayTip = await Amba.content.getToday('Daily Tips');
console.log(todayTip.body); // "Tip 1" on day 1, "Tip 2" on day 2, ...

getToday(libraryName) resolves to the current scheduled item for the calling user — segment-aware where the schedule is targeted, broadcast otherwise.

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
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.

Create a schedule

POST /admin/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"
  }
}

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.

POST /admin/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>"
}
POST /admin/content/schedules
 
{
  "library_id": "<direct-copy-library-uuid>",
  "name": "IRS install agreement — direct tone",
  "schedule_type": "daily_rotation",
  "config": { "timezone": "UTC" },
  "segment_id": "<tone-direct-segment-uuid>"
}

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.

PATCH /admin/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 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.

PATCH /admin/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>. This key is what the SDK sees on GET /client/content/today — it's 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 item based on schedule_type:
    • daily_rotation / sequential: compute position from (count(deliveries) mod count(items)).
    • random: ORDER BY random() LIMIT 1.
  3. Writes a row to content_deliveries with the chosen item id and timestamp.
  4. The SDK's getToday() reads content_deliveries plus content_items.

SDK consumption

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

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

ToolDescription
amba_content_schedules_createCreate a delivery schedule on a library

Routes reference

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

  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

On this page