> ## Documentation Index
> Fetch the complete documentation index at: https://docs.amps.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Scheduling

> A device schedule is a list of slots set on the device. A slot is a push action plus an optional recurrence. Push is still the only execution primitive.

Scheduling on Amps has one execution primitive: a push. A push fires immediately, at a future instant, or across a window. A schedule sits on the device as configuration, made of slots, where each slot is a push action plus an optional recurrence. Understand the push and you understand scheduling.

## Push is the primitive

A push targets a single device. `start` and `end` declare temporal intent:

| Shape     | Body              | Behaviour                          |
| --------- | ----------------- | ---------------------------------- |
| Immediate | No `start`        | Fire on receipt.                   |
| Scheduled | `start` only      | Persist; fire at `start`.          |
| Windowed  | `start` and `end` | Run from `start`, revert at `end`. |

The same canonical body works for all three, defined on [canonical actions](/concepts/canonical-actions). Each command on a device declares an `execution` array stating which of these shapes it accepts.

```json theme={null}
{
  "action": {
    "command": "charge",
    "parameters": { "power": { "value": 5, "unit": "kw" } },
    "start": "2026-06-01T22:00:00",
    "end":   "2026-06-02T05:00:00"
  }
}
```

`start` accepts plant-local wall-clock ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) or a relative duration (`30m`, `1.5h`, `2h`). The platform interprets the wall-clock in the device's plant timezone. `start` must be in the future. The horizon is 30 days; further-out submissions return 422 `START_OUT_OF_RANGE`.

