# Schedules

Run a command every so often, or at a time of day, and have a stopped sandbox started for it and stopped again after.

`POST /v1/sandboxes/{id}/schedules`

```shell
curl -X POST https://agental.io/v1/sandboxes/{id}/schedules \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"scrape","command":"python scrape.py","every":"1h"}'
```

- `command` is one line, run by `sh -c`, with the variables and files any command of the sandbox has.
- `every` is a number and `m`, `h` or `d`, from `5m` to `31d`, counted from when the schedule is made: what has to run more often is better left running in a sandbox that stays up. Or give `cron`, five fields: minute, hour, day of the month, month and day of the week, such as `"0 7 * * 1-5"`, with no two runs less than 5 minutes apart, read in `timeZone`, such as `"Europe/Sofia"`, UTC unless it says. Or give `at`, a date and time up to a year away, and it runs once: a check to come back to, such as a deploy at 09:00. Afterwards its `nextRunAt` is null, and it is kept, with its run, until it is deleted.
- The answer is `201` with the schedule, its `nextRunAt`, and `lastRun`, null until it has run. `name` is lower-case letters, digits and dashes, unique on the sandbox: left out, it is `schedule-1`, `schedule-2` and so on. A sandbox has 20 at most.

## When the sandbox is stopped

A run that finds the sandbox stopped starts it, runs the command, and stops it again when the command ends, keeping its disk. The sandbox is billed by the second for the minutes it runs, and at the stopped price between runs: an hourly job of two minutes comes to about a tenth of what the same sandbox costs left running. A run is made within about 15 seconds of its time, and a stopped sandbox takes some seconds more to start. A cron's or a one-off's time is also given the schedule's own delay of up to a minute, the same each time and in its `nextRunAt`, so that the schedules due at 07:00 do not all start their sandboxes in the same moment.

| Field | What it does |
| --- | --- |
| `ifStopped` | `start`, the default: start it for the run, then stop it again. `skip`: a run that finds it stopped is skipped, so the schedule runs only while the sandbox runs. |
| `keepMemory` | `true` stops it with its memory kept, at the stopped price with memory, so the next start puts back its processes as they were. A sandbox that was stopped with its memory is stopped so again, whatever this says. |

A run leaves the sandbox running when someone else began to use it meanwhile: it was started again, its stop was moved, or a command was sent into it by `/exec`, `/exec/stream` or `/commands`. An SSH session or the desktop is not seen: move the stop, as with `PUT /v1/sandboxes/{id}/auto-stop`, to keep it up. When two runs go in one sandbox, the last one to end stops it.

A schedule that starts its sandbox takes a key that may start and stop it, `sandbox.resume` and `sandbox.stop`, as well as `exec`; one with `ifStopped: "skip"` takes `exec` alone. Another key is answered `403 key_action_forbidden`.

## Delete the sandbox after

```shell
curl -X POST https://agental.io/v1/sandboxes/{id}/schedules \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"last-run","command":"./finish.sh","at":"2026-11-04T09:00:00Z","deleteAfter":true}'
```

- A schedule that runs once, with `at`, may delete its sandbox after its run: `deleteAfter: true`. Once the command exits 0, the sandbox is deleted as `DELETE /v1/sandboxes/{id}` deletes it, whether the run started it or it was running already, and the run's `deletedSandbox` is `true`. Its `sandbox.deleted` event has `reason: "schedule"`.
- A run that fails, times out, is skipped, is replaced, could not start or is cut short by a restart of the API leaves the sandbox, stopped again if the run started it, so that you can look at what went wrong. Its `reason` says the sandbox was not deleted.
- `deleteAfter` on `every` or `cron` is refused with `400 invalid_request`, and so is a change that makes a one-off with it run again and again: give `deleteAfter: false` with it. Making, changing or running now a schedule with it takes a key that may delete the sandbox, `sandbox.delete`, or `sandbox.delete-own` for one the key made, as the delete itself does. Those rights are checked then, not when the run ends: a schedule acts for the account, so it still deletes the sandbox after the key that made it is revoked or the member who made it leaves. Delete the schedule, or give it `deleteAfter: false`, to stop that.
- A scheduled run still going in the sandbox when it is deleted ends as `failed`. The schedule and its runs can be read until the sandbox's history is cleared. From a terminal, `agental schedule add <sandbox-id> --at <date-time> --delete-after -- ./finish.sh`.

