> ## 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.

# Canonical Actions

> One intent-based push grammar across batteries, EV chargers, and thermostats: mode-keyed commands, Quantity parameters, conflict strategies.

A canonical action is the single body shape every device push accepts, regardless of OEM and regardless of device type. Build it from the device's read response, post it, get the action `id` back. The same shape works for immediate dispatch, scheduled dispatch, and slots inside a schedule, and it works whether the device on the other end is a battery, an EV charger, or a thermostat.

Agents do not click buttons. They read a response, choose a verb, build the parameters, and post. One read and one write are enough to drive any supported device, and the read teaches the write. A developer who learns the loop on a battery already knows it for an EV charger.

The action push surface covers batteries, EV chargers, and thermostats. **Solar inverters and vehicles are read-only:** they publish no push endpoint and carry no `commands` map. Use those families for monitoring; control the surrounding system via the writable device types.

The JSON snippets on this page show only the action grammar and the relevant slices of the device read. Every real API response is wrapped in `{ success, data, meta }`: the action body lands under `data`, and the device read's `state`, `commands`, and `settings` all sit inside `data`. See [error envelope](/concepts/error-envelope) for the canonical wrapper and the failure variant.

## The action shape

```json theme={null}
{
  "action": {
    "command": "charge",
    "parameters": {
      "power": { "value": 3.0, "unit": "kw" },
      "target": { "value": 80, "unit": "percent" }
    },
    "start": "2026-06-01T18:00:00",
    "end": "2026-06-01T22:00:00"
  },
  "onConflict": "cancel_and_replace"
}
```

`command` selects the variant. `parameters` carries the mode's inputs. `start` and `end` declare temporal intent. `onConflict` resolves collisions with active actions on the same device. Every field you send is either honoured, validated, or rejected. No keys are silently discarded.

## Command vocabulary is intent, not feature flags

A command names an intent the device should pursue, not a button on a vendor app. The verb is the same canonical token whichever manufacturer answers, and the request shape is the same whichever device type answers. Each device type defines its own set of commands, drawn from one shared design language.

The canonical battery surface defines six commands:

| Command        | Meaning                                                                                          |
| -------------- | ------------------------------------------------------------------------------------------------ |
| `charge`       | Direct imperative. Charge from grid or solar, optionally bounded by `target` and `power`.        |
| `discharge`    | Direct imperative. Discharge to load or grid, optionally bounded by `target` and `power`.        |
| `idle`         | Pause charge and discharge. The battery sits.                                                    |
| `auto.balance` | Optimise for self-consumption. Charge when solar is plentiful, discharge when the home needs it. |
| `auto.reserve` | Reserve capacity for grid outage. Stay charged above the reserve floor.                          |
| `auto.export`  | Maximise grid export. Discharge when the export tariff is attractive.                            |

The EV charger defines five:

| Command                     | Meaning                                                                                                                             |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `charge`                    | Begin or resume charging the connected vehicle, optionally rate-capped by `power` or `current` and stopped by `target` or `energy`. |
| `idle`                      | Pause charging, holding the session.                                                                                                |
| `auto.charge_tariff`        | Hand timing to the charger's own price optimiser, so it charges whenever energy is cheapest.                                        |
| `auto.charge_surplus_only`  | Charge from on-site generation the home is not using, and pause when that surplus falls below the charger's floor.                  |
| `auto.charge_surplus_first` | Use that surplus first and top up from the grid, so charging never pauses.                                                          |

The two surplus modes answer different questions. `auto.charge_surplus_only` optimises for zero imported energy and accepts that the vehicle may not fill. `auto.charge_surplus_first` optimises for a full vehicle and accepts an import. Pick the one that matches what the driver is actually asking for.

There is no `discharge` command on a charger. Vehicle-to-home and vehicle-to-grid export is outside the command surface, though the device read still reports a `discharging` status with a negative `currentPower` for a charger exporting under its own or a third party's control.

