Skip to content
🐋Cetacean

API

Cetacean serves its cached view of the swarm over HTTP. Reads use GET; writes use PUT, POST, PATCH, and DELETE, gated by operations level. Authentication is pluggable via auth.mode and defaults to anonymous access.

The OpenAPI spec is served as JSON at GET /api and as YAML at GET /api/openapi.yaml. Browsers get an interactive playground at /api; the hosted copy is the API explorer. The event streams have a description of their own, an AsyncAPI 3.0 document at GET /api/asyncapi, with a YAML twin at /api/asyncapi.yaml.

Authentication

Every endpoint on this page is authenticated by whichever provider auth.mode selects. A client that has no browser session can instead present an access token this deployment issued, as Authorization: Bearer—see API access tokens. A token carries its user’s identity, so grants and the Allow header treat it exactly as they treat that person’s session.

A token minted for /mcp is refused here, and one minted for the API is refused there. The 401 names the metadata document for the resource you reached, so follow resource_metadata rather than assuming a location.

Cross-origin writes

A browser attaches your session, certificate, or proxy-supplied identity to every request, whichever page started it. So Cetacean refuses a write that a browser marks as coming from another site, with 403 and code CSR001. Anything other than GET, HEAD, and OPTIONS counts as a write.

Two kinds of origin pass: the one at server.public_url, and each one listed in server.cors.origins. A wildcard there lets browsers read from any origin but can’t grant writes, so name every origin that should be allowed to make them.

Requests that carry no browser headers, such as curl or a script, aren’t affected. Neither are /mcp and the OAuth token, revoke, and register endpoints, which authenticate from the request itself.

Content negotiation

Every resource URL serves JSON, HTML (the embedded dashboard), SSE, or a feed format depending on what the client asks for. There’s no /api/v1/ prefix; versioning lives in the media type.

