> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tensor9.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authoring Templates

An ops command template is a small file you check into a Git repo (or
ship straight to your control plane) that describes one runnable
operation, the data it touches, and the side effects it has.
Customers review the template once and either approve a single
execution or pre-approve repeated runs within constraints. The actual
work happens inside your customer's appliance, not on your laptop.

<img src="https://mintcdn.com/tensor9/Q7wEl9vOj9lICJg4/images/diagrams/templates-howitworks-dark.svg?fit=max&auto=format&n=Q7wEl9vOj9lICJg4&q=85&s=e6ba063c5b4df2f57ae3c51d434437da" className="block dark:hidden" alt="Why templates: source from Git so PR review gates every change, your customer pre-approves the body and variable constraints once, then you fill in variable values per run with no per-run approval within the signed scope." width="820" height="200" data-path="images/diagrams/templates-howitworks-dark.svg" />

<img src="https://mintcdn.com/tensor9/Q7wEl9vOj9lICJg4/images/diagrams/templates-howitworks-light.svg?fit=max&auto=format&n=Q7wEl9vOj9lICJg4&q=85&s=d9b8ed7d2f985812b90e05836ba2d4ff" className="hidden dark:block" alt="Why templates: source from Git so PR review gates every change, your customer pre-approves the body and variable constraints once, then you fill in variable values per run with no per-run approval within the signed scope." width="820" height="200" data-path="images/diagrams/templates-howitworks-light.svg" />

## Three template flavors

There are three template kinds, each identified by file extension.
Both `.tensor9.*` and `.t9.*` are accepted.

| Flavor        | Extension                          | Wire-level type   | When to use                                                                                                              |
| ------------- | ---------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Terraform** | `.tensor9.tf` / `.t9.tf`           | `TfFromTmpl`      | Mutating cloud or Kubernetes work, anything that benefits from declarative HCL plus provisioners or data-source queries. |
| **Script**    | `.tensor9.sh` / `.t9.sh`           | `ScriptFromTmpl`  | One-shot bash. Metadata lives in a `: <<'TENSOR9' ... TENSOR9` heredoc at the top of the file.                           |
| **Kubectl**   | `.tensor9.kubectl` / `.t9.kubectl` | `KubectlFromTmpl` | Plain `kubectl` invocations against the appliance's K8s cluster.                                                         |

The wire-level type names appear in the audit log and in `tensor9 ops
command list` output; you'll mostly see them when correlating events
across systems.

Most teams reach for Terraform. The provisioner-driven shape works
across cloud APIs, Kubernetes, and arbitrary local-exec, and the
data-source-driven shape is even more reviewable for read-only
queries against a cloud provider's API. The script flavor exists for
cases where bash is genuinely simpler.

## Anatomy of a Terraform template

There are two canonical shapes for `.tensor9.tf` templates,
distinguished by where the actual work happens. Both are common in
the open-source `tensor9ine/cmdlib` reference repo.

### Shape A: provisioner-driven (shell pipeline wrapped in HCL)

The simplest example is `linux/disk-usage`. It runs `df -h` on the
appliance host and surfaces the output. Read-only.

```terraform theme={null}
terraform {
  required_providers {
    tensor9 = { source = "tf-providers.prod-1.tensor9.com/tensor9/tensor9", version = "~> 2.41" }
    null    = { source = "hashicorp/null", version = "~> 3.2" }
  }
}

provider "tensor9" {
  mode = "ops"
}

variable "MOUNT_PREFIX" {
  type        = string
  default     = "/"
  description = "Filesystem path prefix; only mounts under this prefix are reported"
  validation {
    condition     = startswith(var.MOUNT_PREFIX, "/")
    error_message = "MOUNT_PREFIX must be an absolute path beginning with /"
  }
}

resource "tensor9_command" "this" {
  name        = "disk-usage"
  display     = "Disk usage"
  description = "Show human-readable disk usage (df -h) for mounts under MOUNT_PREFIX. Read-only."
  icon        = "disk"
  data_access = ["Storage"]
}

resource "null_resource" "df" {
  triggers = {
    mount_prefix = var.MOUNT_PREFIX
  }
  provisioner "local-exec" {
    command = "df -h | awk 'NR==1 || $6 ~ \"^${var.MOUNT_PREFIX}\"'"
  }
}
```

