For the complete documentation index, see llms.txt. Any page on this site can be fetched as Markdown by appending .md to its URL.

Secrets & Variables

Store an organization's API keys and shared values once, and reference them by name from a voice agent's prompt and tools and from any workflow node.

Secrets are credentials: API keys, tokens, passwords. You write one in, and no page or API ever shows it to you again. Variables are ordinary shared values (a brand name, a support address, a business-hours string) that anyone with access can read and edit.

Both are organization-wide, and both are readable from either half of the platform — a voice agent's configuration and a workflow's nodes. Set the value once, and everything that references it by name picks up the change without anything being re-saved. Allow up to five minutes for a change to reach every call — see When a change takes effect.

Find the page in the sidebar under Admin → Secrets & Variables.

This page is gated on the administrator role, and the gate is enforced on the server as well as in the sidebar: reading the list is an administrator action too, because a secret's name and its last four characters are themselves sensitive. Every role Tandem issues today carries that role, so the gate keeps nobody out yet — see Manage Users.

Store an API key and use it in an agent tool header

This is what secrets are for: a tool that calls your API needs an Authorization header, and the key must not sit in the agent's configuration where a prompt or a transcript could carry it away.

Store the key as a secret

On Admin → Secrets & Variables, click Add Secret. Give it a Name — lowercase letters, digits, and underscores, up to 64 characters — and paste the key into Value.

Use a name you'll recognize in a tool six months from now: stripe_key, not key2.

Connect the API as an integration

A secret is not an integration. It holds one string; the integration holds the base URL and the connection itself. Create the integration first, under Integrations, then point the tool at it.

Reference the secret from the tool's header

Open the agent, click the Custom Tools row on the editor's Conversation step, and add an Integration tool against that integration. In its Parameters, add one:

FieldValue
NameAuthorization
Locationheader
Value fromFixed
Fixed valueBearer {{secrets.stripe_key}}

Once Location is header or body, the Fixed value field's help text names the secrets your organization has stored, so you don't have to remember them — up to four of them, then "and N more". Names only, never a value; and the help text is absent in a query or path parameter, where a secret is not allowed.

The reference is replaced with the real key at the moment the request leaves the platform, and nowhere before that. The agent never sees it, the model never sees it, and it never reaches a log or a transcript.

Save

Click Save. A name that doesn't exist is refused with unknown secret: secrets.stripe_key — check the spelling against the names the field offers, or the full list on Admin → Secrets & Variables.

Saving an agent that references a secret requires the organization administrator role, even though editing everything else about an agent doesn't. Referencing a secret is the only way to make its value leave the platform, so it's held to the same permission as storing one. If you aren't an administrator the save is refused and nothing is written — ask someone on your team who is.

Where a secret may and may not be used

{{secrets.name}} is resolved in exactly one place in the whole platform: the moment an outbound REST request leaves for one of your integrations. Two things can send that request: a voice agent's integration tool, covered here, and a workflow's Integration node, covered below. Nothing else resolves the namespace at all.

In an agent's configuration, that means a secret is allowed in exactly two places:

  • an integration tool's constant parameter values, where the location is header or body, and
  • that tool's request path.

A constant parameter in the query or path location is rejected. Values in those two locations are URL-encoded on their way out, which would mangle the reference into something the platform no longer recognizes as one — the encoded text would then be sent to your API in place of the key. Put the credential in a header, which is where almost every API expects it anyway; a key in a query string also ends up in server access logs and Referer headers along the way.

Everywhere else is rejected when you save the agent: prompts, greetings, returning greetings, thinking cues, disposition descriptions, steering rules, tool descriptions, a model-supplied parameter's name, description, and enum values, a response extraction's name and default, workflow tool parameters, and agent variables.

That restriction is the reason the feature is shaped this way. Anything an agent reads can end up in a model's context, a log line, or a transcript. If a system prompt does escape, it carries the text {{secrets.stripe_key}} — a name, not a credential.

Two fields go further and refuse any reference, secret or not: a tool's name and a disposition's name. Both are strings something downstream matches on: the model calls the tool by its exact name, and the workflow compares the disposition against what the agent reported. Substituting a value into either would change which thing it is, and rendering empty would erase it. Put the variable in the description instead.

In a workflow: the Integration node

A workflow can use a secret too, in the Integration node — the node that calls an external API through a pre-configured integration. And there the rule is wider: {{secrets.name}} works in its Headers, its Path (including a query string), and its Request Body. The narrower agent-tool rule above exists because an agent tool URL-encodes its query and path parameters, which would mangle the reference; this node sends its path exactly as you wrote it, so there is nothing to mangle.