Resolution order

  1. File extension appended to the path, which wins over everything else
  2. Accept header, parsed per RFC 9110 with q values and wildcards
  3. application/json when the client sends */* or no Accept header

Supported types

Accept valueExtensionResult
application/json.jsonJSON
application/vnd.cetacean.v1+jsonJSON, versioned alias of application/json
application/ld+jsonJSON—every JSON response is a JSON-LD document
text/html, application/xhtml+xml.htmlThe dashboard
text/event-streamSSE, on endpoints that support it
application/atom+xml.atomAtom feed
application/feed+json.feedJSON Feed 1.1
text/csv.csvCSV, on list endpoints
application/vnd.jgf+json.jgfJSON Graph Format, /topology only
application/graphml+xml.graphmlGraphML, /topology only
text/vnd.graphviz.dotGraphviz DOT, /topology only
application/yaml.yaml, .ymlCompose file on a stack or a service; the API descriptions

All negotiated responses include Vary: Accept. Requesting a type an endpoint can’t produce returns 406 Not Acceptable with code API003; asking for SSE on an endpoint without a stream returns 406 with API001. A graph format asked of a resource endpoint is refused the same way—the table says which endpoints serve one.

The two API descriptions, /api and /api/asyncapi, also answer to the media types their specifications register, such as application/vnd.oai.openapi+json, and serve YAML for .yaml or application/yaml.

The refusal is each endpoint’s own. A document with a single representation—the OpenSearch description, the API catalog, the JSON-LD context—answers a client asking for exactly the type it serves, whether or not that type appears in the preceding table.

GET /services HTTP/1.1
Accept: application/json
curl -H "Accept: application/json" http://localhost:9000/services

Compression

JSON, Atom, JSON Feed and CSV responses, and the /topology graph formats, are compressed when the request offers a coding Cetacean serves and the body exceeds 1 KiB. Two codings are served, zstd and gzip, negotiated from Accept-Encoding per RFC 9110 §12.5.3—q values and * included, and zstd preferred at equal weight. Smaller bodies are sent uncompressed regardless; there’s nothing to gain and a frame header to pay for.

Vary: Accept-Encoding is sent on every one of these responses whether or not anything was compressed, since what they return does depend on the header. Content-Encoding appears only when a coding was actually applied.

A compressed representation carries its coding on the ETag, as a suffix inside the quotes:

curl -sD- -H 'Accept-Encoding: zstd' -o/dev/null http://localhost:9000/services/web
# < Content-Encoding: zstd
# < Vary: Accept-Encoding
# < ETag: "a1b2c3d4e5f60718-zstd"

The suffix distinguishes the two representations for caches, which is what RFC 9110 §8.8.3 requires. It doesn’t stop the validator being used as If-Match on a write: the hash is always taken over the uncompressed body, so a validator obtained under compression and one obtained without it agree, and either is accepted verbatim.

/search and its feeds are never compressed. The response repeats the query you sent back to you alongside content filtered by your grants, and compressed length would leak whether a guessed query matched something you can see.

Feeds

Resource list and detail endpoints, plus /events, /history, /search, and /recommendations, serve Atom 1.0 and JSON Feed 1.1. A feed carries the resource’s change history, not its current state.

Supported endpoints

  • /nodes, /nodes/{id}
  • /services, /services/{id}
  • /tasks, /tasks/{id}
  • /stacks, /stacks/{name}
  • /configs, /configs/{id}
  • /secrets, /secrets/{id}
  • /networks, /networks/{id}
  • /volumes, /volumes/{name}
  • /events, /history, /search, /recommendations

Endpoints that produce no resource change data (write sub-resources, log streams, metrics, topology) return 406.

Feed pagination

Feeds page by cursor. The feed carries a next link while more entries exist, per RFC 5005.

ParameterDescription
before
Return entries older than this cursor ID
limit
Entries per page (default 50, max 200)
GET /services.atom HTTP/1.1

GET /history.atom?limit=50 HTTP/1.1

GET /history.atom?before=<cursor-id>&limit=50 HTTP/1.1
curl -H "Accept: application/atom+xml" http://localhost:9000/services
curl http://localhost:9000/services.atom
curl "http://localhost:9000/history.atom?limit=50"
curl "http://localhost:9000/history.atom?before=<cursor-id>&limit=50"

Feed caching

Feeds carry an ETag. Pass If-None-Match with a previous value to get 304 Not Modified when nothing changed. Responses add Vary: Authorization, Cookie alongside Vary: Accept so caches separate formats and users.

Feed identity

An Atom feed identifies itself with a tag URI naming the same host its own links carry: server.public_url when set, otherwise the host the request arrived on. A tag URI is meant to be permanent, so set server.public_url behind a reverse proxy—derived from the request, a feed’s identity changes with the hostname a reader happened to reach the server by.

Feed autodiscovery

JSON responses on feed-capable endpoints carry a Link header with rel="alternate" for each feed type. The dashboard injects an Atom <link rel="alternate"> into the HTML <head> on resource, history, search, and recommendations pages, so feed readers can find the feed from the page.

Spreadsheets

List endpoints also serve RFC 4180 CSV, for the one consumer the other formats don’t serve: a person pasting the cluster into a spreadsheet.

curl -o services.csv http://localhost:9000/services.csv
curl -H 'Accept: text/csv' 'http://localhost:9000/tasks?filter=state == "failed"'

Responses carry Content-Type: text/csv; charset=utf-8; header=present and an RFC 6266 Content-Disposition, so a browser opening /services.csv saves services-2026-09-11.csv rather than rendering it. A task list hanging off a parent names it—/nodes/{id}/tasks.csv saves tasks-worker-1-2026-09-11.csv.

A cell that starts with =, +, -, @, a tab or a carriage return is prefixed with ', so a spreadsheet shows a label someone set to a formula instead of running it. A number such as -1 is left as it is.

Supported endpoints

  • /nodes, /services, /tasks, /stacks, /configs, /secrets, /networks, /volumes
  • /nodes/{id}/tasks, /services/{id}/tasks
  • /history, /recommendations

Detail endpoints serve no CSV: one resource isn’t a table. /search serves none either—its results are of mixed type, and one header row can’t describe them.

Columns

A resource list renders the compact row the dashboard’s own tables and the MCP find tool render: the name, the state, and the one secondary fact that identifies the type—the image for a service, the role for a node, the node for a task, the driver for a network or volume—plus replica counts where a replica count means something. The full Docker object is what the JSON representation is for.

EndpointColumns
/services
name, stack, state, image, desired, running, id
/nodes
name, state, role, id
/tasks
name, state, node, id
/stacks
name, services, id
/configs, /secrets
name, stack, id
/networks, /volumes
name, stack, driver, id
/history
timestamp, type, action, name, id, summary
/recommendations
severity, category, scope, target, resource, message, current, configured, suggested

Pagination differs

search, filter, sort and authorization filtering apply exactly as they do to the JSON. Paging does not: a CSV request that names no page renders every row, where the JSON would return the first 50. An export truncated at a default carries nothing inside the file to say it was truncated, and the Link header that says so for JSON isn’t something a downloaded file keeps.

Naming a page still works, and then means what it means everywhere else:

curl 'http://localhost:9000/tasks.csv?limit=100&offset=200'

A Range header isn’t honored for CSV: that exchange answers 206 with a Content-Range, and a download is always a plain 200. Ask for a page with limit and offset instead.

Compose export

A stack or a single service also serves a Compose file, for the consumer no other format serves: someone who wants the running state as something they can read, keep in version control, or redeploy.

curl -o myapp.yaml http://localhost:9000/stacks/myapp.yaml
curl -H 'Accept: application/yaml' http://localhost:9000/services/r8m2x5nk3p7q

The promise is narrow and the file says so in a header comment: it redeploys to the same running state on the same cluster. It isn’t the file that originally created the stack, and it isn’t portable on its own.

What’s external, and why

Secrets are never exported—that rule holds here as everywhere else. Configs aren’t exported either, even though their content is available: inlining it through Compose’s content: field would have a redeploy create a new config rather than reuse the one the running service is already mounting, which would break the preceding promise. Both are referenced as external: true, so another cluster needs them created first.

Networks and volumes split. One carrying the stack’s com.docker.stack.namespace label is the stack’s own, and is declared with its driver and options. Everything else—a shared monitoring overlay the services merely attach to—is external: true under its full name. Getting this backwards is the single most likely way to produce a file that reads correctly and fails to deploy, in both directions.

Names are shortened by the stack’s own prefix, because docker stack deploy adds it back: the service Swarm calls myapp_api is api in the file. A name another resource already spells in full is left long rather than collapsed onto it—an owned myapp_data and an adopted data stay two volumes. A single-service export shortens nothing and declares everything external, because a service creates none of what it references.

Anything the projection couldn’t carry—a custom seccomp profile, a mount option Compose has no field for—is listed in the header comment rather than dropped silently.

Pagination

List endpoints page by query parameter or by HTTP Range header.

Query parameters

ParameterTypeDefaultDescription
limitint50Items per page, capped at 200
offsetint0Starting position
sortstringnoneSort field, varies by resource
dirstringascSort direction: asc or desc
searchstringnoneCase-insensitive substring match on name
filterstringnoneexpr-lang expression, max 512 chars
GET /services?limit=10&offset=20 HTTP/1.1

GET /nodes?sort=hostname&dir=desc HTTP/1.1

GET /configs?search=nginx HTTP/1.1
curl "http://localhost:9000/services?limit=10&offset=20"
curl "http://localhost:9000/nodes?sort=hostname&dir=desc"
curl "http://localhost:9000/configs?search=nginx"

Range header pagination

List endpoints accept Range: items 0-24 and answer 206 Partial Content with Content-Range: items 0-24/142. Every list response sets Accept-Ranges: items. An offset past the end returns 416 with Content-Range: items */142. Query parameters take precedence when both are present.

