CLICommandsl4 tags

l4 tags apply

l4 tags apply makes a virtual key in LevelFour match a YAML file. When no virtual key has the file's name, it creates one. When one does, the file replaces that key's whole definition. The command prints the difference before it sends anything.

l4 tags apply -f <file> [flags]
Nothing changes in your cloud account. A virtual key changes how LevelFour labels your costs, and your provider tags stay as they are.

What it changes

The command reads GET /api/v1/tags/keys to find a virtual key named like the file, then:

  • with no such key, posts the file to /api/v1/tags/virtual and creates one
  • with one whose rules the file changes, sends PUT /api/v1/tags/virtual/{key_id} with the whole file, and the key's rules_version as expected_version
  • with one whose rules the file leaves alone, sends PATCH /api/v1/tags/virtual/{key_id} with the settings that moved, so editing a description does not reprocess a year of spend

The file replaces the definition, so a config you removed from the file is gone from the key once the command finishes. A change to the configs, the collapsed keys, can_override or effective_from starts a job that reprocesses the key from effective_from to the current month, and the key reads reprocessing until it ends.

apply finds the key by name. Change name in the file and the next run creates a second key, leaving the first in place. To rename a key, send its new name to PATCH /api/v1/tags/virtual/{key_id}, then change the file to match.

File format

A tag file describes one virtual key. l4 tags preview reads the same format.

teams.yaml
name: Teams
description: Owning team across AWS and GCP.
effective_from: "2026-06"        # optional, YYYY-MM; omitted = current month
can_override: false               # optional
collapsed_keys:                   # optional, in priority order
  - source: {origin: provider, key: owner}
    providers: [aws]              # optional scope
    prefix: ""                    # optional value prefix
configs:                          # in priority order, first match wins
  - value: data
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
  - value: team-a
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: tag, key: team, operator: flexible_match, values: [Team A]}
  - split:
      - {value: infra, pct: 60}
      - {value: mobile, pct: 40}
    rules:
      - providers: [aws]
        where:
          - {dimension: account, operator: is, values: ["999900001111"]}
  - cost_based:
      source: {origin: provider, key: team}
      input:
        - providers: [aws]
          where:
            - {dimension: charge_type, operator: is, values: [Usage]}
    rules:
      - providers: [aws]
        where:
          - {dimension: service, operator: starts_with, values: [AWS Support]}
    time_frame: {start: "2026-07-01"}   # optional
FieldMeaning
nameRequired. The key's name, and how apply finds the saved key
descriptionFree text
effective_fromFirst billing month the key covers, as a quoted YYYY-MM. Omitted means the current month
can_overrideOn a key named after a provider key, whether configs replace the provider value. Defaults to false
collapsed_keys[].sourceThe key to copy from: origin (provider or virtual) and key
collapsed_keys[].providersLimits the source to these providers
collapsed_keys[].prefixText put in front of the copied value
configs[]Required. Each config carries one of value, split or cost_based, and only one, plus rules and an optional time_frame
configs[].valueOne value for every line the config matches
configs[].split[]value and pct pairs that add up to 100
configs[].cost_basedsource, the key whose values share the cost, and input, rules that select the cost to measure the shares on
configs[].rules[]providers (aws, gcp) and where. One matching rule is enough
configs[].rules[].where[]Conditions, all of which must hold: dimension, key, operator (default is) and values
configs[].time_framestart and end dates as YYYY-MM-DD. Either one can be left out

l4 rejects a field it does not recognise, so a typo such as condition: for where: fails the command instead of dropping the rule. Virtual tags has the dimensions, the operators, and a worked example for each kind of config.

Flags

FlagDescription
-f <file>Tag file to apply. Required
--dry-runPrint the difference and send nothing. Exits 2 when the file differs from the saved key
--yesSkip the confirmation prompt

Global flags (--json, --jq, --template, --quiet, --token, --api) apply. See CLI overview.

Dry run

--dry-run reads the saved key, prints the difference and stops. It only reads, so a read-only key can run it. The exit code says what it found:

ExitMeaning
0The saved key matches the file
2Applying would change the key, or no key has that name yet
1 or 4An error, as in Errors

Run it on each pull request that touches a tag file, and the pipeline fails whenever the saved key and the file disagree:

l4 tags apply -f tags/teams.yaml --dry-run --quiet
A dry run that exits 2 is the check doing its job: it found a difference to apply. Exit 1 is the one that means something broke.

Confirmation

