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

today(scheduleName) 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"
  }
}

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 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.
POST /admin/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 1100; 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.

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 today() reads content_deliveries plus content_items.

SDK consumption

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

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

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