History pagination

/history pages by cursor rather than offset, because new entries arrive at the front while you read. A full page carries a Link with rel="next", whose before names the oldest entry on it. search matches names across the whole log, not only the page you have, and stays on the next link along with type and resourceId.

GET /history?search=web&limit=50 HTTP/1.1

GET /history?search=web&before=<entry-id>&limit=50 HTTP/1.1
curl "http://localhost:9000/history?search=web&limit=50"
curl "http://localhost:9000/history?search=web&before=<entry-id>&limit=50"

Sort fields by resource

ResourceSortable fields
Nodes
hostname, role, status, availability
Services
name, mode
Tasks
state, service, node
Stacks
name
Configs
name, created, updated
Secrets
name, created, updated
Networks
name, driver, scope
Volumes
name, driver, scope

Filter fields by resource

Filter expressions use expr-lang syntax and must evaluate to a boolean. Operators: ==, !=, <, >, <=, >=, contains, startsWith, endsWith, in, not in, &&, ||, !.

ResourceFields
Nodes
id, name (hostname), state (ready/down/unknown), role (manager/worker), availability (active/pause/drain)
Services
id, name, image, mode (replicated/global), stack
Tasks
id, state, desired_state, image, exit_code (int), error, service (ID), node (ID), slot (int)
Stacks
name, services, configs, secrets, networks, volumes (all counts)
Configs
id, name
Secrets
id, name
Networks
id, name, driver, scope (swarm/local)
Volumes
name, driver, scope

Task state takes the Docker task states: new, allocated, pending, assigned, accepted, preparing, ready, starting, running, complete, shutdown, failed, rejected, remove, orphaned. exit_code is a number, 0 until the task reaches a terminal state, so exit_code > 0 selects the tasks that exited with an error. A task that exited cleanly needs its state as well: state == "complete" && exit_code == 0.

An expression that names a field the resource doesn’t have, such as the typo staet == "failed", or compares values of different types, such as exit_code != "0", is refused with 400 FLT002. An expression that takes too long to evaluate over the whole list, builds a string longer than 1 MiB, or runs a costly pattern over a long string is refused with 400 FLT004.

