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=Dataandteam=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
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:
- 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.
- Collapsed keys, in order. The first source key present on the line supplies the value.
- 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.
- The provider's value, when Can Override is on and no step above matched.
- 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:
| 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:
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:
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:
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.yamlDimensions 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.
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,regionandresourceexist on every provider. On Kubernetes,accountandregionare the cloud account and region the cluster runs in, andresourceis a workload writtennamespace/kind/name.service,subcategory,usage_unit,charge_typeandproviderexist on the cloud bills (AWS, Google Cloud, Azure and DigitalOcean) and on every connected tool.providernames which one billed the line.billing_accountexists on the cloud bills only, andcategoryandmarketplaceon AWS only.skuandpersonexist on connected tools.skuis the model, plan or meter a line names.personis 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,namespaceandworkloadexist on Kubernetes only.
The rest read keys:
tagmatches the value of a provider tag, a Google Cloud label or a Kubernetes pod label, and needskeytaggedchecks that a key is present and takes no values. Leave outkeyto match a line carrying any keyvirtual_tagmatches the value of another virtual key, and needskey
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
| 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 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 byvirtual_tag_keyandvirtual_tag_value. l4 tags costsprints spend per value, andl4 costs breakdowngroups 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
| 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.
{
"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.
weighttakes 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-taglike 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}/duplicatename 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"
}| 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 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 tagskeeps 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
Filtering and sorting
Filter, sort, group and pipe LevelFour cost and recommendation data from the terminal, with the same parameters in the SDKs.
GitHub Actions
Workflows that comment a cost summary on a pull request, run a scheduled cost check with the Python or TypeScript SDK, and upload a daily report as a build artifact.