The thermostat defines five:

| Command         | Meaning                                                                        |
| --------------- | ------------------------------------------------------------------------------ |
| `heat`          | Actively heat to a `target` temperature.                                       |
| `cool`          | Actively cool to a `target` temperature.                                       |
| `auto.maintain` | Maintain a comfort band: heat below `heatSetpoint`, cool above `coolSetpoint`. |
| `idle`          | Pause all heating and cooling.                                                 |
| `auto.schedule` | Resume the device's native program, relinquishing any active override.         |

The vocabulary is shared on purpose. `idle` is the canonical stop verb everywhere: it pauses a battery, pauses a charging session, and pauses heating and cooling, so an agent that learns "send `idle` to stop" applies it across every device type. `target` is the canonical "aim for this level" parameter: a battery charges to a `target` percent, a thermostat heats to a `target` degree. The same name, the same `{value, unit}` shape, a different physical quantity carried by the unit. Direct commands (`charge`, `discharge`, `heat`, `cool`, `idle`) are explicit instructions; the auto commands (`auto.balance`, `auto.reserve`, `auto.export`, and the thermostat's `auto.maintain`) name a device's own self-managing mode in canonical vocabulary.

A command present in the device's `commands` map is supported. Absent means rejected. There is no `supported: boolean` field. See [capabilities](/concepts/capabilities) for how each device declares its subset.

<Callout icon="clock" color="#ED6D2C">
  **Coming soon.** Live control for EV chargers and thermostats. The sandbox environment runs the full command surface end to end, so you can build and test the entire integration. A live EV charger or thermostat push returns 503 `NOT_YET_AVAILABLE` until the live path opens; battery control is live.
</Callout>

The same body shape carries every command. Reading once and writing once works regardless of which device type you point at:

```json theme={null}
// EV charger: begin charging
{ "action": { "command": "charge" } }
```

```json theme={null}
// Thermostat: heat to 21°C
{ "action": { "command": "heat", "parameters": { "target": { "value": 21, "unit": "celsius" } } } }
```

```json theme={null}
// Thermostat: hold a comfort band
{
  "action": {
    "command": "auto.maintain",
    "parameters": {
      "heatSetpoint": { "value": 20, "unit": "celsius" },
      "coolSetpoint": { "value": 24, "unit": "celsius" }
    }
  }
}
```

The envelope, the `command` selector, the `{value, unit}` parameters, and the `onConflict` resolution are identical to the battery example above. Only the verbs and the parameter names differ, and the device tells you those on the read.

## Self-documenting parameters

Every numeric parameter is a `Quantity`: `{ value, unit }`. The unit travels with the value, so `80` is never ambiguous between percent, kilowatts, or amps.

```json theme={null}
{ "value": 80, "unit": "percent" }
{ "value": 3.5, "unit": "kw" }
{ "value": 10, "unit": "percent" }
```

Parameters are scoped to the command that accepts them, and the device read declares the set. Three exist on the battery surface: `target`, `power`, `reserve`. `target` is an upper bound for `charge` and a lower bound for `discharge`. `power` caps the rate. `reserve` preserves a state-of-charge floor while the mode runs. The thermostat reuses `target` for `heat` and `cool`, and adds `heatSetpoint` and `coolSetpoint` for the `auto.maintain` comfort band. The EV charger's `charge` takes the rate caps `power` and `current` and the stop conditions `target` and `energy`; `idle` and every `auto.*` mode take none. The shape is always `{value, unit}`; the unit (`percent`, `kw`, `celsius`) tells you the quantity.

`power` and `current` cap the same rate in different units, so supplying both on one command leaves the ceiling ambiguous and returns 422 `UNSUPPORTED_PARAMETER_COMBINATION` with `details.conflictingParameters` naming the pair. A device declaring both is normal; send whichever unit you think in.

An `auto.*` mode takes no parameters on any device type, and that is deliberate rather than pending. A parameter on an `auto.*` mode reads as a ready-by goal ("80% by 07:00"), and meeting one takes a planner that watches the vehicle's level and decides when to draw. Accepting the parameter and switching the mode on instead would answer a goal with a mode change and report success, so the surface refuses it: 422 `UNSUPPORTED_PARAMETER`.

Send a unit the device does not accept and the API returns 422 `UNSUPPORTED_UNIT`. Send a parameter the mode does not accept and you get 422 `UNSUPPORTED_PARAMETER` with a `deviceCapabilities` snapshot in `details`, so you can fix the call in one round trip. Send a value outside the declared range and you get 422 `PARAMETER_OUT_OF_RANGE`; the bounds live on the device read, not in the request schema. See [error envelope](/concepts/error-envelope) for the full list.

## Per-mode execution support

Each command on a device declares an `execution` array drawn from `{ "immediate", "scheduled", "windowed" }`. Each token names a distinct request shape:

| Token       | Request shape     | Meaning                                     |
| ----------- | ----------------- | ------------------------------------------- |
| `immediate` | No `start`        | Fire on receipt.                            |
| `scheduled` | `start` only      | Defer firing until `start`.                 |
| `windowed`  | `start` and `end` | Run between `start` and `end`, then revert. |

A push whose shape does not match the mode's `execution` array returns 422 `EXECUTION_NOT_SUPPORTED` with `details: { requestedExecution, supportedExecution }`. On a battery, `auto.balance` declares `["immediate", "scheduled"]` (no `end` makes sense for a long-running mode), while `charge` may declare `["immediate", "scheduled", "windowed"]`. EV charger and thermostat commands also declare scheduling shapes: an EV charger's `charge` and a thermostat's `heat`/`cool` declare `["immediate", "scheduled", "windowed"]`, while `idle`, the EV charger's three `auto.*` modes, and the thermostat's `auto.maintain` accept `["immediate", "scheduled"]`. The thermostat's `auto.schedule` is `["immediate"]` only. The array tells you, per command, exactly which shapes the device runs, so you never have to assume.

An `auto.*` mode is never windowed on any device type. The window machinery turns a mode on at `start` and sends `idle` at `end`, so an `end` on a charging strategy would stop the charge at the moment the driver asked for the car to be ready. A strategy runs until another command replaces it; to bound one, push it, then push `idle` when you want it to stop.

The execution shape also decides the action's starting lifecycle state. An **immediate push** (no `start`) lands in `acknowledged` and reaches `completed` or `failed` from there. A **deferred** or **windowed push** (with `start`, optionally with `end`) waits in `scheduled` until the fire time, then advances to `acknowledged` and on. `scheduled` is a state only deferred and windowed pushes pass through; an immediate push never sits there.

<Tip>
  Read the device once. Pick a command. Inspect its `execution` array. Build the request shape that array allows. The API tells you what is acceptable, so you do not need to know per-OEM physics.
</Tip>

## Conflict resolution

Only one non-terminal action can target a device at a time. Submit a new push while another is active or scheduled and `onConflict` decides what happens:

| Strategy             | Behaviour                                                               |
| -------------------- | ----------------------------------------------------------------------- |
| `cancel_and_replace` | Cancel the conflicting action, run the new one.                         |
| `queue_after`        | Defer the new action's start to the conflict's end.                     |
| Omitted              | Return 409 with the conflicting action ID and the available strategies. |

A device honours the strategies that make sense for it. `cancel_and_replace` works everywhere. `queue_after` is honoured only where the device declares it, on `conflictStrategies` in the device read: a strategy outside that list returns 422 `STRATEGY_NOT_SUPPORTED`. Thermostats declare `cancel_and_replace` alone; a charger that can run a windowed `charge` declares both, because a window has an end to queue behind. `queue_after` still needs the *conflicting* action to have an end, so queueing behind an open-ended `auto.*` strategy returns 409 and points at `cancel_and_replace` instead. The 409 body lists the strategies a given device accepts, so you never guess. The model is documented end-to-end on [conflict resolution](/concepts/conflict-resolution).

## Datetime and time-window contract

`start` and `end` are **plant-local wall-clock** ISO 8601 strings: `YYYY-MM-DDTHH:MM:SS`. No timezone offset, no `Z`. The platform interprets the wall-clock in the device's plant timezone, so a customer in any timezone reads "5pm at the device" the same way. Any offset (`Z`, `+01:00`, `-05:00`) is rejected at the API boundary — the error explains how to drop the suffix.

Relative durations (`30m`, `1.5h`) are accepted on `start` only and normalised to absolute instants at request time. `start` must be in the future and within 30 days. `end` must be after `start` in wall-clock terms (DST gaps are handled — see below). Time windows are right-open intervals `[start, end)`.

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

DST behaviour: wall-clocks inside a spring-forward gap (e.g. `02:30` on the night the UK jumps from 01:00 GMT to 02:00 BST) are rejected with `START_NONEXISTENT_WALL_CLOCK`. Wall-clocks that occur twice (autumn fall-back overlap) resolve to the first occurrence (the pre-transition offset). Submit a wall-clock outside the overlap if the second occurrence is required.

Responses normalise `start` and `end` to absolute UTC. You send `2026-06-10T22:00:00` for a plant in `Europe/London` during BST, the action read returns `2026-06-10T21:00:00.000Z`. The wall-clock you submitted is preserved in plant time, then resolved against the plant's IANA timezone for storage and dispatch. The UTC echo is the dispatch-ready instant, not a re-interpretation of your intent.

## Capability introspection

The device read returns the same vocabulary you post back. A `GET /battery/{deviceId}` carries `commands` alongside the rest of the device record under `data`:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_abc123",
    "vendor": "example_vendor_a",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:30:00.000Z" },
    "metadata": { "model": "Hybrid 5kWh", "source": "cache" },
    "state": { "status": "charging", "level": 50, "capacity": 10.4, "chargeRate": 2.4, "dischargeLimit": 10, "currentMode": "charge" },
    "conflictStrategies": ["cancel_and_replace", "queue_after"],
    "commands": {
      "charge": {
        "parameters": {
          "power": { "unit": "kw", "min": 0, "max": 5.0 },
          "target": { "unit": "percent", "min": 10, "max": 100 }
        },
        "execution": ["immediate", "scheduled", "windowed"]
      },
      "auto.balance": {
        "parameters": {},
        "execution": ["immediate", "scheduled"]
      }
    },
    "settings": { "discharge_floor": { "value": 10, "unit": "percent" } },
    "lastAction": null,
    "currentSchedule": null
  },
  "meta": { "requestId": "req_8a2Bf3kP", "environment": "live", "timestamp": "2026-06-01T10:30:00.000Z", "latencyMs": 12 }
}
```

`data.commands.charge.parameters` on the read maps directly to `action.parameters` on the write. The bounds (`min`, `max`) and units come straight from the device's declared capabilities. Bounds are optional; an absent `min` or `max` means open-ended on that side, as documented in [capabilities](/concepts/capabilities). Presence in the map means supported. Absence means rejected.

## Actions are not settings

Actions answer "what should the device do?" Settings answer "within what bounds should it operate?" Actions are time-boundable, conflict-able, and audited. Settings are persistent, non-conflicting, and fire-and-forget. The litmus test: "does this make sense with a time window?" You can say "charge from 6pm to 10pm". You would never say "set the safety reserve to 10% from 6pm to 10pm". If it is time-boundable, it is an action.

The boundary drives where a capability lives. An EV charger's maximum charging power is a ceiling the charger operates under, not an intent it pursues, so it is the `max_charge_rate` setting (in kw) or `max_charge_current` (in amps), written through `POST /ev-charger/{deviceId}/settings`, exactly as the battery's `max_charge_rate` is. The charger's five actions stay clean intents: `charge`, `idle`, and the three charging strategies. You set the ceiling once, then start, stop, and hand over sessions against it.

The same rate expressed twice is still one ceiling, so `max_charge_rate` and `max_charge_current` cannot ride the same request on a charger that holds a single rate register. That returns 422 `UNSUPPORTED_SETTING_COMBINATION` with `details.conflictingSettings`. Send one.

Actions run through [`POST /battery/{deviceId}`](/api-reference/battery/push-battery-action), `POST /ev-charger/{deviceId}`, or `POST /hvac/{deviceId}` and create an audit record. Settings, on the device types that declare a settings surface ([`POST /battery/{deviceId}/settings`](/api-reference/battery/update-battery-device-settings) and `POST /ev-charger/{deviceId}/settings`), do not. A thermostat exposes commands but no settings surface. See the [API reference](/api-reference/introduction) for the full surface.

## Frequently asked questions

### Do EV chargers and thermostats use the same push shape as batteries?

Yes. Every device type accepts the same `{ action: { command, parameters }, onConflict }` body. The verbs differ by type: a battery takes `charge`, `discharge`, `idle`, and the auto modes; an EV charger takes `charge`, `idle`, `auto.charge_tariff`, `auto.charge_surplus_only`, and `auto.charge_surplus_first`; a thermostat takes `heat`, `cool`, `idle`, `auto.maintain`, and `auto.schedule`. The envelope, the `{value, unit}` parameters, and the conflict resolution are identical. Read the device's `commands` map to see which verbs it accepts.

### What is auto.balance?

`auto.balance` is a canonical battery mode that names the device's self-managing self-consumption mode under a shared name. It is one of three auto modes (`auto.balance`, `auto.reserve`, `auto.export`) that every supported battery speaks. The client sends the same request shape across every device.

### How do I push an action with a time window?

Send `start` and `end` as plant-local wall-clock ISO 8601 strings (`YYYY-MM-DDTHH:MM:SS`, no offset, no `Z`) in the action body. The command must declare `windowed` in its `execution` array; a battery's `charge` and `discharge` do, and so does an EV charger's `charge`. Auto modes do not, on any device type. The action runs from `start`, then reverts at `end`. ISO strings with offsets (`Z`, `+01:00`, etc.) are rejected — the platform interprets the wall-clock in the device's plant timezone. Sub-minute windows are rejected on devices whose schedulers operate at minute resolution.

### What is the difference between command and parameters in the action body?

`command` is the verb the device should pursue, drawn from the set its type defines (a battery's `charge`, an EV charger's `charge`, a thermostat's `heat`). `parameters` is the bag of inputs that verb accepts. On a battery's `charge`: `target` (an SOC bound), `power` (a rate cap), `reserve` (an SOC floor). On an EV charger's `charge`: `power` or `current` (a rate cap), `target` or `energy` (a stop condition). On a thermostat's `heat`: `target` (a temperature). Some commands accept none; `idle` and every `auto.*` mode carry no parameters, since their behaviour is intent-only.

### Can I send a charge and an auto.balance action at the same time?

Only one non-terminal action targets a device at a time. Submit `auto.balance` while a `charge` is `scheduled` or `acknowledged` and the API returns 409 `CONFLICT` unless `onConflict` resolves it. Use `cancel_and_replace` to drop the existing action and run the new one, or `queue_after` to defer the new action until the existing one ends. One active intent per device.

### What happens if I send a parameter the device does not support?

The API returns 422 `UNSUPPORTED_PARAMETER` with `details.unsupportedParameters[]` and a `deviceCapabilities` snapshot. The snapshot has the same shape as the GET response, so the caller rebuilds the request from the rejection without a second fetch. Sending an unknown unit returns 422 `UNSUPPORTED_UNIT` with the supported units. Sending a value outside the declared range returns 422 `PARAMETER_OUT_OF_RANGE`.

## Related concepts

<CardGroup cols={2}>
  <Card title="Capabilities" icon="list-checks" href="/concepts/capabilities">
    How the device tells you which commands, parameters, and units it accepts.
  </Card>

  <Card title="Conflict Resolution" icon="git-merge" href="/concepts/conflict-resolution">
    How `onConflict` handles overlap and 409s.
  </Card>

  <Card title="Scheduling" icon="calendar" href="/concepts/scheduling">
    Push as the only execution primitive. Schedules as coordinators.
  </Card>

  <Card title="Error Envelope" icon="circle-alert" href="/concepts/error-envelope">
    The shape of every rejection, including `UNSUPPORTED_UNIT` and `EXECUTION_NOT_SUPPORTED`.
  </Card>
</CardGroup>

For a worked walkthrough, see the cookbook: [schedule a charge for later](/guides/cookbook/schedule-charge-later).

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
__html: JSON.stringify({
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: [
    {
      "@type": "Question",
      name: "Do EV chargers and thermostats use the same push shape as batteries?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Yes. Every device type accepts the same { action: { command, parameters }, onConflict } body. The verbs differ by type: a battery takes charge, discharge, idle, and the auto modes; an EV charger takes charge, idle, auto.charge_tariff, auto.charge_surplus_only, and auto.charge_surplus_first; a thermostat takes heat, cool, idle, auto.maintain, and auto.schedule. The envelope, the {value, unit} parameters, and the conflict resolution are identical. Read the device's commands map to see which verbs it accepts."
      }
    },
    {
      "@type": "Question",
      name: "What is auto.balance?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "auto.balance is a canonical battery mode that names the device's self-managing self-consumption mode under a shared name. It is one of three auto modes (auto.balance, auto.reserve, auto.export) that every supported battery speaks. The client sends the same request shape across every device."
      }
    },
    {
      "@type": "Question",
      name: "How do I push an action with a time window?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Send start and end as plant-local wall-clock ISO 8601 strings (YYYY-MM-DDTHH:MM:SS, no offset, no Z) in the action body. The command must declare windowed in its execution array; a battery's charge and discharge do, and so does an EV charger's charge. Auto modes do not, on any device type. The action runs from start, then reverts at end. ISO strings with offsets (Z, +01:00, etc.) are rejected. The platform interprets the wall-clock in the device's plant timezone. Sub-minute windows are rejected on devices whose schedulers operate at minute resolution."
      }
    },
    {
      "@type": "Question",
      name: "What is the difference between command and parameters in the action body?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "command is the verb the device should pursue, drawn from the set its type defines (a battery's charge, an EV charger's charge, a thermostat's heat). parameters is the bag of inputs that verb accepts. On a battery's charge: target (an SOC bound), power (a rate cap), reserve (an SOC floor). On an EV charger's charge: power or current (a rate cap), target or energy (a stop condition). On a thermostat's heat: target (a temperature). Some commands accept none; idle and every auto.* mode carry no parameters, since their behaviour is intent-only."
      }
    },
    {
      "@type": "Question",
      name: "Can I send a charge and an auto.balance action at the same time?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Only one non-terminal action targets a device at a time. Submit auto.balance while a charge is scheduled or acknowledged and the API returns 409 CONFLICT unless onConflict resolves it. Use cancel_and_replace to drop the existing action and run the new one, or queue_after to defer the new action until the existing one ends. One active intent per device."
      }
    },
    {
      "@type": "Question",
      name: "What happens if I send a parameter the device does not support?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "The API returns 422 UNSUPPORTED_PARAMETER with details.unsupportedParameters[] and a deviceCapabilities snapshot. The snapshot has the same shape as the GET response, so the caller rebuilds the request from the rejection without a second fetch. Sending an unknown unit returns 422 UNSUPPORTED_UNIT with the supported units. Sending a value outside the declared range returns 422 PARAMETER_OUT_OF_RANGE."
      }
    }
  ]
})
}}
/>
