Centralize values that repeat across resources — callback URLs, a default model
name, a shared prompt fragment, a brand name — in one place, and reference them
from any resource file with a {{name}} placeholder. At push the placeholder is
replaced with the managed value; at pull the placeholder is preserved.
- Add a
variablessection to the state file.vapi-state.<env>.json: - Reference a variable anywhere in a resource file with a whole-value
placeholder:
model: model: "{{default_model}}" maxTokens: "{{max_tokens}}" # becomes the NUMBER 260, not "260" server: url: "{{callback_url}}"
npm run push -- <env>substitutes the values.npm run pull -- <env>restores the{{...}}placeholders. Drift detection treats a templated file and its rendered platform resource as in sync — no phantom drift.
- Whole-value only. A placeholder substitutes only when the entire value
is a single
{{name}}. Embedded placeholders inside a longer string ("Call us at {{callback_url}}") are not substituted — they ship verbatim. In-string interpolation is intentionally unsupported because it cannot round-trip cleanly through pull/drift. - Type is preserved. The managed value keeps its JSON type.
"{{max_tokens}}"withmax_tokens: 260sends the number260; an object-valued variable sends the object. (YAML needs the placeholder quoted, but the result is the native type.) - Variable names are
[A-Za-z0-9_.-]+— letters, digits, underscore, dot, hyphen. No spaces. Whitespace inside the braces is ignored:{{ x }}=={{x}}. - Undefined placeholders are a validation error.
npm run validate/pushfail (or warn, without--strict) on any{{name}}with no matching entry invariables, so a typo can't silently ship the literal string"{{name}}". - Variables compose with references. Substitution runs before resourceId →
UUID resolution, so a variable whose value is a resourceId works:
toolIds: ["{{tool_ref}}"]withtool_ref: "my-tool"resolves to the tool's UUID.
The state file is the single source of values. push and pull never mutate the
variables section — you hand-edit it; the engine preserves it verbatim.
- push —
{{name}}→ value, then the usual reference/credential resolution, then the API call. - drift — the local file is hashed with variables rendered, and the
platform resource is already rendered, so both sides hash in one basis. A
templated resource you haven't changed reads as
clean, neverboth-diverged. - pull — the platform value is written back, but a
{{name}}placeholder is restored at any path where your local file already had that placeholder and the value still matches. This is guided by your file, so a literal value that merely happens to equal a variable's value is never rewritten into a placeholder. If a field was changed on the dashboard so its value no longer matches the variable, pull writes the literal value (the dashboard is now the source of truth for that field) — re-apply the placeholder by hand if you want it back.
- No in-string interpolation — whole-value only (see Rules).
- No nested/recursive variables — a variable whose value itself contains a
{{...}}placeholder is not re-resolved. Single pass. - No
${ENV_VAR}expansion — variable values are literal. For per-developer secrets use.env.<env>(loaded intoprocess.env), not the committed state file. - Do not store secrets in
variables. The state file is committed to git. Tokens, API keys, and signing secrets belong in credentials or.env.<env>. .tsresources keep their authored value (placeholder restoration on pull only applies to.yml/.yaml/.md), consistent with how.tsfiles are hashed.- Avoid
null-valued variables. Null leaves are dropped by the hash canonicalization, so anull-valued placeholder may not round-trip cleanly through pull/drift. Prefer a sentinel value, or omit the field. - Templating an entire
.mdsystem prompt as a single{{var}}works for the normal case (one system message). Placeholder restoration on pull matches model messages positionally, so if the platform returns the messages in a different order than your local file, the literal rendered prompt is written instead of the placeholder — re-apply by hand if that happens.
Variables are the value analogue of the name → { uuid } reference maps the
resolver already consults. Keeping them in .vapi-state.<env>.json means one
lookup table, one commit, one merge surface. The migration guard
(assertStateMigrated) and npm run migrate explicitly exempt the variables
key, so its raw values are never mistaken for the legacy fat-state shape and
migrate never drops them.
{ "assistants": { "...": { "uuid": "..." } }, "variables": { "callback_url": "https://example.com/vapi/webhook", "default_model": "gpt-4.1", "max_tokens": 260 } }