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
- File extension appended to the path, which wins over everything else
Acceptheader, parsed per RFC 9110 withqvalues and wildcardsapplication/jsonwhen the client sends*/*or noAcceptheader
Supported types
Accept value | Extension | Result |
|---|---|---|
application/json | .json | JSON |
application/vnd.cetacean.v1+json | JSON, versioned alias of application/json | |
application/ld+json | JSON—every JSON response is a JSON-LD document | |
text/html, application/xhtml+xml | .html | The dashboard |
text/event-stream | SSE, on endpoints that support it | |
application/atom+xml | .atom | Atom feed |
application/feed+json | .feed | JSON Feed 1.1 |
text/csv | .csv | CSV, on list endpoints |
application/vnd.jgf+json | .jgf | JSON Graph Format, /topology only |
application/graphml+xml | .graphml | GraphML, /topology only |
text/vnd.graphviz | .dot | Graphviz DOT, /topology only |
application/yaml | .yaml, .yml | Compose 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.
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.
/servicesname,stack,state,image,desired,running,id/nodesname,state,role,id/tasksname,state,node,id/stacksname,services,id/configs,/secretsname,stack,id/networks,/volumesname,stack,driver,id/historytimestamp,type,action,name,id,summary/recommendationsseverity,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
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 50 | Items per page, capped at 200 |
offset | int | 0 | Starting position |
sort | string | none | Sort field, varies by resource |
dir | string | asc | Sort direction: asc or desc |
search | string | none | Case-insensitive substring match on name |
filter | string | none | expr-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
- 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, &&, ||, !.
- 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
| Situation | Status | Codes | What to do |
|---|---|---|---|
| Resource changed between your read and your write | 409 | SVC001, NOD002, CFG005, SEC005 | Re-read the resource and retry |
| Endpoint above the configured operations level | 403 | OPS001 | Raise the operations level |
| Grants deny read or write | 403 | ACL001, ACL002 | The response names the resource and permission checked |
| Cross-origin write from an origin that isn’t allowed | 403 | CSR001 | Add the origin to server.cors.origins |
PATCH sent with the wrong Content-Type | 415 | API004 | Use application/json-patch+json or application/merge-patch+json |
| Docker daemon unreachable | 503 | ENG001 | Check the socket and the daemon |
| Name in the path identifies more than one resource | 409 | API015 | Address 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}'
- no
Prefer - unchanged
wait=30, service settles in time200with the settled service, plusPreference-Applied: wait=30wait=30, service doesn’t settle202with the last progress line, and noPreference-Appliedwait=600- clamped to the 300 second ceiling;
Preference-Applied: wait=300reports the wait applied respond-async202straight away, plusLocationandPreference-Applied: respond-asyncrespond-async, wait=10- waits up to 10 seconds, then
202withLocation return=minimal, wait=30- waits, then
204;Preference-Appliednames 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:
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
syncevent 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"
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.
| Stream | Limit | Code |
|---|---|---|
SSE event clients (/events and per-resource streams) | 256 | SSE001 |
| SSE event clients from one caller | 64 | SSE001 |
Log streams (/services/{id}/logs, /tasks/{id}/logs) | 128 | LOG001 |
| Log streams from one caller | 32 | LOG001 |
| Log reads (the same paths as JSON) | 32 | LOG009 |
| Log reads from one caller | 8 | LOG009 |
Metrics streams (/metrics as SSE) | 64 | MTR005 |
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
- 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/loginand/auth/callbackin 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.
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.
Browser search
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.