# Virtual tags

> Assign one tag key to every cloud cost line from ordered rules, to fix inconsistent tags, map accounts and services to owners, and split shared costs.

Source: https://docs.levelfour.ai/guides/virtual-tags


A virtual tag is a tag key LevelFour assigns to each cost line of every provider you connect, from rules you write: cloud bills, connected tools such as Claude Code, Cursor or Datadog, and Kubernetes clusters. Your provider tags stay as they are in the bill. The virtual key sits beside them, and you group, filter and split costs by it the way you would by any tag.

Use one when:

* your tags disagree, as when one team shows up as `team=data`, `team=Data` and `team=data-prod`
* ownership follows an account, a project or a service rather than a tag
* one key has to cover the tags of every provider you connect, Google Cloud labels included
* a shared cost, such as the AWS Support fee, has to be divided between teams

> **Warning**
>
> Virtual tags read detailed cost data: the [Cost and Usage Report](/connectors/aws/detailed-cost-data) on AWS and the [detailed billing export](/connectors/gcp/billing-export) on Google Cloud. On Azure they read the cost lines your [Azure connection](/connectors/azure) already loads, which carry their tags. In an account without detailed cost data, LevelFour cannot assign a value to spend, and a per-team total leaves that account out. `GET /api/v1/tags/dimensions` names those accounts under `capabilities.accounts_without_detailed_data`.

## How LevelFour picks a value

For each cost line, LevelFour works down this list and stops at the first step that produces a value:

1. **The provider's value**, when the virtual key has the same name as a provider key and [Can Override](#extend-or-replace-a-provider-key) is off. A line that already carries the provider key keeps that value.
2. **Collapsed keys**, in order. The first source key present on the line supplies the value.
3. **Configs**, in order. The first config whose time frame covers the line's date and whose filter matches assigns its value, or splits the line across several values.
4. **The provider's value**, when Can Override is on and no step above matched.
5. **Unallocated**, for any line left.

A config's filter holds a list of rules, and one matching rule is enough. A rule matches when the line comes from one of the rule's providers and all of its conditions hold.

Position is the only precedence. To pin a narrow case, such as one resource owned by another team, put its config above the broad config that would claim it. [`l4 tags preview`](/cli/commands/tags/preview) reports the spend a lower config matches after a higher one has taken it.

## Build a Teams key

The examples below build one `Teams` key. A config is one entry in the key's ordered list of values, with its rules underneath. The key is a YAML file you pass to [`l4 tags apply`](/cli/commands/tags/apply), and [the file format](/cli/commands/tags/apply#file-format) lists every field. Each snippet is one entry under `configs:`, and [the whole file](#the-whole-file) puts them together.

### One value across two providers

AWS bills Amazon Redshift, Google Cloud bills BigQuery, and the data team owns both. One rule covers both providers, and its condition lists both service names:

```yaml
- value: data
  rules:
    - providers: [aws, gcp]
      where:
        - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
```

A condition with several values matches any one of them.

### An AWS account and a Google Cloud project

The mobile team owns the AWS account `111122223333` and the Google Cloud project `mobile-prod`. Give the `mobile` value one rule per provider. The `account` dimension reads the account ID on AWS and the project ID on Google Cloud:

```yaml
- value: mobile
  rules:
    - providers: [aws]
      where:
        - {dimension: account, values: ["111122223333"]}
    - providers: [gcp]
      where:
        - {dimension: account, values: [mobile-prod]}
```

`operator` defaults to `is`, so these conditions leave it out. Quote account IDs so YAML keeps them as text.

### Fold several spellings into one value

On AWS, the data team's resources carry `team=data`, `team=data-prod` and `team=Data`. On Google Cloud they carry the label `team=data`. Add two rules to the `data` config and all of them become `data`:

```yaml
- value: data
  rules:
    - providers: [aws, gcp]
      where:
        - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
    - providers: [aws]
      where:
        - {dimension: tag, key: team, values: [data, data-prod, Data]}
    - providers: [gcp]
      where:
        - {dimension: tag, key: team, values: [data]}
```

A `tag` condition names its key in `key`. `is` is case-sensitive, so `Data` needs its own entry next to `data`.

### Match spellings nobody has typed yet