GET /nodes?filter=role+%3D%3D+%22manager%22+%26%26+state+%3D%3D+%22ready%22 HTTP/1.1

GET /tasks?filter=state+%3D%3D+%22failed%22+%7C%7C+error+!%3D+%22%22 HTTP/1.1

GET /stacks?filter=services+>+5 HTTP/1.1
curl "http://localhost:9000/nodes?filter=role+%3D%3D+%22manager%22+%26%26+state+%3D%3D+%22ready%22"
curl "http://localhost:9000/tasks?filter=state+%3D%3D+%22failed%22+%7C%7C+error+!%3D+%22%22"
curl "http://localhost:9000/stacks?filter=services+>+5"

Response format

All JSON responses carry JSON-LD annotations (@context, @id, @type). Collection responses wrap items as { items, total, limit, offset } with RFC 8288 Link headers for pagination. Detail responses wrap the resource with its cross-references, such as the services using a config, or the service and node for a task.

Resource identifiers

Detail paths accept a resource’s ID or its name. A name is answered with 307 Temporary Redirect to the canonical, ID-addressed URL:

curl -i -H 'Accept: application/json' http://localhost:9000/services/shop_web
# < HTTP/1.1 307 Temporary Redirect
# < Location: /services/x3k9m2p8q1w7

307 preserves the method and the body, so writes may be addressed by name too: PUT /services/shop_web/scale reaches the scale endpoint with its payload intact. Most clients follow the redirect on request (curl -L).

Volumes and stacks are keyed by name already, so their paths never redirect. Tasks are addressable by ID only: a task’s <service>.<slot> name is derived from its parent rather than stored on it.

A name matching more than one resource is answered 409 Conflict with API015, whose detail lists every ID the request could have meant.

Errors

Errors follow RFC 9457 problem details, with Content-Type application/problem+json and Cache-Control: no-store.

{
  "@context": "/api/context.jsonld",
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "node abc123 not found",
  "instance": "/nodes/abc123",
  "requestId": "a1b2c3d4e5f6"
}

Error codes

Domain-specific errors carry a stable code as the last path segment of type:

{
  "@context": "/api/context.jsonld",
  "type": "/api/errors/SVC001",
  "title": "Service Version Conflict",
  "status": 409,
  "detail": "service was modified by another client",
  "instance": "/services/abc123/scale",
  "requestId": "a1b2c3d4e5f6"
}

Codes are a three-letter domain prefix plus a three-digit number. The error reference lists every code with its status, meaning and resolution, grouped by domain.

Generic HTTP errors use "type": "about:blank". GET /api/errors lists every code with its description and suggestion; GET /api/errors/{code} returns one.

Common errors

SituationStatusCodesWhat to do
Resource changed between your read and your write409SVC001, NOD002, CFG005, SEC005Re-read the resource and retry
Endpoint above the configured operations level403OPS001Raise the operations level
Grants deny read or write403ACL001, ACL002The response names the resource and permission checked
Cross-origin write from an origin that isn’t allowed403CSR001Add the origin to server.cors.origins
PATCH sent with the wrong Content-Type415API004Use application/json-patch+json or application/merge-patch+json
Docker daemon unreachable503ENG001Check the socket and the daemon
Name in the path identifies more than one resource409API015Address the resource by ID; the detail lists the candidates

Caching

JSON responses carry an ETag and Cache-Control: no-cache; GET /swarm/unlock-key sends no-store instead, so no cache keeps the key. Use If-None-Match for conditional requests:

curl -v http://localhost:9000/services
# < ETag: "3a7f..."

curl -H 'If-None-Match: "3a7f..."' http://localhost:9000/services
# < HTTP/1.1 304 Not Modified

Detail endpoints also set Last-Modified from the resource’s update timestamp and honor If-Modified-Since. When both conditional headers are present, If-None-Match wins.

/api and /api/context.jsonld return Cache-Control: public, max-age=3600; /api/scalar.js returns max-age=86400. SSE and streaming endpoints set no caching headers.

Response headers

Allow on GET and HEAD responses lists the methods available for that resource under the current operations level and your grants. Inspect it before attempting a write. It names the resource’s own methods only: DELETE on /services/{id} removes the service.

A resource’s sub-resources are linked instead. A detail response carries a Link with rel="edit" for each sub-resource you may write, so </services/{id}/scale>; rel="edit" means PUT /services/{id}/scale is open to you. A sub-resource the operations level or your grants close isn’t linked.

Allow: GET, HEAD, DELETE
Link: </services/abc/scale>; rel="edit", </services/abc/labels>; rel="edit"

