Schedules through the API¶
The concept behind agents is explained in Agents.
On this page you create a schedule through the API, read what it produced, and change or stop it. A schedule is work you store once and Core does for you at a set time: a request Core sends, or an evaluation suite Core runs (below). Most of this page is about the first: a digest every weekday at six, a weekly summary on Monday morning, or one reminder at a moment you name. What it sends is an ordinary response request, so anything you can ask for yourself a schedule can ask for you: free input, a stored prompt or an agent.
Your key needs the schedules capability, which needs runs: every fire is a
background turn, and you read its answer at GET /responses/{id}
exactly as you read one you asked for. You do the same with a mouse in
Schedules in the console.
Create a schedule¶
curl -s -X POST "$CORE_URL/schedules" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"name": "weekday-digest",
"description": "Yesterday, summarised, before anyone is in",
"cron": "0 6 * * 1-5",
"timezone": "Europe/Amsterdam",
"request": {"agent": {"id": "agt_5Nj2..."},
"input": "Summarise yesterday in five bullets."}}'
A successful request answers 201 and a Location.
| Field | What it does |
|---|---|
name |
unique within your organisation |
cron |
five fields -- minute, hour, day-of-month, month, day-of-week -- read in timezone; 1#1 is the first Monday, 5L the last Friday |
timezone |
an IANA zone name such as Europe/Amsterdam; UTC when you name none |
at |
one moment to fire at instead of a cron; the schedule switches itself off afterwards |
enabled |
false stores it without running it |
kind |
response (the default -- send request), evaluation (run a suite) or workflow (start a workflow run) |
request |
the body every fire sends, for kind: "response" |
evaluation_id |
the suite every fire runs, for kind: "evaluation" |
subject_id |
the person every fired turn is for (building for people). Sent with X-Core-Subject naming the same person, the attributes she asserts apply to every fire for 30 days |
labels |
your own classification, which you can filter the list on |
Name exactly one of cron and at. Naming both, or neither, is 422.
A request may carry tool_variables, the values every fire sends for the call variables of its
tools. A value for a name a tool of your organisation declares secret is stored sealed and never
read back: a read leaves that name out of request.tool_variables and answers it in
tool_variable_secrets as {"has_value": true}, and a PATCH whose request leaves the
name out keeps the stored value.
A recording in the stored request is named by file_id: an inline input_audio part is refused
422 on request.input, because the schedule would keep the recording for every time it fires
(Transcribe audio).
Your key schedules as itself. There is no field for naming another key: the turns this schedule fires are your key's, spend your key's budget, and reach exactly what your key reaches. An administrator creating one in the console picks a key instead, because a person has none. A schedule written with a service account's own key fires for the account, with its attributes (Give it standing work).
A schedule a person writes herself keeps her attributes for 30 days. Send the create, or a
later PATCH, with the key the schedule runs as and X-Core-Subject naming the same person as
subject_id, and the attributes she asserts apply to every fire, so your rules over them decide
her scheduled turns as they decide her live ones. Each such write of hers starts the 30 days
again, and one that asserts no attributes removes them. Another key of your organisation that
switches the schedule on or off leaves them as they are. subject_attributes_until on every read says until when; it is null when the fires
carry only her subject_id, which is what a schedule written without her, one pointed at
somebody else, and one whose 30 days have passed do. The attributes themselves are never read
back (Building for people).
What a cron expression means¶
Before you save one, ask:
curl -s -X POST "$CORE_URL/schedules/preview" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"cron": "0 6 * * 1-5", "timezone": "Europe/Amsterdam"}'
It answers the next five moments the expression fires, in that zone, and stores nothing. Reading one schedule answers the next three beside everything else about it.
A weekday can carry a place in the month. 1#1 is the first Monday, MON#2 the second, 5L the
last Friday and L in the day-of-month field the last day. To see "the first Monday at nine":
curl -s -X POST "$CORE_URL/schedules/preview" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"cron": "0 9 * * 1#1", "timezone": "Europe/Amsterdam"}'
1#5 is a fifth Monday, which only some months have, so it is skipped in the others; for the last
Monday of every month write 1L.
The zone is the point: 0 6 * * * in Europe/Amsterdam is six in the morning there in January
and in July, and a schedule set for half past two fires at three on the night the clocks go
forward instead of being skipped.
What a fire does, and what it does not¶
At the moment your expression names, Core sends your stored body as a background turn. Four fields
of that body are the schedule's own and are refused when you send them: stream, background,
store and previous_response_id. A fire is always a stored background run of its own.
thread_id is allowed, and it is how you write a standing conversation: every fire adds to the
same thread.
A schedule reads the clock in its own timezone. If the request uses a prompt with a variable
whose built-in value is date, time, datetime or
weekday, Core reads it in the schedule's timezone, unless the stored request names a
timezone of its own. Such a variable need not be in the request: Core fills it in at the moment
the schedule fires. When you save the schedule, it does not count as a missing value either.
Three things are worth knowing before you rely on one:
- Nothing runs twice. If a turn a fire started is still running when the next moment comes round, that one is skipped rather than queued behind it.
- A backlog does not pile up. If Core was down through several of your moments, the schedule fires once and then carries on as normal.
- A failure does not switch it off. If the turn cannot be made -- your key was revoked, your policy now refuses the model, the month's budget is spent -- the schedule records why and tries again at the next moment.
Read that record on the schedule itself:
{"last_fired_at": "2026-09-23T04:00:00Z",
"last_response_id": "resp_8Kd1...",
"last_outcome": "fired",
"last_error": null,
"next_run_at": "2026-09-24T04:00:00Z"}
last_outcome is fired, skipped or failed, and last_error is a short word, never a
sentence:
last_error |
What stopped the fire |
|---|---|
key_revoked |
the key the schedule fires as was deleted or expired; nominate a key that works |
key_disabled |
the key the schedule fires as is disabled; enable it again with POST /keys/{key_id}/enable |
service_account_paused |
the key belongs to a service account that is paused; resume the account |
tenant_unavailable |
your organisation is suspended or being deleted |
no_key_to_run_as |
the suite this schedule runs names no key any more |
evaluation_gone |
the suite was deleted while the fire was being prepared |
subject_erased |
the person the schedule is for was erased, which switched it off |
tool_variables_unreadable |
the stored secret tool variables could not be opened; send them again with a PATCH |
policy_refused |
your organisation's policy refused the turn |
cap_exceeded |
the key reached its monthly cap |
target_gone |
something the schedule names, such as its agent or workflow, is gone |
request_invalid |
the stored request is no longer valid |
capability_withdrawn |
the key lost a capability the fire needs |
too_many_open |
your organisation already has as many open runs as the deployment allows |
Measuring an agent on a clock¶
A schedule can queue an evaluation run instead of sending a turn. Set
kind: "evaluation" and name the suite; there is no request, because what runs is the suite's
own conversations:
curl -s -X POST "$CORE_URL/schedules" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"name": "nightly-quality",
"cron": "0 3 * * *",
"timezone": "Europe/Amsterdam",
"kind": "evaluation",
"evaluation_id": "ev_7Hq1..."}'
Every fire queues one run per agent the suite measures -- the same thing
POST /evaluations/{id}/runs queues, on the same clock rules as any other schedule: a missed
night fires once rather than catching up, and a suite whose run is still going is skipped rather
than measured twice.
The runs act as the suite's key, not yours. Every evaluation run acts as the key the suite
nominates, so the suite must name one: a suite with no run_as_key_id is 409 when you write the
schedule, and one whose key is cleared afterwards leaves the schedule failing with
no_key_to_run_as in last_error until you set it again.
Read what it has done through the suite's own list, narrowed to this schedule:
curl -s "$CORE_URL/evaluations/ev_7Hq1.../runs?schedule_id=sch_3Qp8..." \
-H "Authorization: Bearer $CORE_KEY"
Each run says schedule_id and fired_for, the moment that was due. Running this kind by hand
answers the ids of the runs it just queued rather than the schedule:
{"object": "schedule.evaluation_fire", "kind": "evaluation", "run_ids": ["evr_..."]}.
The kind is fixed once written -- a schedule's history is one kind's, so PATCH with kind
is 422 and the answer is to write the other schedule. Deleting the suite deletes the schedules
that stood for it; the runs they queued stay.
Starting a workflow on a clock¶
A schedule can start a run of a workflow instead of sending a turn. Set
kind: "workflow", name the workflow by its name and give the input every run starts with; your
key needs workflows beside schedules:
curl -s -X POST "$CORE_URL/schedules" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"name": "morning-intake",
"cron": "0 6 * * 1-5",
"timezone": "Europe/Amsterdam",
"kind": "workflow",
"workflow": "intake",
"workflow_input": {"source": "mailbox"}}'
A workflow that does not exist or that your key's policy does not permit is 422 on workflow,
and input the workflow's input schema refuses is 422 naming the field under workflow_input.
Every fire starts one run of the workflow's latest version, as the key the schedule runs as and
for the person in subject_id if it names one. While the run the schedule started last is still
open, the next fire is skipped rather than started beside it, so a workflow that waits for
somebody's decision holds its schedule until it ends.
last_run_id on the schedule names the latest run, and the run list narrows to this schedule:
curl -s "$CORE_URL/workflow_runs?trigger_id=sch_3Qp8..." \
-H "Authorization: Bearer $CORE_KEY"
Each run says "trigger_kind": "schedule" and the schedule's id in trigger_id. A fire that
could not start a run says why in last_error: target_gone when the workflow was deleted,
request_invalid when the stored input no longer fits, too_many_open when your organisation
holds as many open runs as the deployment allows. PATCH may replace workflow_input; the
workflow a schedule names is fixed, as its kind is.
Reading what it produced¶
curl -s "$CORE_URL/schedules/sch_3Qp8.../responses" -H "Authorization: Bearer $CORE_KEY"
Newest first, one entry per turn this schedule fired, each with the id you read in full at
GET /responses/{id}. The response body is the vendor's object and names no schedule; this list
is how a turn is traced back to the schedule that fired it. A schedule of kind: "evaluation"
fires no turns, so this list is empty for it -- read its runs through the suite
(above).
Run a schedule now¶
curl -s -X POST "$CORE_URL/schedules/sch_3Qp8.../run" -H "Authorization: Bearer $CORE_KEY"
202, and the turn is queued exactly as the clock would have queued it. It does not move the
time the schedule runs at. While the previous turn is still running you get 409 instead, and so
you do when the fire could not happen at all -- the detail leads with the same short kind the
schedule's last_error records, such as policy_refused or no_key_to_run_as. A 202 therefore
always means something was really queued.
Changing and stopping¶
PATCH /schedules/{id} changes what you send and recomputes the next moment; you can switch a
cron schedule to a one-shot at and back. "enabled": false stops it without losing it, which
is what to reach for first -- DELETE /schedules/{id} removes the schedule, and the turns it
already fired stay where they are.
Filtering the list¶
curl -s "$CORE_URL/schedules?enabled=true&label=department:payroll" \
-H "Authorization: Bearer $CORE_KEY"
?label= takes name:value and may be repeated; repeating it means all of these, and
repeating a name asks for all of those values: ?label=department:sales&label=department:finance
finds the schedules labelled with both. A schedule whose label holds several values matches when
the value is one of them.
Common mistakes¶
| What you see | What happened |
|---|---|
422 on cron |
not five fields, or an expression that can never fire (the 30th of February) |
422 on timezone |
not an IANA zone name |
422 on at |
in the past, or without a timezone offset |
422 on request.model |
the model is not in this deployment's catalogue, or nothing names one |
404 on create |
the agent or prompt your body names does not exist, or your key may not reach it |
409 on create |
you already have a schedule with that name, or the suite you named runs as nobody (run_as_key_id) |
422 on evaluation_id |
no suite of yours has that id |
422 on request |
you sent both a request and an evaluation_id, or neither, or a request for a schedule that runs a suite |
404 on every route |
your key does not have the schedules capability |