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

# Capabilities

> Per-device capability introspection across batteries, EV chargers, and thermostats. Presence-based contract narrowing. One commands shape, every device type.

The canonical surface defines what the API accepts in general. Every device declares which subset of that surface it actually supports. One read is enough to know which commands fire, which parameters apply, which units are accepted, and which execution shapes the device can run. That declaration is the contract for that specific device.

## Capabilities live on the read response

There is no separate `/capabilities` endpoint. The declaration ships inside the device read: `GET /battery/{deviceId}`, `GET /ev-charger/{deviceId}`, `GET /hvac/{deviceId}`, and their list endpoints all carry the `commands` block. One read returns state and commands, plus settings on the device types that declare a settings surface (battery and EV charger; a thermostat has commands but no settings). Settings ride the single-device read; list views carry commands, not settings:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_abc123",
    "vendor": "foxess",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:14:23.000Z" },
    "metadata": { "model": "FoxESS H1-5.0-E", "source": "cache" },
    "state": { "status": "charging", "level": 50, "capacity": 10.4, "chargeRate": 2.4, "dischargeLimit": 10 },
    "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", "min": 0, "max": 100 }
    },
    "lastAction": null,
    "currentSchedule": null
  },
  "meta": { "requestId": "req_8a2Bf3kP", "environment": "live", "timestamp": "2026-06-01T10:14:23.000Z", "latencyMs": 12 }
}
```

The shape mirrors the push body. `commands.charge.parameters.power` on the read maps directly to `action.parameters.power` on the write. See [canonical actions](/concepts/canonical-actions).

`conflictStrategies` advertises the `onConflict` values the device accepts when a new push collides with an action already in flight. Read it before sending `onConflict`: a strategy outside this list is rejected with 422 `STRATEGY_NOT_SUPPORTED`. It is present on every commandable device type and absent on read-only types, which have no push to conflict. See [conflict resolution](/concepts/conflict-resolution).

## The same shape across device types

The `commands` block is one contract, not a battery-specific one. The EV charger declares its commands the same way. Its `charge` carries the rate caps and stop conditions; `idle` and the three charging strategies carry nothing:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_ev789",
    "vendor": "example_vendor_a",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:14:24.000Z" },
    "metadata": { "model": "7kW AC Charger", "source": "cache" },
    "state": {
      "status": "charging",
      "isConnected": true,
      "isCharging": true,
      "currentPower": 7.2,
      "maxCurrent": 32,
      "powerRateLimit": 7.4,
      "sessionEnergy": 12.6,
      "phases": 1,
      "voltage": 230,
      "activeControlMode": "charge"
    },
    "conflictStrategies": ["cancel_and_replace", "queue_after"],
    "sessions": true,
    "commands": {
      "charge": {
        "parameters": {
          "power":   { "unit": "kw", "min": 1.4, "max": 22, "step": 0.1 },
          "current": { "unit": "amps", "min": 6, "max": 32, "step": 1 },
          "energy":  { "unit": "kwh", "min": 1, "max": 100 }
        },
        "execution": ["immediate", "scheduled", "windowed"]
      },
      "idle":                      { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.charge_tariff":        { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.charge_surplus_only":  { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.charge_surplus_first": { "parameters": {}, "execution": ["immediate", "scheduled"] }
    },
    "settings": {
      "max_charge_rate":    { "value": 11, "unit": "kw", "min": 0, "max": 50, "step": 0.1 },
      "max_charge_current": { "value": 32, "unit": "amps", "min": 6, "max": 32, "step": 1 },
      "cable_lock":         { "value": true }
    },
    "vehicle": { "id": "device_veh456", "links": { "self": "/vehicle/device_veh456" } },
    "lastAction": null,
    "currentSchedule": null
  },
  "meta": { "requestId": "req_8a2Bf3kP", "environment": "live", "timestamp": "2026-06-01T10:14:24.000Z", "latencyMs": 14 }
}
```

This charger declares `target` on none of its commands. A charger can only stop at a state of charge it can read, and most cannot see inside the car, so they declare `energy` and count on their own meter instead. Absence is the contract: send `target` here and it is refused with 422 `UNSUPPORTED_PARAMETER`.

`vehicle` names the car this charger is filling, when the platform can tell which one it is. It is a pointer, not a copy: the state of charge and range live on the vehicle resource at `links.self`. It is `null` whenever the answer would be a guess.