Accept-Patch lists the patch formats a resource accepts, and appears where its own Allow carries PATCH. Labels and service env take application/json-patch+json, application/merge-patch+json; every other patchable sub-resource takes application/merge-patch+json alone.

A method a path doesn’t serve answers 405 Method Not Allowed, with an Allow naming the methods it does.

Write endpoints honor RFC 7240 Prefer: return=minimal, answering 204 No Content (or 201 Created for a create) with Preference-Applied: return=minimal instead of the updated resource.

Every response sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Content-Security-Policy. HSTS is added when TLS is enabled.

Waiting for a change to take effect

Docker accepts a service change the moment you ask for it, which says nothing about whether it worked—the image may still be pulling, or a placement constraint may be unsatisfiable. Send Prefer: wait=<seconds> to hold the response until the cluster has actually settled on the change:

curl -X PUT http://localhost:9000/services/abc123/scale \
  -H 'Content-Type: application/json' \
  -H 'Prefer: wait=30' \
  -d '{"replicas":5}'
RequestResponse
no Prefer
unchanged
wait=30, service settles in time
200 with the settled service, plus Preference-Applied: wait=30
wait=30, service doesn’t settle
202 with the last progress line, and no Preference-Applied
wait=600
clamped to the 300 second ceiling; Preference-Applied: wait=300 reports the wait applied
respond-async
202 straight away, plus Location and Preference-Applied: respond-async
respond-async, wait=10
waits up to 10 seconds, then 202 with Location
return=minimal, wait=30
waits, then 204; Preference-Applied names both preferences

At most 32 writes wait at once. Past that, a wait is answered as respond-async would be, with a Retry-After.

A 200 from an honored wait describes the service as it settled, read back after convergence—not the snapshot Docker returned when it accepted the write, whose UpdateStatus is still mid-rollout and whose Version a follow-up write would collide on.

A 202 carries Location pointing at the service itself, which is where the rollout can be followed: its UpdateStatus reports convergence, and the same URL opens a live stream with Accept: text/event-stream. The body is the service as Docker returned it, plus a progress field holding the last convergence line observed—waiting: 2/5 replicas running. return=minimal doesn’t apply on this path: there’s a progress line to deliver, so the 202 carries a body and doesn’t name return=minimal in Preference-Applied. Whether that preference is honored therefore depends on how quickly the cluster settles. A client that hangs up cancels its own wait.

The preferences apply to the five service endpoints that change what the cluster has to schedule: scale, image, endpoint-mode, rollback and restart. Node availability takes no wait—draining is a different rule, with a different notion of what “settled” means.

Real-time events

Send Accept: text/event-stream to any resource list or detail URL to open a stream scoped to that path.

Per-resource streams

A list stream carries events for one resource type. A detail stream carries events for one resource plus the resources that belong to it: a node stream includes that node’s tasks, a service stream includes that service’s tasks, and a stack stream includes its member services, tasks, configs, secrets, networks, and volumes.

GET /nodes HTTP/1.1
Accept: text/event-stream

GET /services/abc123 HTTP/1.1
Accept: text/event-stream

GET /stacks/myapp HTTP/1.1
Accept: text/event-stream
curl -H "Accept: text/event-stream" http://localhost:9000/nodes
curl -H "Accept: text/event-stream" http://localhost:9000/services/abc123
curl -H "Accept: text/event-stream" http://localhost:9000/stacks/myapp

The dashboard uses per-resource streams for live updates on every page.

Global event stream

/events carries every resource change in one stream:

GET /events HTTP/1.1
Accept: text/event-stream
curl -H "Accept: text/event-stream" http://localhost:9000/events

Event format

A single event uses the resource type as the event name:

id: 1
event: service
data: {"@id":"/services/abc","@type":"Service","type":"service","action":"update","id":"abc","resource":{...}}

The action field says what happened:

ActionMeaning
create
The resource appeared. Clients tracking a collection’s size should increment it.
update
An existing resource changed. The collection’s size is unaffected.
remove
The resource is gone.
ref_changed
A resource this one cross-references changed.
full_sync
Sent as a sync event when the stream couldn’t be replayed from the client’s cursor. Refetch.

Events arriving within the batch interval (server.sse.batch_interval, default 100 ms) are sent together as a batch event:

id: 2
event: batch
data: [{"@id":"/services/abc","@type":"Service","type":"service","action":"update","id":"abc","resource":{...}},...]

Filtering

?types= on /events narrows the stream:

GET /events?types=service,node HTTP/1.1
Accept: text/event-stream
curl -H "Accept: text/event-stream" "http://localhost:9000/events?types=service,node"

