Skip to content

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