Responses normalise `start` and `end` to absolute UTC. A scheduled or windowed push submitted as `2026-06-10T22:00:00` for a UK plant during BST reads back as `2026-06-10T21:00:00.000Z`. You write wall-clock, you read the dispatch-ready instant. See [canonical actions](/concepts/canonical-actions#datetime-and-time-window-contract) for the full contract.

## Lifecycle states

Every action moves through a small state machine:

| State          | Meaning                                                                                  |
| -------------- | ---------------------------------------------------------------------------------------- |
| `scheduled`    | Persisted, queued to fire at `start`.                                                    |
| `acknowledged` | The platform has dispatched the command to the OEM. Initial state for an immediate push. |
| `completed`    | The OEM accepted the command.                                                            |
| `failed`       | The OEM rejected the command, or a transient failure exhausted retries.                  |
| `cancelled`    | `POST /actions/{id}/cancel` was called before dispatch.                                  |

`completed` records that the OEM accepted the command, not that the device verified end-state. To confirm physical state, subscribe to `push.completed` and read the device on the webhook. The API does not retry a failed push; you decide whether to re-submit, and with what.

Subscribe to `push.completed` and `push.failed` via [webhooks](/concepts/webhook-model), or poll the action endpoint. Both are first-class.

## Cancellation

`POST /actions/{id}/cancel` transitions a `scheduled` action to `cancelled` and removes it from the queue. Already-executing actions cannot be cancelled: a 409 `ACTION_NOT_CANCELLABLE` comes back. Amps does not attempt to recall in-flight OEM calls.

## Dispatch guarantees

Every scheduled action dispatches to the OEM exactly once. If the platform retries delivery, the OEM is never called twice for the same action.

## Schedules as device config

<Callout icon="clock" color="#ED6D2C">
  **Coming soon.** First-class schedules are on the roadmap. Until they ship, every schedule route answers `501 Not Implemented`. The shape below is the contract you will write to.

  Schedule the work one command at a time instead. Send a push with `start` to run a command later, or with `start` and `end` to run it for a window. Send one push for each time you want something to happen.
</Callout>

A schedule is device configuration. You set it on the device, the same way you set [settings](/concepts/capabilities): `PUT /{type}/{id}/schedule`. There is no separate resource to create a schedule on, because a schedule belongs to one device. The body is `{ "slots": [...] }`, where each slot carries a command and its temporal intent. `GET /{type}/{id}/schedule` reads it back; `DELETE /{type}/{id}/schedule` clears it.

A slot is a push action plus an optional `recurrence`. The slot shape is derived from the action shape, so the fields you already use on a push are the fields you use in a slot: a slot with no `recurrence` is exactly a one-off deferred action expressed in schedule form. Whether `GET /{type}/{id}/schedule` returns in-flight deferred actions as slots is a decision left for when the scheduler ships.

### A one-off slot

A slot without `recurrence` fires once. It is the slot form of a deferred or windowed push.

```json theme={null}
PUT /battery/device_abc123/schedule

{
  "slots": [
    {
      "command": "charge",
      "start": "2026-06-01T22:00:00",
      "end":   "2026-06-02T05:00:00",
      "parameters": { "target": { "value": 80, "unit": "percent" } }
    }
  ]
}
```

### A recurring slot

Add `recurrence` to repeat the same command across days, weeks, or months. The slot's `start` and `end` define the wall-clock window the recurrence steps through.

```json theme={null}
PUT /battery/device_abc123/schedule

{
  "slots": [
    {
      "command": "charge",
      "start": "2026-06-01T22:00:00",
      "end":   "2026-06-02T05:00:00",
      "parameters": { "target": { "value": 80, "unit": "percent" } },
      "recurrence": { "frequency": "daily" }
    }
  ]
}
```

### Recurrence fields

`recurrence` is iCal-shaped: a small set of fields, each optional except `frequency`.

| Field        | Type                             | Meaning                                                                                     |
| ------------ | -------------------------------- | ------------------------------------------------------------------------------------------- |
| `frequency`  | `daily` \| `weekly` \| `monthly` | How often the slot repeats. Required.                                                       |
| `interval`   | positive integer                 | Repeat every N periods. Default `1`. `interval: 2` with `frequency: weekly` is fortnightly. |
| `byDay`      | array of weekday names           | Weekly only: the days the slot fires (e.g. `["saturday", "sunday"]`).                       |
| `byMonthDay` | integer 1-31                     | Monthly only: the day of the month the slot fires.                                          |
| `until`      | wall-clock ISO 8601              | Plant-local wall-clock after which the slot stops repeating. Omit for open-ended.           |

A weekly slot that only fires on weekends:

```json theme={null}
{
  "command": "discharge",
  "start": "2026-06-06T16:00:00",
  "end":   "2026-06-06T19:00:00",
  "parameters": { "target": { "value": 30, "unit": "percent" } },
  "recurrence": {
    "frequency": "weekly",
    "byDay": ["saturday", "sunday"],
    "until": "2026-09-30T00:00:00"
  }
}
```

The `slots` array is the authoritative record of what you asked for. The platform expands the recurrence to drive dispatch; the slot definitions are what you wrote.

The write is a full replace, never a partial edit. `PUT /{type}/{id}/schedule` overwrites the previous schedule wholesale, so the device holds exactly what you last sent. `DELETE /{type}/{id}/schedule` clears it.

### Listing schedules across devices

To see every schedule at once rather than device by device, the cross-device read will be `GET /schedules`, the same way `GET /actions` is the cross-device read of actions across your fleet. `GET /schedules/{id}` will read a single schedule by its handle for the list-to-detail hop. Both routes return `501 NOT_IMPLEMENTED` today and arrive with the rest of the scheduler. The list will be the only lifted read; setting, changing, and clearing a schedule stays on the device-scoped routes above.

### Following a device's own schedule today

Some devices hold a program of their own, set in the manufacturer's app. `auto.schedule` tells such a device to run it. Send it on a push (`POST /{type}/{id}` with `{ "action": { "command": "auto.schedule" } }`) and the device follows whatever program it currently holds.

Read the device's `commands` map first. Only a device that lists `auto.schedule` accepts it, and one that does not answers 422 `UNSUPPORTED_MODE`. The command conflicts with the operating-mode commands like `charge`, so the one-action-per-device rule holds: a device either follows its own program or runs a direct command, never both.

`auto.schedule` runs a program the device already holds. It does not write one. Writing is what comes later.

## One contract across OEMs

The same schedule surface and response shape apply across every supported OEM, regardless of how the device runs the schedule under the hood. No request field selects how execution happens. You write the schedule once, the platform delivers it.

## Conflict and overlap

A new action that overlaps an existing pending or active action on the same device returns 409 `CONFLICT` with `conflictingActionIds[]`, unless `onConflict` resolves it. Amps does not auto-cancel existing schedules on a conflicting submission. Read [conflict resolution](/concepts/conflict-resolution) for the full model, including how the platform handles native device schedulers that are already running.

<Note>
  Amps does not poll devices to detect drift between scheduled intent and physical state. Schedules are declarative on submission; the platform delivers them and the device runs them.
</Note>

## What scheduling does not do

* No price or tariff inputs. Slots carry wall-clock times. Tariff optimisation stays on your side, and the control plane stays decoupled from market data feeds.
* No pause and resume. The `paused` state is reserved in the enum but unused. Cancel and re-create instead.
* No priorities or preemption. Conflicts surface as 409, and you drive resolution.
* No fleet or multi-device actions. Every action targets one `deviceId`. Aggregation across devices is the caller's responsibility.

## Frequently asked questions

### What states can a scheduled action be in?

Five: `scheduled` (persisted, queued to fire at `start`), `acknowledged` (the platform is talking to the OEM), `completed` (the OEM accepted), `failed` (the OEM rejected, or transient retries exhausted), and `cancelled` (a client cancelled before dispatch). `scheduled` and `acknowledged` are non-terminal. The other three are terminal. Actions never move backward through the state machine.

### Can I cancel a scheduled action?

Yes, while it is in `scheduled`. `POST /actions/{id}/cancel` transitions the action to `cancelled` and removes it from the queue. Already-executing actions (`acknowledged`) cannot be cancelled and return 409 `ACTION_NOT_CANCELLABLE`. Amps does not attempt to recall in-flight OEM calls. When the scheduler ships, `DELETE /{type}/{id}/schedule` will clear a device's schedule and cancel every pending child action it spawned.

### What happens if my schedule conflicts with another scheduled action?

The new action returns 409 `CONFLICT` with `conflictingActionIds[]` if `onConflict` is absent. Supply `cancel_and_replace` to drop the conflicting action and run the new one, or `queue_after` to defer the new action until the conflicting one's `end`. Window-overlap predicates are not part of the test: two scheduled discharges for the same device collide on submission even with disjoint times. The model trades flexibility for an unambiguous one-action-per-device invariant.

### How does recurrence work?

Recurrence ships when the scheduler lands. A slot with a `recurrence` field repeats on `frequency` (daily, weekly, or monthly), stepped by an optional `interval`. Weekly recurrences pick specific weekdays via `byDay`; monthly recurrences pick a day-of-month via `byMonthDay`. An `until` wall-clock bounds the recurrence; omit it for open-ended. Each repetition becomes a child action that moves through the same lifecycle as a direct push. Clearing the device's schedule cancels every pending child action.

### Is a one-off slot the same as a deferred push?

In intent, yes. A slot with no `recurrence` carries the same fields as a deferred push (`command`, `start`, optional `end`, `parameters`) and produces the same one-shot dispatch. The two surfaces will converge in practice; whether `GET /{type}/{id}/schedule` surfaces in-flight deferred actions as slots is decided when the scheduler ships.

## Related concepts

<CardGroup cols={2}>
  <Card title="Canonical Actions" icon="zap" href="/concepts/canonical-actions">
    The shape every push uses, and the shape every slot extends.
  </Card>

  <Card title="Conflict Resolution" icon="git-merge" href="/concepts/conflict-resolution">
    `onConflict`, 409 envelopes, foreign-state preflight.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/concepts/webhook-model">
    Action and schedule lifecycle events delivered to your endpoint.
  </Card>

  <Card title="Error Envelope" icon="circle-alert" href="/concepts/error-envelope">
    `START_IN_PAST`, `START_OUT_OF_RANGE`, `EXECUTION_NOT_SUPPORTED`.
  </Card>
</CardGroup>

For worked examples, see the cookbook: [schedule a charge for later](/guides/cookbook/schedule-charge-later) and [discharge during a peak window](/guides/cookbook/discharge-windowed).

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
__html: JSON.stringify({
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: [
    {
      "@type": "Question",
      name: "What states can a scheduled action be in?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Five: scheduled (persisted, queued to fire at start), acknowledged (the platform is talking to the OEM), completed (the OEM accepted), failed (the OEM rejected, or transient retries exhausted), and cancelled (a client cancelled before dispatch). scheduled and acknowledged are non-terminal. The other three are terminal. Actions never move backward through the state machine."
      }
    },
    {
      "@type": "Question",
      name: "Can I cancel a scheduled action?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Yes, while it is in scheduled. POST /actions/{id}/cancel transitions the action to cancelled and removes it from the queue. Already-executing actions (acknowledged) cannot be cancelled and return 409 ACTION_NOT_CANCELLABLE. Amps does not attempt to recall in-flight OEM calls. When the scheduler ships, DELETE /{type}/{id}/schedule will clear a device's schedule and cancel every pending child action it spawned."
      }
    },
    {
      "@type": "Question",
      name: "What happens if my schedule conflicts with another scheduled action?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "The new action returns 409 CONFLICT with conflictingActionIds[] if onConflict is absent. Supply cancel_and_replace to drop the conflicting action and run the new one, or queue_after to defer the new action until the conflicting one's end. Window-overlap predicates are not part of the test: two scheduled discharges for the same device collide on submission even with disjoint times. The model trades flexibility for an unambiguous one-action-per-device invariant."
      }
    },
    {
      "@type": "Question",
      name: "How does recurrence work?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Recurrence ships when the scheduler lands. A slot with a recurrence field repeats on frequency (daily, weekly, or monthly), stepped by an optional interval. Weekly recurrences pick specific weekdays via byDay; monthly recurrences pick a day-of-month via byMonthDay. An until wall-clock bounds the recurrence; omit it for open-ended. Each repetition becomes a child action that moves through the same lifecycle as a direct push. Clearing the device's schedule cancels every pending child action."
      }
    },
    {
      "@type": "Question",
      name: "Is a one-off slot the same as a deferred push?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "In intent, yes. A slot with no recurrence carries the same fields as a deferred push (command, start, optional end, parameters) and produces the same one-shot dispatch. The two surfaces will converge in practice; whether GET schedule surfaces in-flight deferred actions as slots is decided when the scheduler ships."
      }
    }
  ]
})
}}
/>
