Wingsmith
Overview

API Conventions

Control plane API conventions: envelopes, errors, pagination, identifiers, idempotency, filters.

These conventions apply to the control plane API on every route. The gateway uses OpenAI formats instead; see Calling the gateway.

Response shapes

Every list response is an envelope, never a bare array:

{
  "items": [],
  "count": 0,
  "next_cursor": null
}

count is the number of items in this page. A bare array cannot grow a cursor or a total without breaking every client, and lists that never paginate are a temporary condition.

Every detail response is the object itself, unwrapped. A singular envelope buys nothing.

Error format

Errors are uniform:

{
  "code": "not_found",
  "message": "human readable, never parsed",
  "detail": {}
}

code is the contract; message is for people. HTTP status carries the same information for anything that only speaks status codes, but clients should branch on code.

Error codes

  • 400: a malformed request, such as a body that does not decode or an unparseable query parameter. An unknown query parameter is 400 with code: "unknown_parameter". detail carries unknown (the offending names) and accepted (everything that route declares)
  • 401 not_authorized: missing, unrecognized, expired or tampered credential
  • 403 forbidden: credential of the wrong kind for the route, or a browser write from a disallowed origin
  • 404 not_found: object or route does not exist
  • 409 conflict: clash with current state, such as a stale If-Match revision or a duplicate name
  • 415 unsupported_media_type: body is not application/json
  • 422 invalid: a well-formed request the plane refuses, such as a validation failure
  • 429 rate_limited: too many failed sign-ins; see Authentication
  • 500 internal: server error

Unknown parameters are rejected

Unknown query parameters are rejected with 400 and code: "unknown_parameter". The default web-framework behaviour (ignore what you do not recognise) is the wrong one for a filtered collection: a misspelt, retired, or invented filter answers 200 OK with the whole set, and the response is indistinguishable from a correctly filtered one. A silently ignored filter is a lie the caller cannot detect.

The accepted set is derived from the matched handler’s own signature at request time, under each parameter’s wire name. /health, /metrics and the OpenAPI routes are exempt.

Pagination

List routes accept limit and cursor params. limit caps the page size: the default is 50, the maximum is 200 (larger values are clamped), and a value below 1 is 422 invalid. Follow next_cursor for more:

GET /v1/clusters?limit=20
GET /v1/clusters?limit=20&cursor=eyJ...

When next_cursor is null, you’ve reached the end.

Identifiers

Prefixed, opaque, stable: clu_, nod_, dev_, tpd_, pol_, art_, rec_, dep_, rep_, end_, prj_, qta_, opr_, cmp_, ten_, prc_, plc_, alc_, wkl_, evt_.

The body is a Crockford base32 ULID, lowercase. Crockford is case-insensitive when decoding, but these ids are opaque strings that get compared, indexed and put in URLs, so exactly one spelling is canonical.

IDs and names

A path segment accepts either the id or the name where an object has a unique name:

GET /v1/clusters/iad-1
GET /v1/clusters/clu_01hq0v8p2m

Both are the same request. Prefix matching is not supported by the API: hgr resolves prefixes client-side.

Timestamps

Timestamps are RFC 3339, UTC, millisecond precision. Nullable where the event may not have happened; last_sync_at on a never-synced cluster is null, not the epoch.

Declared and observed

An object’s declared and observed halves are returned together but never merged. The rule is visible in the field names:

{
  "id": "clu_01hq0v8p2m",
  "name": "iad-1",
  "phase": "active",
  "reachable": true,
  "sync": {
    "state": "connected",
    "last_sync_at": "2026-10-27T12:34:56.789Z",
    "lag_ms": 340
  }
}

phase is declared: what we asked for, one of pending | active | draining | disabled. reachable and sync are observed: what is true right now.

The split is not cosmetic. Folding reachability into phase as an unreachable value makes “the network is down” indistinguishable from “someone disabled this cluster.” Those need different responses from a human and different behaviour from the placement layer, so they cannot share a field.

Idempotency

POST /v1/operations accepts an Idempotency-Key header. A second request with the same key and the same body returns the original operation without creating another. The same key with a different request is 409 conflict.

POST /v1/operations
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

No other route reads this header.

Conditional updates

PATCH requests on declared objects (clusters, pools, artifacts, workloads, endpoints, deployments, tenants, projects, quotas and similar) accept If-Match against the object’s revision, as does DELETE /v1/clusters/{ref}. The revision is the object’s last write time in microseconds, sent as the bare value:

PATCH /v1/clusters/clu_01hq0v8p2m
If-Match: 1761568496789123

A mismatch is 409 conflict with the current revision in detail.revision, so the caller can read fresh state and retry.

Partial updates

PATCH is a partial update. A field absent from the body is untouched. null explicitly clears a nullable field.

Common query params

Most collections support:

  • limit: page size
  • cursor: pagination cursor
  • search: substring match on name or id
  • label: repeatable filter, k=v format. Example: ?label=env=prod&label=region=us

Object-specific filters vary by collection. Examples: ?cluster=iad-1, ?phase=running, ?project=prj_xyz.