Skip to content

Serve mode

swarmkit serve starts a persistent HTTP server for production workloads.

Quick start

swarmkit serve ./workspace --host 0.0.0.0 --port 8000

Endpoints

The complete list — every route, generated from the server's OpenAPI document — is the HTTP API reference. Below are the ones a caller integrates with and what they take.

Jobs

Method Path Description
POST /run/{topology} Submit a topology run (async); returns a job id. Body: input, optional attachments, correlation_id, labels, supersedes
GET /jobs/{id} Poll job status (in-memory, then the durable store)
GET /jobs/{id}/stream The job's events as server-sent events
GET /jobs/{id}/diff The unified diff a harness run produced, per agent
POST /jobs/{id}/resume Continue a run parked on a human gate (deferred)
POST /jobs/{id}/stop Ask a running job to stop at its next agent boundary
GET /jobs/history Every recorded run, newest first (survives restart)

Attachments

A caller that already holds a file — a snapshot, an uploaded photo — passes it beside the input instead of making an agent go and fetch it:

POST /run/describe-scene
{
  "input": "What is at the gate?",
  "attachments": [
    { "path": "snapshots/gate-1732.jpg" }
  ]
}
Field
path workspace-relative exactly one of path / data
data base64 for a caller holding bytes rather than a file
name optional display/filename; derived from path when absent
handling preprocess (default) or native intent for non-image types; inert today

There is no type field. The media type is read from the file's content — sending one is a 422, because a caller's claim about bytes that are about to be forwarded to a model is not evidence. url and stream sources are refused for the same reason and a related one: the runtime does not fetch caller-supplied addresses, and an attachment is re-read on every turn of a tool loop, so it has to be re-readable.

Attachments reach the entry agent's first message and no downstream node. An agent that wants a file it was not handed asks for one through a skill.

A bad path is a 422 on this request, not a job that fails a moment later — so a job id means the file was readable. Only images are carried today (image/png, image/jpeg, image/gif, image/webp); anything else is refused by name. Per-attachment ceiling is 20 MB (SWARMKIT_ATTACHMENT_MAX_BYTES).

Every attachment is written to the audit log as a run.attachments event carrying name, media type, size, SHA-256 and source path — never the bytes. The digest is what makes the reference checkable later; storing content would put arbitrary material into a log meant to stay readable.

The CLI equivalent is swarmkit run <ws> <topology> --attach <path> (repeatable).

The portal's Jobs view shows live queue health from GET /queue/stats — backlog depth, oldest-unclaimed age, queue-wait and execution p95, and depth by topology (queue-observability.md):

The portal Jobs view with the queue-health strip

Conversations

Method Path Description
POST /conversations Create a new conversation
GET /conversations List conversations
GET /conversations/{id} Load full conversation history
POST /conversations/{id}/messages Send message (SSE streaming)

CRUD (topologies, skills, archetypes)

Method Path Description
GET /api/topologies List topologies
GET /api/topologies/{id} Get topology details
GET /api/topologies/{id}/yaml Get raw YAML
PUT /api/topologies/{id} Replace the YAML (validated before it is written)
POST /api/topologies Create new topology
DELETE /api/topologies/{id} Delete topology

Same pattern for /api/skills and /api/archetypes.

Usage tracking

Method Path Description
GET /usage Global token usage summary
GET /usage/{job_id} Per-job usage breakdown

MCP endpoint

Method Path Description
POST /mcp/ Streamable HTTP MCP endpoint — every topology as a run_<name> tool, behind the same auth (Level 11)

Each topology becomes an MCP tool. External agents can call your swarm topologies via standard MCP protocol.

A2A endpoint

Off by default; server.a2a.enabled: true turns it on (no restart — a reload is enough).