The integration must be a REST one. A generic integration is either REST or SOAP, and only the REST path substitutes secrets. A {{secrets.name}} sent to a SOAP integration is not resolved and not stripped — the literal text goes upstream in place of the credential.

Only the {{ }} spelling works for a secret. Everywhere else on the platform $(name) and {{name}} are interchangeable, and this is the one exception: a secret is matched at the moment the request leaves, against the exact form {{secrets.name}}. A $(secrets.name) is refused outright rather than resolved, and the request is not sent.

A secret reference is resolved only where you typed it into the node. When a variable's value holds one (a caller's reply, a field from an API response, a per-call value), the Integration node refuses to send the request. The check covers every field of the node, either spelling, any letter case or spacing, and a reference assembled from several variables, such as $(a)$(b) where a holds {{secrets and b holds .stripe_key}}. The run ends with this error:

a variable value may not contain a {{secrets.*}} reference

Nothing is sent to the API. The node does not take its fallback exit, and a Try / Catch around it does not catch the error, because the request is refused before the node runs.

An escaped reference you typed yourself, \{{secrets.name}}, ends the run the same way. The escape renders it as a real reference, which the node cannot tell apart from one a variable supplied.

No other node resolves a secret. A reference in a Set Variable value, a TTS field, or an SMS body renders as an empty value. It is not sent anywhere, and nothing leaks, but nothing works either.

Clicking Validate in the editor is the only thing that reports a misplaced reference. Saving a workflow does not check, so a workflow with a secret in the wrong field saves cleanly and then quietly sends a blank. Validate before you publish.

A secret echoed back by your API

Some APIs return the request they received, in a debug mode, an error body, or a test endpoint. When a response contains the value of a secret the request used, that value comes back as [REDACTED] in the response body, headers, and status text, so it does not reach a workflow variable, an agent, or the editor's test panel. Its URL-encoded forms are replaced too. A secret shorter than 4 characters is not replaced, and an API that sends the value back transformed (base64, hashed, or split across fields) is not caught.

Calling the integrations API directly

A request you send to the integrations API yourself (POST /v1/integrations/generic/{id}/execute) that references a {{secrets.name}} needs the administrator role, and is refused with a 403 otherwise. A request that references no secret is not affected, and neither is the Integration node or an agent's integration tool. Every role Tandem issues today carries the administrator role, so this keeps nobody out yet.

Secrets

Add a secret

Click Add Secret, enter a Name and a Value, and click Add Secret again in the dialog. The name must be 1–64 characters of lowercase letters, digits, and underscores; a name already in use is refused rather than silently overwriting the existing secret.

The value is never shown again

Once saved, no page and no API returns a secret's value. The list shows a Hint — •••• followed by the last four characters, or an em dash when the value was shorter than eight characters and a hint would give too much of it away.

There is no "reveal" and no export. If you've lost the original key, get a new one from the provider and rotate.

Rotate a secret

Click the pencil on its row, type the new key into New value, and click Save. There is no redeploy and no version to pick.

Rotation is not instant, which matters when you are rotating a compromised key. The new value is stored immediately, but calls already in progress keep using the old one until they end, and new calls can keep using it for up to five minutes — see When a change takes effect. Saving here does not revoke the old credential. If the key may be compromised, revoke it at the provider as well; that is the only step that stops it working.

Changing a secret's description means re-entering its value. The description field stays disabled until you've typed a new value, because every write to a secret must carry one. There is no description-only update. This is a rough edge, not a safety rule: if all you wanted was to fix a typo in the description, you'll need the key in hand.

Delete a secret

Click the trash icon on its row and confirm. Nothing checks whether the name is still referenced, so a tool or an Integration node can be left pointing at a name with nothing behind it. That request is refused rather than sent — the platform will not put an unresolved reference on the wire — and comes back as unknown secret: secrets.<name>. Nothing is sent upstream, but nothing works either, and the failure surfaces during a call rather than when you delete. Deleting cannot be undone; the value is gone even to us.

Variables

A variable is a plain shared value. Unlike a secret, it is readable: it shows in the list, in the API, and — once referenced — in the agent's prompt.

The four types

TypeIn the APIHoldsExample
TextstringA stringNorthwind Traders
NumbernumberA number30
BooleanbooleanOn or offtrue
JSONjsonAn object or array, entered as JSON{"tier1": "Billing"}

Pick the type that matches the value. A value that doesn't match its declared type is refused with value does not match the declared type, and the message names the API spelling, so choosing Text and entering a number reports "string", not "Text".

