Schedules
Cron-triggered runs that act as an end user — plus run-as, the on-demand sibling.
A schedule fires an agent on a cron cadence, server-side, with no browser involved. Each fire starts a fresh conversation that acts as a named end user — their durable memory, usage metering, and retrieval scope — and lands in that user's conversation history like any run.
This is a management-tier surface: schedule management rides an operator api-key or the dashboard, never a customer session. (The dashboard's Scheduled-tasks page does all of this with a live cron preview; this page is the API/SDK half.)
// Schedule management and run-as are MANAGEMENT-tier: an operator api-key rides
// as the bearer token, server-side only — never a customer session. The API
// origin defaults to production.
const ops = createOps({ session: process.env.INLAY_SECRET_KEY! });Creating one
async function createDailyBrief(ops: OpsFacade, agentId: string, userId: string) {
return ops.createSchedule(agentId, {
cron: "0 9 * * *", // standard 5-field cron — 9am daily
timezone: "America/Toronto", // IANA; default "UTC"
task: "Run the morning brief.", // the run's initial user message
userId, // the end user the runs act AS
// version: 3, // optional pin; omit to track the latest published version
});
}cronis standard 5-field cron evaluated intimezone(IANA; defaultUTC). A bad expression or zone is a400at create time, and the firstnextRunAtis computed immediately — a schedule that would never fire cannot be created.taskis the kickoff instruction. It becomes the run's first user message, so the agent runs on it.userIdis who the run acts as (below). Forgery-resistant values are enforced: reserved ids likeshared/anonymous/system:-prefixed are rejected.versionpins the schedule to an immutable agent version. On update it is a tri-state: omit it to leave the pin alone, passnullto clear it (track latest-published), pass a number to pin. A pin is validated against the agent's version list, so a typo fails at write time — not as five hundred failed runs.setScheduleEnabledpauses/resumes;updateScheduleedits cron, timezone, task, enabled, and the pin (a cadence change recomputesnextRunAt);deleteScheduleremoves.
The act-as model
A scheduled run executes under a system principal acting as the end user —
not as the operator, and not as a faceless batch job. Concretely: the run's
usage meters against that user (their quota gate applies), their durable
user-scope memory is what the agent sees, and their retrieval ACL applies.
The conversation appears in their history — useInlayHistory /
GET /v1/conversations/mine — with task as the first user message.
The corollary: schedule non-interactive agents. A run that pauses for input or a client tool has nobody to answer it, and a paused run counts as a failure for the circuit breaker below.
The circuit breaker
Schedules are self-protecting:
- A fire whose conversation does not reach
completedis a failure — failed, cancelled, and paused all count. - After a failure the schedule backs off 10 minutes and increments
consecutiveFailures; past 5 consecutive failures it auto-disables. - A success resets the counter and advances to the next cron occurrence — missed beats are skipped, not replayed.
- A quota denial (the act-as user is out of allowance) backs off without feeding the breaker — a billing condition, not a broken agent.
Listing
Two lists, two tiers, and the difference matters:
listTenantSchedules— admin-only, every agent and every act-as user in the tenant, including each schedule'staskanduserId. Never render this to end users.listMySchedules— the end-user-safe view (a customer session): only the caller's own schedules, and only the whitelist fields (MyScheduledropstaskanduserId). Safe to render as a "your scheduled tasks" page.
async function mySchedules(ops: OpsFacade) {
// The end-user-safe list (a customer session): only this user's schedules,
// only the whitelist fields — no `task`, no `userId`.
const schedules = await ops.listMySchedules();
return schedules.map((s) => ({ id: s.id, nextRunAt: s.nextRunAt, enabled: s.enabled }));
}Run-as: the on-demand sibling
runAs fires one synchronous run as an end user, right now — for an operator
"run it now" button, a support replay, or wiring your own trigger (a webhook,
a queue) where the cadence is yours.
async function fireNow(ops: OpsFacade, agentId: string, userId: string) {
// The on-demand sibling of a schedule: one sync run, acting as the end user.
const { conversationId, status } = await ops.runAs(agentId, {
userId,
task: "Summarize this week's numbers.",
});
return { conversationId, status };
}createSchedule and runAs deliberately do not auto-retry on network
failure: neither is idempotent, and a silent retry would double-create or
double-fire.
Honest limits
- At-least-once. Fires are claimed under a 10-minute lease; a crashed worker's claim is re-taken, and a run longer than the lease can fire twice. If that matters for your agent, keep scheduled runs short or make their effects idempotent.
- No provenance marker yet. A scheduled run is indistinguishable from the
user's own action in the audit trail today — a
triggered_byfield is tracked follow-up work. - Schedules run on the persistent (Postgres) deployment, not the in-memory dev mode.
Next
Versions and publishing — what a schedule's version pin interacts with: drafts, immutable versions, and rollback.