The thermostat does too. Its `heat` and `cool` commands take a `target` temperature; `auto.maintain` takes a `heatSetpoint` and `coolSetpoint` comfort band; `idle` and `auto.schedule` take nothing:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_hvac456",
    "vendor": "example_vendor_b",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:14:25.000Z" },
    "metadata": { "model": "Smart Thermostat v3", "source": "cache" },
    "state": { "temperature": 20.5, "active": true, "heatSetpoint": 20, "coolSetpoint": 24, "holdType": "schedule", "mode": "heat" },
    "conflictStrategies": ["cancel_and_replace"],
    "commands": {
      "heat": {
        "parameters": { "target": { "unit": "celsius", "min": 10, "max": 35 } },
        "execution": ["immediate", "scheduled", "windowed"]
      },
      "cool": {
        "parameters": { "target": { "unit": "celsius", "min": 10, "max": 35 } },
        "execution": ["immediate", "scheduled", "windowed"]
      },
      "auto.maintain": {
        "parameters": {
          "heatSetpoint": { "unit": "celsius", "min": 10, "max": 35 },
          "coolSetpoint": { "unit": "celsius", "min": 10, "max": 35 }
        },
        "execution": ["immediate", "scheduled"]
      },
      "idle": { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.schedule": { "parameters": {}, "execution": ["immediate"] }
    },
    "lastAction": null,
    "currentSchedule": null
  },
  "meta": { "requestId": "req_8a2Bf3kP", "environment": "live", "timestamp": "2026-06-01T10:14:25.000Z", "latencyMs": 18 }
}
```

The keys differ because the commands differ, but the shape is identical: a map of commands, each with a `parameters` map of units and bounds, and an `execution` array. Read the block, find the parameter bounds, build a valid push. The loop is the same whichever device answers.

Commands are keyed by their canonical name; the read returns only canonical vocabulary. Conflict resolution is device-level, not per-command: one action is active per device at a time. Any new command conflicts with the one in flight, whether it is a different mode on the same device or the same mode again, and `onConflict` resolves the overlap. There is no per-command grouping to reason about; the capability block tells you the commands and their bounds, the [conflict model](/concepts/conflict-resolution) tells you how concurrent requests resolve.

<Callout icon="clock" color="#ED6D2C">
  **Coming soon.** Live control for EV chargers and thermostats. The sandbox environment serves the full `commands` and `settings` surface so you can build the whole integration against it. A live EV charger or thermostat push returns 503 `NOT_YET_AVAILABLE` until the live path opens; battery control is live.
</Callout>

## Some device types are read-only

Not every device type accepts commands. A solar inverter and a vehicle expose state, sync status, and metadata, and no `commands` block at all:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_solar321",
    "vendor": "enphase",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:14:23.000Z" },
    "metadata": { "model": "IQ8+", "source": "cache" },
    "state": { "status": "producing", "currentPower": 4.2, "producing": true, "energyTotal": 18400 }
  },
  "meta": { "requestId": "req_8a2Bf3kP", "environment": "live", "timestamp": "2026-06-01T10:14:23.000Z", "latencyMs": 22 }
}
```

The absence is the contract. The canonical surface for these device types defines reads and no push actions, so the read carries everything they support. A push against a read-only device type has no command to name. This is complete, not a gap: the device declares the full surface it offers, and that surface is state.

## Presence-based support

A capability is supported iff its key is present. Absent means rejected.

```json theme={null}
{ "commands": { "charge": "...", "auto.balance": "..." } }
```

A push for `discharge` against this device returns 422 `UNSUPPORTED_MODE`. A push for `charge` with a `reserve` parameter (when `parameters.reserve` is absent on the read) returns 422 `UNSUPPORTED_PARAMETER`. There is no `supported: boolean` field, and no "parameter exists but is disabled" middle state. Either the device declares it and honours it, or the API rejects with structured detail you can act on.

An empty `parameters: {}` is a valid declaration. It means the command is supported and takes no parameters. `auto.*` commands typically declare empty parameters because their behaviour is intent-only; strategy parameters belong to the underlying optimisation, not to the canonical request.

## Per-mode execution shapes

Each command declares an `execution` array drawn from `{ "immediate", "scheduled", "windowed" }`:

| Token       | Request shape     | Validation                                     |
| ----------- | ----------------- | ---------------------------------------------- |
| `immediate` | No `start`        | Allowed only if `"immediate"` is in the array. |
| `scheduled` | `start` only      | Allowed only if `"scheduled"` is in the array. |
| `windowed`  | `start` and `end` | Allowed only if `"windowed"` is in the array.  |

A request shape that does not match the array returns 422 `EXECUTION_NOT_SUPPORTED` with `details: { requestedExecution, supportedExecution }`. Validation is per-mode, not per-device: a device may accept `auto.balance` immediately but require `charge` to be windowed, and the capability response expresses that directly.

## Units and bounds

Numeric parameters carry a unit and optional bounds:

```json theme={null}
{ "power": { "unit": "kw", "min": 0, "max": 5.0 } }
```