Valid types: node, service, task, config, secret, network, volume, stack. sync events always pass the filter.

Keepalive

The server writes an SSE comment line every 15 idle seconds so proxies and load balancers keep the connection open. EventSource clients ignore it.

Reconnection and replay

Each event carries an id:. Treat it as opaque: it names a position in the change history of one Cetacean process. EventSource clients send Last-Event-ID on reconnect and the server replays what they missed from the change history. Detail and stack streams can’t be replayed, and neither can a cursor older than the history buffer or one written before Cetacean restarted; all three send a sync event telling the client to refetch.

Metrics streams

GET /metrics with Accept: text/event-stream pushes periodic updates instead of proxying one query to Prometheus.

GET /metrics?query=up&step=15&range=3600 HTTP/1.1
Accept: text/event-stream
curl -H "Accept: text/event-stream" "http://localhost:9000/metrics?query=up&step=15&range=3600"
EventDescription
initial
Full range query result on connect, same shape as Prometheus query_range.
point
Single instant query result appended at each tick.

Append point events to the data you already hold to build a rolling window.

Under an ACL policy, the stream and the Prometheus proxy (GET /metrics, GET /metrics/labels, GET /metrics/labels/{name}) answer 403 ACL001 unless the caller holds read on the bare resource *.

Stream contract

GET /api/asyncapi describes all twenty streams as an AsyncAPI 3.0 document: their channels, messages, and cursor semantics. Add .json or .yaml, or negotiate on Accept. Browse it in AsyncAPI Studio.

Two details the preceding examples don’t show: a batch payload is an array of envelopes, and a replayed event carries no resource.

Connection limits

There’s no general rate limiting. Concurrent streams and log reads are capped, and a request over the cap returns 429 Too Many Requests with a Retry-After of 5 to 14 seconds. The value steps between refusals, so clients turned away together aren’t told to come back together; none is told less than 5.

StreamLimitCode
SSE event clients (/events and per-resource streams)256SSE001
SSE event clients from one caller64SSE001
Log streams (/services/{id}/logs, /tasks/{id}/logs)128LOG001
Log streams from one caller32LOG001
Log reads (the same paths as JSON)32LOG009
Log reads from one caller8LOG009
Metrics streams (/metrics as SSE)64MTR005

A caller is the signed-in identity, or the client address under the none auth mode. Behind a reverse proxy, set server.trusted_proxies so that address is the client’s rather than the proxy’s.

Endpoints

GET /api serves the full OpenAPI spec with request and response schemas, and the API explorer renders it interactively. The following tables are the shape of the surface.

Reads

AreaEndpoints
Entry point
/, /index
Nodes
/nodes, /nodes/{id}, /nodes/{id}/tasks, /nodes/{id}/labels, /nodes/{id}/role, /nodes/{id}/drain-impact
Services
/services, /services/{id}, /services/{id}/tasks, /services/{id}/logs
Service spec sections
/services/{id}/ + env, labels, resources, healthcheck, placement, ports, update-policy, rollback-policy, log-driver, configs, secrets, networks, mounts, container-config, mode, endpoint-mode
Tasks
/tasks, /tasks/{id}, /tasks/{id}/logs
Stacks
/stacks, /stacks/summary, /stacks/{name}
Configs and secrets
/configs, /configs/{id}, /configs/{id}/labels, /secrets, /secrets/{id}, /secrets/{id}/labels
Networks and volumes
/networks, /networks/{id}, /volumes, /volumes/{name}
Plugins
/plugins, /plugins/{name}, /swarm/plugins
Cluster
/cluster, /cluster/metrics, /cluster/capacity, /swarm, /disk-usage
Cross-resource
/search?q=, /history, /events, /recommendations, /topology, /profile
Metrics
/metrics, /metrics/status, /metrics/labels, /metrics/labels/{name}
Documentation
/api, /api/openapi.yaml, /api/asyncapi, /api/asyncapi.yaml, /api/context.jsonld, /api/errors, /api/errors/{code}
Discovery
/.well-known/api-catalog, /opensearch.xml
Identity
/auth/whoami, plus /auth/login and /auth/callback in OIDC mode
Meta
/-/health, /-/ready, /-/metrics, /-/licenses, /-/licenses/texts/{id}, /-/notices, /-/sbom.cdx, /-/docker-latest-version

GET /search takes q (required, max 200 characters) and limit (per type, default 3; 0 or a value above 1000 returns up to 1000). POST /-/resync forces a full refetch from the Docker socket; unlike the other /-/ endpoints it requires authentication and a grant, because each call sweeps the whole Docker API. A call made while a re-sync is already running waits for that one and shares its result, and it isn’t gated on the operations level—it re-reads the cluster and never changes it. A failed re-sync answers with problem details: 504 (ENG005) when Docker didn’t answer in time, otherwise 502 (ENG006), naming what each list answered.