`flexible_match` ignores case, spaces, hyphens and underscores on both sides. One condition on `Team A` matches `teamA`, `team_a`, `team-a` and `TEAM A`:

```yaml
- value: team-a
  rules:
    - providers: [aws, gcp]
      where:
        - {dimension: tag, key: team, operator: flexible_match, values: [Team A]}
```

A resource someone tags `Team-A` next quarter lands in `team-a` with no change to the key.

### Change the owner on a date

The infrastructure team ran the `eks-prod` cluster until June 30, and the platform team took it over on July 1. Two configs with the same rule and adjoining time frames keep both halves right:

```yaml
- value: infrastructure
  time_frame: {end: "2026-06-30"}
  rules:
    - providers: [aws]
      where:
        - {dimension: resource, operator: contains, values: [eks-prod]}
- value: platform
  time_frame: {start: "2026-07-01"}
  rules:
    - providers: [aws]
      where:
        - {dimension: resource, operator: contains, values: [eks-prod]}
```

`time_frame` takes `start`, `end` or both, as `YYYY-MM-DD`, and LevelFour compares them with each cost line's date. A config without one applies on every date.

### Split a shared account by percentage

Account `999900001111` runs shared services, and the teams agreed on 60 percent for `infra` and 40 for `mobile`:

```yaml
- split:
    - {value: infra, pct: 60}
    - {value: mobile, pct: 40}
  rules:
    - providers: [aws]
      where:
        - {dimension: account, values: ["999900001111"]}
```

Each matched line becomes one line per value, its cost multiplied by the percentage. The percentages add up to 100, and a value appears once per split.

### Split a support fee by usage

The AWS Support fee serves every team. A cost-based config divides it in proportion to each team's AWS usage:

```yaml
- cost_based:
    source: {origin: provider, key: team}
    input:
      - providers: [aws]
        where:
          - {dimension: charge_type, values: [Usage]}
  rules:
    - providers: [aws]
      where:
        - {dimension: service, operator: starts_with, values: [AWS Support]}
```

For each day and provider, LevelFour measures how the cost that `input` matches divides across the values of `source`. It then splits each line that `rules` matches in the same proportions. When lines tagged `team=data` carry 30 percent of a day's AWS usage, `data` takes 30 percent of that day's support fee.

The shares follow `source` as it reads on the bill, so `data` and `Data` receive separate shares. `source` can also name another virtual key, though not the key you are defining.

### Backfill past months

`effective_from` is the first billing month LevelFour applies the key to, as `YYYY-MM`. Leave it out and the key starts with the current month. Past months are reprocessed from it.

```yaml
name: Teams
effective_from: "2026-06"
```

Saving a change to the configs, the collapsed keys, Can Override or `effective_from` reprocesses the key from that month on. The key's status reads `reprocessing` until the job finishes, and [`l4 tags show`](/cli/commands/tags/show#reprocessing-jobs) reports its progress. Months before `effective_from` carry no value for the key, and [Limits](#limits) caps how far back it reaches.

On AWS the job rebuilds a month it no longer holds by reading the Cost and Usage Report again, so backfill reaches the full window. On Google Cloud it reaches only as far as the billing export LevelFour has already loaded, and an earlier month stays empty for the key.

### Extend or replace a provider key

Name a virtual key after a provider key, `team` for instance, and it builds on that key's values. `can_override` settles lines where both have an answer:

| Line                     | `can_override: false` (default)                      | `can_override: true`                                                                              |
| ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Carries the provider key | Keeps the provider value                             | Takes the first collapsed key or config that matches, and keeps the provider value when none does |
| Lacks the provider key   | Takes the first collapsed key or config that matches | Takes the first collapsed key or config that matches                                              |

With `false`, the key fills gaps: untagged lines get a value and tagged ones keep theirs. With `true`, your rules correct what the provider tags say.

## Collapse several keys into one

Your teams spell one key three ways: `environment` on most resources, `env` on older AWS ones, `Environment` on Google Cloud labels. A collapsed key copies the value of a source key, and the first source present on a line wins:

```yaml title="environments.yaml"
name: Environments
collapsed_keys:
  - source: {origin: provider, key: environment}
  - source: {origin: provider, key: env}
    providers: [aws]
  - source: {origin: provider, key: Environment}
    providers: [gcp]
configs: []
```

