Run an agent on a schedule
By the end of this tutorial you will have an agent that
runs on its own every morning — a schedule trigger
pointed at it, proven by one fire you start by hand and a generation that names
the trigger that started it.
Four steps:
- Create a schedule trigger that runs the agent.
- Fire it once by hand, so you don't wait for the schedule.
- Read the generation it produced.
- Read the firing log — where every scheduled run will show up from now on.
Every step is one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
- A
nat_sk_…API key exported asNATURALI_TOKEN, and a client set up as in Create a provider. - A working agent — what Your first agent generation builds. Arrive with both ids exported:
export NATURALI_TOKEN=nat_sk_...
export PROJECT=proj_...
export AGENT=agent_...
An account holds at most 3 triggers on Free, 30 on Pro and 500 on
Business, counted across all its projects. A schedule may fire at most
hourly on Free, every 5 minutes on Pro and every minute on Business.
Past either, the create answers 403 plan_limit_reached. Every firing is a
generation, so each one counts as a
run against the plan.
See Triggers and Pricing.
1. Create a schedule trigger
A trigger says what to run (target_type + target_id) and what starts it
(type). For schedule, cron is a 5-field expression in UTC —
0 9 * * * is every day at 09:00, which every plan allows. For an agent target,
input.message becomes the user message of each run.
- CLI
- SDK
- curl
naturali create-trigger \
--project-id "$PROJECT" \
--name morning-refund-reminder \
--type schedule \
--cron "0 9 * * *" \
--target-type agent \
--target-id "$AGENT" \
--input '{"message":"Write today'\''s one-line reminder of our refund window."}'
const { data: trigger } = await naturali.triggers.createTrigger({
path: { project_id: process.env.PROJECT! },
body: {
name: 'morning-refund-reminder',
type: 'schedule',
cron: '0 9 * * *',
target_type: 'agent',
target_id: process.env.AGENT!,
input: {
message: "Write today's one-line reminder of our refund window.",
},
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/triggers" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"morning-refund-reminder\",
\"type\": \"schedule\",
\"cron\": \"0 9 * * *\",
\"target_type\": \"agent\",
\"target_id\": \"$AGENT\",
\"input\": { \"message\": \"Write today's one-line reminder of our refund window.\" }
}"
{
"id": "trg_KipkvLcSYfHYNBmA",
"project_id": "proj_cT9LACJi0WypPf5U",
"name": "morning-refund-reminder",
"type": "schedule",
"target_type": "agent",
"target_id": "agent_7VDoEY4pPnWYUyMV",
"input": {
"message": "Write today's one-line reminder of our refund window."
},
"cron": "0 9 * * *",
"active": true,
"next_fire_at": "2026-10-03T09:00:00.000Z"
}
next_fire_at is computed for you. The schedule is live from here: nothing
else needs to run on your side.
export TRIGGER=trg_KipkvLcSYfHYNBmA
A cron tighter than your plan allows — */15 * * * * on Free — is refused
before anything is created:
{
"error": {
"code": "plan_limit_reached",
"message": "The free plan schedules a trigger at most once every hour.",
"details": { "plan": "free", "resource": "trigger_interval", "limit": 3600 }
}
}
2. Fire it once by hand
Waiting until 09:00 is a poor test. A fire runs the same target with the
same input right now and answers with the finished
firing.
- CLI
- SDK
- curl
naturali fire-trigger \
--project-id "$PROJECT" \
--trigger-id "$TRIGGER"
const { data: firing } = await naturali.triggers.fireTrigger({
path: {
project_id: process.env.PROJECT!,
trigger_id: process.env.TRIGGER!,
},
});
curl -X POST \
"https://api.naturali.ai/v1/projects/$PROJECT/triggers/$TRIGGER/fire" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "trg_fire_WCXDKoYaTTSPY2vm",
"trigger_id": "trg_KipkvLcSYfHYNBmA",
"source": "manual",
"status": "succeeded",
"input": {
"message": "Write today's one-line reminder of our refund window."
},
"result": {
"target_type": "agent",
"result_id": "gen_TcQ6rGwVeOXdXfqf",
"status": "completed",
"output": "Don't forget: You have 30 days from delivery to request a refund."
},
"error": null
}
result.result_id is the generation the firing produced. A failed
firing carries error instead — usually the agent's provider refusing the call.
export GENERATION=gen_TcQ6rGwVeOXdXfqf
3. Read the generation it produced
The generation is an ordinary one, with one difference: it names the trigger that started it.
- CLI
- SDK
- curl
naturali get-generation \
--project-id "$PROJECT" \
--generation-id "$GENERATION"
const { data: generation } = await naturali.generations.getGeneration({
path: {
project_id: process.env.PROJECT!,
generation_id: process.env.GENERATION!,
},
});
curl "https://api.naturali.ai/v1/projects/$PROJECT/generations/$GENERATION" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "gen_TcQ6rGwVeOXdXfqf",
"agent_id": "agent_7VDoEY4pPnWYUyMV",
"status": "completed",
"stop_reason": "stop",
"trigger_id": "trg_KipkvLcSYfHYNBmA",
"agent_version": 1,
"usage": { "cost_usd": 0.00000911, "input_tokens": 33, "output_tokens": 17 }
}
trigger_id is what makes an unattended run traceable: any generation can be
followed back to the schedule that started it.
4. Read the firing log
Every firing — yours and the scheduler's — lands in one log per trigger. This is where you check, tomorrow, that the 09:00 run happened.
- CLI
- SDK
- curl
naturali list-trigger-firings \
--project-id "$PROJECT" \
--trigger-id "$TRIGGER"
const { data: firings } = await naturali.triggers.listTriggerFirings({
path: { project_id: process.env.PROJECT! },
query: { trigger_id: process.env.TRIGGER! },
});
curl "https://api.naturali.ai/v1/projects/$PROJECT/trigger-firings?trigger_id=$TRIGGER" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"data": [
{
"id": "trg_fire_WCXDKoYaTTSPY2vm",
"trigger_id": "trg_KipkvLcSYfHYNBmA",
"source": "manual",
"status": "succeeded",
"result": {
"target_type": "agent",
"result_id": "gen_TcQ6rGwVeOXdXfqf",
"status": "completed"
}
}
],
"total": 1,
"limit": 50,
"offset": 0
}
After 09:00 UTC the next entry reads "source": "schedule" — the schedule
running without you.
Pausing the schedule
active: false stops the firings and keeps the trigger; true resumes it.
A negative credit balance also sets it to false, and nothing turns it back on
for you — see
A debt turns a schedule off.
- CLI
- SDK
- curl
naturali update-trigger \
--project-id "$PROJECT" \
--trigger-id "$TRIGGER" \
--active false
await naturali.triggers.updateTrigger({
path: {
project_id: process.env.PROJECT!,
trigger_id: process.env.TRIGGER!,
},
body: { active: false },
});
curl -X PATCH "https://api.naturali.ai/v1/projects/$PROJECT/triggers/$TRIGGER" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
What's next
- Score an agent change — a schedule pointed at
an eval
(
target_type: eval) runs that suite nightly. - Triggers —
webhookandeventstarters, andtool_contextfor agents whose tools need a credential on every run. - Debug a failed run — when a firing comes back
failed, follow its generation down to the call that broke.