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

# Variables

> Upload lookup tables and create AI-derived custom variables to reference in scripts and messages

## Overview

**Lookup variables** come from labeled CSVs you upload, and you reference their values in
scripts and messages as `{{label.column}}`. Each variable resolves **per job**: Tenzo matches
a job to a row in your table and substitutes the value from that row.

Lookup variables live on the **Variables** tab, alongside
[Custom Variables](#custom-variables) — variables derived from your other variables with an
AI prompt.

This is useful when a value depends on some attribute of the job rather than being fixed
across the whole organization. For example, a base pay rate that varies by state, a site
address that varies by location, or a bonus that varies by role family.

Lookup tables are managed from **Admin → Variables → Lookup Variables**.

***

## How a lookup table works

A lookup table is a CSV with a header row. After you upload it, choose which spreadsheet
columns Tenzo should use to find the right row for each job. A row matches when **all**
selected fields have the same values in Tenzo and the spreadsheet.

When Tenzo needs the value of a `{{label.column}}` variable for a specific job, it looks for
that matching row. When a row matches, **every** column of that row becomes available as a
`{{label.column}}` variable — including columns you did not use to find the row.

<Steps>
  <Step title="Upload a CSV and give it a label">
    Drag a CSV into **Admin → Variables → Lookup Variables**, then give the table a
    **label**. The label is how you reference the table in scripts, so it must be lowercase
    with only letters, numbers, and underscores (no spaces or dots) — for example,
    `state_lookup` or `pay_rate_bands`. It also can't be one of the reserved built-in namespaces
    (`job`, `candidate`, `application`, `call`), since those prefixes are used by the built-in
    `{{candidate.email}}`-style variables. Columns are detected automatically from the CSV
    header row.
  </Step>

  <Step title="Choose how the spreadsheet matches jobs">
    Open the table and, for each column needed to identify the correct row, choose a Tenzo
    field under **Match to Tenzo field**. A row matches a job only when **all** selected
    fields have the same values in Tenzo and the spreadsheet. Only select fields needed to
    identify the row — other spreadsheet columns will still be available after a match is
    found. Tenzo fields include standard fields (such as the job's state or location), custom
    fields from your ATS integration, and custom variables (AI-derived variables) that resolve
    at the job/config stage.
  </Step>

  <Step title="Reference the values in a script">
    In any script or message field, type `{{label.column}}` to insert the matched row's value.
    Every column of the matched row is available — both the columns used to match and the
    other value columns.
  </Step>
</Steps>

### Example

Suppose you upload a table labeled `state_lookup` with these columns:

| state    | base\_pay\_rate | shift\_differential |
| -------- | --------------- | ------------------- |
| Colorado | 18.50           | 2.00                |
| Texas    | 16.75           | 1.50                |

Match the **state** column to the job's **state** field. Now, for a job located in Colorado,
`{{state_lookup.base_pay_rate}}` resolves to `18.50` and
`{{state_lookup.shift_differential}}` resolves to `2.00`. For a job in Texas, the same
variables resolve to that row's values instead.

<Note>
  Fields used to match support flexible matching:

  * **Multiple values per row**: A cell can contain comma- or semicolon-separated values (for example, `Denver, Austin, Boulder` or `CO, CA, WA`). A job matches the row if its value matches any of the listed items.
  * **List-valued job fields**: For multi-valued job fields (such as `hiring_team.full_name` or multiple locations), a row matches if any element of the list matches the cell (or any item in a comma-separated cell).
  * **State abbreviations**: State fields used to match accept both the full name and abbreviation (for example, a `Colorado` row matches a job whose state is `CO`, and vice versa), including within comma-separated lists.
</Note>

***

## Fallbacks

A job might not match any row in the table — for example, a new job in a state you haven't
added yet. To make sure a candidate never sees a blank where a value should be, give a
lookup-table variable a **fallback** value using `or`, wrapped in **quotes**:

```
{{state_lookup.base_pay_rate or "$18.00/hr"}}
{{state_lookup.base_pay_rate or "20"}}
```

Both double quotes (`"..."`) and single quotes (`'...'`) work. Quotes are required so the
fallback's boundaries are unambiguous — an unquoted value like `or 20` is read as another
variable to try, not as a default. To include the quote character itself inside a default,
escape it (`'Bob\'s Diner'`) or use the other quote style (`"Bob's Diner"`). The fallback is
used whenever the job matches no row, or the matched cell is empty.

<Note>
  The screening **question** and its **grading**, **follow-up guidance**, and **loop** (stop
  criteria / later-iteration) fields resolve variables (including lookup-table values) from the
  job's value map at call / grading time. **Background Info templates** also resolve lookup-table
  and config-stage custom variables from that map when the job is known. Other generation-input
  fields (job description text and script-generation instructions) still do not receive lookup
  values, so a fallback is what gets used there.
</Note>

### Chaining fallbacks

You can chain `or` to try several sources before landing on a literal default. Tenzo reads the
operands left to right and uses the first one that resolves to a real value; if none do, it uses
the quoted literal at the end:

```
{{state_lookup.base_pay_rate or region_lookup.base_pay_rate or "$18.00/hr"}}
```

Each operand is either a variable or a quoted string literal. The **first quoted literal is the
effective default** — it always resolves, so anything written after it is never reached. A literal
default must be quoted: a bare `or 20` is not treated as a default.

### When you'll see a "may be blank" warning

Tenzo checks each placeholder ahead of time: does something in it **guarantee** a value? Only a
quoted fallback (`or "…"`) does. Every variable — including built-ins like `{{job_name}}` — can
come back empty depending on how the job and org are configured. When nothing in a placeholder
guarantees a value — for example a bare
`{{state_lookup.base_pay_rate}}`, or a chain like `{{state_lookup.rate or region_lookup.rate}}`
with no quoted default — the editor marks it with a **dotted amber underline** and saving shows
a **warning** that it may be blank when sent.

The warning is advisory — it never blocks the save. To clear it, end the chain with a quoted
fallback — for example, change `{{state_lookup.base_pay_rate}}` to
`{{state_lookup.base_pay_rate or "$18.00/hr"}}`.

The editor also flags **unreachable** parts of a chain: in `{{a or "first" or "second"}}` the
`"second"` default can never be used, because the first quoted default always resolves and ends
the chain there.

Any variable can take an `or` fallback, and adding one is the only way to guarantee a value.

***

## Malformed placeholders are rejected

Separately from the fallback rules, Tenzo checks that every `{{...}}` placeholder is well-formed
before it lets you save. A malformed placeholder **blocks the save**, and the script editor
**highlights it inline** so you can find and fix it. A placeholder is malformed when it has:

* a **dangling `or`** — nothing after the `or`, as in `{{some_value or}}`,
* an **unterminated string literal** — a quote that never closes, as in `{{some_value or "abc}}`, or
* an **un-namespaced or unrecognized variable in organization templates** — organization-level templates (Background Info, Script Generation, Lifecycle Emails, Voicemail Prompts, and Custom Variable prompts) cannot rely on individual job ATS attributes and require canonical names or `job.*` namespaces (e.g. `{{job.pay_rate}}` rather than `{{pay_rate}}`).

Give the `or` a value, close the quote, or use a canonical variable name, and the highlight clears so the save can go through.

***

## How lookup-table variables are colored in the editor

Lookup-table variables are color-coded so you can see, at a glance, whether a value was
found for the current job:

* **Gray** — you're editing a **job template**. The job match hasn't happened yet, because
  a template isn't tied to a single job. The variable will resolve per job once the template
  is applied.
* **Blue** — you're editing an **individual job** and the job matched a row, so a real value
  was found.
* **Yellow** — you're editing an individual job and the match **failed**. No row matched, so
  the fallback value will be used.
* **Purple** — the variable **will resolve, but not yet**. Its value isn't known while you're
  editing because it depends on something only available later. Hover to see exactly when;
  there are two cases:

  * **Resolves per candidate at send** — for example `{{candidate_name}}`,
    `{{candidate.email}}`, `{{candidate.date_available}}`, or `{{application.stage}}`, or a
    custom variable whose prompt references one. These are filled in on every send. Prefer a
    fallback when the value can be missing, such as
    `{{candidate.date_available or "your next available date"}}`.
  * **Resolves once a call exists — renders blank before the call** — the `{{call.*}}` family.
    These render blank on any pre-call message, so give them a fallback
    (`{{call.length_sec or "the call"}}`) when the sentence must read correctly beforehand.

  Purple is **not** an error either way; it simply isn't previewable in the editor.

This is in addition to the coloring for regular variables, which are **blue** when recognized
and **red** when unknown or misspelled. For the full color reference, see
**[Variable coloring](/getting-started/advanced-create-interview#variable-coloring)**.

<Note>
  A yellow lookup-table variable is not an error — the fallback will be used. It's a signal
  that this specific job didn't match a row, which is expected for jobs your table doesn't
  cover yet.
</Note>

***

## Propagating template changes to jobs

When you change a template that has linked jobs and propagate the change, Tenzo re-runs the
match check for each linked job. Any jobs where a lookup-table variable failed to match a
row are surfaced as **warnings**, so you can confirm the fallback is acceptable or add the
missing rows to your table. The change still propagates — the fallback covers the unmatched
jobs.

***

## Managing tables

From **Admin → Variables → Lookup Variables** you can:

* **Upload** a new labeled table.
* **View** a table's matching setup and a **Spreadsheet Preview** of its rows.
* **Preview a match** against a specific job to see whether Tenzo would find a row, which
  fields hit or missed, and the values that would become available.
* **Rename** a table's label.
* **Reupload** to replace a table's data while keeping its match settings. The new CSV must
  keep every existing column (matched by name); you can add new columns.
* **Delete** a table you no longer need.

<Warning>
  You cannot delete a lookup table if any of its variables (`{{label}}` or `{{label.column}}`) are referenced in your scripts, templates, prompt banks, or settings. Choosing **Delete** scans those places first — it can take a few seconds if you have a lot of jobs — and blocks the delete if anything still references the table. Remove or replace all references before deleting it.
</Warning>

***

## Custom Variables

A **custom variable** is derived from your other variables using an AI prompt. Where a lookup
table maps a job to a row, a custom variable transforms existing values — for example, cleaning
a raw ATS company name (`nexora staffing 12`) into a display name (`Nexora`).

Custom variables are managed from **Admin → Variables → Custom Variables** and
referenced in scripts as `{{name}}` (a plain name, unlike a lookup table's `{{label.column}}`).

<Steps>
  <Step title="Add a custom variable">
    Choose **Add custom variable** and set:

    * a **Name** (lowercase letters, numbers, and underscores — for example, `clean_company`), and
    * a **Prompt** describing the transformation. The Prompt field's example placeholder is
      `e.g., Normalize the company name from {{ats.company}}`. Reference other variables
      inline (for example, `{{ats.company}}` or `{{custom_field_values.postingLocation}}`).
      Typing `{{` suggests the same shared variable catalog used in job scripts — built-ins,
      job/ATS fields (including custom fields), lookup variables, and
      candidate/application/call fields. Values resolve later per job at derivation/send time.
      Any variable you reference needs a fallback (for example,
      `{{ats.company or "the company"}}`).
  </Step>

  <Step title="Preview it">
    Use **Preview** to run the derivation on a sample filled-in prompt and see the result, so
    you can confirm it behaves before using the variable in a script.

    The preview also shows a **readiness breakdown** — each variable your prompt references,
    with when it becomes available:

    * **Resolves now** — a job/lookup value that's known while editing.
    * **Resolves at send** — a per-candidate value (like `{{candidate_name}}`) that isn't known
      until the message is sent.
    * **Resolves once a call exists** — a `{{call.*}}` value, which isn't known until a call has
      happened, so the variable is derived only on post-call surfaces.
    * **Will use its fallback** — the value won't be found, so the `or "…"` fallback applies.
    * **Unrecognized** — the name doesn't match any variable (a typo, another custom variable, or a
      variable that can't feed a custom variable — see the note below).

    A custom variable itself resolves **as late as its latest input**: if every input resolves now,
    it can be derived and previewed immediately; if any input resolves at send — or only once a
    call exists (a `{{call.*}}` input) — the whole variable is deferred to that stage and shows
    **purple** in the editor.
  </Step>

  <Step title="Reference it in a script">
    Type `{{name}}` anywhere you use variables. At send time the prompt's variables are resolved
    for the job, the AI derives the value, and it is substituted.
  </Step>
</Steps>

<Warning>
  You cannot delete a custom variable if it is referenced in your scripts, templates, prompt banks, or settings. Choosing **Delete** scans those places first — it can take a few seconds if you have a lot of jobs — and blocks the delete if anything still references the variable. Remove or replace all references before deleting it.
</Warning>

<Note>
  A custom variable's prompt **cannot reference another custom variable**. Chaining one AI-derived
  value into another is not supported (it would create runaway or circular AI calls at send time),
  so a `{{name}}` that points at another custom variable is rejected when you save. Reference job,
  lookup-table, and candidate variables directly instead — you can combine as many of those in one
  prompt as you need.

  A few built-in variables also **can't feed a custom variable** because they're filled in after the
  AI derivation runs: `{{interview_link}}`, `{{book_call}}`, `{{interviewer_name}}`, and
  `{{custom_outro}}`. You can still use these directly in a script, but referencing one inside a
  custom variable's prompt shows as **Unrecognized** in the readiness breakdown — the value isn't
  available when the variable is derived.

  The `{{call.*}}` family behaves differently again: a custom variable **may** reference it, but
  because a call's details only exist once the call has happened, such a variable is deferred and
  is **not** derived on any pre-call send — it renders blank there rather than being derived early
  from a missing value.
</Note>

### How values are cached

Derived values are **cached by the resolved prompt**, so the same filled-in prompt always
produces the same output — the derivation runs once per distinct resolved prompt and is reused
everywhere. Open a custom variable's **cache** (the database icon next to its row) to view the
input → output mappings, **edit** an output, **add** a mapping manually, or **delete** one.

The cache table includes an **Inputs** column: a comma-separated list of the variable values
used for that cache entry — the same values that feed the per-invocation cache key. Older
(legacy) rows may show Inputs as unavailable until the entry is recreated.

To add a mapping, fill in a value for each variable the prompt references, plus the output you
want; the override applies whenever the prompt resolves with exactly those values. A value you
edit by hand becomes a **manual override** that the AI will never overwrite. Changing a
variable's prompt clears its AI-derived cache (manual overrides are kept) so values re-derive
under the new definition.

<Note>
  Custom variables resolve in the same places as lookup-table variables and follow the same
  [fallback rules](#fallbacks). Prefer a fallback wherever a value might be missing — including
  job description text, script-generation instructions, and optional fields inside Background Info
  templates — e.g. `{{clean_company or "the company"}}`. The editor shows a may-be-blank warning
  when a placeholder has no guaranteed value; saving is never blocked.
</Note>

***

## Related

* **[Variables](/getting-started/advanced-create-interview#variables)** — the full set of
  variables available across scripts and messages, plus autocomplete and coloring.
* **[Rules Engine](/ats-integrations/rules-engine)** — automatically apply templates and
  settings to jobs during ATS sync.