`providers` limits a source to one provider's lines. `prefix` puts text in front of the copied value, so `prefix: "gcp-"` turns `prod` into `gcp-prod` and keeps two sources apart. A source can also be a virtual key, with `origin: virtual`.

LevelFour applies collapsed keys before any config. On the `Teams` key, a collapsed source on `team` would copy `Data` through before the rule that folds it into `data` ran.

## Nest one virtual tag inside another

A `virtual_tag` condition reads the value another virtual key assigned. Say an `Applications` key maps resources to `checkout`, `search` and `billing`. A `Business units` key rolls them up:

```yaml title="business-units.yaml"
name: Business units
configs:
  - value: commerce
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: virtual_tag, key: Applications, values: [checkout, search]}
  - value: finance
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: virtual_tag, key: Applications, values: [billing]}
```

LevelFour evaluates `Applications` first, so the condition reads its finished values. The API refuses a definition that breaks any of these:

* a key that another key reads cannot hold split or cost-based configs
* two keys cannot read each other in a loop
* a chain of nested keys stops at the depth in [Limits](#limits)

LevelFour also refuses to delete `Applications` while `Business units` reads it.

## Take the value from a key

A `key` assign gives each line the value it already holds under a source. The source is a provider key, another virtual key, or a dimension. Say you want one value per account name, with no rule per account:

```json
{
  "filter": {"rules": [{"providers": ["aws", "gcp", "azure"], "conditions": []}]},
  "assign": {"type": "key", "source": {"origin": "dimension", "key": "account_name"}}
}
```

`account_name` reads the account's name where LevelFour knows one (an AWS account, a Google Cloud project, an Azure subscription) and its id otherwise. `origin` is `provider`, `virtual` or `dimension`. `resource` is refused as a source, because it holds too many values.

A line with no value under the source falls through to the next config, so a `key` config placed late works as a catch-all and one placed first wins. This assign is available in the dashboard, over REST and over MCP. The `l4 tags` file format does not take it yet.

## Split evenly

A `percent` assign with `even: true` shares the matched cost equally between the values it names, however many there are. Leave out `pct` on each split:

```json
{
  "type": "percent",
  "even": true,
  "splits": [{"value": "infra"}, {"value": "mobile"}, {"value": "data"}]
}
```

Three values take a third each, and the lines still add up to the bill. In the dashboard it is the **Split evenly** toggle on **Split by percent**.

## The whole file

The `Teams` key with every config above, in priority order:

```yaml title="teams.yaml"
name: Teams
description: Owning team across every cloud.
effective_from: "2026-06"
can_override: false
configs:
  - value: infrastructure
    time_frame: {end: "2026-06-30"}
    rules:
      - providers: [aws]
        where:
          - {dimension: resource, operator: contains, values: [eks-prod]}
  - value: platform
    time_frame: {start: "2026-07-01"}
    rules:
      - providers: [aws]
        where:
          - {dimension: resource, operator: contains, values: [eks-prod]}
  - cost_based:
      source: {origin: provider, key: team}
      input:
        - providers: [aws]
          where:
            - {dimension: charge_type, values: [Usage]}
    rules:
      - providers: [aws]
        where:
          - {dimension: service, operator: starts_with, values: [AWS Support]}
  - value: data
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
      - providers: [aws]
        where:
          - {dimension: tag, key: team, values: [data, data-prod, Data]}
      - providers: [gcp]
        where:
          - {dimension: tag, key: team, values: [data]}
  - value: mobile
    rules:
      - providers: [aws]
        where:
          - {dimension: account, values: ["111122223333"]}
      - providers: [gcp]
        where:
          - {dimension: account, values: [mobile-prod]}
  - 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, values: ["999900001111"]}
```

The narrow configs sit on top. The support fee comes before `mobile`, so a support line billed to the mobile account still gets split by usage. A line in the shared account tagged `team=data` goes to `data`, because that config sits above the split. Move the split up and the shared account wins instead.

Try the file, then save it:

```bash
l4 tags preview -f teams.yaml
l4 tags apply -f teams.yaml
```

## Dimensions and operators

| Operator             | Matches when the line's value                                                   |
| -------------------- | ------------------------------------------------------------------------------- |
| `is` (default)       | equals one of `values`                                                          |
| `is_not`             | equals none of `values`                                                         |
| `contains`           | contains one of `values`                                                        |
| `not_contains`       | contains none of `values`                                                       |
| `starts_with`        | starts with one of `values`                                                     |
| `ends_with`          | ends with one of `values`                                                       |
| `flexible_match`     | equals one of `values` once case, spaces, `-` and `_` are ignored on both sides |
| `not_flexible_match` | equals none of `values` under the same comparison                               |

Every operator except the flexible pair is case-sensitive.

> **Warning**
>
> A negative operator on a `tag` condition also matches lines that lack the key. `team is_not data` catches untagged cost along with the other teams' cost. To catch only lines tagged with another team, add `{dimension: tagged, key: team}` to the same rule.

Conditions read these dimensions straight from the bill, and which ones a rule can use depends on the providers it names:

* `account`, `region` and `resource` exist on every provider. On Kubernetes, `account` and `region` are the cloud account and region the cluster runs in, and `resource` is a workload written `namespace/kind/name`.
* `service`, `subcategory`, `usage_unit`, `charge_type` and `provider` exist on the cloud bills (AWS, Google Cloud, Azure and DigitalOcean) and on every connected tool. `provider` names which one billed the line.
* `billing_account` exists on the cloud bills only, and `category` and `marketplace` on AWS only.
* `sku` and `person` exist on connected tools. `sku` is the model, plan or meter a line names. `person` is who the line bills, on the sources that bill people one by one: Claude Code, Codex and Cursor usage, and Claude or ChatGPT Enterprise seats. It is written in lowercase.
* `cluster`, `namespace` and `workload` exist on Kubernetes only.

The rest read keys:

* `tag` matches the value of a provider tag, a Google Cloud label or a Kubernetes pod label, and needs `key`
* `tagged` checks that a key is present and takes no values. Leave out `key` to match a line carrying any key
* `virtual_tag` matches the value of another virtual key, and needs `key`

The [Tags API reference](/api-reference/tags) maps each dimension to its billing column on each provider. `GET /api/v1/tags/dimensions` returns the catalog per provider, and `GET /api/v1/tags/dimensions/{dimension}/values` returns the values a condition can match in your data.

## Limits

| Limit                         | Value         |
| ----------------------------- | ------------- |
| Virtual keys per organization | 50            |
| Configs per key               | 100           |
| Values per key                | 100           |
| Rules per config              | 20            |
| Conditions per rule           | 10            |
| Values per condition          | 200           |
| Collapsed keys per key        | 20            |
| Value prefix                  | 64 characters |
| Nesting depth                 | 5             |
| Preview window                | 31 days       |
| Backfill                      | 12 months     |

A definition over a limit fails with `limit_exceeded`.

## Unallocated cost

A line no step assigns is unallocated. A breakdown grouped by a virtual key shows that cost as its own group, so the groups add up to the bill. Without it, the per-team totals would sum to less than your spend, with nothing to say why.

The API returns the group as `__unallocated__`. [`l4 tags show`](/cli/commands/tags/show) prints a key's `unallocated_spend`, and [`l4 tags coverage`](/cli/commands/tags/coverage) compares the share of spend provider tags cover with the share covered once virtual tags are added. To shrink it, draft a config, run [`l4 tags preview`](/cli/commands/tags/preview), and check how much spend leaves unallocated before you save.

### Name the unallocated line

`unallocated_name` is what a key calls its unallocated cost: `Shared platform` instead of `Unallocated`. Send it on a definition, or alone with `PATCH /api/v1/tags/virtual/{key_id}`, or over MCP with `update-virtual-tag-settings`. It changes no figure and reprocesses nothing. The wire value stays `__unallocated__` and the name rides beside it on the key's reads, so an agent or a script keeps one sentinel to match on. An empty string clears the name. The name cannot equal one of the key's values.

## Where virtual keys appear

A saved virtual key works where a provider tag key does:

* The provider cost breakdown groups by it (`group_by=virtual_tag`) and filters by its values. Split lines arrive with their cost divided, so totals still match the bill.
* The Kubernetes fleet and cluster cost views group by a key with Kubernetes rules (`group_by=virtual_tag:<key>`). See [Kubernetes in a tag](/virtual-tags#kubernetes-in-a-tag).
* Costs by tag and cost allocation take a virtual key in `tag_key`.
* Recommendations carry `virtual_tags`, the value of each virtual key on the recommendation, and filter by `virtual_tag_key` and `virtual_tag_value`.
* [`l4 tags costs`](/cli/commands/tags/costs) prints spend per value, and [`l4 costs breakdown`](/cli/commands/costs) groups and filters by a key with `--virtual-tag-key`.
* The MCP server reads a key, tries rules before they are saved, and defines and edits one. [Tools](/mcp/tools) is generated from the surface the server advertises and names each one.

## Predefined tags

Every organization carries 19 [predefined tags](/virtual-tags#predefined-tags) that LevelFour keeps: three global ones (**AI Spend**, **Marketplace Spend**, **Taggable Spend**), six on AI models and ten on FOCUS fields. A key reads `managed: true`, refuses every write with `409 managed_key`, and is duplicated to change it. History starts three months back.

### AI taxonomy by provider

The six AI tags (**Model Name**, **Family**, **Brand**, **Channel**, **Lifecycle**, **Token Type**) read one field each. Every provider names its model in a different place, so LevelFour reads each bill line once, normalizes it, and keeps the answer. A rule can match the same fields as the dimensions `ai_model`, `ai_family`, `ai_brand`, `ai_channel`, `ai_lifecycle` and `ai_token_type`.

A field a provider cannot answer leaves the line on the tag's unallocated line (**Not classified**). Two limits apply everywhere. **Lifecycle** comes from the vendors' own deprecation pages, kept in a table, and reads `Unknown` for a model the vendor does not publish. A model released after the last table update reads `Unknown` too.

#### AWS

| Field                                 | Answers                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Model Name**, **Family**, **Brand** | Yes for Amazon Bedrock, read from the usage type (`USE1-DeepSeek-R1-input-tokens`). On an AWS Marketplace line the usage type names the token and not the model, so the model needs the Marketplace product name from the Cost and Usage Report. A Marketplace line without one keeps its brand from the seller and has no model. Amazon SageMaker bills by instance, so it has no model |
| **Channel**                           | `Bedrock`, or `Bedrock Marketplace` for a Marketplace line                                                                                                                                                                                                                                                                                                                               |
| **Lifecycle**                         | From the model, so `Unknown` where the model is empty                                                                                                                                                                                                                                                                                                                                    |
| **Token Type**                        | From the usage type, Marketplace lines included. SageMaker has none                                                                                                                                                                                                                                                                                                                      |

#### Google Cloud

| Field                                 | Answers                                                                                                                                     |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **Model Name**, **Family**, **Brand** | Yes, read from the SKU description of Vertex AI and Gemini API lines (`Generate_content cached image input token count for gemini 3 flash`) |
| **Channel**                           | `Vertex AI` or `Gemini API`                                                                                                                 |
| **Lifecycle**                         | From the model                                                                                                                              |
| **Token Type**                        | Yes, from the SKU description                                                                                                               |

#### Azure

| Field                                 | Answers                                                                                                                              |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Model Name**, **Family**, **Brand** | Yes, read from the meter name of Azure AI services lines. The meter's product lends the brand when the meter name does not carry one |
| **Channel**                           | `AI Foundry`                                                                                                                         |
| **Lifecycle**                         | From the model                                                                                                                       |
| **Token Type**                        | Yes, from the meter name                                                                                                             |

#### Anthropic and OpenAI

| Field                                 | Answers                                                                                                                                                          |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Model Name**, **Family**, **Brand** | Yes, read from the model the bill names. A cloud's wrapped id (`us.anthropic.claude-sonnet-5-5-v1:0`, `claude-sonnet-5-5@20260901`) reads as the model inside it |
| **Channel**                           | `Anthropic` or `OpenAI`, and `Bedrock` or `Vertex AI` when a coding agent called the model through that cloud                                                    |
| **Lifecycle**                         | From the model                                                                                                                                                   |
| **Token Type**                        | Yes: input, output, cache read, cache write                                                                                                                      |

#### Cursor

| Field                                 | Answers                              |
| ------------------------------------- | ------------------------------------ |
| **Model Name**, **Family**, **Brand** | Yes, read from the SKU               |
| **Channel**                           | `Cursor`                             |
| **Lifecycle**                         | From the model                       |
| **Token Type**                        | No. Cursor reports tokens as one sum |

#### Claude Code

| Field                                 | Answers                                      |
| ------------------------------------- | -------------------------------------------- |
| **Model Name**, **Family**, **Brand** | Yes, read from the model the telemetry names |
| **Channel**                           | `Anthropic`, or the cloud the model id says  |
| **Lifecycle**                         | From the model                               |
| **Token Type**                        | No. The source sums tokens                   |

#### Codex

| Field                                 | Answers                                      |
| ------------------------------------- | -------------------------------------------- |
| **Model Name**, **Family**, **Brand** | Yes, read from the model the telemetry names |
| **Channel**                           | `OpenAI`                                     |
| **Lifecycle**                         | From the model                               |
| **Token Type**                        | No. The source sums tokens                   |

## Telemetry

Some cost has no tag and no account to follow, such as a shared inference cluster used by several customers. A `telemetry` assign splits the matched cost by usage you send. You push a dataset of daily rows, each with dimensions (such as `customer` or `workflow`) and a `weight`. A config then names the dataset and one of its dimensions, and every value of that dimension takes its share of the cost.

```json
{
  "type": "telemetry",
  "dataset": "requests_by_customer",
  "key": "customer"
}
```

### Push a dataset

Over REST:

```text
POST /api/v1/tags/telemetry
```

```json
{
  "dataset": "requests_by_customer",
  "rows": [
    {"date": "2026-10-30", "dimensions": {"customer": "acme", "workflow": "checkout"}, "weight": 1200},
    {"date": "2026-10-30", "dimensions": {"customer": "globex", "workflow": "search"}, "weight": 800}
  ]
}
```

Over MCP, `update-virtual-tag-telemetry` takes the same `dataset` and `rows`, and `list-virtual-tag-telemetry` lists the datasets with their dimensions, first and last day and row count. `GET /api/v1/tags/telemetry` does the same over REST.

* **A push replaces each day it names.** Days it leaves out stay as they were. Resend a day to correct it, and a retry never doubles anything. Put all of one day's rows in one call.
* **Only the share matters.** `weight` takes any unit: requests, tokens, seconds. A row's share of its day's total is what counts.
* **Limits.** At most 10,000 rows per call, and a dimension set once per day. Dataset and dimension names take letters, digits and `_ . : -`. A day more than 12 months back or in the future is refused, and so is the value `__unallocated__`. Over MCP a call also stops at 256 KB, so send a long history over several calls of whole days.
* **Keys that read the dataset reprocess.** The response lists the jobs.
* **Name teams, products or customers**, not people. Dimension values are stored as you send them.

### The built-in `ai_usage` dataset

LevelFour fills `ai_usage` from Claude Code and Cowork usage, with the dimensions `person`, `model` and `product`. Use it as a dataset without sending anything. A push to it is refused.

### A day with no data

A billed day with no usage rows takes the month's ratio: each value's share of the month's total weight. A gap in your feed therefore never leaves a day unallocated.

## Relational Virtual Tags

A telemetry dataset can carry several dimensions, such as `team` and `workflow`. A relation turns them into several tags from one telemetry and one cost filter, and lets you read any two together: **Team** by **Workflow**.

Create a relation with 2 to 10 keys. Each key names a dimension of the dataset, and all of them share out the same cost:

```text
POST /api/v1/tags/relations
```

```json
{
  "dataset": "inference_usage",
  "filter": {"rules": [{"providers": ["aws"], "conditions": [{"dimension": "service", "values": ["Amazon Bedrock"]}]}]},
  "keys": [
    {"name": "Team", "dimension": "team"},
    {"name": "Workflow", "dimension": "workflow", "unallocated_name": "No workflow"}
  ],
  "effective_from": "2026-10"
}
```

The keys are created together, or none is. Over MCP, `create-virtual-tag-relation` takes the same fields, `get-virtual-tag-relation-costs` reads spend for a pair of keys, and `delete-virtual-tag-relation` removes the relation.

* **Each key is an ordinary tag.** It groups Breakdown, Boards and `get-costs-by-tag` like any other, and its values add up to the cost the filter matched.
* **Two keys of one relation read together.** Breakdown and costs by tag take a second key, which shows team by workflow. Two keys from different relations are refused, because joining them would count a line twice.
* **Only complete usage counts.** A usage row must carry every dimension of the relation to count, on each key and on the pairs. Cost no usage reached reads as unallocated on each key.
* **Related keys keep their rules.** To change the filter or the first month, delete the relation and create it again. Name, description and unallocated name stay editable, and so does the name of a value.
* **Not for Kubernetes.** A relation filter cannot name Kubernetes.
* **No form in the dashboard.** A relation is created over REST or MCP. Its tags show in the dashboard, and the tag page shows the pair table.

## Duplicate a tag

```text
POST /api/v1/tags/virtual/{key_id}/duplicate
```

`name` and `expected_version` are both optional. The copy is named `Copy of <name>`, with a number when that name is taken, and it spends what its source spends. A predefined key duplicates the same way, which is how you edit one. MCP calls it `create-virtual-tag-copy`.

A key named like a provider key reads that key by name, which a copy's new name would lose. The copy writes it out: with `can_override` off, the provider key becomes a collapsed source ahead of the others and any other spelling of it (`Team` on AWS, `team` on Google Cloud) a first config. With `can_override` on, each spelling becomes a last config with a `key` assign and a `tagged` condition. Either way the copy's spend matches the source's. A source that starts earlier than a new key may gets the oldest first month allowed.

## Change notifications

A write to a key fires the `virtual_tag.changed` webhook event once it commits. The key's creator also gets a notification, unless they made the change themselves. A key created through an API key has no one to notify, and predefined keys announce nothing.

### The `virtual_tag.changed` event

```json
{
  "key_id": "vtk_8f3a1c",
  "name": "Teams",
  "action": "updated",
  "version": 7,
  "actor": "ana@example.com"
}
```

| Field     | Meaning                                                          |
| --------- | ---------------------------------------------------------------- |
| `key_id`  | The key that changed                                             |
| `name`    | Its name after the change                                        |
| `action`  | `created`, `duplicated`, `updated`, `value_renamed` or `deleted` |
| `version` | The key's `rules_version` after the write                        |
| `actor`   | Who made the change                                              |

Subscribe an endpoint to `virtual_tag.changed` the way [webhooks](/webhooks) describes.

## Generate a tag by category over MCP

An assistant can build an Environment, Application, Team or Customer tag from your bill with whichever model your client runs. LevelFour supplies the lists, so two models reach the same candidates.

### Rank the candidates

`list-tag-category-candidates` takes the category and returns the provider keys that could carry it, best first. Each has its providers, value count, spend and share of spend. A key whose whole name is a word for the category (`env`, `Environment`) ranks as an `exact` match, one that contains such a word as `partial`. Where no key fits, `dimension_fallbacks` names the dimensions to match instead, such as account or namespace.

### Read the values, draft, preview

`list-tag-dimension-values` gives the values a rule can match. The assistant drafts the rules, builds on the first candidate that covers most of the bill, and uses the fallbacks for the spend it leaves out. `preview-virtual-tag` reports what each value would claim and what stays unallocated before anything saves.

### Create it

Once you agree, `create-virtual-tag` saves the tag. `get-virtual-tag-job` says when the figures are final.

The `virtual_tag_setup` prompt walks this whole path. Pass it a `category` and a client that lists MCP prompts runs the steps in this order. A client without prompt support can follow the same three calls by hand.

## Permissions

Any member, and any API key, can read virtual tags. Creating, changing, duplicating or deleting one takes the `org:tags:write` permission, which a read-write API key carries. Pushing telemetry and creating a relation take it too. [Key scopes](/authentication#key-scopes) covers the two kinds of key.

## Next

* [`l4 tags`](/cli/commands/tags) keeps virtual keys in YAML files you apply from the terminal
* [Costs](/resources/costs) reads the breakdowns a virtual key groups
* [Tags API reference](/api-reference/tags) has every endpoint and every validation code
