Guides

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.

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
Virtual tags read detailed cost data: the Cost and Usage Report on AWS and the detailed billing export on Google Cloud. On Azure they read the cost lines your Azure connection 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 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 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, and the file format lists every field. Each snippet is one entry under configs:, and 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:

- 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:

- 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:

- 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:

- 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:

- 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:

- 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:

- 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.

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 reports its progress. Months before effective_from carry no value for the key, and 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:

Linecan_override: false (default)can_override: true
Carries the provider keyKeeps the provider valueTakes the first collapsed key or config that matches, and keeps the provider value when none does
Lacks the provider keyTakes the first collapsed key or config that matchesTakes 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:

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:

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

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:

{
  "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:

{
  "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:

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:

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

Dimensions and operators

OperatorMatches when the line's value
is (default)equals one of values
is_notequals none of values
containscontains one of values
not_containscontains none of values
starts_withstarts with one of values
ends_withends with one of values
flexible_matchequals one of values once case, spaces, - and _ are ignored on both sides
not_flexible_matchequals none of values under the same comparison

Every operator except the flexible pair is case-sensitive.

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 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

LimitValue
Virtual keys per organization50
Configs per key100
Values per key100
Rules per config20
Conditions per rule10
Values per condition200
Collapsed keys per key20
Value prefix64 characters
Nesting depth5
Preview window31 days
Backfill12 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 prints a key's unallocated_spend, and l4 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, 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.
  • 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 prints spend per value, and l4 costs breakdown 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 is generated from the surface the server advertises and names each one.

Predefined tags

Every organization carries 19 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

FieldAnswers
Model Name, Family, BrandYes 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
ChannelBedrock, or Bedrock Marketplace for a Marketplace line
LifecycleFrom the model, so Unknown where the model is empty
Token TypeFrom the usage type, Marketplace lines included. SageMaker has none

Google Cloud

FieldAnswers
Model Name, Family, BrandYes, read from the SKU description of Vertex AI and Gemini API lines (Generate_content cached image input token count for gemini 3 flash)
ChannelVertex AI or Gemini API
LifecycleFrom the model
Token TypeYes, from the SKU description

Azure

FieldAnswers
Model Name, Family, BrandYes, 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
ChannelAI Foundry
LifecycleFrom the model
Token TypeYes, from the meter name

Anthropic and OpenAI

FieldAnswers
Model Name, Family, BrandYes, 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
ChannelAnthropic or OpenAI, and Bedrock or Vertex AI when a coding agent called the model through that cloud
LifecycleFrom the model
Token TypeYes: input, output, cache read, cache write

Cursor

FieldAnswers
Model Name, Family, BrandYes, read from the SKU
ChannelCursor
LifecycleFrom the model
Token TypeNo. Cursor reports tokens as one sum

Claude Code

FieldAnswers
Model Name, Family, BrandYes, read from the model the telemetry names
ChannelAnthropic, or the cloud the model id says
LifecycleFrom the model
Token TypeNo. The source sums tokens

Codex

FieldAnswers
Model Name, Family, BrandYes, read from the model the telemetry names
ChannelOpenAI
LifecycleFrom the model
Token TypeNo. 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.

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

Push a dataset

Over REST:

POST /api/v1/tags/telemetry
{
  "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:

POST /api/v1/tags/relations
{
  "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

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

{
  "key_id": "vtk_8f3a1c",
  "name": "Teams",
  "action": "updated",
  "version": 7,
  "actor": "ana@example.com"
}
FieldMeaning
key_idThe key that changed
nameIts name after the change
actioncreated, duplicated, updated, value_renamed or deleted
versionThe key's rules_version after the write
actorWho made the change

Subscribe an endpoint to virtual_tag.changed the way 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 covers the two kinds of key.

Next

  • l4 tags keeps virtual keys in YAML files you apply from the terminal
  • Costs reads the breakdowns a virtual key groups
  • Tags API reference has every endpoint and every validation code

On this page

Ask the FinOps Agent about your cloud spend