Your customer reviews the metadata block (`tensor9_command "this"`),
the variable, and the literal shell pipeline. On approval, the
appliance shells out via `local-exec`, and Tensor9 captures every
line prefixed with `<resource> (local-exec): ` from the apply log as
the command's stdout, uploads the captured bytes to your blob store
(S3 in your customer's account), and stores only a small
`[blob: ...]\\n<presigned-url>` payload on the command record.
Templates can write to stdout freely up to the 5 GiB cap; the
release script fetches the actual bytes via the URL during
preview, sha256-verifies, and shows them to the customer.

### Shape B: data-source-driven (cloud API queries)

For read-only questions that map cleanly onto a cloud provider's API
(list buckets, find idle instances, describe a workflow), you can
write a template with no `null_resource` at all. Use `data` blocks
to query, `locals` to filter / transform, and `output` blocks to
emit JSON.

```terraform theme={null}
terraform {
  required_providers {
    tensor9 = { source = "tf-providers.prod-1.tensor9.com/tensor9/tensor9", version = "~> 2.41" }
    aws     = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "tensor9" {
  mode = "ops"
}

variable "REGION" {
  type        = string
  default     = "us-east-1"
  description = "AWS region for the provider"
}

provider "aws" {
  region = var.REGION
}

resource "tensor9_command" "this" {
  name        = "list-public-s3-buckets"
  display     = "List public S3 buckets"
  description = "Diagnostic: list S3 buckets whose ACL grants READ or WRITE to AllUsers / AuthenticatedUsers. Read-only."
  icon        = "search"
  data_access = ["Infrastructure"]
}

data "aws_s3_buckets" "all" {}
data "aws_s3_bucket_acl" "by_bucket" {
  for_each = toset(data.aws_s3_buckets.all.buckets[*].name)
  bucket   = each.value
}

locals {
  public_buckets = [/* ... filtering logic ... */]
}

output "public_buckets" {
  value = local.public_buckets
}
```

When the template has `output { ... }` blocks and no `local-exec`,
the appliance captures `tofu output -json` as the command's stdout.
The surface a customer sees at review time names the exact AWS APIs
that will be called, which is the information they need to approve.
The trade-off is more verbose HCL and per-API network round trips at
execution time.

### Three things doing real work in either shape

**`provider "tensor9" { mode = "ops" }`**. `mode = "ops"` tells the
Tensor9 provider that this template is metadata-only from its
perspective. The provider does not need to reach the control plane,
and the `endpoint` argument is not required. Use this mode for every
ops template; the only time you'd set `mode` to something else is
for non-template stacks the same provider also serves. The full
provider reference (other modes, every `tensor9_*` resource and data
source) is published alongside the provider releases at
`tf-providers.prod-1.tensor9.com/tensor9/tensor9`.

**`resource "tensor9_command" "this"`**. Exactly one of these per
template. It is the metadata block customers review at approval
time:

| Attribute      | Required | Purpose                                                                                                    |
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `name`         | yes      | Machine-readable identifier (lowercase, hyphenated, 1-64 chars). Defaults to the filename stem if omitted. |
| `display`      | yes      | Human-readable label shown on your customer's review screen.                                               |
| `description`  | yes      | One or two sentences describing what the command does. Your customer reads this before approving.          |
| `icon`         | no       | Hint for the review UI (e.g. `disk`, `server`, `activity`).                                                |
| `data_access`  | yes      | List of data categories the command can see. See "Data access tags" below.                                 |
| `side_effects` | no       | List of side-effect tags. See "Side-effect tags" below.                                                    |

**The execution shape itself**, which is either `null_resource` +
`provisioner "local-exec"` (Shape A), or `data` blocks + `output`
blocks (Shape B), or a mix. See "Execution model" below for the
operational details.

#### Source of truth: HCL bytes are canonical

The HCL bytes the appliance executes and the metadata your customer
sees in the review pane come from the same source. At template-import
time, the bytes are parsed once, the parsed `tensor9_command "this"`
metadata is rendered into the review pane, and a content hash binds
the parsed view to the executed bytes. If the parser ever produced
a different result than the executor (a Tensor9 bug), the content
hash check would catch the divergence. When you author a template,
the literal HCL `description = "..."`, `data_access = [...]`, etc.
are exactly what your customer reads at approval time. There is no
separate "review-display" layer to keep in sync.

#### Outputs are surfaced verbatim; `sensitive = true` does not redact

Terraform's `output { value = ...; sensitive = true }` controls how
`tofu` displays the value in its own CLI; it does **not** affect the
Tensor9 release flow. Any `output` block's value flows through the
release pipeline as-is: your customer sees the raw decrypted value
in the support portal before signing release, and you see whatever
your customer releases.

If your template legitimately emits a secret (e.g.
`aws/rotate-iam-access-key.tensor9.tf` returns a freshly-minted
secret access key), call this out in the template's `description`
so your customer's reviewer knows what they're approving:
"Emits a fresh AWS access key in the released output." Your customer
still sees the value, and the audit chain still records that it was
released, but the surface-level expectation is now clear.

## Variables

Each `variable` block becomes a parameter you set at submission time:

```bash theme={null}
tensor9 ops command create \
  --appName my-app \
  --customerName acme-corp \
  --template linux-disk-usage \
  --vars MOUNT_PREFIX=/var/lib/myapp \
  --commandName check-myapp-disk \
  --reason "investigating disk pressure"
```

Validation runs on the appliance before the template executes. If
the value you pass fails the `condition`, the command fails with
the `error_message` you wrote. Validation is your customer's only
guarantee that the values you supply are constrained, so spend a
moment writing real conditions on every variable that can vary.

## Data access tags

`data_access` declares the categories of appliance data the command
can touch. Your customer sees this list at approval time and can
reject if a template asks for more than they expect. Values must
come from the canonical enum:

| Tag               | What it covers                                                                      |
| ----------------- | ----------------------------------------------------------------------------------- |
| `Secrets`         | API keys, credentials, signing keys.                                                |
| `Pii`             | Personally-identifiable information.                                                |
| `Rbac`            | Identity and access bindings.                                                       |
| `Logs`            | Application logs, system logs (`log show`, `journalctl`).                           |
| `Configs`         | Application configuration files.                                                    |
| `Infrastructure`  | Pod / node / cluster metadata, AWS resource state, system processes.                |
| `Network`         | Network connections, routing, security-group state.                                 |
| `Storage`         | Disk usage, file listings, snapshot metadata, EBS volumes.                          |
| `CustomResources` | Application-defined resources (Temporal workflows, custom CRDs, app-owned objects). |
| `Metrics`         | Application metrics, telemetry endpoints, performance counters.                     |

Pick the smallest set that honestly describes what the command can
see. A `kubectl get pods` is `Infrastructure`. A `tail` on an app log
is `Logs`. A `df` is `Storage`. Adding more tags than necessary
makes templates harder to approve.

## Side-effect tags

`side_effects` declares what the command does to the system, beyond
just reading. The shape is a free-form list of strings you write;
your customer's review screen surfaces each tag verbatim.
The convention in `cmdlib` is kebab-case verb-object phrases:
`pod-restarts`, `node-drain`, `pod-evictions`, `iam-key-rotation`,
`ebs-snapshot`, `rds-snapshot`.

```terraform theme={null}
resource "tensor9_command" "this" {
  name         = "drain-node"
  display      = "Drain node"
  description  = "Cordon the node and evict its pods so it can be safely terminated."
  icon         = "server"
  data_access  = ["Infrastructure"]
  side_effects = ["node-drain", "pod-evictions"]
}
```

Read-only templates omit `side_effects` entirely. Mutating templates
list every distinct visible-to-customer effect. If you add templates
against `cmdlib`, match the existing tag vocabulary in that repo to
keep your customer-side review experience consistent.

## Permission tiers

Three tiers govern what a Kubectl-tier command can do, set at
submission time on `tensor9 ops command create`:

| Tier        | Intent                                                                                              |
| ----------- | --------------------------------------------------------------------------------------------------- |
| `ReadOnly`  | Inspect-only. The template's RBAC role grants `get` / `list` / `describe` verbs in Kubernetes.      |
| `ReadWrite` | Mutate state your customer can re-create (restart deployments, scale replicas, rotate credentials). |
| `Admin`     | Destructive or platform-touching (drop database, delete namespace, rotate root keys).               |

The tier selects one of three pre-provisioned ServiceAccounts on the
appliance (one per tier, set up at install time). Each tier SA has a
fixed cluster-wide role granting that tier's verbs; the actuator
does not yet narrow the role to the specific namespace or resource
list your template declares. Your customer's pre-approval review is
where scope is discussed; per-command role minting bounded by the
template's declarations is on the roadmap. Choose the lowest tier
that still lets the template do its job. The tier is only consulted
for Kubectl-tier commands today; Tf and Script templates run with
the appliance host's process identity and rely on `data_access` plus
`side_effects` for customer-side review.

## Versioning

Pin every `required_providers` constraint with `~>` (pessimistic
lower bound), not `>=`. The execution model below explains why
constraint discipline matters more here than in normal Terraform:

```terraform theme={null}
terraform {
  required_providers {
    tensor9    = { source = "tf-providers.prod-1.tensor9.com/tensor9/tensor9", version = "~> 2.41" }
    null       = { source = "hashicorp/null", version = "~> 3.2" }
    aws        = { source = "hashicorp/aws", version = "~> 5.0" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.20" }
  }
}
```

`~> 2.41` accepts `2.41.x` patch updates but rejects a `3.0.0` major.
`~> 5.0` accepts `5.x` minor updates but rejects a `6.0`. Pin every
external provider, not just `tensor9`. The provider source URL for
the `tensor9` provider is published as part of each release; use the
exact URL above.

There is no committed lock file (`.terraform.lock.hcl`); the
execution model uses a fresh working directory per run, so lock
files have nowhere to persist. Constraint discipline is the only
thing standing between a clean re-execution and a surprise breaking
change in a transitive provider.

## Execution model

The operational details of how the appliance turns a template into
output are worth understanding, because the model is intentionally
narrower than normal Terraform.

### Per-execution working directory

Every command execution gets a fresh temporary directory:

1. Appliance creates a unique tempdir.
2. Writes the template's HCL to `main.tf` in that tempdir.
3. Runs `tofu init -input=false` (downloads providers; consults
   public registry and `tf-providers.prod-1.tensor9.com`).
4. Runs `tofu apply -auto-approve -input=false -no-color -refresh=false`.
5. Runs `tofu output -json`.
6. Captures local-exec stdout (if any) and the JSON output (if any).
7. Deletes the tempdir.

State is born and dies with each invocation. There is no remote
backend, no state lock, no `terraform.tfstate` carried between runs,
no `terraform import` step, no workspaces.

### Network requirements for `tofu init`

Because step 3 runs every time and there's no shared plugin cache,
the appliance must be able to reach **two registry endpoints over
HTTPS on every command**:

* `registry.opentofu.org` (or the equivalent Terraform registry your
  external provider sources resolve through), to fetch
  `hashicorp/aws`, `hashicorp/null`, `hashicorp/kubernetes`, etc.
* `tf-providers.prod-1.tensor9.com`, to fetch the `tensor9` provider.

Customers running their appliance in a restricted-egress VPC must
whitelist both endpoints. Without that, every ops command fails at
the `tofu init` step with a generic "provider download failed" error,
which the appliance surfaces as `ExecutionFailed` with the init
stderr in the output. Local plugin caching (to amortize download
cost across runs) is a roadmap item; for now, every execution pays
the full init cost.

### What this implies for what you can write

* **Every `resource` block looks like a fresh `Create` to Terraform**
  on every execution. There is no prior state to compute drift
  against. For naturally-stateful resources (`aws_iam_user`,
  `aws_iam_access_key.old_disabled` that depends on something
  imported), the apply will attempt to create from scratch and fail
  with `EntityAlreadyExists`. The right strategy depends on the
  resource: AWS APIs vary, and there is no single workaround.
  Examples that work in practice:
  * `RunInstances` and `CreateSnapshot` accept idempotency tokens.
  * `kubernetes_annotations` is an upsert against an existing object.
  * `aws s3 cp` honors conditional headers (`--if-none-match`).
  * `CreateUser` requires a check-then-create via
    `null_resource + local-exec` shelling out to `aws iam get-user`
    first.
  * `cmdlib`'s `aws/snapshot-ebs-volume.tensor9.tf` and
    `aws/rotate-iam-access-key.tensor9.tf` are good reference
    patterns.
    The general rule: assume your `apply` runs against an empty state,
    and design the body to either upsert correctly or to no-op cleanly
    when the resource already exists.
* **`-refresh=false` is set deliberately.** The appliance does not
  call provider `read` APIs to verify state before computing the
  diff (there's no prior state to refresh). Three consequences worth
  designing for:

  1. **No drift detection.** A `kubernetes_annotations` (or similar)
     resource just calls the upsert; it does not first check whether
     the upstream object already has the value. This is the right
     model for at-least-once upserts; it does not give you
     compare-and-set semantics.
  2. **No transactional rollback across resources.** If a
     `null_resource` runs a multi-step `local-exec` that succeeds
     halfway and then fails, the next execution starts from an empty
     state again. Partial-mutation rollback is the template author's
     responsibility: design idempotent steps or use a single
     all-or-nothing `local-exec`.
  3. **`data` blocks still run every invocation.** They're the only
     refresh-equivalent and they hit the cloud API on every command,
     not just when something changes. Bill accordingly.

  The general rule: every mutating template is at-least-once. Assume
  you'll re-execute on partial failure and design the body to either
  upsert correctly or to no-op cleanly when the resource already
  exists.
* **Your customer at review time sees the template's HCL, not a
  `tofu plan`.** Plans depend on data-source results which can only
  run after the appliance is authorized to do so. Customers are
  reviewing "what this template will do" plus the declared
  `data_access` and `side_effects`, not a fully-resolved diff. For
  a `for_each` over a data source, the cardinality is not visible
  at review time; bound the blast radius via `data_access` and
  `side_effects` declarations rather than relying on review to
  catch fan-out.
* **`timestamp()` in `triggers` is not necessary.** Because state is
  empty every run, `null_resource` will Create on every execution
  regardless of triggers. The cargo-cult `run_at = timestamp()`
  pattern adds nothing here. Use `timestamp()` only when you need a
  timestamp value at execution time (e.g.
  `kubectl.kubernetes.io/restartedAt = timestamp()` for K8s rolling
  restart annotations, or `timeadd(timestamp(), "-${N}h")` for
  date-window filtering).

### Appliance runtime environment

The local-exec environment ships with a known set of binaries and
environment variables. Templates can rely on:

| Binary                                                              | Notes                                                                                                              |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `tofu`                                                              | OpenTofu (the apply runner). Always present.                                                                       |
| `bash`, `awk`, `sed`, `grep`, `df`, `tail`, `head`, `find`, `xargs` | Standard POSIX tools. Always present.                                                                              |
| `kubectl`                                                           | Pre-configured against the appliance's cluster (no `update-kubeconfig` needed for in-cluster operations).          |
| `aws`                                                               | Available; AWS credentials are injected as env vars (see below) when the template targets an AWS-backed appliance. |
| `jq`                                                                | Available for JSON pipelines in local-exec.                                                                        |

`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`
are injected into the local-exec environment for AWS appliances.
The Terraform AWS provider picks them up automatically from the
ambient environment.

If your template needs a binary not in the list above (e.g. `helm`,
`psql`, a custom CLI), it will fail at execution time. We are
adding more binaries as patterns surface; flag what you need and
we'll discuss.

## Direct-create alternative

If you don't want a Git source for a one-off template, you can ship
a single template directly from a JSON spec file:

```bash theme={null}
tensor9 ops template create \
  --appName my-app \
  --file ./my-template.json
```

The JSON shape mirrors the on-disk template lifted into a structured
form. For a Tf template, the file contains:

```json theme={null}
{
  "name": "disk-usage",
  "displayName": "Disk usage",
  "description": "Show human-readable disk usage (df -h). Read-only.",
  "spec": {
    "kind": "Tf",
    "hcl": "terraform {\n  required_providers { ... }\n}\n\n..."
  },
  "dataAccess": ["Storage"],
  "sideEffects": [],
  "variables": [
    {
      "name": "MOUNT_PREFIX",
      "default": "/",
      "description": "Filesystem path prefix",
      "pattern": "^/.*"
    }
  ]
}
```

For Script templates, replace the `spec` block with
`{"kind":"Script","script":"#!/bin/bash\n..."}`; for Kubectl, with
`{"kind":"Kubectl","invocation":"kubectl get pods -n ${NAMESPACE}"}`.

#### HCL ↔ JSON field name mapping

The on-disk HCL `tensor9_command` block uses snake\_case field names
because that's HCL convention; the JSON envelope uses camelCase
because that's the wire-protocol convention. The two shapes carry
the same information; only the spelling differs:

| HCL (`tensor9_command "this"`) | JSON envelope | Notes           |
| ------------------------------ | ------------- | --------------- |
| `name`                         | `name`        | identical       |
| `display`                      | `displayName` | rename + suffix |
| `description`                  | `description` | identical       |
| `icon`                         | `icon`        | identical       |
| `data_access`                  | `dataAccess`  | snake → camel   |
| `side_effects`                 | `sideEffects` | snake → camel   |

Templates imported from a Git source go through HCL → JSON lifting
automatically, so authors only deal with one shape at a time. You
only see the JSON shape if you use the direct-create path explicitly.

Most teams instead register a Git source (see [Git template
libraries](/fundamentals/operations/sources)) so the templates live
next to the rest of their infra code and benefit from PR review.
The JSON path is best reserved for prototypes and test scaffolding.

<Note>
  The persisted template name is the dir-prefixed filename stem when
  the template comes in via a Git source. `linux/disk-usage.tensor9.tf`
  becomes a template called `linux-disk-usage`. This keeps siblings
  across directories from colliding (e.g. `linux/disk-usage` and
  `darwin/disk-usage` coexist as `linux-disk-usage` and
  `darwin-disk-usage`). The HCL's
  `tensor9_command "this" { name = ... }` is overridden by the
  dir-prefixed stem when imported from a source.
</Note>

## Listing and retrieving templates

```bash theme={null}
# List all templates this app exposes
tensor9 ops template list --appName my-app

# Inspect a single template in full
tensor9 ops template retrieve --appName my-app --templateId <id>
```

Both accept `--output json` for scripting.

## Related

* [Git template sources](/fundamentals/operations/sources): how a folder of templates becomes a re-syncable source.
* [Submitting and tracking ops commands](/fundamentals/operations/lifecycle): how customers see a command and release output.
* [Pre-approvals](/fundamentals/operations/preapproval): how to let customers approve a template once and let you run it many times.