By default, the command prints the difference, then asks for confirmation with a [y/N] prompt. Anything other than y or yes aborts, prints Aborted., and exits 0 without sending the change. Pass --yes to skip the prompt in an interactive shell. When the file matches the saved key, there is nothing to confirm and nothing is sent.

When stdout is not a TTY, so in a pipeline or a CI job, the prompt never appears and the change goes through. A job that runs apply without --dry-run rewrites the key and reprocesses its costs from effective_from with no one asked to confirm.

Requires a read-write key

Writes need the org:tags:write permission, which a read-write key carries, and l4 auth login mints a read-only key. A read-only key fails with exit 1 and permission denied: this API key lacks permission for this operation. See CLI authentication for how to create a read-write key.

Re-running it

A second run with the same file finds no difference and sends nothing. The file wins over the dashboard: a run after someone edited the key there replaces their edit, and the printed difference shows what it undoes.

If someone changes the key between the command's read and its write, the API answers 409 version_conflict and saves nothing. Run apply again to diff against the new version.

Examples

l4 tags apply -f teams.yaml
l4 tags apply -f teams.yaml --dry-run
l4 tags apply -f teams.yaml --yes --json
LEVELFOUR_TOKEN=$L4_RW_KEY l4 tags apply -f teams.yaml --yes --quiet

Apply several files in order, with the key that another one reads going first:

for f in tags/applications.yaml tags/business-units.yaml; do
  l4 tags apply -f "$f" --yes || exit 1
done

Output

Default output is the difference, then a success line naming the key and the reprocessing job. With --json the whole API envelope is printed. The fields it carries:

.success
.data.key                    # the key as l4 tags show --json prints it
.data.key.id
.data.key.rules_version
.data.key.status             # "reprocessing" while the job runs
.data.job                    # null when nothing needs reprocessing
.data.job.id
.data.job.status             # "queued" or "running" right after a change
.data.job.from_period
.data.job.to_period
.timestamp
.data.job.status at queued or running is the expected result after a rules change: the definition is saved and LevelFour is reprocessing costs. Follow the job with l4 tags show. A run that only changed settings sends PATCH and answers with .data.job at null, which is also correct.

--dry-run --json prints the plan instead of an API envelope: action (create, update or none), key_id on an existing key, and definition, the body the real run would send.

apply asks for confirmation the same way delete does. Outside a terminal, without --yes, it exits 1 with applying &lt;key&gt; outside a terminal needs --yes and sends nothing. A pipeline that means to apply passes --yes; one that means to check passes --dry-run.

Validation problems

A file that breaks a rule fails with 422 validation_failed, and the error lists every problem at once. Each problem carries a code and, where one applies, the position of the config, rule, condition, split or collapsed key at fault. The API counts those from 0 and the CLI prints them from 1, matching how l4 tags show numbers configs. The codes a file author meets most:

CodeCause
rule_no_providerA rule lists no provider
rule_no_condition_valueA condition has no values
key_requiredA tag or virtual_tag condition has no key
dimension_unsupported_for_providerThe dimension does not exist on one of the rule's providers, such as category on GCP
operator_unsupportedThe operator does not apply to the dimension
split_not_100A split's percentages do not add up to 100
split_value_duplicateA value appears twice in one split
time_frame_invalidThe time frame ends before it starts
effective_from_invalideffective_from is not a YYYY-MM month
effective_from_unavailableeffective_from is further back than the backfill limit
collapsed_source_unknownA collapsed key names a key that does not exist
cost_source_unknownA cost-based source names a key that does not exist
nested_key_unknownA virtual_tag condition names a key that does not exist
nested_cycleA chain of keys that read each other loops back to its start
nesting_too_deepA chain of nested keys goes past the depth limit
nested_allocation_unsupportedA virtual_tag condition reads a key that holds split or cost-based configs
limit_exceededThe key goes past one of the limits

The Tags API reference lists every code.

Errors

ExitSymptomCause
4Authentication requiredNo token resolvable; run l4 auth login or set LEVELFOUR_TOKEN
2--dry-run found a differenceThe file and the saved key differ. Nothing was sent
1The file does not parse, or has an unknown fieldFix the file against File format
1401 authentication failedToken revoked, expired, or from the wrong environment
1403 permission deniedThe key is read-only, or your role lacks org:tags:write
1409 version_conflictThe key changed between the read and the write. Run apply again
1422 validation_failedThe file breaks a rule. See Validation problems