## When a run is still going

For a schedule that runs again and again: a one-off's run never finds one of its own still going.

| overlap | The run that comes due |
| --- | --- |
| `skip`, the default | is skipped, and the one going goes on. |
| `replace` | stops the one going and takes its place. |
| `parallel` | runs beside it, five of one schedule at most. |

- A run gets `timeoutMs`, `600000` (10 minutes) unless it says, up to `21600000` (6 hours). One that runs out is given up with exit code `124`.
- A run that could not be made within two minutes of its time, as while the sandbox was being moved to another server, is told as `missed` and never made up later. One under way when the API restarts is `interrupted`, and a sandbox it started is stopped.
- After 10 runs one after another failed or timed out, the schedule pauses itself, so that a broken command does not start and bill its sandbox on and on. `pausedReason` says why, and the event `sandbox.schedule_paused` is sent. A resume, `{"enabled":true}`, starts it again and clears the reason. Skipped and missed runs, and runs that could not start the sandbox, which cost nothing, count neither way; nor do the failures before a pause or a resume. An `at` names its zone, as `Z` or `+02:00`.

## Its runs

`GET /v1/sandboxes/{id}/schedules/{scheduleId}/runs`

Its runs, the latest first, 100 unless `limit` says, at most 500. Each has `dueAt`, `startedAt` and `finishedAt`, `outcome`, `exitCode`, `startedSandbox`, `stoppedSandbox` (`disk`, `memory` or null), `deletedSandbox` and `commandId`, whose output `GET /v1/sandboxes/{id}/commands/{commandId}` gives. A run's `reason` says, in words, why it ended as it did where the outcome does not.

| outcome | What it means |
| --- | --- |
| `running` | It is going. |
| `succeeded`, `failed`, `timed_out` | Its command exited 0, exited with another code, or ran out of time. |
| `skipped` | The sandbox was stopped and the schedule skips then, or the last run was still going. |
| `replaced` | A later run of the same schedule took its place. |
| `missed` | It could not be made on time. |
| `could_not_start` | The sandbox could not be started: its `reason` says why, such as the account's plan. |
| `interrupted` | The API restarted while it ran. |

Each finished run is also the event `sandbox.scheduled_run`, sent to the [webhooks](https://agental.io/docs/webhooks) that take it, with the run's outcome and exit code, and a schedule that pauses itself is `sandbox.schedule_paused`.

## Run now, pause, change, delete

- `POST /v1/sandboxes/{id}/schedules/{scheduleId}/run` makes a run now, by the schedule's own rules, paused or not, and answers `202` as it begins. Its next run does not move.
- `PATCH /v1/sandboxes/{id}/schedules/{scheduleId}` changes what it is given and keeps the rest. `{"enabled":false}` pauses it; a change of when it runs, or a resume, sets its next run from now. A run under way goes on as it began, save that turning `deleteAfter` off while it runs keeps its sandbox; turning it on does not delete the sandbox after a run already under way.
- `DELETE /v1/sandboxes/{id}/schedules/{scheduleId}` deletes it with its runs. `GET /v1/sandboxes/{id}/schedules` lists a sandbox's, each with its last run.
- From a terminal, `agental schedule add <sandbox-id> --every 1h -- python scrape.py`, and `agental schedules <sandbox-id>` lists them. In the console, a sandbox's Schedules view.

---

Every page of these docs as Markdown: [llms.txt](https://agental.io/llms.txt)