GET /nodes/{id}/drain-impact assesses a drain before you make it: each service with a task on the node is movable, with an edge to every node that could take it, stranded, with what blocked it in detail, or global. It’s the same assessment as the MCP get_topology drain-impact view, and the dashboard shows it before a drain.

GET /-/health carries status, version, commit, buildDate, and the configured operationsLevel, and a watcher object reporting whether Cetacean is still tracking the cluster: connected for the Docker event stream, and lastSyncAt / lastSyncAgeSeconds for the last successful read. /-/metrics carries the same as cetacean_watcher_connected, cetacean_cache_last_sync_timestamp_seconds and cetacean_cache_sync_failures_total.

Writes

Each row gives the minimum operations level the endpoint needs. Every write also passes the per-resource write grant check.

EndpointLevel
PUT /services/{id}/scale
1
PUT /services/{id}/image
1
POST /services/{id}/rollback
1
POST /services/{id}/restart
1
PATCH /services/{id}/env
2
PATCH /services/{id}/labels
2
PATCH /services/{id}/resources
2
PUT, PATCH /services/{id}/healthcheck
2
PUT /services/{id}/placement
2
PATCH /services/{id}/ports
2
PATCH /services/{id}/update-policy
2
PATCH /services/{id}/rollback-policy
2
PATCH /services/{id}/log-driver
2
PATCH /services/{id}/configs
2
PATCH /services/{id}/secrets
2
PATCH /services/{id}/networks
2
PATCH /services/{id}/mounts
2
PATCH /services/{id}/container-config
2
PUT /services/{id}/endpoint-mode
3
DELETE /services/{id}
3
PATCH /nodes/{id}/labels
2
PUT /nodes/{id}/availability
3
PUT /nodes/{id}/role
3
DELETE /nodes/{id}
3
DELETE /tasks/{id}
3
DELETE /stacks/{name}
3
POST /configs
2
PATCH /configs/{id}/labels
2
DELETE /configs/{id}
3
POST /secrets
2
PATCH /secrets/{id}/labels
2
DELETE /secrets/{id}
3
DELETE /networks/{id}
3
DELETE /volumes/{name}
3
PATCH /swarm/orchestration
2
PATCH /swarm/raft
2
PATCH /swarm/dispatcher
2
PATCH /swarm/ca
3
PATCH /swarm/encryption
3
POST /swarm/rotate-token
3
POST /swarm/rotate-unlock-key
3
POST /swarm/force-rotate-ca
3
GET /swarm/unlock-key
3
POST /swarm/unlock
3
DELETE /tokens/{subject}
3
POST /plugins/{name}/enable
2
POST /plugins/{name}/disable
2
PATCH /plugins/{name}/settings
2
POST /plugins
3
POST /plugins/privileges
3
POST /plugins/{name}/upgrade
3
DELETE /plugins/{name}
3

POST /plugins and POST /plugins/{name}/upgrade grant only the privileges their body names, in the privileges array POST /plugins/privileges returned. A request without one is refused with 400 (PLG005), and one whose plugin now asks for a different set—a tag republished after the review—with 409 (PLG006).

One read is held to a tier as well: GET /swarm includes the join tokens only at level 3, and only to a caller with write on swarm:cluster. Below that, JoinTokens comes back empty.

Two level-2 endpoints refuse part of a request below level 3: PATCH /services/{id}/mounts a mount the service doesn’t already have that is a bind, a named pipe, a volume with driver options, or a named volume the manager doesn’t hold as a plain local volume; and PATCH /services/{id}/container-config a capability added to capabilityAdd.

Preconditions

Every write endpoint whose exact path also serves a GET accepts an optional If-Match request header (RFC 9110 §13.1.1). Supply the ETag a GET on that same path returned; if the resource has changed since, the write is refused with 412 Precondition Failed (error code API013) instead of being applied. The header is always optional—omit it and the write proceeds exactly as it did before this existed.

A 412 always means the resource moved. A resource that is gone answers 404, the same as it would without the header—RFC 9110 §13.2.1 keeps the precondition out of the way of an answer that was never going to be a success. Where the current representation can’t be read at all—DELETE /plugins/{name} inspects the daemon rather than the cache—the write answers 503 (ENG001) or 500 (ENG004) instead, so an unreachable daemon isn’t reported as a stale ETag.

On an update to a service, node, config or secret, the check and the write are one step: the write names the version the check validated, and Docker refuses it, again with 412, if anything changed in between. Docker’s removals take no version, so a DELETE is checked just before it runs, and a change in that instant goes unnoticed.