| Field  | Behaviour                                                                                                                                                       |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unit` | Required. Sending a different unit returns 422 `UNSUPPORTED_UNIT`.                                                                                              |
| `min`  | Optional. Open-ended on the lower side if absent.                                                                                                               |
| `max`  | Optional. Open-ended on the upper side if absent.                                                                                                               |
| `step` | Optional. The increment the value must land on, counted from `min` (or from 0 when no `min` is declared). Absent means any value inside the bounds is accepted. |

A push value outside the declared range returns 422 `PARAMETER_OUT_OF_RANGE`, and so does one inside the range but off the `step` grid. A charger declaring `{ "unit": "kw", "min": 1.4, "max": 22, "step": 0.1 }` accepts `4.2` and refuses `4.25`. Round to the grid before sending; the same three bounds ride on `settings` values, so the rule is one rule. The bounds you act on come from the device read, not from a fixed request schema. The thermostat's `target` and `heatSetpoint` follow the same rule as the battery's `power`: read the bound off `commands`, build a value inside it, post. Because the bound lives on the read, it is per-device, so a value valid for one device can be rejected by another of the same type. Bounds reflect the device's documented operating range merged with Amps-side safety. They do not change the canonical name or unit; they only constrain the value.

## Settings

`settings` declares persistent device configuration with current values and bounds. It sits beside `commands` on the read, and like `commands` it is a per-device-type surface: settings are how a device operates, not what it does. The battery's canonical writable surface:

| Setting              | Type   | Unit    | Meaning                                                     |
| -------------------- | ------ | ------- | ----------------------------------------------------------- |
| `safety_reserve`     | number | percent | Lowest SOC the battery will reach, even during a power cut. |
| `discharge_floor`    | number | percent | Lowest SOC during normal operation.                         |
| `charge_ceiling`     | number | percent | Highest SOC the battery will charge to.                     |
| `export_limit`       | number | watts   | Maximum power the battery sends to the grid.                |
| `max_charge_rate`    | number | amps    | Maximum charge current.                                     |
| `max_discharge_rate` | number | amps    | Maximum discharge current.                                  |

The EV charger has a settings surface too. It rides the same `{value, unit}` shape and the same `POST /ev-charger/{deviceId}/settings` write path as the battery's:

| Setting              | Type    | Unit | Meaning                                                            |
| -------------------- | ------- | ---- | ------------------------------------------------------------------ |
| `max_charge_rate`    | number  | kw   | Ceiling the charger draws under, in kilowatts.                     |
| `max_charge_current` | number  | amps | The same ceiling in amperes, for a charger that thinks in current. |
| `cable_lock`         | boolean | —    | Whether the cable stays locked to the charger.                     |

A ceiling is configuration, not an intent, so it lives here rather than as a push parameter, which keeps the charger's five commands clean intents. See [canonical actions](/concepts/canonical-actions) on the actions-versus-settings boundary.

Read-only settings (e.g., `scheduler_enabled`) appear in the read response but reject writes with 422 `READ_ONLY_SETTING`. Unknown setting keys reject with 422 `UNSUPPORTED_SETTING`. Writes use a separate endpoint and a sparse-map body. See [What to expect](/concepts/what-to-expect) on read-write parity.

### Settings that cannot share a request

`max_charge_rate` and `max_charge_current` are one rate ceiling written in two units. A charger that holds a single rate register cannot take both in one request without one silently winning, so it refuses the pair with 422 `UNSUPPORTED_SETTING_COMBINATION` and names them in `details.conflictingSettings`. Which settings collide is a property of the device, so send one of a colliding pair and read `details` if a request comes back refused.

## Computed at read time

The capability declaration is computed at read time, not cached separately; an updated declaration takes effect on the next read. Only canonical names appear in the response.

## Availability tiers

Devices graduate from sandbox-only simulations to general availability. Sandbox devices are visible only in the sandbox environment. Generally-available devices are visible to everyone in live. Tiers in between gate visibility while an integration is firming up; promotion to general requires no per-customer changes.

## How agents use this

A read-then-build-then-write loop is what makes agentic device control mechanical. The agent does not need to know which OEM is on the other end; the device tells it what is acceptable and the agent picks a valid request shape. This is the "build once, control any device" property in code.

```javascript theme={null}
const device = await api.get(`/battery/${deviceId}`);
const cmd = device.commands.charge ? 'charge' : 'auto.balance';
const spec = device.commands[cmd];

const parameters = Object.fromEntries(
  Object.entries(spec.parameters).map(([name, c]) => [name, { value: chooseValue(name, c), unit: c.unit }])
);
const shape = spec.execution.includes('immediate') ? {} : { start: '30m', end: '1h' };

