Download OpenAPI specification:
A narrow, mTLS-authenticated Docker control API.
One agent runs per Docker host. It is the sole holder of the Docker socket and never proxies arbitrary Engine calls: the operations below are the complete reachable surface, and read responses are allowlisted projections rather than Engine payloads — a container's environment and a volume's driver options never leave the host.
Versioning. The path prefix /v1 is the contract version, and
GET /v1/status reports it as api_version. A client negotiates against
that field, not against agent_version, which moves with releases that do
not change this document.
Authentication is mutual TLS. The agent is its own certificate
authority; pairing is the act of issuing a device a certificate. Every route
except GET /v1/status and POST /v1/pair requires a client certificate
issued by that CA, and the calling device is resolved from the certificate —
never from a header, a body field, or a path parameter. A client pins the
agent's CA; no public root store knows it.
What a client may do is bounded twice. By the host's policy mode, fixed
at agent startup and advertised in /v1/status, which a client can never
widen; and by the agent's permanent exclusion of its own container from
every mutating route, in every mode.
Retention, policy, and rate limits are all startup configuration on the host. There is deliberately no endpoint that changes any of them.
The only payload served without a client certificate, and a strict allowlist of five fields. It carries no host, container, or credential data, and it can never issue or renew anything.
Its purpose is diagnostic: an expired device certificate and a
regenerated server identity produce the same handshake failure but
demand opposite user guidance. Comparing ca_fingerprint against the
value recorded at install separates them, and server_time lets a
client tell a real expiry from its own clock being wrong.
{- "api_version": "v1",
- "agent_version": "0.6.0",
- "policy_mode": "default",
- "server_time": "2026-08-11T09:12:44Z",
- "ca_fingerprint": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
}Reachable without a client certificate because a device that has none is
exactly what this route exists to serve: the operator-minted pairing
code is the credential for this one request. Codes are short-lived and
single-use, and are minted on the host with
devmon-agent device pair-code --name <device>.
The device generates its own keypair and sends only the CSR; the private key never leaves it. The response carries the issued certificate and the CA certificate to pin.
Every reason this call can fail — an unknown, expired, or already-spent
code, or a malformed CSR — answers an identical 401. A caller must not
be able to tell which, or the route becomes an oracle for probing codes.
| pairing_code required | string A short-lived, single-use code minted on the host with |
| csr_pem required | string A PKCS#10 certificate signing request, PEM-encoded as a
|
{- "pairing_code": "string",
- "csr_pem": "string"
}{- "device_id": "string",
- "certificate_pem": "string",
- "ca_certificate_pem": "string",
- "not_after": "2019-08-24T14:15:22Z"
}Renewal is proactive and silent: a client renews well before expiry, while its current certificate still authenticates it. Once a certificate has expired there is no authenticated channel left to authorise its replacement, so a client that waits has to be re-paired on the host.
The device identity comes from the client certificate alone, so this
route can only ever renew the caller's own. The previous certificate is
marked superseded but is neither deleted nor shortened — it keeps working
until its own not_after, so a lost response cannot strand a device.
| csr_pem required | string A PKCS#10 CSR, PEM-encoded. A fresh keypair is recommended but not required. |
{- "csr_pem": "string"
}{- "certificate_pem": "string",
- "not_after": "2019-08-24T14:15:22Z"
}Permitted under every policy mode, including read-only: giving up your
own access is not a privileged act. It takes effect on the next request.
A device can only ever act on itself. There is no route for revoking
another device, because with no roles there is no administrator client —
any such capability would belong equally to a stolen phone. Revoking a
lost device is a host-side operation: devmon-agent device revoke <id>.
{- "error": "client certificate required"
}| all | boolean Default: false Include stopped containers. Anything that does not parse as a
boolean is treated as |
{- "truncated": true,
- "items": [
- {
- "id": "string",
- "names": [
- "string"
], - "image": "string",
- "image_id": "string",
- "command": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "state": "running",
- "status": "Up 4 hours (healthy)",
- "health": "healthy",
- "labels": {
- "property1": "string",
- "property2": "string"
}, - "ports": [
- {
- "ip": "string",
- "private_port": 65535,
- "public_port": 65535,
- "protocol": "tcp"
}
], - "protected": true
}
]
}The projection deliberately omits the container's environment. Redacting a value would still disclose that a secret exists and what it is called; omission is the only version of this that cannot leak.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
{- "id": "string",
- "name": "string",
- "image": "string",
- "image_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "state": "running",
- "running": true,
- "paused": true,
- "restarting": true,
- "exit_code": 0,
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z",
- "health": "string",
- "restart_count": 0,
- "restart_policy": "no",
- "platform": "linux",
- "labels": {
- "property1": "string",
- "property2": "string"
}, - "command": "string",
- "args": [
- "string"
], - "entrypoint": [
- "string"
], - "working_dir": "string",
- "user": "string",
- "mounts": [
- {
- "type": "bind",
- "name": "string",
- "source": "string",
- "destination": "string",
- "read_write": true
}
], - "networks": [
- {
- "network_name": "string",
- "network_id": "string",
- "ip_address": "string",
- "gateway": "string",
- "mac_address": "string",
- "aliases": [
- "string"
]
}
], - "ports": [
- {
- "ip": "string",
- "private_port": 65535,
- "public_port": 65535,
- "protocol": "tcp"
}
], - "protected": true
}Permitted in every policy mode. tail defaults to 200 here.
stdout and stderr are interleaved in the order the container wrote them: the agent demultiplexes Docker's stream framing itself rather than splitting the two, because that ordering is most of what a log is worth during an incident.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
| tail | integer [ 1 .. 2000 ] Number of lines to return. Bounded to 1…2000; a value that is absent, unparsable, or out of range falls back to the route's default (200 historical, 100 for the stream) rather than failing the request. |
| since | string <date-time> Example: since=2026-08-08T10:02:14.882Z Return only lines at or after this timestamp, RFC 3339 with nanosecond
precision. On the stream route this is the resume cursor: pass the Unlike |
{- "truncated": true,
- "items": [
- {
- "ts": "2026-08-08T10:02:14.882Z",
- "stream": "stdout",
- "line": "string",
- "truncated": true
}
]
}Permitted in every policy mode. tail defaults to 100 here, seeding the
backlog before the stream follows live output.
Each frame carries one line, with the timestamp lifted out of Docker's wire format into its own field so a client never parses that format to find its resume cursor:
id: 2026-08-08T10:02:14.882Z
event: log
data: {"ts":"2026-08-08T10:02:14.882Z","stream":"stderr","line":"panic: nil map write"}
Resuming is at-least-once. After a connection loss, reconnect with
?since=<the last id seen>. Docker's since filter is inclusive at its
granularity boundary, so a resume can repeat the last line or two;
dedupe on (ts, line). A repeated line is cosmetic, a dropped one is a
diagnostic failure, and the trade goes that way round deliberately.
A silent stream is still live. A keepalive comment frame
(: keepalive) is written every 20 seconds, which is also how the agent
learns a client vanished without closing.
Eight concurrent streams per agent, after which this route answers
503. Each stream holds a goroutine, an Engine connection, and a socket
for its whole life.
Response headers are withheld until there is a first frame to send,
which is what keeps 404 and 502 available as ordinary JSON
responses. Once the stream has started, a later failure can only arrive
as a terminal event: error frame on a response that already said 200.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
| tail | integer [ 1 .. 2000 ] Number of lines to return. Bounded to 1…2000; a value that is absent, unparsable, or out of range falls back to the route's default (200 historical, 100 for the stream) rather than failing the request. |
| since | string <date-time> Example: since=2026-08-08T10:02:14.882Z Return only lines at or after this timestamp, RFC 3339 with nanosecond
precision. On the stream route this is the resume cursor: pass the Unlike |
id: 2026-08-08T10:02:14.882Z event: log data: {"ts":"2026-08-08T10:02:14.882Z","stream":"stderr","line":"panic: nil map write"} : keepalive id: 2026-08-08T10:02:44.001Z event: error data: {"error":"docker engine unavailable"}
Permitted in every policy mode: the stream discloses a strict subset of
what GET /v1/containers already returns.
The snapshot replaces replay. The first frame is always one
event: snapshot carrying the current state of every container, taken
only after the Engine-side event subscription is live, so no event can
fall between the two. There is no since parameter and no
Last-Event-ID resume: a client reconciles from the snapshot on every
connect, which is one mechanism that is always true rather than a replay
window that is bounded and stale at its edges.
After the snapshot, one event: health frame per forwarded Engine
event. Exactly six container events are forwarded —
health_status: healthy, health_status: unhealthy, die, start,
stop, and oom. Everything else, including every event attribute the
Engine attaches (attributes are the container's label set), is dropped
agent-side.
One stream per paired device, newest wins. A second connection from
the same device closes the older one with a terminal
event: error / event stream superseded frame — the one terminal
error a client must not retry, since reconnecting would fight the
newer stream. This limit is independent of the log-stream budget: an
event stream consumes no log-stream slot, because every event stream
shares one Engine subscription per agent process.
A gap becomes a disconnect. If the Engine feed dies, every client
receives event: error / docker engine unavailable and the stream
closes; a client that falls too far behind receives
event stream fell behind. Both are retryable with backoff, and a
reconnect serves a fresh snapshot.
A : heartbeat comment frame is written every 25 seconds so NAT and
proxies keep the connection open, and so a client can detect a dead
stream.
id is the join key across routes. name here carries the Engine name
with its leading / stripped (the Engine's list and event APIs disagree
on the form), which differs from GET /v1/containers's names.
Snapshot health requires Engine 29+ / API v1.52: on an older Engine
every container snapshots as none while the events themselves are
unaffected.
Response headers are withheld until the snapshot is ready to send, which
keeps 502 available as an ordinary JSON response. Once the stream has
started, a later failure can only arrive as a terminal event: error
frame on a response that already said 200.
event: snapshot data: [{"id":"a1b2c3","name":"api","state":"running","health":"healthy"},{"id":"d4e5f6","name":"cron","state":"exited","health":"none"}] : heartbeat event: health data: {"id":"a1b2c3","name":"api","event":"health_status","health":"unhealthy","time":"2026-08-25T09:14:02Z"} event: health data: {"id":"d4e5f6","name":"cron","event":"die","time":"2026-08-25T09:31:47Z"} event: error data: {"error":"event stream superseded"}
Requires policy mode full.
Delete never force-stops. A running container is a 409, so removing
one is stop-then-delete: two deliberate operations leaving two audit
rows, rather than one tap that destroys a running service.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
{- "error": "invalid object reference"
}Requires policy mode default or full.
Starting a container that is already running is a 204, not an error:
the goal is already met, and reporting a failure would invite a retry
that cannot help.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
{- "error": "invalid object reference"
}Requires policy mode default or full.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
{- "error": "invalid object reference"
}Requires policy mode default or full.
Stopping a container that is already stopped is a 204, on the same
reasoning as start.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
{- "error": "invalid object reference"
}Requires policy mode full.
Always SIGKILL — there is no ?signal= parameter, because the kill
button means "stop this now". A graceful stop is POST .../stop.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Container ID (full or short) or name, matching the reference pattern
every object route enforces — see the A reference is interpolated into the Engine's own request URL, so it is
validated at the boundary rather than trusted to two layers of framework
path handling. Anything outside the pattern is a |
{- "error": "invalid object reference"
}{- "truncated": true,
- "items": [
- {
- "id": "sha256:a1b2c3d4e5f6…",
- "parent_id": "string",
- "repo_tags": [
- "string"
], - "repo_digests": [
- "string"
], - "created_at": "2019-08-24T14:15:22Z",
- "size": 0,
- "containers": 0,
- "labels": {
- "property1": "string",
- "property2": "string"
}
}
]
}The image's baked-in environment is not mapped, for the same reason a container's is not.
| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Image ID as bare hex, or an untagged repository name. The reference pattern excludes |
{- "id": "string",
- "repo_tags": [
- "string"
], - "repo_digests": [
- "string"
], - "created_at": "2019-08-24T14:15:22Z",
- "size": 0,
- "architecture": "amd64",
- "os": "linux",
- "author": "string",
- "comment": "string"
}{- "truncated": true,
- "items": [
- {
- "id": "string",
- "name": "string",
- "driver": "bridge",
- "scope": "local",
- "created_at": "2019-08-24T14:15:22Z",
- "internal": true,
- "enable_ipv6": true,
- "labels": {
- "property1": "string",
- "property2": "string"
}
}
]
}| id required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Network ID or name, matching the same reference pattern every object route enforces. |
{- "id": "string",
- "name": "string",
- "driver": "bridge",
- "scope": "local",
- "created_at": "2019-08-24T14:15:22Z",
- "internal": true,
- "enable_ipv6": true,
- "labels": {
- "property1": "string",
- "property2": "string"
}, - "containers": [
- {
- "container_id": "string",
- "name": "string",
- "ipv4_address": "string",
- "ipv6_address": "string"
}
]
}{- "truncated": true,
- "items": [
- {
- "name": "string",
- "driver": "local",
- "mountpoint": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "scope": "local",
- "labels": {
- "property1": "string",
- "property2": "string"
}, - "size_bytes": 0
}
]
}There is no separate detail shape for a volume: inspect returns the same object a list entry carries.
Driver options are not mapped. For tmpfs and CIFS/NFS volumes they routinely contain credentials.
| name required | string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ Volume name. Volumes are named rather than IDed, which is why this
parameter is |
{- "name": "string",
- "driver": "local",
- "mountpoint": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "scope": "local",
- "labels": {
- "property1": "string",
- "property2": "string"
}, - "size_bytes": 0
}