29 endpoints support it: PATCH /services/{id}/env, PATCH /services/{id}/labels, PATCH /services/{id}/resources, PUT/PATCH /services/{id}/healthcheck, PUT /services/{id}/placement, PATCH /services/{id}/ports, PATCH /services/{id}/update-policy, PATCH /services/{id}/rollback-policy, PATCH /services/{id}/log-driver, PATCH /services/{id}/configs, PATCH /services/{id}/secrets, PATCH /services/{id}/networks, PATCH /services/{id}/mounts, PATCH /services/{id}/container-config, PUT /services/{id}/endpoint-mode, DELETE /services/{id}, PATCH /nodes/{id}/labels, PUT /nodes/{id}/role, DELETE /nodes/{id}, PATCH /configs/{id}/labels, DELETE /configs/{id}, PATCH /secrets/{id}/labels, DELETE /secrets/{id}, DELETE /networks/{id}, DELETE /volumes/{name}, DELETE /tasks/{id}, DELETE /stacks/{name}, DELETE /plugins/{name}.

19 refuse every If-Match, * included, with 412: they are action-style endpoints with no GET at that exact path, so there is no ETag the header could match. They are PUT /services/{id}/scale, PUT /services/{id}/image, POST /services/{id}/restart, POST /services/{id}/rollback, PUT /nodes/{id}/availability, POST /plugins/{name}/enable, POST /plugins/{name}/disable, POST /plugins/{name}/upgrade, PATCH /plugins/{name}/settings, POST /plugins/privileges, PATCH /swarm/ca, PATCH /swarm/dispatcher, PATCH /swarm/encryption, PATCH /swarm/orchestration, PATCH /swarm/raft, POST /swarm/rotate-token, POST /swarm/rotate-unlock-key, POST /swarm/force-rotate-ca, and POST /swarm/unlock. A missing service, node or plugin still answers 404. Send these without the header.

Four ignore it. POST /auth/logout ends a session rather than changing the cluster. POST /configs, POST /secrets and POST /plugins are excluded deliberately: their nearest GET is the collection listing, and its ETag turns over on any member change, which would make “create only if the collection is unchanged” a precondition almost nothing could ever satisfy.

MCP server

Cetacean can also serve its cluster view over the Model Context Protocol. Set mcp.enabled to true and the server mounts at /mcp. See MCP server for transport, authorization, and prompts, and MCP tools and resources for the catalog.

Self-discovery

Every response outside the /-/ meta endpoints carries RFC 8631 Link headers:

Link: </api>; rel="service-desc"; type="application/json", </api/asyncapi>; rel="service-desc"; type="application/vnd.aai.asyncapi+json;version=3.0.0", </api/context.jsonld>; rel="describedby", </.well-known/api-catalog>; rel="api-catalog"

The two service-desc links are told apart by type: /api describes the request/response API, /api/asyncapi the event streams. describedby points at the JSON-LD context document, and api-catalog at the API catalog.

GET /opensearch.xml is an OpenSearch 1.1 description document, advertised by the dashboard with a <link rel="search">. Browsers that support the convention can be taught a keyword, so the address bar searches the cluster directly; Firefox still offers this, Chromium has narrowed it. The document also carries Atom and JSON templates for the same search, and requires authentication like any other endpoint.

Request ID

Every response carries a Request-Id header. Send your own in the Request-Id request header (max 64 printable ASCII characters) or the server generates one. The value appears in error responses as requestId and in the server logs.

Entry point

GET / is the web API’s entry point. A browser gets the dashboard; a JSON client gets a JSON-LD document naming every top-level collection, so knowing the origin is enough to find everything else:

{
  "@context": "/api/context.jsonld",
  "@id": "/",
  "@type": "EntryPoint",
  "name": "Cetacean",
  "resources": {
    "nodes": { "@id": "/nodes" },
    "services": { "@id": "/services" }
  },
  "version": "0.14.0"
}

/index redirects here permanently, keeping any .json or .html suffix—/index.json lands on /.json, since the suffix is the only thing naming the representation.

The API catalog is unauthenticated and points here; this document is not. Discovery is public, the API behind it isn’t.

API catalog

GET /.well-known/api-catalog lists the APIs this deployment publishes, as an RFC 9264 linkset served as application/linkset+json (RFC 9727). No authentication required.

Each item names an API; the contexts beside it carry that API’s service-desc, service-doc, describedby and status links. The MCP server appears only when mcp.enabled is set, and its authorization metadata only when auth.mode isn’t none.

URIs are absolute—set server.public_url behind a reverse proxy.