| name | tfctl |
| description | Interact with HCP Terraform / Terraform Cloud / Terraform Enterprise using the tfctl CLI. Full API coverage.
Use for ANY HCP Terraform or Terraform Cloud or Terraform Enterprise question or action — listing workspaces,
starting/diagnosing runs, reading vars, modifying resources, calling API operations.
|
| license | MPL-2.0 |
tfctl — HCP Terraform CLI
Single binary, full v2 API coverage. Already authenticated.
Hard rules
-
Never pipe tfctl JSON to an external jq. Use the built-in --jq '<expr>' flag — it implies --json and runs gojq on the response envelope.
-
Resolve names with -p, not separate lookup calls. Paths with {workspace}/{team}/{project}/{varset} accept -p workspace=NAME etc. — tfctl resolves name→ID for you. Don't fetch the ID first.
-
Trust the first answer. data: [], data: null, relationships.X.data: null, or stderr "no current run"/"not found" ARE the answer. Don't re-query in another format. Don't walk relationships "to verify".
-
When a named resource is not found, stop completely. Exit code 2 or absence from a listing IS the full answer. Never:
- Try a different resource ID "to verify the endpoint works"
- Pivot to another org/workspace that appeared in the available list
- Explore related resources to find "similar" information
- Use Rule 3 to justify switching to a different resource: if you listed orgs and 'platform' isn't there, the first answer is "platform doesn't exist" — stop, don't use whatever org IS listed instead.
Examples: run-POLICY returns exit 2 → stop, don't query other run IDs. Listing orgs shows no 'platform' → stop, don't use the org that IS listed.
-
Never run tfctl harness exec yourself to self-authorize. The --allow-delete grant is a human's opt-in for your session. If a delete is refused, relay the printed harness exec --allow-delete=<class> command back to the human — do not run it (or set TFCTL_EXEC_SESSION) to grant yourself permission. See Deleting resources.
Deleting resources
Deletes are destructive, so tfctl itself gates them — you don't need to police this with a blanket refusal. A noninteractive tfctl ... -X DELETE only goes through when a human has opted in for this session by launching you via tfctl harness exec --allow-delete=<class> -- <command> (which sets TFCTL_EXEC_SESSION). Otherwise tfctl refuses on its own and tells you what to do.
So when you're asked to delete something, just run the normal command and let tfctl be the gate:
tfctl api PATH -X DELETE
- If the session authorizes that resource's class, it succeeds — that's the human's intent, not a violation; proceed and report the result.
- If it doesn't,
tfctl refuses with a self-documenting message and prints the exact command to hand back (including the harness exec --allow-delete=<class> a human can use to authorize you). Relay that rather than forcing it; don't run harness exec yourself to self-authorize.
- You can't tell in advance whether the session grants a class — so don't guess. Attempt the delete and let the refusal tell you. The refusal is a plain exit 1 with a message naming the class and the
--allow-delete=<class> command; that is your cue to relay. This is a grant gap, NOT an auth failure: never report it as an expired token, an exit code 3, or tell the human to re-login unless tfctl actually says the token is expired/invalid.
- Apply ordinary caution to irreversible deletes (
organizations, projects): they are never covered by the reversible/all wildcards and must be granted by name. For a high-stakes target, confirm intent with the human first even when the session would allow it.
URL shape: per-workspace subpaths live at /workspaces/{workspace}/...
Most "things attached to a workspace" (vars, runs, varsets, remote-state-consumers, configuration-versions, notification-configurations, state-versions) are under /workspaces/{workspace}/..., NOT /organizations/{org}/workspaces/{name}/.... The org-nested form only exists for the workspace resource itself (/organizations/{org}/workspaces/{name}). For everything else, use /workspaces/{workspace}/X -p workspace=NAME.
Exception: Policy checks and run-specific data live under /runs/{run-id}/..., not under workspace paths. For example: /runs/{run-id}/policy-checks.
Anti-patterns to avoid
These paths do not exist; don't try them:
- ❌
/organizations/{org}/workspaces/{name}/vars — use /workspaces/{workspace}/vars -p workspace=NAME instead
- ❌
/organizations/{org}/workspaces/{name}/state-versions — use /workspaces/{workspace}/state-versions -p workspace=NAME
- ❌
/organizations/{org}/workspaces/{name}/configuration-versions — use /workspaces/{workspace}/configuration-versions -p workspace=NAME
- ❌
/workspaces/{workspace}/policy-checks — use /runs/{run-id}/policy-checks instead
- ❌
/organizations/{org}/varsets (partially wrong path) — use /organizations/{organization}/varsets with correct org placeholder
- ❌ External
jq pipes like tfctl ... | jq '...' — always use tfctl api ... --jq '...' with built-in flag
Cookbook — one-line answers for common tasks
tfctl api /organizations/{organization}/workspaces --page-size 1 --jq '.meta.pagination.["total-count"]'
tfctl api /organizations/{organization}/workspaces -f 'search[name]=TERM' --jq '.data[] | {id, name: .attributes.name, current_run: .relationships.["current-run"].data}'
tfctl api /organizations/{organization}/workspaces --all --jq '.data[] | select(.attributes.["terraform-version"] | startswith("1.8")) | .attributes.name'
tfctl api /workspaces/{workspace}/vars -p workspace=NAME --jq '.data[] | {key: .attributes.key, category: .attributes.category, sensitive: .attributes.sensitive}'
tfctl run status NAME_OR_ID
tfctl api /organizations/{organization}/workspaces/NAME --jq '.data.relationships.["current-run"].data.id'
tfctl api /workspaces/{workspace}/runs -p workspace=NAME --jq '.data[] | select(.attributes.status == "planned") | {id, status: .attributes.status}'
tfctl api /organizations/{organization}/workspaces --all \
--jq '.data[] | select(.attributes.["vcs-repo"] != null and .attributes.["vcs-repo"].identifier == "org/repo") | {id: .id, name: .attributes.name}'
tfctl api /organizations/{organization}/teams -f 'filter[names]=TEAM_NAME' --jq '.data[0].id'
tfctl api /team-workspaces -f 'filter[team][id]=TEAM_ID' --jq '.data[] | {workspace_id: .relationships.workspace.data.id, access: .attributes.access}'
tfctl api /organizations/{organization} --jq '.data | {id, name: .attributes.name, created_at: .attributes."created-at", terraform_version_default: .attributes."terraform-version"}'
tfctl api /workspaces/{workspace}/current-state-version -p workspace=NAME --jq '.data | {serial: .attributes.serial, created_at: .attributes.["created-at"], status: .attributes.status}'
tfctl api /organizations/{organization}/varsets --all --jq '.data[] | {name: .attributes.name, id: .id, var_count: (.relationships.vars.data | length)}'
tfctl api /workspaces/{workspace}/configuration-versions -p workspace=NAME --jq '.data[] | {id, source: .attributes.source, created_at: .attributes.created-at}'
tfctl api /workspaces/{workspace}/notification-configurations -p workspace=NAME --jq '.data[] | {id, type: .attributes.destination-type, trigger: .attributes.triggers}'
tfctl api /organizations/{organization}/workspaces --all --jq '.data[] | select(.attributes.["terraform-version"] | ltrimstr("v") | split(".") | [.[0], .[1]] | join(".") | tonumber >= 1.7) | {name: .attributes.name, tf_version: .attributes.["terraform-version"]}'
tfctl api /organizations/{organization}/workspaces --all --jq '.data[] | select(.attributes.name | test("^temp-|^old-") | not) | .attributes.name'
tfctl api /workspaces/{workspace}/runs -p workspace=NAME --all --jq '[.data[] | .attributes.status] | group_by(.) | map({status: .[0], count: length})'
tfctl api /runs/RUN_ID --jq '.data.relationships | {plan: .plan.data.id, apply: .apply.data.id}'
tfctl api /plans/PLAN_ID --jq '.data.attributes.["log-read-url"]'
tfctl run start NAME_OR_ID
tfctl api /workspaces/{workspace}/relationships/remote-state-consumers -p workspace=NAME \
-i '{"data":[{"type":"workspaces","id":"ws-CONSUMER_ID"}]}'
tfctl api /workspaces/{workspace}/relationships/varsets -p workspace=NAME \
-X POST -i '{"data":[{"type":"varsets","id":"varset-VARSET_ID"}]}'
tfctl api /runs/{run-id}/policy-checks --jq '.data[] | {id: .id, status: .attributes.status, enforced: .attributes.enforcement-level}'
tfctl api schema search "KEYWORD" --json
tfctl api schema get OPERATION_ID
Output flags
| Need | Flag |
|---|
| Filter / extract | --jq '<expr>' |
| Full JSON | --json |
| Render for human | --markdown |
| Audit a mutation | --dry-run |
--jq implies --json. Don't pass both. Always pass one explicitly — don't rely on auto-detect.
JSON:API conventions
Responses are JSON:API envelopes: {data: {id, type, attributes, relationships}} (or data: [...] for lists). To follow a link, read data.relationships.<name>.data which gives {id, type} or null. A null is final — there is no related resource.
Pagination: default page 1. Add --all for all pages (cap 2000), or --page-size N / --page-number N for explicit control. For counts, use --page-size 1 --jq '.meta.pagination.["total-count"]'.
Mutations & batch updates
tfctl api /workspaces/{workspace}/vars -p workspace=NAME -a key=VARKEY -a value=VALUE -a category=env
tfctl api /workspaces/{workspace}/vars -p workspace=NAME -a key=VAR1 -a value=VAL1 -a category=env
tfctl api /workspaces/{workspace}/vars -p workspace=NAME -a key=VAR2 -a value=VAL2 -a category=env
tfctl api /workspaces/{workspace}/X -X PATCH -p workspace=NAME -i '{"data":{"type":"X","attributes":{…}}}'
Add --dry-run to preview without sending.
Exit codes (quick map)
| Code | Meaning | Action |
|---|
| 0 | Success | Done |
| 1 | Informational message or usage error | Read stderr; if it says "no current run" or similar, that IS the answer — stop |
| 2 | Not found (workspace/run doesn't exist) OR invalid auth | Verify the ID/name is correct, then check token if still failing |
| 3 | Auth token expired or invalid | Re-authenticate |
| 4 | Network error | Retry after brief delay |
| 5 | Rate limited (429) or server error (5xx) | Retry with backoff |
| 6 | Resource has an error state | The error is already diagnosed in output (e.g., plan failed); read it |
Important: When an API returns data: [] (empty list) or data: null, that IS the answer. Don't retry with different flags or endpoints.
Common troubleshooting
| Symptom | Cause | Fix |
|---|
exit 2 when listing workspaces | Organization doesn't exist or auth token has no access | Verify org name and re-authenticate |
exit 1 with "no current run" | Workspace simply has no active run (informational) | This is the answer — stop, don't verify |
exit 3 when making any API call | Auth token expired | Re-authenticate with tfctl login |
exit 5 (429 rate limit) | Too many requests | Wait and retry; tfctl will backoff automatically |
exit 6 with "plan is errored" | Terraform plan had syntax errors (not CLI error) | Read the plan output for details |
Empty list (data: []) when filtering | No resources match criteria | Verify criteria is correct; empty list is valid answer |
Smart defaults
{organization} resolves from active profile if set.
{workspace} resolves from a local cloud {} block in CWD.
- Already-formed IDs (
ws-…, team-…, prj-…, varset-…) are passed through as-is.