Call variables
Give each call its own data — a name, a balance, an appointment — and let the agent's greeting, lines, instructions and SMS templates use it.
Call variables are the data one call carries: the person's first name, an
order number, a balance, an appointment time, a list of items. You declare
the variables an agent uses once, write them into the agent's greeting,
workflow lines, instructions and SMS templates as {{name}}, and each call
supplies its own values.
- A literal line — the greeting, a workflow line, an SMS — is spoken or sent with the values filled in.
- The agent's instructions name variables but never contain their values:
{{balance}}reads asbalance, and the values reach the model as data, in one block the model is told never to follow as instructions.
Declare the variables
An agent's variables, SMS templates and caller lookup live in one config, read
with GET /v1/agents/{agent_id}/variables and replaced with
PUT /v1/agents/{agent_id}/variables (see the API below).
{
"variables": {
"version": 1,
"fields": {
"first_name": { "type": "string", "max_length": 80 },
"order": { "type": "string", "required": true },
"balance": { "type": "number" },
"is_vip": { "type": "boolean" },
"items": {
"type": "list",
"max_items": 5,
"item": { "desc": "string", "amount": "number" }
}
}
}
}| Setting | Meaning |
|---|---|
type | string, number, boolean or list |
required | a call without this value is refused before it is placed |
max_length | strings: at most this many characters (1–512, default 512) |
max_items | lists: at most this many items (1–16, default 16) |
item | lists: each item's own keys, each string, number or boolean; an item key cannot be required, so it is printed inside its own section |
An agent declares at most 32 variables. Names are letters, digits and _, not
starting with a digit, up to 80 characters.
An agent takes effect automatically when its configuration is valid; until
then its calls are refused with a reason. You can write the lines first and
declare their variables after: an agent whose greeting names {{plan}} before
plan is declared is saved, but stays unpublished, and its
metadata.publication names the reason (publication_variables_invalid,
UNDECLARED_VARIABLE, where it was found). The PUT that declares plan
publishes it, and its next call uses it.
Workflow agents declare variables on the workflow
An agent with a workflow declares its variables on the workflow draft instead, and they are checked when the workflow is published. Its agent config then holds only the SMS templates, the SMS mode and the caller lookup.
Built-in variables
Every call also carries these, without declaring them. They never collide with your names.
| Built-in | Value |
|---|---|
contact.first_name, contact.name | the contact the call is for (a campaign contact, or the caller matched by caller lookup) |
contact.language, contact.time_zone, contact.external_reference | that contact's own fields |
campaign.id, campaign.name, campaign.run_id, campaign.attempt | the campaign a call belongs to |
call.direction, call.counterparty_number, call.agent_number | the call itself |
call.local_date, call.local_time | the time when the call was admitted, in the contact's zone -- or, without one, the campaign's zone, then the default zone of the other party's number, then UTC |
Write them into lines
| Marker | Renders |
|---|---|
{{order}} | the value |
{{#first_name}}…{{/first_name}} | its content only when first_name has a value (for a boolean, when it is true; for a list, once per item) |
{{^first_name}}…{{/first_name}} | its content only when first_name has no value (or is false, or an empty list) |
Hello{{#first_name}} {{first_name}}{{/first_name}}, this is Acme about order {{order}}.
{{#items}}{{#desc}}{{desc}}{{/desc}}{{#amount}}: {{amount}}{{/amount}}. {{/items}}{{^items}}Nothing is due.{{/items}}- Spaces or tabs inside the braces are fine:
{{ order }}. - Sections nest two deep: a condition inside a list, for example
{{#items}}{{#paid}}(paid){{/paid}}{{/items}}. A list inside a list is not allowed. - An optional variable may only be printed inside its own section, so a
missing value can never leave a gap like "Hello , your order is ready":
{{#first_name}} {{first_name}}{{/first_name}}. Arequiredvariable can be printed anywhere. A list item's own keys are always optional, so inside a list each is printed in its own section too:{{#items}}{{#desc}}{{desc}}{{/desc}}{{/items}}. - Values are used exactly as given and are never read as markers: a value
containing
{{…}}is spoken as those characters. - Numbers are written plainly:
45300.0reads45300,0.5reads0.5. A number needs at most 6 decimals and an absolute value below 10¹⁵. - Anything else that starts with
{{— a misspelled or unclosed marker, a close for the wrong name — is refused when the agent or workflow is published, never silently dropped. {{is reserved. Every{{in a line starts a marker, and there is no escape: a line cannot contain two literal opening braces. The same holds for the per-callfirst_messageandinstructionsyou send withPOST /v1/calls: they are templates like the agent's own. Infirst_messagea marker naming a built-in or a declared variable is filled in; ininstructions, as in the agent's own instructions, it only names the variable ({{balance}}reads asbalance) and the value reaches the model in the data block. An unresolved or malformed{{…}}in either is refused withcall_variables_invalid,wherenamingruntime_config.first_messageorruntime_config.instructions.
Supply the values
- An API call sends them in
call_contextonPOST /v1/calls:{"call_context": {"order": "A-17", "first_name": "Mari"}}. Only declared names are used; any other key is ignored. - A campaign takes them from the contact list's columns, and fills the
contact.*andcampaign.*built-ins itself. See Campaigns. - A Talk or Chat test in the dashboard, or a browser session over the
API, takes them as
test_values. The test is admitted as a call is: the greeting and instructions render with the test values, and a required value that is missing, or one that does not match its declaration, refuses the test with400and the admission code inreason(call_variables_missing,call_variables_invalidorcall_variables_over_budget). A Chat test sends the same values with every turn.
A value that does not match its declaration — the wrong type, too long, too many list items — refuses the call before it is placed, with the variable named and the value never echoed.
Protected variables
Some values must not be said until the person on the line has shown they are
the right person: a balance, a date of birth. Mark those protected, with the
check that releases them:
"balance": { "type": "number", "protected": true, "requires": "confirmation" }confirmation: released once the workflow's identity step is confirmed.knowledge_match: released once the caller correctly states a value you hold, such as a postcode or a date of birth (formatsets how a date is compared:date:YYYY-MM-DD,date:DD/MM/YYYYordate:MM/DD/YYYY;identifierfor an account or reference number).
Protected variables need a workflow agent on the cascade engine: the workflow decides when each line may be said, and a line before the confirming step that names a protected value is refused when the workflow is published.
Live engines do not protect values
GPT-Live and Gemini Live agents cannot hold protected variables: a live engine speaks freely and nothing gates what it says. Declare their variables as ordinary ones, and give them only data that may be said to whoever answers.
Caller lookup
For inbound calls an agent can look the caller's number up in one of its
contact lists ("caller_lookup": {"contact_list_id": "cl_..."}), which fills
the contact.* built-ins. A number match is not proof of who is calling: on an
inbound call every other looked-up field is treated as protected, and only
contact.first_name and contact.name may be said before confirmation — unless
you mark a field "before_confirmation": true. On an agent without a workflow
nothing can confirm the caller, so a looked-up field without
before_confirmation never reaches the call at all; if it is also required,
every call from a matched number is refused as call_variables_missing.
Limits
Every line the call speaks is rendered with the call's values before the call is placed, and the call is refused rather than cut short when something does not fit:
| What | Limit |
|---|---|
| a greeting or workflow line | 1,000 characters once filled in |
| the data block the model reads | 8 KB |
An SMS is checked when it is sent, with the text it really renders: at most
6 segments. A message over that is refused, never truncated, and the call goes
on. When the agent sends a template during a call, the tool's result records
OUTPUT_BUDGET_EXCEEDED; a workflow's Send SMS step records SMS failed
with that reason; POST /v1/agents/{agent_id}/messages answers 400.
A refused call is answered
400 {"error": "BadRequestError", "message", "reason", "field", "where"}:
field names the variable and where the destination when they apply, and
reason is one of call_variables_missing (a required value is
absent), call_variables_invalid (a value does not match its declaration),
call_variables_over_budget (a filled-in line is empty or over its limit),
workflow_binding_failed, schema_pair_mismatch or
per_call_text_on_workflow (per-call first_message or instructions sent to
a workflow agent, which speaks only its authored lines).
What changes for existing integrations
Call variables change what some existing requests and payloads carry. If your integration relies on any of these, update it:
- Tool and connection webhooks receive
context.variables— the call's declared variables and built-ins, never a protected value before confirmation — instead of the rawcontext.call_context. - Lists in
call_contexthold at most 16 items (previously 32), onPOST /v1/calls, gateway outbound calls and test values. Acall_contextoutside its bounds -- a string over 512 characters, a list over 16 items, an object over 32 keys, or more than 4,096 bytes in all -- is refused by the request check before the call's variables are read: a plain400that names the bound, with noreasoncode and no variable. - Campaign calls'
call_contextno longer carriescampaign_id,campaign_run_id,attempt_noorcontact_name. They are the built-inscampaign.id,campaign.run_id,campaign.attemptandcontact.name. - Per-call
first_messageandinstructionsare templates: an unresolved or malformed{{…}}in them is refused onPOST /v1/calls(400,reasoncall_variables_invalid), and a workflow agent refuses per-call text altogether (per_call_text_on_workflow). - Every agent's greeting and instructions are checked when it is
published, whether or not it declares variables. A line naming an
undeclared variable keeps an unpublished agent unpublished, with the reason
(see Declare the variables), and refuses the edit
of a published one with
400 variables_config_invalid. This applies to every hosted agent, whether or not it has a language set: each one is published by its first valid save, and itsmetadata.publicationrecords why while it is not. - Tools are refused on workflow calls and on calls that carry protected values (see Agent workflows).
- Built-in variables reach the model on every engine, GPT-Live and Gemini Live included, in the call's data block.
- An agent's edits apply through its publication. A hosted agent that is
not published — because its configuration is not valid yet — refuses real
calls with the reason (
agent_not_published), rather than running whatever its settings say. In the dashboard the agents list shows it as Not ready for calls, linked to the part of its setup to fix, and the setup names each reason ("Not ready for calls — …"). A call placed to it is refused with "The AI agent is not ready for calls: its configuration has not been published yet." Fix the named part and save: a valid save publishes it. - Call reads (
GET /v1/calls/{call_id}and the MCP call tools) no longer include the call's variables snapshot, and a call'scall_contextis returned without its protected values. - Workflow versions published before call variables no longer run: see Agent workflows.
When your workspace moves to call variables
Skysay moves existing agents to call variables for you, in a short scheduled maintenance window. What your callers hear does not change: every workflow's lines were checked to produce the same words before and after. Greetings, instructions, language versions and per-call text are not rewritten.
Workflow agents. Each workflow draft changes in three ways you will see in the editor:
- It gains a
variablesblock declaring every name its lines and reads use. The values its disclosing lines speak, and the facts read for those lines, are declared protected, so they are disclosed only after the caller is confirmed; any other value is declared as an ordinary variable. - A line that disclosed a value is rewritten to the template language: each
value and the spacing before it become a section,
{{#amount}} {{amount}}{{/amount}}, so a missing value is dropped exactly as it was before. Thediscloses_debtsetting is removed; the protected declaration replaces it. - On an agent that takes campaign calls, the campaign names it used become
built-ins:
contact_nameiscontact.name,campaign_idiscampaign.id,campaign_run_idiscampaign.run_idandattempt_noiscampaign.attempt. On any other agent those names stay your own variables. An agent whose campaign calls and direct calls both send one of those names waits for you to say which one its lines mean.
Its published versions change as described in Versions from before call variables.
If a draft's lines cannot be moved with the same words guaranteed, the move
waits until that draft is fixed, and nothing in your workspace changes in the
meantime. A saved draft that does not yet pass validation is moved as far as
it goes and does not hold up the move -- unless it contains {{ markup the
template language would now read differently, which holds the move until it is
fixed, wherever it sits. A draft that cannot be read at all does not hold it up
either: it gets an empty variables block, and once you repair it, it
publishes under the call-variables rules.
Agents on GPT-Live or Gemini Live without a workflow get a Variables
config declaring the per-call keys the platform used to pass to their model --
name, contact_name, amount, amount_owed, overdue_amount, currency,
due, due_date, reference, reference_id and invoice -- typed from
their calls, so the same data keeps reaching the model, now in the call's data
block. Other keys in call_context never reached the model and are not
declared; declare them yourself if you want the model to see them.
Hosted agents that never published a language set get their current
language published as their language set -- on GPT-Live and Gemini Live, the
single language their calls already run, unchanged. They then show as
published with that language, and later edits apply through publishing, like
every other agent. An active agent's language set is published only when its
calls run exactly as before; if that cannot be done as it stands, the move waits
until it is fixed, as it does for a draft. Inactive agents take no calls, so
the move does not wait for them: if an inactive agent's configuration cannot be
published as it stands -- an incomplete voice stack, for example -- the move
records the reason on the agent exactly as a refused save would. The dashboard
shows it Not ready for calls with that reason (such as tts.en), and
metadata.publication carries it. Reactivating such an agent takes a save
that makes its configuration valid, which publishes it.
SMS templates
An agent can hold SMS templates, sent by its send_sms tool during a call or
through the API with no call behind it.
{
"sms_mode": "free_text",
"sms_templates": {
"shipped": {
"text": {
"en": "Hi{{#first_name}} {{first_name}}{{/first_name}}, order {{order}} ships {{when}}.",
"de": "Hallo{{#first_name}} {{first_name}}{{/first_name}}, Bestellung {{order}} wird {{when}} verschickt."
},
"slots": { "when": { "source": "model", "max_length": 40 } }
}
}
}slotsare filled by the model at send time (at most 8 per template, each up tomax_lengthcharacters, 120 at most). The model fills only slots — never your variables.sms_mode: "templates_only"refuses any free-text SMS from the agent: it can send only its templates.- An agent holds at most 20 templates; ids are lowercase letters, digits and
_.
Which language is sent. A template's text is chosen in this order:
- the call's language (for an API send, the
languageyou name); - that language's base (
et-EE→et); - the agent's primary language — its opening language;
- as a last resort, the template's texts in language-code order.
So a template never fails to send for want of a translation — but a caller may get the primary language instead of their own. Translate every template into every language the agent speaks.
The API
| Method | Path | Scope | |
|---|---|---|---|
GET | /v1/agents/{agent_id}/variables | agents:read | the config, its revision and schema_hash |
PUT | /v1/agents/{agent_id}/variables | agents:write | replace the config |
POST | /v1/agents/{agent_id}/messages | sms:send + agents:read | send one of the agent's templates |
PUT replaces the whole config and republishes the agent in the same step,
so the next call uses it; calls already under way keep what they started with.
The body is {"config": {…}, "base_revision": n} -- config is the object
shown in Declare the variables, and any other
top-level key is refused. If the agent cannot be published for any other
reason -- an incomplete voice stack, or a language its workflow cannot speak --
the whole PUT is refused and nothing is stored. Send back the revision you
read as base_revision: if the agent was changed
and republished since — by another edit of this config, or of its voice,
prompt or languages — the write is refused with
409 {"error": "revision_conflict", "revision": <current>} and nothing
changes. A config the checks refuse is
400 {"error": "variables_config_invalid", "message", "issues": [...]}: every
problem at once, each issue naming its code and, where it applies, the
field it is about and where it was found; a misspelled or unbalanced marker
also names the marker text. Nothing is stored or published.
{"error": "variables_config_invalid", "message": "call variables cannot be published: OPTIONAL_OUTSIDE_SECTION",
"issues": [{"code": "OPTIONAL_OUTSIDE_SECTION", "field": "first_name", "where": "first_message.en"}]}where is one of:
where | The problem is in |
|---|---|
variables | the declared variables |
sms_mode, sms_templates, caller_lookup | that part of the config |
sms_templates.<id> | one template's shape, slots or languages |
sms_templates.<id>.text.<language> | one template's text in one language |
first_message.<language> | the greeting in that language |
system_prompt.<language> | the instructions in that language |
steps.<step_id> | a line of the agent's active workflow (see below) |
A config that is valid in itself but would let the agent's active workflow say
a protected value before the caller is confirmed -- turning on caller lookup
protects the looked-up fields that are not before_confirmation -- is refused
with 409 {"error": "workflow_revalidation_failed", "message", "issues": [...]},
each issue a PROTECTED_BEFORE_CONFIRMATION at steps.<step_id>. The same
409 with ACTIVE_WORKFLOW_UNREADABLE means the active workflow could not be
read to check it. Nothing is stored.
Issue codes
| Code | What to fix |
|---|---|
INVALID_VARIABLES_CONFIG | The config is not an object of variables, sms_mode, sms_templates and caller_lookup. |
INVALID_VARIABLES | variables must be {"version": 1, "fields": {...}}. |
TOO_MANY_FIELDS | More than 32 variables. |
INVALID_NAME | A name is not letters, digits and _ (not starting with a digit, up to 80). |
RESERVED_NAMESPACE | A name starts with contact., call. or campaign.: those are the built-ins. |
UNKNOWN_NAMESPACE | A name contains a dot; your own names have none. |
INVALID_FIELD, INVALID_FIELD_TYPE | A declaration is not an object, or its type is not string, number, boolean or list. |
UNKNOWN_FIELD_PROPERTY, INVALID_FIELD_PROPERTY | A declaration has a setting that does not exist, or required, protected or before_confirmation is not true/false. |
INVALID_MAX_LENGTH, INVALID_MAX_ITEMS | max_length (strings only, 1–512) or max_items (lists only, 1–16) is out of range or on the wrong type. |
INVALID_LIST_ITEM | A list's item is missing or not {key: "string" | "number" | "boolean"}, or a non-list has one. |
INVALID_FORMAT | format is not one of the supported formats, or is on a non-string. |
PROTECTED_FIELD_WITHOUT_VERIFICATION | A protected variable on an agent without a workflow: nothing could confirm the caller before it is said. |
PROTECTED_FIELD_ON_LIVE_ENGINE | A protected variable on a GPT-Live or Gemini Live agent, which cannot hold a value back. |
REQUIRES_WITHOUT_PROTECTED, INVALID_REQUIRES | requires on an unprotected variable, or not confirmation / knowledge_match. |
BEFORE_CONFIRMATION_WITHOUT_LOOKUP | before_confirmation without a caller_lookup, in an agent's own variables. A workflow's variables may keep it without one; it only changes anything for a looked-up field. |
VARIABLES_BELONG_TO_WORKFLOW | The agent has a workflow: declare the variables on the workflow instead. |
INVALID_CALLER_LOOKUP, CALLER_LOOKUP_LIST_NOT_FOUND | caller_lookup is malformed, or names a contact list this agent's project does not have. |
INVALID_SMS_MODE | sms_mode is not free_text or templates_only. |
INVALID_SMS_TEMPLATE, TOO_MANY_SMS_TEMPLATES | A template id or shape is invalid, or there are more than 20 templates. |
SMS_TEMPLATE_PRIMARY_LANGUAGE_MISSING | A template has no text in the agent's primary language. |
SLOT_COLLISION | An SMS template slot has the name of a declared variable or a built-in. |
TOO_MANY_SMS_SLOTS, INVALID_SMS_SLOT | More than 8 slots, or a slot that is not {"source": "model", "max_length": 1–120}. |
UNDECLARED_VARIABLE | A line names a variable that is not declared (or a list item key outside its list). |
OPTIONAL_OUTSIDE_SECTION | An optional variable printed outside its own {{#name}} section. |
BOOLEAN_PLACEHOLDER, LIST_PLACEHOLDER, LIST_IN_LIST | A boolean or a list printed as {{name}}, or a list section inside another. |
MARKER_MALFORMED, SECTION_UNCLOSED, SECTION_UNOPENED, SECTION_MISMATCHED, NESTING_TOO_DEEP, TOO_MANY_NODES | A marker is misspelled or unbalanced, sections nest more than two deep, or a line has more than 200 markers and text runs. |
PROTECTED_BEFORE_CONFIRMATION | A line the caller hears before confirmation would say a protected value. |
POST …/messages is project-scoped, unlike POST /v1/messages: the send
uses the agent's own project and a sending number from it. Send the
template_id, the variables the template uses, the model slots, an optional
language, and the usual to, purpose and recipient_type of a message.
An agent in another workspace or project and an unknown template are the same
404. A protected variable cannot be sent this way — there is no call to
confirm who you are writing to — and a template whose text names one cannot be
sent through this route at all, even with no values. A key in variables the
agent does not declare is refused with 400.
curl -X POST "$SKYSAY_BASE_URL/v1/agents/$AGENT_ID/messages" \
-H "Authorization: Bearer $SKYSAY_API_KEY" -H "Content-Type: application/json" \
-d '{"template_id": "shipped", "variables": {"first_name": "Mari", "order": "A-17"},
"slots": {"when": "tomorrow"}, "from_number_id": "pn_...",
"to": "+12025550111", "purpose": "support", "recipient_type": "business"}'During a scheduled maintenance window the two writes, PUT /v1/agents/{agent_id}/variables and POST /v1/agents/{agent_id}/messages,
answer 503 with "error": "platform_maintenance" and a Retry-After
header; nothing changes and nothing is sent. A message request is checked first
(the agent, template, values and sending number), so a malformed one is still
answered with its own 400 or 404 during the window. Reading the config
keeps working.
From an MCP client
The same three operations are MCP tools, with the same
scopes and answers: get_agent_variables, set_agent_variables and
send_agent_template_message. A refused call returns isError with the
route's refusal under error.body, so an assistant can read the issues of a
refused config, or the current revision after a conflict, and correct itself.
Agent workflows
Design governed conversations on a visual canvas, compile them into a deterministic runtime contract, and test them by talking, typing, simulating, or phoning before you publish.
Simulations Lab
Author realistic caller scenarios, replay them against one pinned agent version, and read hard verdicts with the exact snapshot each attempt exercised.