|
| 1 | +# Managed variables |
| 2 | + |
| 3 | +Centralize values that repeat across resources — callback URLs, a default model |
| 4 | +name, a shared prompt fragment, a brand name — in one place, and reference them |
| 5 | +from any resource file with a `{{name}}` placeholder. At push the placeholder is |
| 6 | +replaced with the managed value; at pull the placeholder is preserved. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## TL;DR |
| 11 | + |
| 12 | +1. Add a `variables` section to the state file `.vapi-state.<env>.json`: |
| 13 | + ```jsonc |
| 14 | + { |
| 15 | + "assistants": { "...": { "uuid": "..." } }, |
| 16 | + "variables": { |
| 17 | + "callback_url": "https://acme.com/vapi/webhook", |
| 18 | + "default_model": "gpt-4.1", |
| 19 | + "max_tokens": 260 |
| 20 | + } |
| 21 | + } |
| 22 | + ``` |
| 23 | +2. Reference a variable anywhere in a resource file with a **whole-value** |
| 24 | + placeholder: |
| 25 | + ```yaml |
| 26 | + model: |
| 27 | + model: "{{default_model}}" |
| 28 | + maxTokens: "{{max_tokens}}" # becomes the NUMBER 260, not "260" |
| 29 | + server: |
| 30 | + url: "{{callback_url}}" |
| 31 | + ``` |
| 32 | +3. `npm run push -- <env>` substitutes the values. `npm run pull -- <env>` |
| 33 | + restores the `{{...}}` placeholders. Drift detection treats a templated file |
| 34 | + and its rendered platform resource as **in sync** — no phantom drift. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## Rules |
| 39 | + |
| 40 | +- **Whole-value only.** A placeholder substitutes only when the *entire* value |
| 41 | + is a single `{{name}}`. Embedded placeholders inside a longer string |
| 42 | + (`"Call us at {{callback_url}}"`) are **not** substituted — they ship |
| 43 | + verbatim. In-string interpolation is intentionally unsupported because it |
| 44 | + cannot round-trip cleanly through pull/drift. |
| 45 | +- **Type is preserved.** The managed value keeps its JSON type. `"{{max_tokens}}"` |
| 46 | + with `max_tokens: 260` sends the number `260`; an object-valued variable sends |
| 47 | + the object. (YAML needs the placeholder quoted, but the result is the native |
| 48 | + type.) |
| 49 | +- **Variable names** are `[A-Za-z0-9_.-]+` — letters, digits, underscore, dot, |
| 50 | + hyphen. No spaces. Whitespace *inside* the braces is ignored: `{{ x }}` == |
| 51 | + `{{x}}`. |
| 52 | +- **Undefined placeholders are a validation error.** `npm run validate`/`push` |
| 53 | + fail (or warn, without `--strict`) on any `{{name}}` with no matching entry in |
| 54 | + `variables`, so a typo can't silently ship the literal string `"{{name}}"`. |
| 55 | +- **Variables compose with references.** Substitution runs *before* resourceId → |
| 56 | + UUID resolution, so a variable whose value is a resourceId works: |
| 57 | + `toolIds: ["{{tool_ref}}"]` with `tool_ref: "my-tool"` resolves to the tool's |
| 58 | + UUID. |
| 59 | + |
| 60 | +## How it round-trips (push / pull / drift) |
| 61 | + |
| 62 | +The state file is the single source of values. push and pull never mutate the |
| 63 | +`variables` section — you hand-edit it; the engine preserves it verbatim. |
| 64 | + |
| 65 | +- **push** — `{{name}}` → value, then the usual reference/credential resolution, |
| 66 | + then the API call. |
| 67 | +- **drift** — the local file is hashed *with variables rendered*, and the |
| 68 | + platform resource is already rendered, so both sides hash in one basis. A |
| 69 | + templated resource you haven't changed reads as `clean`, never `both-diverged`. |
| 70 | +- **pull** — the platform value is written back, but a `{{name}}` placeholder is |
| 71 | + restored at any path where **your local file already had that placeholder and |
| 72 | + the value still matches**. This is guided by your file, so a literal value that |
| 73 | + merely *happens* to equal a variable's value is never rewritten into a |
| 74 | + placeholder. If a field was changed on the dashboard so its value no longer |
| 75 | + matches the variable, pull writes the literal value (the dashboard is now the |
| 76 | + source of truth for that field) — re-apply the placeholder by hand if you want |
| 77 | + it back. |
| 78 | + |
| 79 | +## Limitations (v1) |
| 80 | + |
| 81 | +- **No in-string interpolation** — whole-value only (see Rules). |
| 82 | +- **No nested/recursive variables** — a variable whose value itself contains a |
| 83 | + `{{...}}` placeholder is not re-resolved. Single pass. |
| 84 | +- **No `${ENV_VAR}` expansion** — variable values are literal. For per-developer |
| 85 | + secrets use `.env.<env>` (loaded into `process.env`), not the committed state |
| 86 | + file. |
| 87 | +- **Do not store secrets in `variables`.** The state file is committed to git. |
| 88 | + Tokens, API keys, and signing secrets belong in credentials or `.env.<env>`. |
| 89 | +- **`.ts` resources** keep their authored value (placeholder restoration on pull |
| 90 | + only applies to `.yml`/`.yaml`/`.md`), consistent with how `.ts` files are |
| 91 | + hashed. |
| 92 | +- **Avoid `null`-valued variables.** Null leaves are dropped by the hash |
| 93 | + canonicalization, so a `null`-valued placeholder may not round-trip cleanly |
| 94 | + through pull/drift. Prefer a sentinel value, or omit the field. |
| 95 | +- **Templating an entire `.md` system prompt** as a single `{{var}}` works for |
| 96 | + the normal case (one system message). Placeholder restoration on pull matches |
| 97 | + model messages positionally, so if the platform returns the messages in a |
| 98 | + different order than your local file, the literal rendered prompt is written |
| 99 | + instead of the placeholder — re-apply by hand if that happens. |
| 100 | + |
| 101 | +## Why the state file (and not a separate file)? |
| 102 | + |
| 103 | +Variables are the value analogue of the `name → { uuid }` reference maps the |
| 104 | +resolver already consults. Keeping them in `.vapi-state.<env>.json` means one |
| 105 | +lookup table, one commit, one merge surface. The migration guard |
| 106 | +(`assertStateMigrated`) and `npm run migrate` explicitly exempt the `variables` |
| 107 | +key, so its raw values are never mistaken for the legacy fat-state shape and |
| 108 | +`migrate` never drops them. |
0 commit comments