Add and edit a variable

Click Add Variable, enter a Name (same rules as a secret's), pick the Type, and enter the Value. To change one later, click the pencil on its row; unlike a secret, a variable's current value is shown and can be edited in place, and its description can be changed on its own.

A variable's value can be up to 10,000 characters, and a description up to 280.

Use a variable in an agent

Reference it as {{org.<name>}} anywhere the agent's configuration takes text — the system prompt, the greeting, a returning caller's greeting, a thinking cue, a disposition description:

You are the virtual receptionist for {{org.brand}}. Office hours are {{org.hours}}.

The value is substituted once, when the call starts. A call already under way keeps the value it started with, however long it runs.

A notice at the foot of the Persona card names up to four of the organization variables you can reference, so you don't have to remember them. It shows names only — never a value — and when the organization has none it names no token at all and says where variables come from instead. You can reference a variable whether or not you're an administrator; only creating and editing them is restricted.

If the platform cannot read your organization's values as a call starts, {{org.*}} references go through unrendered. A connected caller is never dropped for this, so the model receives the literal text {{org.brand}} rather than a blank or an error, and may read it aloud. The agent's prompt and greeting still fill in sys., agent., and vars. values. Its tools do not: every template in a tool's request goes out unrendered for that call, whichever variables it names. This is a platform-side failure, not a configuration mistake, and it clears on its own.

Use a variable in a workflow

The same {{org.<name>}} works in any workflow node — an SMS body, an email, an Integration node's header, a Set Variable value. Unlike a secret, it is not restricted to one node. Type either {{ or $( in a node field and the editor's variable menu lists your organization's variables under Organization, alongside the flow's own variables and the sys. call-context list:

Thanks for contacting {{org.brand}}. Reply STOP to opt out, or email {{org.support_email}}.

Two differences from an agent:

  • The value is read when the node runs, not pinned for the whole workflow. A long-running workflow that pauses overnight picks up an edit made in between.
  • A node that references an organization variable fails closed if the value can't be read. The run stops with an error at that node rather than sending a message with a blank where the value should be. It does not fall through to an exit, so the flow cannot route around it. Nodes that reference no organization variable are unaffected: the read only happens for a node that names the namespace, so a workflow not using the feature never makes the request at all. This is the opposite of the agent side, which degrades rather than stops, and the reason is the caller: an SMS that silently lost its support address cannot be taken back, while a caller on the line should not be dropped.

An organization variable that doesn't exist reads as empty, the same as everywhere else. Two namespaces do not work in a workflow and are flagged when you click Validate: {{agent.<name>}} belongs to one voice agent, and {{vars.<name>}} travels from a workflow into an agent rather than back out.

When a change takes effect

Editing an organization secret or variable stores the new value straight away, but three things sit between the store and what a caller hears:

  • A call already in progress never sees the change. Values are read once when the call starts, so an agent mid-conversation keeps using what it had.
  • New calls can use the old value for up to five minutes. Values are cached to keep the start of a call fast.
  • The five minutes are counted separately by each server handling calls, so for a few minutes after an edit some calls can get the new value while others still get the old one. There is no way to tell from the outside which a given call received.

In practice: edit a value, then allow five minutes before assuming every call is using it. For a rotated credential, see the warning under Rotate a secret — the old key keeps working until you revoke it at the provider.

In a workflow the timing is different. A workflow node reads the value when it runs, so it is never more than five minutes stale and a long workflow picks up an edit partway through. Everything above about caching still applies. It is counted per node rather than per call. Workflows also have their own variables, which are a separate thing; System Variables covers the difference.

Deleting a variable that is still referenced fails quietly. Nothing checks for references before deleting, and a reference with nothing behind it renders as an empty string rather than raising an error, so a greeting of Thanks for calling {{org.brand}}. becomes Thanks for calling . on every call, with nothing logged and no failed call to investigate. The agent's next save is rejected with unknown org variable, but nothing prompts anyone to save it. Check what references a name before removing it.

Variables for a single agent

Some values belong to one agent rather than the whole organization — the queue it transfers to, the product line it covers. Those live on the agent, not here.

Open the agent and find the Variables section under Persona. Add a name and a value, and reference it as {{agent.<name>}} in that agent's own prompt, greeting, thinking cue, or disposition descriptions. The name follows the same rules as an organization variable's, values are text only, and an agent can hold up to 50.

An agent. name is private to the agent that declares it: two agents can each have a queue with different values, and neither can read the other's. A reference to a name the agent hasn't declared is refused on save with unknown agent variable: agent.queue — declare it in this agent's Variables list.

Values for a single call

An organization variable is the same for every call and an agent variable is the same for every call that agent takes. Per-call values are the third kind: a customer id, an account tier, the result of a lookup the workflow already did.

A workflow passes them at the moment it hands the call over. Open the AI Voice Agent node and fill in Pass to agent:

namevalue
customer_id{{lookup.id}}
tier{{crm.tier}}

The value column is templated against that workflow's own variables, so it can carry anything the workflow has already worked out. The name column is what the agent calls it — reference it as {{vars.customer_id}} in the agent's prompt, greeting, thinking cue, disposition descriptions, or an integration tool's constant parameters.

Greet the caller by name. Their account tier is {{vars.tier}}.

Three things to know before you rely on it:

  • An unpassed name reads as empty, it does not fail the call. An agent can be reached from several workflows, and they need not all pass the same values, so {{vars.tier}} in a prompt is simply blank when the workflow that called didn't set it. Write the prompt so that reads sensibly.
  • The values are fixed when the call is handed over. A workflow that resumes after the agent finishes cannot change what the agent already saw.
  • Do not pass a credential this way. These reach the agent's prompt, which means they can reach the model's context and a transcript — the same reason a secret cannot go in a prompt. Store it as a secret and reference it from an integration tool instead.

A row where you left the name blank is dropped rather than failing the handover: an empty row means nothing. A blank value is kept, because "this call has no customer id" is a real thing to pass.

Limits: 25 values, 2KB each, 16KB in total, counted as UTF-8 bytes — so accented or CJK text costs two to three times its character count. Over any of them the transfer fails and the workflow routes to the node's error exit rather than handing over a half-populated call, and the error lists every violation at once rather than one per attempt. Names follow the same rule as every other name here — 1–64 characters of lowercase letters, digits, and underscores.

Why can't I put a secret in a variable?

Because it would defeat the point of a secret, so the platform refuses it:

a variable value may not reference the secrets namespace

An organization variable and an agent variable are resolved everywhere — a prompt included. A secret is resolved in one place only. If a variable could hold {{secrets.stripe_key}}, then writing {{org.auth}} in a prompt would render the real key into the model's context, through one hop, past the rule that exists to stop exactly that.

The same reasoning rules out several near-misses that assemble the same result out of smaller pieces, so a variable value also cannot escape a delimiter, hold a literal inside a template, or call a function. Put the secret reference where it belongs — an integration tool's constant parameter or request path — and refer to that tool from the agent.

Naming and limits

RuleValue
Name (secret or variable)1–64 characters of a-z, 0-9, and _
Secret value1–8,192 characters
Organization variable valueUp to 10,000 characters (JSON is measured as written)
Agent variable valueUp to 2,000 characters, text only
DescriptionUp to 280 characters
Agent variables per agent50
Per-call values per handover25, 2KB each, 16KB total (UTF-8 bytes)
Secrets and variables per orgNo limit

A secret and a variable can share a name — they're separate namespaces, and {{secrets.brand}} and {{org.brand}} are different things. The same goes for all four: {{org.tier}}, {{agent.tier}}, and {{vars.tier}} are three unrelated values.

Error messages

Saving a secret or a variable

name must be 1-64 characters of a-z, 0-9, or underscore — no capitals, spaces, hyphens, or dots. support_email, not Support Email.

value does not match the declared type "number" — the Type dropdown and the value disagree. Change one.

value exceeds 10000 characters — the variable's value is too long. For a JSON value, that's the length of the JSON as written.

a variable value may not reference the secrets namespace — see Why can't I put a secret in a variable? above.

a variable value may not escape a template delimiter — remove the backslash-escaped "{{" or "$(" — a value containing \{{ or \$(. An escaped delimiter renders as a real one, which downstream can't tell apart from a genuine reference, so it's refused outright.

a variable value may not contain a literal inside a template — write the text outside the "{{ }}" or "$( )" delimiters — something like {{"acme"}}. That's a longer spelling of acme; write the text plainly.

a variable value may not call a function inside a template — use a plain variable reference — something like {{UPPER(org.brand)}}. Functions work in workflow nodes, not here.

a variable value may not contain an unparseable template — close or remove the "{{" or "$(" — an unfinished reference, such as a value ending in {{. Close it or delete it.

Saving an agent

Each of these names the field it came from, and a save reports every offending field at once rather than one per attempt.

a secret reference is not allowed here (secrets.stripe_key) — secrets may only be used in an integration tool's constant parameters and request path — the reference is in a prompt, a greeting, a description, a workflow tool's parameter, or a constant parameter whose location is query or path. Move it to a header or body constant parameter, or to the tool's request path — see Where a secret may and may not be used.

unknown secret: secrets.stripe_key — no secret by that name. Check the spelling against Admin → Secrets & Variables.

only an organization admin can use a secret reference in an agent configuration — the save is refused with a 403 and nothing is written. Editing an agent doesn't need the administrator role, but binding a credential to one does.

unknown org variable: org.brand — no organization variable by that name. Same check.

unknown agent variable: agent.queue — declare it in this agent's Variables list — add it in the agent's Variables section, or correct the spelling. You can add the variable and reference it in the same save.

crm.brand is not a recognized variable — use org, agent, vars, or sys — the namespace itself isn't one of the four. A bare {{brand}} with no namespace lands here too. A name that does start with a real namespace but doesn't exist gets one of the "unknown …" errors above instead.

sys.ctx.workflow_id is not available to a voice agent — that token describes a workflow, and an agent session has no workflow. See System Variables for the tokens an agent does accept.

a variable reference is not allowed in a tool name and a variable reference is not allowed in a disposition name — both messages tell you to put the variable in the description instead. See Where a secret may and may not be used.

functions are not supported in agent configuration — use a plain variable reference — {{UPPER(org.brand)}} and the like. Store the value in the form you want it spoken.

a literal value is not allowed inside a template — write the text outside the "{{ }}" or "$( )" delimiters — something like {{"hello"}}. Write the text plainly.

an escaped template delimiter is not allowed here — remove the backslash-escaped "{{" or "$(" from this value — the same rule as for a variable value, for the same reason.

template could not be parsed — an unfinished or malformed reference. The rest of the message names what the parser tripped on.

organization configuration could not be validated right now — please try again — a temporary failure reading your organization's secrets and variables, so the save couldn't be checked. Nothing was saved; retry. The same message can come back from testing a tool.

Running a workflow

The run stops with an error at a node that references {{org.<name>}} — the node named an organization variable and the values could not be read. This is deliberate, and it is a stop rather than an exit the flow can route around: the alternative is sending a message with a blank where the value should be, which cannot be taken back. Nodes that reference no organization variable keep running normally.

a variable value may not contain a {{secrets.*}} reference — an Integration node's request held a {{secrets.name}} that came from a variable's value rather than from what you typed. The request is not sent and the run ends with an error. See In a workflow: the Integration node.

When the tool runs

These come back as the tool's result during a call, not at save time, so you'll see them in the call's tool output or when you use Test on the tool.

unknown secret: secrets.stripe_key — the secret existed when the agent was saved and does not now, so it was deleted or renamed in between. Nothing scans agents when you delete a secret; this is where you find out. Re-create it under the same name, or point the tool at a different one.

unresolved variable reference reached the outbound request: {{org.brand}} — a reference that should have been filled in before the request left reached the outbound call still written as a reference. The request is refused rather than sent, because sending it would put the literal text where a real value belongs. This one is a fault on our side rather than a configuration mistake — contact support with the agent and tool name.

sys.telephony.ani is not a valid value for an outbound request — the value a reference resolved to itself looked like a template reference. Almost always because something outside your control put one there: a caller can choose the number they present, and a per-call value can carry whatever an upstream API returned. The request is refused, because a value arriving from outside must not be able to name one of your secrets. Nothing is wrong with your configuration; if you see it repeatedly, the upstream data is the place to look.

sys.telephony.ani is not a valid path value — the same guard, on the tool's request path rather than on a parameter value. The request is refused rather than sent, because a value spliced unencoded into a path can add a segment, climb to a parent path, or open a query string on your authenticated integration host.

Validating a workflow

These three come from the editor's Validate button. Each one is advisory: the reference reads as an empty value at runtime and nothing is leaked — but none of them is checked when you save, so Validate is the only place you will see them.

Node 'Save tier' references a secret in 'variables[0].value'. Secrets are only resolved by the Integration node… — a secret reference somewhere that cannot resolve one. Move it into an Integration node's headers, path, or body.

Node 'Send SMS' references 'agent.' in 'body', which resolves to an empty value in a workflow — an agent variable belongs to one voice agent and is readable only inside that agent's own configuration. Use an organization variable if the value is shared.

Node 'Send SMS' references 'vars.' in 'body', which resolves to an empty value in a workflow — per-call values travel from a workflow into an agent, through the AI Voice Agent node's Pass to agent list, and do not come back. Use the workflow's own variables.

Next steps

On this page