Operator API
Run the standalone operator:
angee operator --root . --bind 127.0.0.1 --port 9000Non-loopback binds require --token. Protected endpoints use:
Authorization: Bearer <token>Surface parity between service.Platform, CLI, REST, and GraphQL is tracked in Surface parity.
REST
Health:
GET /healthzStack:
GET /stack/status
POST /stack/init
POST /stack/update
POST /stack/prepare
POST /stack/build
POST /stack/up
POST /stack/dev
POST /stack/down
POST /stack/destroy?purge=true
GET /stack/logs?service=nameServices:
GET /services
POST /services field-based init (image / command / env)
POST /services/create template-based create (Copier template, kind=service)
PATCH /services/{name}
POST /services/{name}/up idempotent create-and-start
POST /services/{name}/start
POST /services/{name}/stop
POST /services/{name}/restart
POST /services/{name}/destroy
GET /services/{name}/logs
GET /services/{name}/logs/stream live per-service log WebSocket
GET /services/{name}/endpoint service endpoint + log-stream descriptorPer-service live log socket
GET /services/{name}/logs/stream is a WebSocket that streams a single service's logs live, one JSON LogLine (service, runtime, message, optional level) per frame, as the runtime produces them. Because the socket is scoped to one service the attribution is exact and no log prefix is parsed.
Pass ?tail=<n> (alias ?n=<n>) to replay the last n lines that are still available before the live follow begins (maps to the backends' --tail); it is clamped to [0, 10000], and 0 / omitted means no backlog cap.
Like the GET /graphql upgrade it carries no Authorization header, so auth runs in the handshake: present a per-service route token (aud=svc:<name>, see Connection and route tokens) or the admin bearer / a minted aud=operator token, via ?token=, the Sec-WebSocket-Protocol header, or Authorization. The upgrader enforces the same Origin allowlist as the GraphQL WebSocket. The stream is opened before the upgrade, so an unknown service or an unconfigured production backend surfaces as a plain HTTP error rather than a socket close; closing the socket (or any read error) tears down the upstream follow.
The operator routes each service's socket to a backend it owns — in development an ephemeral live proxy (no persistence); in production a configured durable backend (stubbed today). GET /services/{name}/endpoint advertises the resolved socket in a log_stream descriptor so a consumer can connect without guessing the URL or minting its own token:
{
"routed": false,
"internal_host": "web",
"log_stream": {
"url": "ws://127.0.0.1:9000/services/web/logs/stream",
"target": "operator",
"protocol": "ws",
"token": "<short-lived route token>",
"expires_at": "2026-06-17T12:00:00Z"
}
}target is informational (operator | edge | production); connecting is identical regardless. In open dev (no configured operator token) the descriptor omits the credential.
POST /services/create body:
{
"template": "agents/claude-code",
"workspace": "my-pa",
"inputs": {"auth_mode": "api_key"},
"name": "agent-my-pa",
"start": false
}The template must declare _angee.kind: service and render a service.yaml containing exactly one service entry. The operator appends that entry to the outer stack's services: map, installs any other rendered files (typically docker/) at <root>/services/<service_name>/, and allocates ports from declared pools under owner service/<name>/<pool>.
Jobs:
GET /jobs
POST /jobs/{name}/runJob output is returned by POST /jobs/{name}/run.
Sources:
GET /sources
GET /sources/{name}/status
POST /sources/{name}/fetch
POST /sources/{name}/pull
POST /sources/{name}/pushPOST /sources/{name}/pull is the top-level "update from upstream" operation: fetch + fast-forward the cached source's tracking ref. The per-workspace-slot equivalent is POST /workspaces/{name}/sync-base (across all slots) and the GraphQL workspaceSourcePull mutation (one slot).
Workspaces:
GET /workspaces
POST /workspaces
GET /workspaces/{name}
PATCH /workspaces/{name}
GET /workspaces/{name}/status
GET /workspaces/{name}/logs
POST /workspaces/{name}/destroy?purge=true
GET /workspaces/{name}/git
POST /workspaces/{name}/push
POST /workspaces/{name}/sync-baseWorkspaces are a pure file primitive — POST /workspaces renders Copier output and materializes sources, but the operator never starts services on a workspace's behalf. If a workspace renders an inner stack and you want it running, drive it with POST /stack/up against the inner root (start a second operator with --root workspaces/<name>/.angee, or run angee stack up --root workspaces/<name>/.angee locally).
Workspace status is the authoritative branch-identity surface for managed git worktrees. Each status source includes the manifest branch, actual current_ref, and state; state: "branch-mismatch" means the worktree is not on its manifest branch, and the workspace top-level state is discrepancy. sync-base updates each workspace branch from its base ref without switching branches; body: {"method":"merge"} or {"method":"rebase"}.
Update scopes
The operator exposes three "update" operations, distinguished by scope:
| Scope | Endpoint | Behaviour |
|---|---|---|
| Whole source | POST /sources/{name}/pull | Fetch and fast-forward the cached top-level source's tracking ref. |
| One workspace slot | GraphQL workspaceSourcePull(workspace, slot) | Fast-forward a single slot's worktree from its tracking ref. The slot lives on the workspace branch, not the source's main branch. |
| All slots of a workspace | POST /workspaces/{name}/sync-base | Merge or rebase each slot's workspace branch against its declared base ref. Stays on the workspace branch — this is "stay current with main". |
GitOps topology (derived view across sources × workspace slots):
GET /gitops/topology[?with_commits=N]Returns the full topology snapshot. with_commits opts in to populating each git source's recent commit history (sources[].commits); omit or set to 0 to keep the response cheap.
Source diffs:
GET /sources/{name}/diff[?ref=...]ref empty → working tree vs HEAD (uncommitted changes); set → HEAD vs ref. Returns []DiffFile.
Per-workspace-source slot operations (slot lives at workspace.sources.<slot> in the manifest):
POST /workspaces/{name}/sources/{slot}/fetch
POST /workspaces/{name}/sources/{slot}/pull
POST /workspaces/{name}/sources/{slot}/push body: {"ref":"..."} (optional)
GET /workspaces/{name}/sources/{slot}/diff[?ref=...]
POST /workspaces/{name}/sources/{slot}/merge body: {"ref":"..."}
POST /workspaces/{name}/sources/{slot}/rebase body: {"ref":"..."}
POST /workspaces/{name}/sources/{slot}/merge-abort
POST /workspaces/{name}/sources/{slot}/rebase-abort
POST /workspaces/{name}/sources/{slot}/rebase-continue
POST /workspaces/{name}/sources/{slot}/publish body: {"remote":"...","branch":"..."}The convergence endpoints (merge, rebase, merge-abort, rebase-abort, rebase-continue, publish) return a GitOpResult with {ok, conflicted, conflictFiles, message}. On conflict the worktree is left in the conflicted state; conflictFiles lists the affected paths.
Workspace preflight:
POST /workspaces/preflight body: WorkspaceCreateRequestValidates the request against the resolved template's input declarations without rendering anything. Returns WorkspaceCreatePreflightResponse with ok, missingRequired, invalidInputs, and the effective input map.
Template introspection:
GET /templates
GET /templates/{ref...}GET /templates enumerates every template under <root>/.templates/<kind>/<name> and <root>/templates/<kind>/<name>. GET /templates/{ref...} resolves a specific ref (relative path, absolute path, or supported remote URL) and returns a single descriptor with the input schema.
Connection tokens:
POST /tokens/mint body: {"actor":"...","ttl":"30m"}Secrets (CRUD against the configured secrets backend):
GET /secrets list declared secrets (metadata only)
GET /secrets/{name} one secret's metadata
GET /secrets/{name}/value privileged read: returns the value
POST /secrets/{name} body: {"value":"..."}
DELETE /secrets/{name} remove the backend entryGET /secrets returns only the declared secrets (entries in stack.secrets). Set/delete/get accept any name matching ^[A-Za-z0-9._-]{1,256}$ — declared or not — so callers can provision values before adding the manifest declaration. The list will only show the secret once it's declared.
Every mutating call (POST, DELETE) is logged to operator stderr with the secret name and the request's remote address. OpenBao keeps its own audit log on top of that; env-file deployments rely on the operator log as the only paper trail.
Files (scoped read/write inside a stack source):
GET /files?source=<name>&path=<rel> read one file
PUT /files?source=<name>&path=<rel> body: {"content":"...","etag":"..."}source and path are query parameters (a file path holds slashes). GET /files returns { path, source, content, etag }. PUT /files writes the body's content and returns metadata only ({ path, source, etag }); an optional etag is a compare-and-set precondition — a stale value is a 409 Conflict. Paths are confined to the source root (traversal and symlink-escape are rejected) and content is UTF-8 text within a 1 MiB cap. Both routes sit behind the admin-bearer/operator-token gate; writes are logged to operator stderr with the source, path, and remote address.
Mints an HS256-signed JWT scoped to the supplied actor. TTL defaults to 1h and is capped at 24h. The signing key resolves via --jwt-secret / ANGEE_OPERATOR_JWT_SECRET / HKDF-from-admin-bearer / per-process random (loopback dev only). The endpoint itself is gated by the admin bearer.
MCP descriptor:
GET /mcp/mcp currently returns a static descriptor; it is not a JSON-RPC MCP server. Live event streaming is GraphQL subscriptions on /graphql (see below) — SSE has no REST equivalent today.
GraphQL
GraphQL is available at:
POST /graphql
Content-Type: application/jsonExample:
curl -s http://127.0.0.1:9000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ stackStatus { name root services { name runtime status } } }"}'The GraphQL schema exposes stack, service, job, source, workspace, log snapshot, and mutation fields corresponding to the REST operations. Workspace source types use the same branch-identity fields as REST (branch, currentRef, state), and workspaceSyncBase(name:, method:) mirrors the REST sync-base endpoint.
The schema source lives at internal/operator/schema.graphql; generated gqlgen runtime files live under internal/operator/gql/.
Files
Read and write files inside a stack source, mirroring the REST /files routes:
query { file(source: "app", path: "settings.yaml") { path source content etag } }
mutation { fileWrite(source: "app", path: "settings.yaml", content: "…", etag: "…") { path source etag } }file returns the content and current etag; fileWrite writes and returns metadata only. The optional etag argument is a compare-and-set precondition — a stale value fails with a 409-equivalent conflict error. Paths are confined to the source root and content is UTF-8 text within a 1 MiB cap. serviceEndpoint likewise exposes a logStream { url target protocol token expiresAt } descriptor mirroring the REST endpoint's log_stream field.
Subscriptions
The operator exposes a Subscription root over Server-Sent Events. The gqlgen SSE transport dispatches on POST /graphql with Accept: text/event-stream; the response is a text/event-stream body that emits one data: frame per change.
Available subscription operations:
| Operation | Argument | Payload |
|---|---|---|
onStackSnapshotChange | — | StackSnapshot aggregate (health, stackStatus, services, jobs, sources, workspaces, templates, secrets, gitOpsTopology), emitted when the aggregate hash changes. |
onGitOpsTopologyChange | — | GitOpsTopology snapshot, emitted when the polled topology hash changes. |
onWorkspaceStatusChange | name: String! | WorkspaceStatus snapshot for that workspace, emitted on change. |
onServiceLogs | name: String! | Service log lines, follow-tailed from the runtime backend. |
onWorkspaceLogs | name: String! | Workspace log lines, follow-tailed from the runtime backend. |
onStackSnapshotChange is the aggregate the web console reads as one — it fans out the same Query-root reads (stackStatus, services, jobs, sources, workspaces, templates, secrets, gitOpsTopology) on a single daemon-side poller, so one server poll replaces a per-browser-tab refetch loop. secrets carries SecretRef metadata only — secret values are never part of the snapshot and remain behind the separately-gated secretValue query.
Snapshot subscriptions (onStackSnapshotChange, onGitOpsTopologyChange, onWorkspaceStatusChange) poll their underlying query on a 2 s tick and publish only when the result hash changes. No initial snapshot is emitted on connect — issue a one-shot snapshot query (the aggregate fields, gitOpsTopology, or workspaceStatus) alongside the subscription if you need the current state at startup. Log subscriptions stream directly from the runtime backend's follow channel; cancelling the subscription tears down the underlying logs --follow process.
Slow subscribers have their per-subscription buffer dropped rather than slowing the producer — clients should treat snapshot subscriptions as "latest known" rather than guaranteed-delivery.
Example (curl, line-buffered):
curl -N http://127.0.0.1:9000/graphql \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{"query":"subscription { onGitOpsTopologyChange { summary { sources workspaces dirty diverged } } }"}'WebSocket transport
The same subscription root is also served over the standard graphql-transport-ws protocol as a WebSocket upgrade on GET /graphql, alongside SSE. Browser GraphQL clients (urql, Apollo, graphql-ws) ship this transport by default; point them at the same /graphql URL. SSE is unchanged and remains the right choice for curl and server-side consumers.
Authentication happens in the connection_init handshake, not via an Authorization header (a browser cannot set headers on a WS upgrade). Put the credential in the client's connectionParams, which the daemon reads as the Authorization payload key and runs through the same two-tier check as the HTTP API — the admin bearer, or a minted aud=operator token (see Connection tokens):
import { createClient } from 'graphql-ws'
const client = createClient({
url: 'ws://127.0.0.1:9000/graphql',
connectionParams: { Authorization: `Bearer ${operatorToken}` },
})An invalid or missing token closes the socket after a connection_error; a valid token receives connection_ack and then next frames. Because the upgrade is a GET (which CrossOriginProtection treats as safe), the upgrader enforces an Origin allowlist instead: loopback origins and requests with no Origin header are always allowed, and additional browser origins are permitted with the repeatable --allowed-origin flag. A disallowed Origin is rejected at the handshake with 403.
Workspace preflight
workspaceCreatePreflight(input: WorkspaceCreateInput!) validates the caller's inputs against the resolved template's input declarations without touching the filesystem. The response carries the effective inputs (defaults plus caller-provided), a missingRequired list, and an invalidInputs list of {field, reason} for type-mismatch errors. Use this from any client that builds workspace-create forms before committing the irreversible-but-recoverable materialisation.
Connection and route tokens
The operator mints two kinds of short-lived HS256 JWT, both returned as a ConnectionToken ({token, actor, expiresAt}) carrying sub=<actor>, iss=angee-operator, plus iat/exp. TTL defaults to 1 h and is capped at 24 h. They differ only in audience and scope:
| Mutation (REST) | Audience | Purpose |
|---|---|---|
mintConnectionToken(actor: String!, scope: [String!], ttl: String) — POST /tokens/mint | operator | An operator-API token the host backend mints (server-side, over the admin bearer) and hands to a browser instead of the admin bearer. Carries the approved capability scope. |
mintRouteToken(actor: String!, service: String!, ttl: String) — POST /tokens/route | svc:<service> | A route token authorizing one service's socket through the edge. Carries no scope. |
The operator accepts an aud=operator token on its API (and on the WebSocket transport) as an alternative to the admin bearer; a route token verifies only against its own svc:<service> audience and is rejected on the operator API. The signing key resolves in this precedence order:
--jwt-secretflag on the operator command line.ANGEE_OPERATOR_JWT_SECRETenv var.- HKDF-derived from the admin
--token(one-way; leaking JWT secret does not reveal the admin bearer). - Per-process random fallback when neither secret nor admin bearer is set (loopback dev only — tokens won't survive an operator restart).
Minting is gated by the admin bearer — the caller (the host backend) sends Authorization: Bearer <admin-token> on the mint request after its own authorization check, then returns the minted token to the browser. The admin bearer never leaves the server. Callers should treat the returned token as opaque.
Ingress
When a stack sets ingress.type: caddy (see the Edge Ingress guide for the full picture, or the manifest reference for the fields), routed services are reached through one Caddy edge instead of host-published ports. Two queries expose the routing, replacing host-side compose-port-scraping:
serviceEndpoint(name: String!): ServiceEndpoint— returns{routed, url, internalHost, internalPort}.routedisfalsewheningress.typeisnone; when routed,urlis the publicwss://…/address andinternalHost/internalPortare the in-network Docker DNS name and port.ingressStatus: IngressStatus— returns{type, domain, routes}whereroutesis[{service, url}]for every routed service.
The edge authenticates each inbound connection against a non-public forward_auth target on the operator:
GET /edge/verify?service=<name>— reads the token from?token=,Authorization: Bearer, orSec-WebSocket-Protocol, and verifies it carriesaud=svc:<name>(a route token frommintRouteToken/POST /tokens/route). Returns 200 on success and 401 otherwise — never101; the edge performs the actual WebSocket upgrade. It is not behind the admin-bearer gate (a route token is not an operator token) and is intended to be reachable only from the edge network.
Commit DAG
gitOpsTopology(withCommits: Int) accepts an opt-in window for commit-DAG population. When withCommits is omitted or 0, sources[].commits stays empty and the query path matches the cheap snapshot used by the topology subscription. Pass a positive integer to receive that many commits per git source, newest first by committer time, with each CommitRef carrying {sha, parents, refs, time, summary, author}.
Source and workspace-source diffs
sourceDiff(name, ref) and workspaceSourceDiff(workspace, slot, ref) return [DiffFile] where each DiffFile carries {oldPath, newPath, mode, isBinary, isNew, isDeleted, isRename, hunks: [DiffHunk]}. The hunks list mirrors unified-diff output: {oldStart, oldLines, newStart, newLines, header, body} with body carrying the raw +/-/ prefixed lines. When ref is empty the diff is "working tree vs HEAD" (uncommitted changes); when set, it is "HEAD vs ref". Only git sources are diffable — local sources surface a typed InvalidInputError.
Convergence operations
The operator exposes per-workspace-source convergence mutations beyond fetch/pull/push. Each returns a GitOpResult:
type GitOpResult {
ok: Boolean!
conflicted: Boolean!
conflictFiles: [String!]!
message: String!
}Operations:
| Mutation | Behaviour |
|---|---|
workspaceSourceMerge(workspace, slot, ref) | git merge --no-ff --no-edit ref. On conflict the worktree is left conflicted and conflictFiles lists the affected paths. |
workspaceSourceRebase(workspace, slot, ref) | git rebase ref. Conflict semantics match merge; resolve and call rebaseContinue, or call rebaseAbort. |
workspaceSourceMergeAbort(workspace, slot) | git merge --abort. |
workspaceSourceRebaseAbort(workspace, slot) | git rebase --abort. |
workspaceSourceRebaseContinue(workspace, slot) | git rebase --continue with core.editor=true so it never opens an editor. |
workspaceSourcePublish(workspace, slot, remote, branch) | git push --set-upstream <remote> <branch>. remote defaults to origin; branch defaults to the workspace source's manifest branch. Useful for publishing a workspace branch to the remote for the first time so a PR can be opened. |
Conflict files come from git ls-files -u, so the list is exact and reflects only paths the index reports as conflicted. The message field carries the combined stdout + stderr from git for diagnostic display.
Template introspection
templates: [TemplateDescriptor!]! enumerates every template under <root>/.templates/<kind>/<name> and <root>/templates/<kind>/<name>. template(ref: String!): TemplateDescriptor resolves an explicit ref (workspaces/dev-pr, an absolute path, or a supported remote URL) and returns the same shape. Each descriptor carries ref, kind, name, path, and a sorted list of TemplateInputDescriptor (name, type, required, immutable, generated, default).