Method Path Description
GET /.well-known/agent-card.json The Agent Card: one A2A skill per topology; public (no auth)
GET /a2a/{topology}/card The same card narrowed to one topology
POST /a2a JSON-RPC 2.0: message/send, message/stream, tasks/get, tasks/list, tasks/cancel, tasks/subscribe; the message's metadata.skill names the topology
POST /a2a/{topology} The same, bound to one topology (the card's per-skill url)

A2A is a transport onto jobs, not a second execution path: message/send is POST /run/{topology} (contextId → correlation_id, file parts → attachments), tasks/get is GET /jobs/{id}, tasks/cancel is POST /jobs/{id}/stop, and the streaming methods re-emit GET /jobs/{id}/stream as A2A status/artifact events. The task id is the job id; the run carries source: a2a and appears in the portal's jobs page like any other. Task states map from job status: pending→submitted, running→working, deferred→input-required, completed, failed, stopped→canceled.

A run parked on a human gate reports input-required with the gate's URL in the status message, but the A2A caller cannot supply that input: approval scopes are un-grantable to agents, so a follow-up message/send on the task is refused with UnsupportedOperationError and the gate URL. A person resolves it through the review queue; a subscribed client sees the task go working again. Push notifications, gRPC and card signing are not implemented (-32003 / -32004). Streaming methods need Accept: text/event-stream. Full mapping: design/details/a2a-interop.md.

When both ends are SwarmKit (a2a-federation.md), a call returns the callee's run id, token/cost usage and an observability pointer, and the caller forwards its remaining budget. The portal's Audit view turns the returned run id into a link — an a2a.remote_usage event opens the callee instance's own job detail, so a federated call no longer dead-ends at "called a remote agent":

The audit log with an a2a.remote_usage deep-link to the callee instance's run

Webhooks

Method Path Description
POST /hooks/{topology_name} Fire a webhook trigger: an HMAC-signed request starts the named topology

Webhook signatures are validated with HMAC-SHA256 when secret is configured on the trigger.

Canary

Method Path Description
GET /canary List canary routes and metrics
POST /canary/{topology}/promote Promote canary version
POST /canary/{topology}/rollback Rollback canary version

Authentication

Configure in workspace.yaml:

server:
  auth:
    provider: jwt
    jwks_url: https://your-idp/.well-known/jwks.json
    audience: swarmkit
    issuer: https://your-idp/

Available providers:

Provider Description
none No authentication (default)
api_key Bearer keys declared in workspace.yaml — server.auth.config.keys[], each a key_ref: env:<VAR> (a reference, never the literal), a client_id and a tier (read / run / admin) or explicit scopes. See the serve auth guide
jwt JWT with JWKS auto-discovery

Access audit

Every authenticated mutating call (serve:run / serve:admin tier, including 403s) is recorded in the serve_access table: client id, auth provider, method, path, required action, status and time. This is the transport access log. Run audit events (what agents did, gates and decisions) are separate and use their own write-through journal.

--access-audit (env SWARMKIT_ACCESS_AUDIT) chooses how the rows are written:

Mode How On kill -9 Cost
batched (default) buffered in memory, written in one transaction every 250 ms off the request path; flushed on clean shutdown; a failed write is retried at the next flush loses at most the last ~250 ms of rows, which are not recovered on restart none on the request path
sync written before the response, one transaction per mutating request nothing acknowledged is lost one database write on the event loop per mutating request

Choose sync when a requirement says every API call must be durably recorded before it is acknowledged. Each row's time is when the call completed in either mode. With several serve processes in batched mode, row ids can be up to one batch window out of time order. See design/details/api-tier-submit-path.md and load-and-scale.md Run 8.

$ swarmkit serve ./ws --access-audit sync
...
access audit: sync — each row written before its response.

Production profile (fail-closed preflight)

swarmkit serve --profile production refuses to start unless the deployment is safe to expose, listing every gap at once instead of running permissively. It is a deployment-time assertion — it does not change request handling (governance is already deny-by-default), it checks that you configured the safe thing. --profile standard (the default) is the unchanged laptop behaviour.

The preflight requires all of:

Requirement Why
Auth is not none/anonymous production does not serve anonymous, on any bind
--insecure is not set the escape hatch is incompatible with the profile
SWARMKIT_OAUTH_KEY is set else the token-encryption key regenerates on restart and silently invalidates stored tokens
Every mcp_servers entry is sandboxed: true a tool process must not run unisolated on the host (design §8.8)
No wildcard --cors-origin * exact origins only
$ swarmkit serve ./ws --profile production
--profile production refuses to start: the deployment is not fail-closed.
  1. auth provider is 'none' — configure server.auth (api_key or jwt); production does not serve anonymous, on any bind
  2. SWARMKIT_OAUTH_KEY is not set — the OAuth token-encryption key would regenerate on restart and silently invalidate every stored token; set a persistent key
  3. MCP server 'files' is not sandboxed — set `sandboxed: true` so the tool process runs isolated (design §8.8)
Fix these, or run without --profile production for the permissive default.

The existing default-secure refusal (an unauthenticated non-loopback bind) still runs regardless of profile; production adds to it. create_app(profile="production") carries the preflight, so an embedder inherits it too.

Server configuration

server:
  host: "0.0.0.0"
  port: 8000
  jobs:
    max_concurrent: 5
    timeout_seconds: 300
  mcp:
    enabled: true
  a2a:
    enabled: false          # publish the Agent Card + serve /a2a
    identity:               # optional; what the card says about this instance
      name: Review desk
      description: Code review and triage swarms, human-gated.
      url: https://swarm.example.com   # set behind a proxy; default is the request's host
      organization: Example Org

/capabilities reports features.a2a so a fleet can see which instances publish a card.

Triggers

Cron and webhook triggers are defined in triggers/*.yaml:

apiVersion: swarmkit/v1
kind: Trigger
metadata:
  id: nightly-review
  name: Nightly Code Review
type: cron
schedule: "0 2 * * *"
topology: code-review
input: "Review all PRs opened today"
apiVersion: swarmkit/v1
kind: Trigger
metadata:
  id: pr-webhook
  name: PR Webhook
type: webhook
topology: code-review
secret: ${WEBHOOK_SECRET}

Docker

docker run -v ./workspace:/workspace \
  -e OPENROUTER_API_KEY=$OPENROUTER_API_KEY \
  -p 8000:8000 \
  ghcr.io/delivstat/swarmkit:latest