await api.post(`/battery/${deviceId}`, { action: { command: cmd, parameters, ...shape } });
```

No OEM-specific knowledge needed, and no device-type-specific knowledge either. Swap the endpoint to `/ev-charger/{id}` or `/hvac/{id}` and the loop is unchanged: read `commands`, pick a command the device declares, build parameters inside the declared bounds, post. The device tells the agent what is acceptable, the agent builds and posts. Build once, control any device.

## Frequently asked questions

### How do I check if a battery supports auto.balance?

Read the device with `GET /battery/{deviceId}` and inspect `commands`. If `auto.balance` is a key on the `commands` map, the device supports it. If absent, a push for `auto.balance` returns 422 `UNSUPPORTED_MODE`. There is no `supported: boolean` field; capability is declared by presence. The same rule holds for parameters and units: present means supported, absent means rejected.

### Where is the capability response defined?

Capabilities live on the device read response, not at a separate `/capabilities` endpoint. `GET /battery/{deviceId}`, `GET /ev-charger/{deviceId}`, and `GET /hvac/{deviceId}` all return `commands` in one body alongside state. The shape is one contract across device types: each command is keyed by its canonical name and carries a `parameters` map of units and bounds and an `execution` array. One read is enough to construct a valid command without external documentation.

### Do all device types support commands?

No, and the read response tells you which do. A battery, EV charger, and thermostat return a `commands` block declaring the actions they accept. A solar inverter and a vehicle return state with no `commands` block, because the canonical surface for those device types is read-only. The absence is the contract: the device declares everything it offers, and for these types that is reads.

### Can capabilities change at runtime?

Yes. The capability declaration is computed at read time, not cached separately, so an updated declaration takes effect on the next read. State values change continuously; the capability declaration changes when the platform ships an update. New capabilities surface automatically; clients that already poll the read pick them up without code changes.

### What are availability tiers?

Devices graduate from sandbox-only simulations to general availability. Sandbox devices are visible only in the sandbox environment. Generally-available devices are visible to everyone in live. Tiers in between gate visibility while an integration is firming up. Promotion does not require per-customer changes.

## Related concepts

<CardGroup cols={2}>
  <Card title="Canonical Actions" icon="zap" href="/concepts/canonical-actions">
    The action shape that mirrors the capability response.
  </Card>

  <Card title="Device State" icon="gauge" href="/concepts/device-state">
    Where the capability response lives on the API.
  </Card>

  <Card title="What to expect" icon="compass" href="/concepts/what-to-expect">
    Why presence-based support and self-describing parameters.
  </Card>

  <Card title="Error Envelope" icon="circle-alert" href="/concepts/error-envelope">
    `UNSUPPORTED_MODE`, `UNSUPPORTED_PARAMETER`, `READ_ONLY_SETTING`.
  </Card>
</CardGroup>

For agent-driven walkthroughs that ground requests in capability data, see the [MCP examples](/ai-tools/mcp/examples).

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
__html: JSON.stringify({
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: [
    {
      "@type": "Question",
      name: "How do I check if a battery supports auto.balance?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Read the device with GET /battery/{deviceId} and inspect commands. If auto.balance is a key on the commands map, the device supports it. If absent, a push for auto.balance returns 422 UNSUPPORTED_MODE. There is no supported: boolean field; capability is declared by presence. The same rule holds for parameters and units: present means supported, absent means rejected."
      }
    },
    {
      "@type": "Question",
      name: "Where is the capability response defined?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Capabilities live on the device read response, not at a separate /capabilities endpoint. GET /battery/{deviceId}, GET /ev-charger/{deviceId}, and GET /hvac/{deviceId} all return commands in one body alongside state. The shape is one contract across device types: each command is keyed by its canonical name and carries a parameters map of units and bounds and an execution array. One read is enough to construct a valid command without external documentation."
      }
    },
    {
      "@type": "Question",
      name: "Do all device types support commands?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "No, and the read response tells you which do. A battery, EV charger, and thermostat return a commands block declaring the actions they accept. A solar inverter and a vehicle return state with no commands block, because the canonical surface for those device types is read-only. The absence is the contract: the device declares everything it offers, and for these types that is reads."
      }
    },
    {
      "@type": "Question",
      name: "Can capabilities change at runtime?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Yes. The capability declaration is computed at read time, not cached separately, so an updated declaration takes effect on the next read. State values change continuously; the capability declaration changes when the platform ships an update. New capabilities surface automatically; clients that already poll the read pick them up without code changes."
      }
    },
    {
      "@type": "Question",
      name: "What are availability tiers?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Devices graduate from sandbox-only simulations to general availability. Sandbox devices are visible only in the sandbox environment. Generally-available devices are visible to everyone in live. Tiers in between gate visibility while an integration is firming up. Promotion does not require per-customer changes."
      }
    }
  ]
})
}}
/>
