Terrapod implements the TFE V2 API for compatibility with the terraform CLI, go-tfe client, and existing CI/CD integrations. All endpoints use JSON:API format.
The interactive API documentation is also available in the web UI under API in the navigation bar, offering both ReDoc and Swagger UI views.
The Terrapod API has four distinct consumer classes. Every API change must update every affected consumer — this is a hard contract:
- Web UI (
web/) — Next.js BFF + React frontend. Server-side SSR + client-sidefetch(). Frontend changes when JSON:API attribute names, endpoint paths, or response shapes change. - go-terrapod (
go-terrapod/) — the canonical Go SDK. Strongly-typed methods over the Terrapod JSON:API surface. Single source of truth for the Go view of the API: the provider and migration tool both import it; third-party Go automation can import it directly (github.com/mattrobinsonsre/terrapod/go-terrapod). Same shape and stability story ashashicorp/go-tfe. - terraform-provider-terrapod (
provider/) — a thin wrapper around go-terrapod. The provider holds only Terraform-plugin-framework code (schema, state translation, lifecycle hooks); every API call goes through go-terrapod. No JSON:API marshalling code lives insideprovider/internal/. - terrapod-migrate (
migrate/) — the migration tool that moves TFE/HCP + Atlantis platforms onto Terrapod. Reads from the source viago-tfe(TFE migrations) or local-clone HCL parsing (Atlantis migrations). Writes to Terrapod via go-terrapod. Distributed as a universal-macOS + linux/windows amd64+arm64 GitHub Release artifact.
- Add the endpoint to the appropriate Python router (
services/terrapod/api/routers/*.py). - Add a typed method on go-terrapod (
go-terrapod/<resource>.go) + tests. - Update each consumer that needs it: the provider's resource file, the frontend page, or the migration tool's writer.
go-terrapod targets the full Terrapod API surface — both the TFE-V2-compatible (/api/v2/) and Terrapod-native (/api/terrapod/v1/) prefixes. The migration tool is a heavy consumer of the API-only routers (config-versions, state-management, registry endpoints) that the UI doesn't surface; the provider mostly consumes the frontend-also routers. Both can rely on the same typed surface in go-terrapod.
go-terrapod pins to a specific Terrapod API version at build time via the SDKVersion constant. Consuming tools call Client.VersionCheck at startup to fail-fast on a mismatch. The migration tool requires this match by default and exposes --allow-api-version-mismatch for advanced operators. Releases of Terrapod, go-terrapod, the provider, and the migration tool all happen at the same tag — they ship together.
Terrapod exposes two API surfaces:
| Prefix | Contract | Audience |
|---|---|---|
/api/v2/ |
Stable TFE V2 subset consumed by terraform, tofu, and tfci. Documented in docs/tfe-cli-surface.md. |
Terraform/OpenTofu CLI, tfci, go-tfe-based clients |
/api/terrapod/v1/ |
Terrapod-native management API (workspaces management, runs, registry CRUD, agent pools, audit, etc.) | Web UI, the Terraform provider for Terrapod, automation |
Example:
# CLI-contract (cloud-block, state, etc.)
https://terrapod.example.com/api/v2/organizations/default/workspaces
# Terrapod-native management
https://terrapod.example.com/api/terrapod/v1/workspaces
A handful of endpoints (e.g. /health, /ready, /.well-known/terraform.json, OAuth flows, /v1/... registry CLI protocol) live at the root for protocol-compatibility reasons.
Include a Bearer token in the Authorization header:
Authorization: Bearer <api-token-or-session-token>
API tokens are obtained via terraform login, the web UI, or the token creation endpoint. Session tokens are obtained via the login flow.
Requests with a body should use:
Content-Type: application/vnd.api+json
Responses use application/json (accepted by go-tfe).
Terrapod is single-organization. The literal organization name default is the only valid value; every API path that contains an organization segment uses organizations/default/ verbatim. Requests to any other organization name return 404.
GET /health
Returns 200 if the API process is running.
Response:
{"status": "healthy"}GET /ready
Checks database, Redis, and storage subsystems. Returns 200 if all healthy, 503 otherwise.
Response (healthy):
{
"status": "ready",
"checks": {
"database": "healthy",
"redis": "healthy",
"storage": "healthy"
}
}GET /.well-known/terraform.json
Returns service discovery document for terraform login and registry protocol.
Response:
{
"login.v1": {
"client": "terraform-cli",
"grant_types": ["authz_code"],
"authz": "/oauth/authorize",
"token": "/oauth/token",
"ports": [10000, 10010]
},
"modules.v1": "/api/terrapod/v1/registry/modules/",
"providers.v1": "/api/terrapod/v1/registry/providers/"
}GET /api/v2/ping
API version handshake. Returns TFE-compatible version headers.
Response headers:
TFP-API-Version: 2.6
TFP-AppName: Terrapod
X-TFE-Version: v0.1.0
GET /api/v2/account/details
Returns the authenticated user's information.
Response:
{
"data": {
"id": "user-abc123",
"type": "users",
"attributes": {
"username": "alice@example.com",
"email": "alice@example.com",
"is-service-account": false
}
}
}GET /api/terrapod/v1/organizations/default
Returns organization details. Only default is valid.
GET /api/v2/organizations/default/entitlement-set
Returns feature flags (all enabled for Terrapod).
GET /api/v2/organizations/default/workspaces
GET /api/v2/organizations/default/workspaces/{name}
GET /api/v2/workspaces/{id}
POST /api/v2/organizations/default/workspaces
Request body:
{
"data": {
"type": "workspaces",
"attributes": {
"name": "my-workspace",
"auto-apply": false,
"execution-mode": "agent",
"terraform-version": "1.9.8",
"resource-cpu": "1",
"resource-memory": "2Gi",
"labels": {
"env": "dev",
"team": "platform"
},
"vcs-repo-url": "https://github.com/org/repo",
"vcs-branch": "main",
"working-directory": "terraform/",
"drift-detection-enabled": false,
"drift-detection-interval-seconds": 86400
},
"relationships": {
"vcs-connection": {
"data": {
"id": "vcs-abc123",
"type": "vcs-connections"
}
}
}
}
}Required permission: Any authenticated user can create workspaces (creator becomes owner).
PATCH /api/v2/workspaces/{id}
Same body format as create. Only include attributes to change.
Required permission: admin on the workspace.
Self-lockout protection: If the request changes labels and the new labels would reduce the caller's own access level, the API returns 409 Conflict with a descriptive error. Re-submit with "force": true in the attributes to confirm the change. Platform admins and workspace owners are immune (their access doesn't depend on labels).
All workspace responses (show and list) include a permissions object reflecting the authenticated user's effective permissions:
{
"permissions": {
"can-update": true,
"can-destroy": true,
"can-queue-run": true,
"can-read-state-versions": true,
"can-create-state-versions": true,
"can-read-variable": true,
"can-update-variable": true,
"can-lock": true,
"can-unlock": true,
"can-force-unlock": true,
"can-read-settings": true
}
}
### Delete Workspace
DELETE /api/terrapod/v1/workspaces/{id}
**Required permission:** `admin` on the workspace.
### Lock Workspace
POST /api/v2/workspaces/{id}/actions/lock
**Required permission:** `plan` on the workspace.
### Unlock Workspace
POST /api/v2/workspaces/{id}/actions/unlock
**Required permission:** `plan` on the workspace (own locks only).
### Drift Detection Attributes
Workspaces support the following drift detection attributes (settable on create and update):
| Attribute | Type | Default | Description |
|---|---|---|---|
| `drift-detection-enabled` | boolean | `true` (VCS) / `false` (non-VCS) | Enable or disable automatic drift detection. Auto-enabled when a VCS connection is set |
| `drift-detection-interval-seconds` | integer | `86400` | How often to run drift detection checks (minimum: 3600 seconds / 1 hour) |
| `drift-ignore-rules` | list[string] | `[]` | Glob-aware patterns silenced by the drift-result classifier (#482). Each rule is a Terraform address optionally suffixed with a dotted attribute path; `*` matches zero or more non-`.` chars (spans `[N]` indices), `[*]` matches any bracketed index. A bare address with no attribute suffix silences any change to that resource — including destroys — so use carefully. Max 50 entries, ≤ 500 chars each. Examples: `aws_iam_role.foo.tags.Environment`, `aws_autoscaling_group.workers[*].desired_capacity`, `module.eks*.argocd_cluster.*.config.tls_client_config.ca_data`. Affects drift-detection runs only — regular plan/apply is untouched. See [drift-ignore-rules.md](drift-ignore-rules.md) for the full grammar and recipes |
### AI Plan Summary Attributes
Workspaces carry two attributes that govern the optional AI plan-summary feature (settable on create and update). When the feature is globally disabled at the deployment level (`api.config.ai_summary.enabled: false`), these fields are stored but inert — no calls are made. See [docs/ai-plan-summary.md](ai-plan-summary.md) for the full operator guide.
| Attribute | Type | Default | Description |
|---|---|---|---|
| `ai-summary-mode` | string | `"default"` | Per-workspace override. One of `"default"` (follow the global toggle), `"enabled"` (always summarise this workspace's plans), or `"disabled"` (never summarise this workspace — overrides global). |
| `ai-summary-context` | string | `""` | Free text up to 4000 characters appended to the model's prompt as workspace-specific facts (e.g. "Fronts the vault for service X — destroying the KMS key causes a global outage."). Additive to the deployment-wide `fleet_context`. |
422 errors:
- `ai-summary-mode` outside the enum
- `ai-summary-context` longer than 4000 characters
- `ai-summary-context` not a string
The following read-only attributes are included in workspace responses when drift detection is enabled:
| Attribute | Type | Description |
|---|---|---|
| `drift-last-checked-at` | string (RFC3339) or null | Timestamp of the last completed drift detection check |
| `drift-status` | string | Current drift status: `""` (never checked), `"no_drift"`, `"drifted"`, or `"errored"` |
| `drift-latest-run-id` | string (run-…) or null | ID of the drift run that produced the current `drift-status`. Lets the workspace-list UI link the badge straight to the run that explains the status. Cleared on a successful (non-drift) apply because the previous drift run is no longer the canonical link. Null when drift detection has never run or when the column predates v0.35.3 |
### Lifecycle Attributes (Autodiscovery)
Workspaces created by autodiscovery carry read-only lifecycle attributes that track rename/delete/orphan reconciliation. They are included in workspace responses and surfaced by the UI as a banner on the workspace detail page and a badge on the workspace list.
| Attribute | Type | Description |
|---|---|---|
| `lifecycle-state` | string | `"active"` (normal), `"pending_deletion"` (the source directory was removed or the workspace was orphaned and the rule did not opt in to destroy — needs an explicit operator action), or `"archived"` (terminal: never-applied orphan auto-archived, or destroyed via an opt-in `destroy` rule, or a superseded speculative rename duplicate) |
| `lifecycle-reason` | string | Human-readable explanation of the current state (e.g. `directory 'accounts/x' removed on 'main'`, `origin PR #14 closed unmerged; never applied — auto-archived`). Empty for `active` workspaces |
| `autodiscovery-pr-number` | integer or null | The PR that created the workspace, while it is still speculative. Cleared (graduated) once that PR merges; `null` for non-autodiscovered workspaces |
The owning autodiscovery rule's `on-directory-delete` policy (`flag` default, or opt-in `destroy`) governs what happens when a tracked directory is removed — see [autodiscovery.md](autodiscovery.md). A rename of a tracked directory moves the existing workspace in place (state and history preserved); it never destroys, even on a `destroy` rule.
### State Divergence Flag
Workspaces also expose a read-only `state-diverged` boolean. It is set to `true` when the runner reports it could not upload state after a successful apply (the apply ran against the real provider, but the corresponding state version did not land in Terrapod), so Terrapod's view of the workspace state and the real-world infrastructure may have drifted. The UI surfaces this as a banner on the workspace detail page. Clear it by running a fresh apply or by manually uploading a state version that matches reality.
```json
"state-diverged": false
GET /api/terrapod/v1/workspaces/{id}/vcs-refs
Returns branches, tags, and the default branch for a VCS-connected workspace. Used by the UI to populate the VCS ref picker when queueing runs.
Required permission: read on the workspace.
Response:
{
"branches": [
{"name": "main", "sha": "abc123..."},
{"name": "feature-x", "sha": "def456..."}
],
"tags": [
{"name": "v1.0.0", "sha": "789abc..."}
],
"default-branch": "main"
}Returns 422 if the workspace is not VCS-connected or the VCS connection is inactive.
GET /api/v2/workspaces/{id}/state-versions
Required permission: read on the workspace.
GET /api/v2/workspaces/{id}/current-state-version
Required permission: read on the workspace.
For agent-mode runs reading another workspace's state via data "terraform_remote_state" (runner-token principals), authorization is by the producer-controlled consumer allowlist instead of per-user RBAC — see Cross-Workspace Remote-State Consumers and the composition guide.
POST /api/v2/workspaces/{id}/state-versions
Request body:
{
"data": {
"type": "state-versions",
"attributes": {
"serial": 1,
"md5": "d41d8cd98f00b204e9800998ecf8427e",
"lineage": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
}
}Required permission: write on the workspace.
GET /api/v2/state-versions/{id}
GET /api/v2/state-versions/{id}/download
Returns a redirect to a presigned URL for the raw state file.
Required permission: plan on the workspace.
For agent-mode runs reading another workspace's state via data "terraform_remote_state" (runner-token principals), authorization is by the producer-controlled consumer allowlist instead of per-user RBAC — see Cross-Workspace Remote-State Consumers.
PUT /api/v2/state-versions/{id}/content
Binary upload of raw state bytes. No auth required (presigned-style -- the state version UUID acts as a capability token). This matches go-tfe behavior.
PUT /api/v2/state-versions/{id}/json-content
Accepted and discarded (placeholder for future use).
State version responses include a created-by attribute (email of the user who created it, or null for runner-created states) and a run relationship linking to the run that produced the state:
{
"data": {
"id": "sv-...",
"type": "state-versions",
"attributes": {
"serial": 1,
"lineage": "...",
"md5": "...",
"size": 1234,
"created-at": "2026-01-01T00:00:00Z",
"created-by": "user@example.com"
},
"relationships": {
"run": {
"data": { "id": "run-...", "type": "runs" }
}
}
}
}DELETE /api/terrapod/v1/state-versions/{id}/manage
Deletes a non-current state version. The current (highest serial) version cannot be deleted.
Required permission: admin on the workspace.
Returns 204 on success, 409 if attempting to delete the current version.
POST /api/terrapod/v1/state-versions/{id}/actions/rollback
Creates a new state version with the content of the specified older version. The new version gets serial = max existing + 1. This is a "copy forward" rollback — no versions are deleted, history is preserved.
Required permission: write on the workspace.
Returns 201 with the new state version.
POST /api/terrapod/v1/workspaces/{id}/state-versions/actions/upload
Upload a raw state JSON file. Serial is auto-assigned (max existing + 1). Useful for state surgery workflows.
Required permission: write on the workspace.
Request body: Raw state JSON (Content-Type: application/json).
Returns 201 with the new state version.
POST /api/v2/runs
Request body:
{
"data": {
"type": "runs",
"attributes": {
"message": "Triggered from API",
"is-destroy": false,
"auto-apply": false,
"plan-only": false,
"target-addrs": ["aws_instance.web"],
"replace-addrs": [],
"refresh-only": false,
"refresh": true,
"allow-empty-apply": false
},
"relationships": {
"workspace": {
"data": {
"id": "ws-abc123",
"type": "workspaces"
}
}
}
}
}Required permission: plan for plan-only runs, write for apply runs.
The configuration-version relationship is optional. Resolution rules:
- Explicit CV in the request body → used as-is.
- No CV + workspace has a VCS connection → Terrapod fetches the latest commit (or the branch/tag from
vcs-ref) and creates a fresh CV automatically. - No CV + non-VCS workspace → falls back to the latest fully-uploaded, non-speculative CV for the workspace. This is the path the UI's "Queue Plan" button takes.
- No CV + non-VCS workspace + no upload has ever succeeded →
422 Unprocessable Entitywith detail"Workspace has no uploaded configuration. Upload one via 'tofu plan' / 'tofu apply' (CLI), or POST a configuration version + tarball before queueing a run.". The same response covers the misconfigured-workspace edge case wherevcs_connection_idis set butvcs_repo_urlis empty.
The CLI plan/apply flow always supplies a CV (it uploads one first), so it's unaffected by the fallback.
| Attribute | Type | Default | Description |
|---|---|---|---|
target-addrs |
array of strings | [] |
Resource addresses to target (equivalent to -target CLI flag) |
replace-addrs |
array of strings | [] |
Resource addresses to force replacement (equivalent to -replace CLI flag, plan phase only) |
refresh-only |
boolean | false |
Refresh-only plan — reconcile state without planning changes (equivalent to -refresh-only) |
refresh |
boolean | true |
Whether to refresh state before planning. Set to false to skip refresh (equivalent to -refresh=false) |
allow-empty-apply |
boolean | false |
Allow apply even when the plan has no changes (equivalent to -allow-empty-apply) |
vcs-ref |
string | "" |
Branch, tag, or SHA to fetch code from instead of the workspace's tracked branch. Only valid on VCS-connected workspaces. Runs with a non-default ref are always plan-only — the server enforces this regardless of the plan-only attribute value |
Run objects include the following drift detection attributes in responses:
| Attribute | Type | Description |
|---|---|---|
is-drift-detection |
boolean | true if the run was created by the drift detection scheduler |
has-changes |
boolean or null | Whether the plan detected infrastructure changes. null if the plan has not completed yet |
Drift detection runs are always plan-only and are not counted in the workspace's normal run queue.
Run objects include peak resource usage + an abnormal-exit signal so the UI (and external clients) can surface memory pressure without operators having to grep pod logs. All five attributes are null / "" for runs that pre-date the feature or never started a Job.
| Attribute | Type | Source | Description |
|---|---|---|---|
resource-cpu |
string | Workspace setting (snapshot) | CPU request applied to the Job (K8s quantity, e.g. "1", "500m"). Limit is 2× this |
resource-memory |
string | Workspace setting (snapshot) | Memory request applied to the Job (K8s quantity, e.g. "2Gi"). Limit is 2× this |
peak-memory-bytes |
integer or null | Runner (cgroup v2) | Peak resident memory observed during the run — /sys/fs/cgroup/memory.peak |
peak-cpu-usec |
integer or null | Runner (cgroup v2) | Cumulative CPU time consumed by the run, microseconds — usage_usec from /sys/fs/cgroup/cpu.stat. Captured but not surfaced in the UI — see note below |
runner-exit-code |
integer or null | Runner | Runner script's exit code captured at exit. null if the trap didn't fire (e.g. SIGKILL) |
runner-exit-reason |
string | Listener (K8s) | Raw K8s container.state.terminated.reason (e.g. "OOMKilled", "Error", "Completed"). "" if not observed |
runner-exit-status |
string | Reconciler (typed bucket) | Stable typed value: "" (unknown / not yet observed), "clean", "oom", "killed", "error". The UI keys on this; reason is shown for context |
Two independent capture paths feed these fields:
- Runner path —
POST /api/terrapod/v1/runs/{run_id}/resource-profilefrom the runner's EXIT trap withpeak_memory_bytes/peak_cpu_usec/exit_code. Fires for any catchable exit (success, plan errored, OPA failed, SIGTERM during apply). - Listener path — when a Job fails, the listener reads
container.state.terminated.{reason, exit_code}and POSTs them on the job-status report. The reconciler maps them torunner_exit_status.
OOM (exit 137 + reason "OOMKilled") is uncatchable, so the runner path never fires on OOM — the listener path is the only signal. Both paths converge on the same five DB columns; whichever signal arrives wins. runner-exit-status is set only by the reconciler (single source of truth for typed bucketing) and is what drives the UI's OOM badge + the typed error message ("Runner OOM-killed (peak memory N.NN Gi). Workspace resource_memory is …. Increase resource_memory + retry.").
See runners.md — Memory Pressure & OOM Visibility for the operator-facing tuning workflow.
GET /api/v2/runs/{run_id}
GET /api/v2/workspaces/{id}/runs
POST /api/v2/runs/{run_id}/actions/apply
Required permission: write on the workspace.
POST /api/v2/runs/{run_id}/actions/discard
Required permission: write on the workspace.
POST /api/v2/runs/{run_id}/actions/cancel
Required permission: write on the workspace.
POST /api/terrapod/v1/runs/{run_id}/actions/retry
Creates a new run from a terminal run (applied, errored, canceled, discarded) using the same workspace, configuration version, VCS metadata, and settings. Returns a 409 if the run is not in a terminal state.
Required permission: plan on the workspace (or write for apply runs).
GET /api/terrapod/v1/workspaces/{workspace_id}/runs/events
Server-Sent Events stream for real-time workspace updates. The stream emits events whenever a run changes state, the workspace is locked/unlocked, workspace settings are updated, or a new state version is created. Used by the web UI workspace detail page for live updates without polling.
Event types:
| Event | Trigger |
|---|---|
run_status_change |
Run transitions to a new state |
workspace_lock_change |
Workspace is locked or unlocked (includes locked boolean) |
workspace_updated |
Workspace settings are modified |
state_version_created |
New state version is uploaded |
workspace_variable_change |
A workspace variable is created, updated, or deleted |
workspace_notification_change |
A notification configuration is created, updated, or deleted |
workspace_run_task_change |
A run task is created, updated, or deleted |
run_trigger_change |
A run trigger to/from this workspace is added or removed (published to both the source and destination workspace channels, so inbound edges appear live) |
remote_state_consumer_change |
A remote-state consumer grant to/from this workspace is added or removed (published to both the producer and consumer channels) |
The stream sends : keepalive comments every ~1 second. Events are JSON-encoded in data: fields. The web UI workspace detail page re-fetches the active tab on receipt of its corresponding event.
Required permission: read on the workspace.
GET /api/terrapod/v1/workspace-events
Server-Sent Events stream for the workspace list page. Emits events whenever any workspace changes (run status, lock, settings, state). The web UI uses this to refresh the workspace list without polling.
Required permission: Any authenticated user.
GET /api/terrapod/v1/runs/{run_id}/plan
Returns plan metadata and log download URL. When the runner has uploaded a structured plan (-out=tfplan → terraform show -json tfplan), the response also carries a json-output attribute pointing at /api/v2/plans/{run_id}/json-output.
GET /api/v2/plans/{plan_id}/json-output
Returns the structured JSON representation of the plan, as produced by terraform show -json tfplan. Useful for downstream tooling that wants to consume the resource changes without parsing the human-readable log. Responds 302 to a presigned object-storage URL.
The endpoint is mounted at /api/v2/ because go-tfe and Terraform's cloud block expect it there. Returns 404 if the runner never uploaded the JSON output (older runs, runs that errored before the plan completed).
GET /api/v2/plans/{plan_id}/summary
Returns the AI-generated plan summary (or failure analysis on errored plans) when the optional ai_summary feature is enabled and a summary has been produced for the run. See docs/ai-plan-summary.md for the operator-side setup.
Required permission: read on the workspace.
plan_id accepts either plan-{uuid} (the canonical form, matching what's returned in the plans relationship of a Run) or a bare run UUID.
Responses:
- 200 OK — the workspace has a summary row for this run. See response shape below.
- 404 Not Found — no summary row exists. Either the feature is globally disabled, the workspace opted out (
ai-summary-mode: disabled), or the summariser hasn't run yet. The UI treats 404 as "no AI surface" and renders nothing.
Response shape:
{
"data": {
"id": "plan-summary-<uuid>",
"type": "plan-summaries",
"attributes": {
"kind": "plan_summary",
"status": "ready",
"description": "Adds a single root-level output named `marker` ...",
"risk-level": "low",
"risk-factors": [
{
"severity": "low",
"title": "Output-only addition",
"detail": "The plan only introduces the `marker` output ...",
"resource_address": "output.marker"
}
],
"model": "bedrock/us.anthropic.claude-opus-4-8",
"input-tokens": 1335,
"output-tokens": 171,
"error-message": "",
"created-at": "2026-06-01T12:00:00Z",
"updated-at": "2026-06-01T12:00:30Z"
},
"relationships": {
"plan": { "data": { "id": "plan-<uuid>", "type": "plans" } },
"run": { "data": { "id": "run-<uuid>", "type": "runs" } }
}
}
}Attribute reference:
| Attribute | Type | Description |
|---|---|---|
kind |
string | "plan_summary" (successful plan → change description + risk assessment) or "failure_analysis" (plan-phase errored → root-cause + suggested fixes). |
status |
string | "pending" (handler running), "ready" (model returned a parseable response), "skipped" (workspace disabled or daily budget hit — see error-message), or "errored" (model call failed — see error-message). |
description |
string | Markdown body. For kind=plan_summary, ~600 words describing the proposed changes. For kind=failure_analysis, root-cause explanation. |
risk-level |
string | One of "low", "medium", "high", "critical". Reflects blast radius + reversibility, not novelty. |
risk-factors |
array of object | Each item carries severity (same enum as risk-level), title (max 120 chars), detail (max 600 chars), and optional resource_address (terraform address). For kind=failure_analysis these are suggested fixes ordered most-likely-to-resolve first. |
model |
string | LiteLLM model string used for this summary (e.g. bedrock/us.anthropic.claude-opus-4-8). |
input-tokens / output-tokens |
integer | Telemetry counts reported by the upstream provider. |
error-message |
string | Populated only for status=errored or status=skipped. Empty for ready. |
Real-time updates: the per-workspace SSE channel (GET /api/terrapod/v1/workspaces/{id}/runs/events) emits one of five lifecycle events as the summary progresses (#463):
| Event | Fires when |
|---|---|
plan_summary_pending |
Handler dispatched (or operator clicked Regenerate). UI shows a placeholder. |
plan_summary_ready |
Initial summary landed; refetch to render. |
plan_summary_errored |
Handler/model failure; refetch to render the error. |
plan_summary_skipped |
Runner died abnormally / workspace opted out / daily budget hit. |
plan_summary_message_posted |
A chat follow-up turn landed (carries message_id). Refetch the transcript. |
All five payloads carry {run_id, workspace_id} at minimum. The UI re-fetches the summary on any of them. For VCS-driven runs, the per-workspace PR/MR status comment is edited in place to include the summary content when it lands.
POST /api/terrapod/v1/runs/{run_id}/plan-summary/regenerate
Re-fires the AI summary handler for a run. Anyone with workspace read can regenerate — the call doesn't mutate infrastructure. Bypasses the 5-minute auto-dedup so operator clicks always go through; budget gating still applies handler-side.
Required permission: read on the workspace.
Responses:
- 202 Accepted — pending row upserted and trigger enqueued. Response shape matches the GET above with
status=pending. - 409 Conflict — run is in a state with no summarisable output yet (still planning, or apply-phase errored).
- 503 Service Unavailable — AI summary is globally disabled (
api.config.ai_summary.enabled: false).
GET /api/terrapod/v1/runs/{run_id}/plan-summary/messages
Full transcript of the AI plan-summary chat thread in chronological order. The initial structured summary lives on the parent PlanSummary row (description + risk-factors); this endpoint returns ONLY the conversational follow-ups. The UI renders message[0] from the parent summary and appends these.
Required permission: read on the workspace.
Responses:
- 200 OK with an array (possibly empty) of
plan-summary-messagesresources. - 404 Not Found — no initial summary exists for this run.
- 409 Conflict — the initial summary is still
pendingorerrored. Can't chat against an unready summary.
{
"data": [
{
"id": "plan-summary-message-<uuid>",
"type": "plan-summary-messages",
"attributes": {
"role": "user",
"content": "How long will the RDS update take?",
"model": "",
"input-tokens": 0,
"output-tokens": 0,
"error-message": "",
"created-at": "2026-06-01T12:01:00Z"
}
},
{
"id": "plan-summary-message-<uuid>",
"type": "plan-summary-messages",
"attributes": {
"role": "assistant",
"content": "An in-place RDS modify with `apply_immediately = false` typically completes during the next maintenance window…",
"model": "bedrock/us.anthropic.claude-sonnet-4-6",
"input-tokens": 14823,
"output-tokens": 412,
"error-message": "",
"created-at": "2026-06-01T12:01:14Z"
}
}
],
"meta": { "count": 2 }
}POST /api/terrapod/v1/runs/{run_id}/plan-summary/messages
Content-Type: application/vnd.api+json
{ "data": { "attributes": { "content": "..." } } }
Posts a user follow-up + returns the synchronous assistant reply. Authorisation is read-on-workspace — anyone who can see the run can chat in the thread (GitHub PR conversation semantics, not per-user threads).
Required permission: read on the workspace.
Responses:
- 201 Created — the response body is the assistant turn (same shape as the GET list entries). The persisted user turn is visible via the next GET call.
- 400 Bad Request — empty body or body > 32 KiB.
- 409 Conflict — initial summary not
ready, or this run already hasfollowup_max_messages_per_runuser turns. The user-turn counter is server-tracked, not advisory. - 429 Too Many Requests — daily AI token budget exhausted.
- 503 Service Unavailable — chat globally disabled (
followup_max_messages_per_run: 0) or workspace opted out. - 502 Bad Gateway — model HTTP / parse failure. The user turn is still persisted in the transcript, and a separate errored assistant row is recorded — so a reload shows the failure cleanly.
The model call uses the same cacheable prefix as the initial summary (provider prompt caching serves the prefix hit). See docs/ai-plan-summary.md#follow-up-chat-463 for the operator-side caps + provider matrix.
GET /api/terrapod/v1/runs/{run_id}/apply
Returns apply metadata and log download URL.
Producer-controlled allowlist of workspaces authorized to read this workspace's state via data "terraform_remote_state". Default is empty (not shared) — secure by default. All mutations require admin/write on the producer (the state owner). Independent of run triggers — see the composition guide.
When a runner-token principal (agent-mode run) hits /api/v2/workspaces/{id}/current-state-version or /api/v2/state-versions/{id}/download for another workspace, authorization is by this allowlist instead of per-user RBAC. User / API-token principals (CLI, UI, automation) continue through the existing per-user RBAC path.
GET /api/terrapod/v1/workspaces/{id}/remote-state-consumers?filter[remote-state-consumer][type]=outbound
outbound (default) — workspaces this workspace shares its state to (this workspace is the producer).
inbound — workspaces whose state this workspace is authorized to read (this workspace is the consumer).
Required permission: read on the workspace.
POST /api/terrapod/v1/workspaces/{producer_id}/remote-state-consumers
Request body:
{
"data": {
"relationships": {
"consumer": {"data": {"id": "ws-CONSUMER", "type": "workspaces"}}
}
}
}Required permission: admin on the producer workspace. A consumer team cannot self-grant.
Errors: 422 self-reference; 409 already authorized; 422 over the per-producer cap.
PUT /api/terrapod/v1/workspaces/{producer_id}/remote-state-consumers
Idempotent declarative replace of the producer's full consumer set in one atomic transaction. Supports the Terrapod provider's set-valued attribute. Body: {"data": [{"type": "workspaces", "id": "ws-..."}, ...]}.
Required permission: admin on the producer.
GET /api/terrapod/v1/remote-state-consumers/{id}
Required permission: read on the producer.
DELETE /api/terrapod/v1/remote-state-consumers/{id}
Required permission: admin on the producer. A consumer cannot self-revoke.
| Attribute | Description |
|---|---|
producer-workspace-name |
Name of the producer workspace |
consumer-workspace-name |
Name of the consumer workspace |
created-at |
RFC3339 timestamp the grant was created |
created-by |
Identity that created the grant |
Plus relationships producer and consumer (both workspaces-typed).
Run triggers create cross-workspace dependency chains. When a source workspace completes an apply, all downstream workspaces with an inbound trigger automatically get a new run queued.
POST /api/terrapod/v1/workspaces/{id}/run-triggers
Request body:
{
"data": {
"relationships": {
"sourceable": {
"data": {
"id": "ws-source-workspace-id",
"type": "workspaces"
}
}
}
}
}Required permission: admin on the destination workspace.
Validation:
- Source and destination must be different workspaces
- No duplicate triggers for the same pair
- Maximum 20 source workspaces per destination
Example:
curl -s \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-X POST \
https://terrapod.example.com/api/terrapod/v1/workspaces/ws-abc123/run-triggers \
-d '{
"data": {
"relationships": {
"sourceable": {
"data": {"id": "ws-def456", "type": "workspaces"}
}
}
}
}'GET /api/terrapod/v1/workspaces/{id}/run-triggers?filter[run-trigger][type]=inbound|outbound
inbound: triggers where this workspace is the destination (what triggers runs here?)outbound: triggers where this workspace is the source (what does my apply trigger?)
The filter[run-trigger][type] parameter is required (422 if missing).
Required permission: read on the workspace.
Example:
curl -s \
-H "Authorization: Bearer $TOKEN" \
"https://terrapod.example.com/api/terrapod/v1/workspaces/ws-abc123/run-triggers?filter[run-trigger][type]=inbound"GET /api/terrapod/v1/run-triggers/{id}
Required permission: read on the destination workspace.
DELETE /api/terrapod/v1/run-triggers/{id}
Required permission: admin on the destination workspace.
Example:
curl -s \
-H "Authorization: Bearer $TOKEN" \
-X DELETE \
https://terrapod.example.com/api/terrapod/v1/run-triggers/rt-abc123A workspace's uploaded source archives are also browsable from the UI — workspace detail → Configurations tab. The list highlights the current configuration with a current badge and supports download + side-by-side diff between any two versions.
POST /api/v2/workspaces/{id}/configuration-versions
Request body:
{
"data": {
"type": "configuration-versions",
"attributes": {
"auto-queue-runs": true
}
}
}Response includes: upload-url attribute with a presigned URL for uploading the tarball.
Required permission: write on the workspace.
PUT <upload-url>
Content-Type: application/octet-stream
<tarball bytes>
No auth required (presigned URL).
GET /api/v2/configuration-versions/{cv_id}
Returns a single CV's metadata.
GET /api/v2/workspaces/{id}/configuration-versions
Newest first. Supports page[size] (default 20, max 100) and page[number].
Response includes meta.current-id — the CV id consumed by the most recent successful apply, or null if none. The UI uses this to badge the current row.
Required permission: read on the workspace.
GET /api/terrapod/v1/configuration-versions/{cv_id}/download
Streams the tarball bytes back as application/x-tar with a Content-Disposition: attachment header. Bearer auth.
Status codes:
- 200 — streaming tarball
- 404 — CV doesn't exist or caller lacks
read - 409 — CV exists but bytes haven't been uploaded yet
- 410 — CV row exists but tarball was swept by retention
Required permission: read on the owning workspace.
POST /api/terrapod/v1/configuration-versions/{cv_id}/download-ticket
Mints a short-lived, single-resource HMAC ticket the browser can paste into a plain <a href> to stream a download natively to the user's save dialog. Opt-in — the default download path above is the simple Bearer-auth flow; tickets exist because plain navigation can't carry an Authorization header.
Request body (optional):
{"data": {"attributes": {"ttl-seconds": 300}}}TTL defaults to 300 s, hard-capped at 1800 s. Negative or zero values fall back to the default.
Response:
{
"data": {
"type": "download-tickets",
"attributes": {
"ticket": "dlticket:cv:{uuid}:...",
"url": "/api/terrapod/v1/configuration-versions/download-by-ticket/dlticket:cv:...",
"expires-at": "2026-05-07T12:34:56Z"
}
}
}Required permission: read on the owning workspace (same gate as the direct download).
GET /api/terrapod/v1/configuration-versions/download-by-ticket/{ticket}
Streams the tarball — no Authorization header. The ticket is the auth: HMAC-SHA256 over the resource id, expiry, and minter email, signed with the same key class as runner tokens. Single-resource (a CV-X ticket cannot fetch CV-Y); short TTL bounds replay.
Status codes:
- 200 — streaming tarball
- 401 — malformed, expired, or bad-signature ticket
- 410 — CV bytes swept by retention since mint
POST /api/terrapod/v1/configuration-versions/diff
Compares two CVs in the same workspace and returns per-file unified diffs.
Request body:
{
"data": {
"attributes": {
"from-id": "cv-...",
"to-id": "cv-..."
}
}
}Response shape:
{
"data": {
"type": "configuration-version-diffs",
"attributes": {
"from-id": "cv-...",
"to-id": "cv-...",
"files": [
{"path": "main.tf", "type": "modified", "diff": "@@ ... @@"},
{"path": "vars.tf", "type": "added", "diff": "..."},
{"path": "old.tf", "type": "removed", "diff": "..."},
{"path": "logo.png","type": "binary-changed"}
],
"oversized": ["modules/big.zip"],
"total-files-changed": 4
}
}
}Limits: per-file 1 MiB (oversized files report only their path), per-pair 32 MiB total (refused with 413 if exceeded). Binary files (NUL byte in first 8 KiB) report only binary-changed.
Status codes:
- 200 — diff returned
- 404 — either CV missing or caller lacks
read - 409 — either CV not yet uploaded
- 410 — bytes swept by retention
- 413 — combined size exceeds the per-pair cap
- 422 — missing ids or cross-workspace request
Required permission: read on the workspace (both CVs must belong to the same workspace).
Read-only labels browser (Terrapod extension). All endpoints are RBAC-filtered: results only include labels carried by entities the caller has at least read on for that entity's permission model. Editing labels still happens on each entity's own edit page — there is no labels-admin surface.
GET /api/terrapod/v1/labels
Returns all label keys in use across readable workspaces, modules, providers, and pools, with per-type counts.
Response shape:
{
"data": [
{"key": "team", "value-count": 4, "by-type": {"workspaces": 12, "modules": 2, "providers": 0, "pools": 1}}
]
}GET /api/terrapod/v1/labels/{key}
Returns distinct values for key, each with per-type counts. Empty data is a valid response.
GET /api/terrapod/v1/labels/{key}/{value}
Returns entities tagged with exactly key=value, grouped by type.
Response shape:
{
"data": {
"workspaces": [{"id": "ws-...", "name": "..."}],
"modules": [{"id": "mod-...", "name": "...", "provider": "..."}],
"providers": [{"id": "prov-...", "namespace": "...", "name": "..."}],
"pools": [{"id": "pool-...", "name": "..."}]
}
}GET /api/v2/workspaces/{id}/vars
Required permission: read on the workspace. Sensitive values are never returned.
POST /api/v2/workspaces/{id}/vars
Request body:
{
"data": {
"type": "vars",
"attributes": {
"key": "AWS_REGION",
"value": "eu-west-1",
"category": "env",
"sensitive": false,
"description": "AWS region for provider"
}
}
}category is either terraform (injected as TF_VAR_{key}) or env (injected as raw env var).
Required permission: write on the workspace.
PATCH /api/v2/workspaces/{id}/vars/{var_id}
DELETE /api/v2/workspaces/{id}/vars/{var_id}
Required permission: write on the workspace.
GET /api/v2/organizations/default/varsets
POST /api/v2/organizations/default/varsets
Required permission: Platform admin.
GET /api/v2/varsets/{varset_id}/relationships/vars
POST /api/v2/varsets/{varset_id}/relationships/vars
PATCH /api/v2/varsets/{varset_id}/relationships/vars/{var_id}
DELETE /api/v2/varsets/{varset_id}/relationships/vars/{var_id}
POST /api/v2/varsets/{varset_id}/relationships/workspaces
DELETE /api/v2/varsets/{varset_id}/relationships/workspaces
GET /api/v2/registry/modules/{namespace}/{name}/{provider}/versions
GET /api/v2/registry/modules/{namespace}/{name}/{provider}/{version}/download
GET /api/terrapod/v1/registry-modules
POST /api/terrapod/v1/registry-modules
GET /api/terrapod/v1/registry-modules/private/default/{name}/{provider}
DELETE /api/terrapod/v1/registry-modules/private/default/{name}/{provider}
PUT /api/terrapod/v1/registry-modules/private/default/{name}/{provider}/versions/{version}/upload
DELETE /api/terrapod/v1/registry-modules/private/default/{name}/{provider}/versions/{version}
PATCH /api/terrapod/v1/registry-modules/private/default/{name}/{provider}
Required permission: admin on the module.
Self-lockout protection: If the request changes labels and the new labels would reduce the caller's own access level, the API returns 409 Conflict. Re-submit with "force": true in the attributes to confirm.
All module responses (show and list) include a permissions object:
{
"permissions": {
"can-update": true,
"can-destroy": true,
"can-create-version": true
}
}PUT /api/terrapod/v1/registry-modules/private/default/{name}/{provider}/versions/{version}/upload
A single streamed PUT of the gzipped module source tarball. The version
is created implicitly on upload — there is no separate create step and
no presigned URL. The server extracts the module interface (inputs and
outputs) and triggers impact runs on any linked workspaces (see
Module Impact Analysis and the workspace-links
section below).
Required permission: write on the module (the owner has admin).
Tooling: the terrapod-publish CLI packages
the source directory and performs this upload.
Removed in the client-signed model: the previous
POST .../versionscreate-then-upload-to-presigned-URL flow has been removed in favour of the single streamedPUTabove.
GET /api/terrapod/v1/registry-modules/private/default/{name}/{provider}/workspace-links
POST /api/terrapod/v1/registry-modules/private/default/{name}/{provider}/workspace-links
DELETE /api/terrapod/v1/registry-modules/private/default/{name}/{provider}/workspace-links/{link_id}
Required permission: admin on the module (create/delete), read on the module (list).
Terraform provider resource: terrapod_module_workspace_link
GET /api/v2/registry/providers/{namespace}/{type}/versions
GET /api/v2/registry/providers/{namespace}/{type}/{version}/download/{os}/{arch}
The download response advertises the publisher's own GPG public key
in signing_keys.gpg_public_keys. Terrapod never re-signs a provider — the
signature terraform init verifies is the one the publisher produced at
publish time (see Publishing a Version).
GET /api/terrapod/v1/registry-providers
POST /api/terrapod/v1/registry-providers
GET /api/terrapod/v1/registry-providers/private/default/{name}
DELETE /api/terrapod/v1/registry-providers/private/default/{name}
GET /api/terrapod/v1/registry-providers/private/default/{name}/versions
DELETE /api/terrapod/v1/registry-providers/private/default/{name}/versions/{version}
Provider publishing is client-signed, direct, and streamed. The
publisher computes SHA256SUMS over the platform zips and GPG-signs it
with its own key; the server verifies that signature against a
registered GPG public key and never re-signs. The version is created
implicitly on the first upload — there is no separate create-version
or finalize step.
Uploads must happen in this exact order:
PUT /api/terrapod/v1/registry-providers/private/default/{name}/versions/{version}/shasums
PUT /api/terrapod/v1/registry-providers/private/default/{name}/versions/{version}/shasums.sig
PUT /api/terrapod/v1/registry-providers/private/default/{name}/versions/{version}/platforms/{os}/{arch}
PUT .../shasums— the rawSHA256SUMSmanifest (one{sha} {zipname}line per platform).PUT .../shasums.sig— the detached GPG signature over the manifest. The server verifies it against a registered GPG key here (the trust gate). Returns 422 if the key isn't registered or the signature doesn't verify. Binaries are refused until this succeeds.PUT .../platforms/{os}/{arch}(one per platform) — each zip is streamed to disk and its SHA checked against the signed manifest. Returns 422 on a SHA mismatch, or if the signature has not yet been verified.
Required permission: write on the provider (the owner has admin).
Tooling: the terrapod-publish CLI performs
these three uploads (in order) and does all packaging, hashing, and GPG
signing client-side. The server never re-signs — the
download response advertises the
publisher's own public key in signing_keys.gpg_public_keys.
Removed in the client-signed model: the previous presigned-URL flow —
POST .../versions(create version) andPOST .../versions/{version}/platforms(create platform, returning a presigned upload URL) — has been removed. There is no server-side re-signing and no presigned-URL or finalize step for provider versions. Use the three streamedPUTendpoints above.
PATCH /api/terrapod/v1/registry-providers/private/default/{name}
Required permission: admin on the provider.
Self-lockout protection: If the request changes labels and the new labels would reduce the caller's own access level, the API returns 409 Conflict. Re-submit with "force": true in the attributes to confirm.
All provider responses (show and list) include a permissions object:
{
"permissions": {
"can-update": true,
"can-destroy": true,
"can-create-version": true
}
}GET /api/terrapod/v1/gpg-keys
POST /api/terrapod/v1/gpg-keys
GET /api/terrapod/v1/gpg-keys/{namespace}/{key_id}
DELETE /api/terrapod/v1/gpg-keys/{namespace}/{key_id}
The public key registered here is the trust anchor for client-signed
provider publishing: PUT .../shasums.sig is verified against it. Register
a key before publishing a provider (or use the terrapod_gpg_key provider
resource). See Publishing to the Private Registry.
GET /api/terrapod/v1/agent-pools
POST /api/terrapod/v1/agent-pools
Request body:
{
"data": {
"type": "agent-pools",
"attributes": {
"name": "aws-prod",
"description": "Production AWS runners"
}
}
}Required permission: Platform admin.
GET /api/terrapod/v1/agent-pools/{id}
DELETE /api/terrapod/v1/agent-pools/{id}
POST /api/terrapod/v1/agent-pools/{id}/authentication-tokens
GET /api/terrapod/v1/agent-pools/{id}/authentication-tokens
POST /api/terrapod/v1/agent-pools/join
Registers a listener using a join token. The token identifies the pool — no pool ID needed in the URL. No Bearer auth required; the join token in the body IS the credential.
Request body:
{
"join_token": "<raw-token>",
"name": "my-listener"
}Response: listener ID, pool ID, X.509 certificate, private key, CA certificate.
If a listener with the same name already exists, its certificate is reissued (handles pod restarts).
Legacy endpoint (still supported):
POST /api/terrapod/v1/agent-pools/{pool_id}/listeners/join
POST /api/terrapod/v1/listeners/{id}/heartbeat
POST /api/terrapod/v1/listeners/{id}/renew
GET /api/terrapod/v1/listeners/{id}/runs/next
Returns the next queued run for this listener.
POST /api/terrapod/v1/listeners/{id}/runs/{run_id}/runner-token
Generates a short-lived HMAC-signed runner token scoped to the specified run. Called by the listener after claiming a run.
Request body (optional):
{
"ttl": 3600
}| Parameter | Type | Default | Description |
|---|---|---|---|
ttl |
integer | runners.tokenTTLSeconds (default 3600) |
Requested token lifetime in seconds. Clamped to runners.maxTokenTTLSeconds (default 7200) |
Response:
{
"token": "runtok:{run_id}:{ttl}:{timestamp}:{hmac_sig}",
"expires_in": 3600
}Auth: Listener certificate.
PATCH /api/terrapod/v1/listeners/{id}/runs/{run_id}
Reports run status changes (planning, planned, applying, applied, errored).
GET /api/terrapod/v1/vcs-connections
POST /api/terrapod/v1/vcs-connections
GitHub example:
{
"data": {
"type": "vcs-connections",
"attributes": {
"name": "my-github",
"provider": "github",
"github-app-id": 12345,
"github-installation-id": 112887490,
"github-account-login": "my-org",
"github-account-type": "Organization",
"private-key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
}
}
}GitLab example:
{
"data": {
"type": "vcs-connections",
"attributes": {
"name": "my-gitlab",
"provider": "gitlab",
"token": "glpat-xxxxxxxxxxxxxxxxxxxx"
}
}
}Required permission: Platform admin.
GET /api/terrapod/v1/vcs-connections/{id}
PATCH /api/terrapod/v1/vcs-connections/{id}
Partial update — only the attributes you include are changed. Notes:
provideris immutable. A different provider is a different connection; delete and recreate to change it (sending a differentproviderreturns422).- Credentials (
private-keyfor GitHub,tokenfor GitLab) are write-only: they are never returned, and are only rotated when you send a non-empty value. Omit them to change the name/server-url/status without touching the stored credential. - Editable:
name,server-url,status(active/disabled), and the GitHub App identifiers (github-app-id,github-installation-id,github-account-login,github-account-type). Changinggithub-installation-idto one already used by another connection returns422.
{
"data": {
"type": "vcs-connections",
"attributes": { "name": "renamed", "server-url": "https://github.example.com/api/v3" }
}
}Required permission: Platform admin.
DELETE /api/terrapod/v1/vcs-connections/{id}
Connection-scoped rules that auto-create workspaces when a PR or default-branch push touches a path matching pattern. See Autodiscovery for the full feature doc.
All endpoints require admin role.
GET /api/terrapod/v1/autodiscovery-rules
POST /api/terrapod/v1/autodiscovery-rules
Request body (every attribute except name, vcs-connection-id, repo-url, pattern is optional):
{
"data": {
"type": "autodiscovery-rules",
"attributes": {
"name": "monorepo",
"vcs-connection-id": "vcs-019e0e7b-...",
"repo-url": "https://github.com/myorg/monorepo",
"branch": "main",
"pattern": "accounts/*/**/*.tf",
"ignore-patterns": ["modules/**"],
"name-template": "ws-{path}",
"enabled": true,
"execution-mode": "agent",
"execution-backend": "tofu",
"agent-pool-id": "apool-019e01db-...",
"terraform-version": "1.12",
"resource-cpu": "1",
"resource-memory": "2Gi",
"auto-apply": false,
"on-directory-delete": "flag",
"labels": {"managed-by": "monorepo-autodiscover"},
"owner-email": "platform@example.com"
}
}
}Returns 201 with the created rule, or 409 if a rule with that name already exists for the connection.
Reserved label keys:
labelsis validated like any other label write — reserved keys (status,pool,mode,backend,owner,drift,version,vcs,locked,branch) are rejected with422. This is enforced at rule create/update so the rule can't materialise workspaces that later become uneditable.
GET /api/terrapod/v1/autodiscovery-rules/{id}
PATCH /api/terrapod/v1/autodiscovery-rules/{id}
Same body shape as create; only the attributes you include are updated.
DELETE /api/terrapod/v1/autodiscovery-rules/{id}
Workspaces auto-created by this rule keep working — their autodiscovery-rule-id foreign key is set to NULL.
Walk the repo and return exactly which workspaces a rule would create against the current state of the tracked branch, with no side effects. Used by the admin UI's "Preview" modal.
GET /api/terrapod/v1/autodiscovery-rules/{id}/preview # preview a saved rule
POST /api/terrapod/v1/autodiscovery-rules/preview # preview an unsaved rule (same attributes body as Create)
Each entry reports workspace_name, working_directory, collision (the row would no-op — a workspace is already bound to that directory or the derived name is taken), and existing_autodiscovered (the no-op is a reuse of a workspace this same rule already materialised). Returns 413 if the VCS provider truncated the repo tree (repo too large to scan in one pass).
POST /api/terrapod/v1/autodiscovery-rules/{id}/scan
Runs the same walk as Preview but actually creates the workspaces (idempotent, collision-safe). Force-enables the rule for the duration of the call so an explicit operator action doesn't silently no-op on a disabled rule. New workspaces are seeded with the tracked-branch HEAD as their last-seen commit, so the first real plan+apply fires when the branch next advances (e.g. the PR merge) — not immediately against a branch where the directory doesn't exist yet.
POST/PATCH rule bodies accept three additional attributes that are materialised onto every workspace the rule creates, so autodiscovered workspaces are fully configured at creation:
var-files— list of var-file paths.run-task-templates— list of run-task specs (same shape as the bulk-updaterun-tasks, below):{name, url, hmac-key?, stage, enforcement-level?, enabled?}.notification-templates— list of notification specs:{name, destination-type, url?, token?, triggers?, email-addresses?, enabled?}.
These use the identical spec shape as the bulk-update endpoint, so a run task defined once can be applied to existing workspaces (bulk-update) and auto-applied to future ones (this template).
Terrapod-native, admin only. Server-side selection + atomic fleet updates (#318).
POST /api/terrapod/v1/workspaces/actions/search
Resolve a structured filter to the matching workspaces — no side effects (the discovery half of the bulk workflow).
{ "filter": {
"labels": {"team": "foundations"},
"name-prefix": "terrapod-testing-",
"execution-backend": "terraform",
"agent-pool-id": "apool-...", "vcs-connection-id": "vcs-...",
"owner-email": "...", "drift-status": "...", "locked": false, "has-vcs": true,
"workspace-ids": ["ws-..."],
"all": false } }Dimensions are AND-combined (narrower = safer). An empty/omitted filter is a 422 (typo guard); matching the whole fleet requires explicit "all": true. Returns {matched, workspaces:[...]}.
POST /api/terrapod/v1/workspaces/actions/bulk-update
Apply update to every workspace matching filter, in a single all-or-nothing transaction.
{ "filter": { "labels": {"team": "foundations"} },
"update": {
"terraform-version": "1.12",
"execution-backend": "tofu",
"auto-apply": false,
"agent-pool-id": "apool-...",
"resource-cpu": "1", "resource-memory": "2Gi",
"var-files": ["envs/prod.tfvars"],
"labels": {"reviewed": "2026-q2"},
"run-tasks": [
{ "name": "opa-policy-check", "url": "http://opa:8080/webhook",
"hmac-key": "secret", "stage": "post_plan", "enforcement-level": "mandatory" }
],
"notification-configurations": [
{ "name": "slack-prod", "destination-type": "slack",
"url": "https://hooks.slack.com/...", "triggers": ["run:errored"] }
]
},
"dry_run": true }Semantics:
- Validated once up front — field enums,
labelsreserved-key check, run-task/notification specs, andagent-pool-idexistence + caller pool-writeRBAC. Any error ⇒422, zero mutation. run-tasks/notification-configurationsupsert by(workspace, name): created if absent, updated in place if present (so re-running with a changedurlrotates it across the fleet).- All-or-nothing: the whole batch commits or nothing does.
dry_run(defaulttrue, not enforced) runs the identical code path and rolls back — the preview is exactly what apply would do, with provably zero side effects. - Triggers no runs — pure config write; the change lands on each workspace's next normal run. Reversible (it only writes settings rows).
- Per-workspace audit entries.
Response: dry-run {dry_run:true, matched, would_change:[{id,name,diff}], unchanged}; apply {dry_run:false, matched, applied, changes, unchanged, errors:[]}; any failure ⇒ 409/422 and nothing applied.
POST /api/terrapod/v1/vcs-events/github
Validates HMAC-SHA256 signature and triggers an immediate poll cycle. The webhook secret must match TERRAPOD_VCS__GITHUB__WEBHOOK_SECRET.
GET /api/terrapod/v1/roles
Returns built-in and custom roles.
Required permission: Platform admin or audit.
POST /api/terrapod/v1/roles
Request body:
{
"data": {
"type": "roles",
"attributes": {
"name": "developer",
"description": "Development workspace access",
"workspace-permission": "write",
"pool-permission": "read",
"registry-permission": "read",
"allow-labels": {"env": "dev"},
"allow-names": [],
"deny-labels": {},
"deny-names": []
}
}
}A role carries three independent permission scalars: workspace-permission (read/plan/write/admin), pool-permission (read/write/admin), and registry-permission (read/write/admin, covering both modules and providers). All default to read. registry-permission is independent of workspace-permission, so a role can grant registry write (e.g. provider-publish CI) without any workspace access.
Required permission: Platform admin.
GET /api/terrapod/v1/roles/{name}
PATCH /api/terrapod/v1/roles/{name}
DELETE /api/terrapod/v1/roles/{name}
Built-in roles cannot be deleted.
GET /api/terrapod/v1/role-assignments
Required permission: Platform admin or audit.
PUT /api/terrapod/v1/role-assignments
Request body:
{
"data": {
"type": "role-assignments",
"attributes": {
"provider-name": "local",
"email": "alice@example.com",
"roles": ["developer", "sre-reader"]
}
}
}Required permission: Platform admin.
DELETE /api/terrapod/v1/role-assignments/{provider}/{email}/{role}
Authentication tokens come in three kinds (kind attribute):
| Kind | Who creates | Effective permissions | Bound to |
|---|---|---|---|
interactive |
anyone (default) | the owner's full live roles | the owner |
service_bound |
anyone | intersection of the token's pinned-roles and the owner's live roles, per resource |
the owner — and the token is rejected if the owner hasn't logged in within auth.bound_token_idle_days (default 7) |
service_detached |
admins only | the token's pinned-roles as an absolute scope |
nobody (unbound) — survives any single person leaving |
A service_bound token can never exceed its owner's access (the intersection caps it) and stops working when the owner is offboarded — see authentication.md and the offboarding runbook in runbooks.md. service_detached is the path for critical machine-to-machine automation.
Response attributes: description, kind, bound-to (null for detached), created-by, pinned-roles (service tokens only; null for interactive), token-type, created-at, rotated-at, last-used-at, expires-at, lifespan-hours, and token (the raw secret — present only in create/rotate responses). Service tokens always carry an expiry, capped by auth.service_token_max_ttl_hours (default 8760 / 1 year).
POST /api/terrapod/v1/users/{user_id}/authentication-tokens
Request attributes: description, kind (default interactive), lifespan_hours, pinned_roles (service kinds). service_detached is admin-only (403 otherwise) and is created unbound regardless of {user_id}.
GET /api/terrapod/v1/users/{user_id}/authentication-tokens
Never includes detached tokens (they are unbound).
GET /api/terrapod/v1/admin/authentication-tokens[?kind={kind}]
Admin-only. Optional kind filter (interactive / service_bound / service_detached); a valid-but-empty kind returns [], not an error.
GET /api/terrapod/v1/authentication-tokens/{id}
PATCH /api/terrapod/v1/authentication-tokens/{id}
interactive ↔ service_bound is owner-or-admin. Converting to/from service_detached is admin-only and unbinds/rebinds the token. Request attributes: kind, pinned_roles.
POST /api/terrapod/v1/authentication-tokens/{id}/actions/rotate
Mints a fresh secret (returned once in token) and resets the expiry clock; the old secret stops working immediately. Surfaced as a "Rotate" action on service tokens in the UI.
GET /api/terrapod/v1/authentication-tokens/expiring
Service tokens within auth.token_expiry_warning_days (default 14) of expiry, scoped to the caller: own bound service tokens for everyone, plus all detached tokens for admins. Drives the in-app expiry banner — no user is warned about another user's bound tokens.
POST /api/terrapod/v1/admin/authentication-tokens/actions/revoke-all
Admin-only urgent-offboarding lever. Body {"email": "..."}; revokes every token bound to that identity and returns {"data": {"email": ..., "revoked": N}}. Detached tokens are unbound and unaffected.
DELETE /api/terrapod/v1/authentication-tokens/{id}
Authenticated endpoints for runner Jobs to download inputs and upload outputs. All endpoints require a runner token (Authorization: Bearer runtok:...) scoped to the specified run_id.
GET /api/terrapod/v1/runs/{run_id}/artifacts/config
Returns 302 redirect to presigned storage URL for the configuration tarball.
GET /api/terrapod/v1/runs/{run_id}/artifacts/state
Returns 302 redirect to presigned storage URL for the current workspace state.
GET /api/terrapod/v1/runs/{run_id}/artifacts/plan-file
Returns 302 redirect to presigned storage URL for the plan binary file.
PUT /api/terrapod/v1/runs/{run_id}/artifacts/plan-log
Content-Type: application/octet-stream
Upload raw plan log bytes. Returns 204 on success.
PUT /api/terrapod/v1/runs/{run_id}/artifacts/plan-file
Content-Type: application/octet-stream
Upload plan binary file. Returns 204 on success.
PUT /api/terrapod/v1/runs/{run_id}/artifacts/apply-log
Content-Type: application/octet-stream
Upload raw apply log bytes. Returns 204 on success.
PUT /api/terrapod/v1/runs/{run_id}/artifacts/state
Content-Type: application/octet-stream
Upload new state after apply. Returns 204 on success.
GET /api/terrapod/v1/runs/{run_id}/artifacts/plan-artifacts
Returns 302 redirect to presigned storage URL for the plan-phase workspace-diff tarball. Used by the apply phase to restore files generated during plan (e.g. data.archive_file outputs, null_resource local-exec scratch) into the apply Job's fresh workspace. Returns 404 if the run was produced by a pre-v0.34.0 runner (no plan-artifacts uploaded). The apply phase tolerates 404 — it logs an error and proceeds.
PUT /api/terrapod/v1/runs/{run_id}/artifacts/plan-artifacts
Content-Type: application/x-tar
Content-Length: <bytes>
Upload the plan-phase workspace-diff tarball. The body is the diff (post_plan - post_init) of files plan created, excluding tfplan, .terraform.lock.hcl, and .terraform/terraform.tfstate (handled by other endpoints). An empty tar is always uploaded — even when plan generated no new files — so the apply-side contract is "404 means upload genuinely failed", not "no new files".
The body is streamed to an ephemeral tempfile on the API pod's PVC and forwarded to object storage; nothing is fully buffered in RAM. Maximum tarball size is runners.planArtifactsMaxBytes (default 256 MiB; minimum 10240 bytes). Both the Content-Length header and the streamed length are enforced — oversize uploads receive HTTP 413.
Returns 204 on success.
POST /api/terrapod/v1/runs/{run_id}/resource-profile
Content-Type: application/json
Called by the runner entrypoint at exit (EXIT trap, fires on every catchable termination — clean success, plan errored, OPA failed, SIGTERM during apply). Captures cgroup-v2 peak memory + cumulative CPU + the runner script's exit code.
Body (all fields optional — the runner sends whatever it could read):
{
"peak_memory_bytes": 1500000000,
"peak_cpu_usec": 42000000,
"exit_code": 0
}peak_memory_bytes— from/sys/fs/cgroup/memory.peakpeak_cpu_usec—usage_usecfrom/sys/fs/cgroup/cpu.statexit_code— script's actual exit status
Negative values, non-integers, or booleans return 400. Missing fields are not clobbered (existing values preserved).
Note: SIGKILL is uncatchable, so this endpoint never fires on OOM-killed runs. Those are covered by the listener's K8s-terminated-state report on the job-status path; runner-exit-status ends up "oom" either way. See Run Response Attributes (Resource Profile / OOM) for the full signal flow.
Returns 204 on success.
GET /api/terrapod/v1/binary-cache/{tool}/{version}/{os}/{arch}
Returns a 302 redirect to a presigned URL for the binary. tool is terraform or tofu.
Required: Authentication (runner token, API token, or session).
GET /api/terrapod/v1/admin/binary-cache
POST /api/terrapod/v1/admin/binary-cache/warm
Pre-cache a specific tool version.
DELETE /api/terrapod/v1/admin/binary-cache/{tool}/{version}
GET /api/terrapod/v1/agent-pools/{pool_id}/events
Server-Sent Events stream for real-time agent pool updates. Emits events when listeners heartbeat or join the pool. Used by the agent pool detail page for live listener status updates.
Event types:
| Event | Trigger |
|---|---|
listener_heartbeat |
A listener sends its periodic heartbeat |
listener_joined |
A new listener joins the pool |
Required permission: Platform admin or audit.
All provider mirror endpoints require authentication (runner token, API token, or session).
GET /v1/providers/{hostname}/{namespace}/{type}/index.json
GET /v1/providers/{hostname}/{namespace}/{type}/{version}.json
Returns platform-specific download URLs with zh: (zip hash) checksums.
GET /api/terrapod/v1/auth/providers
GET /api/terrapod/v1/auth/authorize
GET /api/terrapod/v1/auth/callback
POST /api/terrapod/v1/auth/callback
GET /api/terrapod/v1/auth/sessions
POST /api/terrapod/v1/auth/logout
GET /oauth/authorize
POST /oauth/token
Immutable record of API requests. Requires admin or audit role.
GET /api/terrapod/v1/admin/audit-log
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
filter[actor] |
string | Filter by actor email |
filter[resource-type] |
string | Filter by resource type (e.g. workspaces, runs) |
filter[action] |
string | Filter by HTTP method (GET, POST, PATCH, DELETE) |
filter[since] |
datetime | Only entries after this timestamp (RFC3339) |
filter[until] |
datetime | Only entries before this timestamp (RFC3339) |
page[number] |
integer | Page number (default: 1) |
page[size] |
integer | Page size (default: 20, max: 100) |
Response: JSON:API list of audit-log-entries with pagination metadata.
Example:
curl "https://terrapod.example.com/api/terrapod/v1/admin/audit-log?filter[actor]=admin@example.com&page[size]=10" \
-H "Authorization: Bearer $TERRAPOD_TOKEN"User management endpoints. List and show require admin or audit role. Update and delete require admin role.
GET /api/terrapod/v1/users
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
filter[email] |
string | Filter by email (case-insensitive substring match) |
page[number] |
integer | Page number (default: 1) |
page[size] |
integer | Page size (default: 20, max: 100) |
GET /api/terrapod/v1/users/{email}
PATCH /api/terrapod/v1/users/{email}
Updatable attributes: is-active, display-name.
When is-active is set to false, all sessions for that user are revoked immediately.
DELETE /api/terrapod/v1/users/{email}
Cascades: revokes all sessions, deletes all role assignments.
Workspace-scoped notifications that fire on run lifecycle events. Three destination types: generic (webhook with HMAC-SHA512 signing), slack (Block Kit formatted), and email (SMTP).
POST /api/terrapod/v1/workspaces/{id}/notification-configurations
Request body:
{
"data": {
"type": "notification-configurations",
"attributes": {
"name": "deploy-alerts",
"destination-type": "generic",
"url": "https://example.com/webhook",
"token": "my-hmac-secret",
"enabled": true,
"triggers": ["run:completed", "run:errored"]
}
}
}Destination types:
| Type | Required fields | Optional |
|---|---|---|
generic |
url |
token (HMAC-SHA512 signing) |
slack |
url (Slack webhook URL) |
— |
email |
email-addresses (list) |
— |
Valid triggers: run:created, run:planning, run:needs_attention, run:planned, run:applying, run:completed, run:errored, run:drift_detected
Required permission: admin on the workspace.
GET /api/terrapod/v1/workspaces/{id}/notification-configurations
Required permission: read on the workspace.
GET /api/terrapod/v1/notification-configurations/{id}
Required permission: read on the associated workspace.
PATCH /api/terrapod/v1/notification-configurations/{id}
Same body format as create. Only include attributes to change. Token is never returned in responses — only has-token: true/false.
Required permission: admin on the workspace.
DELETE /api/terrapod/v1/notification-configurations/{id}
Required permission: admin on the workspace.
POST /api/terrapod/v1/notification-configurations/{id}/actions/verify
Sends a test payload to the configured destination and returns the delivery response.
Required permission: admin on the workspace.
OPA policy-as-code enforcement. Policy sets and their policies are admin-managed; per-run policy evaluations are readable by anyone with read on the run's workspace. See policies.md for the Rego authoring contract.
GET /api/terrapod/v1/policy-sets
Returns all policy sets. Required permission: admin or audit.
POST /api/terrapod/v1/policy-sets
{
"data": {
"type": "policy-sets",
"attributes": {
"name": "production-guardrails",
"description": "Mandatory guardrails for production",
"enforcement-level": "mandatory",
"global-scope": false,
"allow-labels": {"env": ["prod"]},
"deny-names": ["prod-sandbox"]
}
}
}enforcement-level is advisory (default) or mandatory. Scoping is global-scope: true (every workspace) or the allow-labels / allow-names / deny-labels / deny-names rules (same label model as roles; deny wins). Required permission: admin.
Set source to "vcs" to create a policy set that syncs .rego files from a git repository instead of managing policies inline via the API:
{
"data": {
"type": "policy-sets",
"attributes": {
"name": "security-baseline",
"enforcement-level": "mandatory",
"source": "vcs",
"vcs-connection-id": "vcs-<uuid>",
"vcs-repo-url": "https://github.com/org/policies",
"vcs-branch": "main",
"policy-path": "policies"
}
}
}VCS-specific attributes:
| Attribute | Type | Description |
|---|---|---|
source |
string | "inline" (default) or "vcs" |
vcs-connection-id |
string | Required when source=vcs. References a VCS connection (vcs-<uuid> format). |
vcs-repo-url |
string | Required when source=vcs. HTTPS clone URL of the repo. |
vcs-branch |
string | Branch to track. Defaults to the repo's default branch if empty. |
policy-path |
string | Directory within the repo containing .rego files. Only direct children are loaded (no recursive descent). Empty string means repo root. |
vcs-last-commit-sha |
string | Read-only. SHA of the last successfully synced commit. |
vcs-last-synced-at |
string | Read-only. RFC 3339 timestamp of last successful sync. |
vcs-last-error |
string|null | Read-only. Error message from the most recent sync attempt, or null. |
When source=vcs, inline policy CRUD is rejected with 409 Conflict — policies are managed exclusively by the linked repository.
POST /api/terrapod/v1/policy-sets/{id}/actions/sync
Triggers an immediate sync of a VCS-sourced policy set. Returns 202 Accepted with the current policy set state; the actual sync runs asynchronously. Returns 409 Conflict if the policy set has source=inline. Required permission: admin.
GET /api/terrapod/v1/policy-sets/{id} # policies embedded
PATCH /api/terrapod/v1/policy-sets/{id} # partial update
DELETE /api/terrapod/v1/policy-sets/{id}
Deleting a set removes its policies; recorded run evaluations are kept (set reference nulled, name snapshot retained). Required permission: admin (admin/audit for show).
POST /api/terrapod/v1/policy-sets/{id}/policies # add a policy
PATCH /api/terrapod/v1/policies/{id} # update
DELETE /api/terrapod/v1/policies/{id}
{
"data": {
"type": "policies",
"attributes": {
"name": "no-public-buckets",
"rego": "package terrapod\n\ndeny contains msg if { ... }"
}
}
}The Rego is validated with opa check on create/update — broken Rego, or Rego that does not declare package terrapod, is rejected with 422. Required permission: admin.
GET /api/terrapod/v1/runs/{run_id}/policy-evaluations
Returns the policy evaluations recorded for a run, plus a meta.summary (status: passed / advisory-failed / blocked, and counts). Each evaluation's result carries the per-policy violations/warnings. This is the endpoint behind the run's policy-checks relationship link. Required permission: read on the run's workspace.
POST /api/terrapod/v1/runs/{run_id}/actions/override-policy
Overrides every failed/errored policy evaluation of a run and immediately re-drives a run held at the post-plan policy gate. Required permission: admin on the run's workspace.
GET /api/terrapod/v1/runs/{run_id}/policy-bundle
Returns the applicable policy sets + run/workspace context for a run. Used by the runner during the plan phase to drive OPA evaluation locally. A persistent fetch failure on the runner is fatal to the run (see docs/runners.md — OPA Policy Evaluation) — there is no silent skip. Response shape is a flat JSON document (not JSON:API — the runner is the only consumer):
{
"policy_sets": [
{
"id": "polset-...",
"name": "...",
"enforcement_level": "mandatory",
"policies": [
{"id": "pol-...", "name": "...", "rego": "package terrapod\n..."}
]
}
],
"context": {
"workspace": {"id": "...", "name": "...", "labels": {...}},
"run": {"id": "...", "message": "...", "source": "...", "is_destroy": false, "plan_only": false}
}
}policy_sets is an empty list when nothing is in scope for the workspace — the runner then skips evaluation entirely. Required permission: runner token scoped to this run_id.
POST /api/terrapod/v1/runs/{run_id}/policy-results
{
"results": [
{
"policy_set_id": "polset-...",
"policy_set_name": "...",
"enforcement_level": "mandatory",
"outcome": "failed",
"result": {
"policies": [
{"policy": "...", "passed": false, "violations": ["..."], "warnings": [], "error": null}
],
"evaluated_at": "2026-05-24T10:00:00Z"
}
}
]
}Records the runner's policy-evaluation outcomes for the run. Persisted via Postgres ON CONFLICT DO NOTHING on (run_id, policy_set_id) so a retried POST after a transient failure is idempotent. The runner POSTs this before posting plan-result, so the API's post-plan gate sees the rows when it queries them. Required permission: runner token scoped to this run_id.
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 204 | Deleted (no content) |
| 302 | Redirect (presigned URLs, binary cache, artifact downloads, OAuth flows) |
| 400 | Bad request (validation error) |
| 401 | Unauthorized (missing or invalid token) |
| 403 | Forbidden (insufficient permissions) |
| 404 | Not found |
| 409 | Conflict (lock conflict, duplicate resource, label change would reduce your access) |
| 422 | Unprocessable entity (semantic validation error) |
| 503 | Service unavailable (readiness check failed) |

