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 as balance, 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" }
      }
    }
  }
}
SettingMeaning
typestring, number, boolean or list
requireda call without this value is refused before it is placed
max_lengthstrings: at most this many characters (1–512, default 512)
max_itemslists: at most this many items (1–16, default 16)
itemlists: 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-inValue
contact.first_name, contact.namethe contact the call is for (a campaign contact, or the caller matched by caller lookup)
contact.language, contact.time_zone, contact.external_referencethat contact's own fields
campaign.id, campaign.name, campaign.run_id, campaign.attemptthe campaign a call belongs to
call.direction, call.counterparty_number, call.agent_numberthe call itself
call.local_date, call.local_timethe 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

MarkerRenders
{{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}}. A required variable 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.0 reads 45300, 0.5 reads 0.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-call first_message and instructions you send with POST /v1/calls: they are templates like the agent's own. In first_message a marker naming a built-in or a declared variable is filled in; in instructions, as in the agent's own instructions, it only names the variable ({{balance}} reads as balance) and the value reaches the model in the data block. An unresolved or malformed {{…}} in either is refused with call_variables_invalid, where naming runtime_config.first_message or runtime_config.instructions.

Supply the values

  • An API call sends them in call_context on POST /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.* and campaign.* 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 with 400 and the admission code in reason (call_variables_missing, call_variables_invalid or call_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 (format sets how a date is compared: date:YYYY-MM-DD, date:DD/MM/YYYY or date:MM/DD/YYYY; identifier for 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:

WhatLimit
a greeting or workflow line1,000 characters once filled in
the data block the model reads8 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:

  1. Tool and connection webhooks receive context.variables — the call's declared variables and built-ins, never a protected value before confirmation — instead of the raw context.call_context.
  2. Lists in call_context hold at most 16 items (previously 32), on POST /v1/calls, gateway outbound calls and test values. A call_context outside 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 plain 400 that names the bound, with no reason code and no variable.
  3. Campaign calls' call_context no longer carries campaign_id, campaign_run_id, attempt_no or contact_name. They are the built-ins campaign.id, campaign.run_id, campaign.attempt and contact.name.
  4. Per-call first_message and instructions are templates: an unresolved or malformed {{…}} in them is refused on POST /v1/calls (400, reason call_variables_invalid), and a workflow agent refuses per-call text altogether (per_call_text_on_workflow).
  5. 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 its metadata.publication records why while it is not.
  6. Tools are refused on workflow calls and on calls that carry protected values (see Agent workflows).
  7. Built-in variables reach the model on every engine, GPT-Live and Gemini Live included, in the call's data block.
  8. 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.
  9. Call reads (GET /v1/calls/{call_id} and the MCP call tools) no longer include the call's variables snapshot, and a call's call_context is returned without its protected values.
  10. 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 variables block 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. The discloses_debt setting is removed; the protected declaration replaces it.
  • On an agent that takes campaign calls, the campaign names it used become built-ins: contact_name is contact.name, campaign_id is campaign.id, campaign_run_id is campaign.run_id and attempt_no is campaign.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 } }
    }
  }
}
  • slots are filled by the model at send time (at most 8 per template, each up to max_length characters, 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:

  1. the call's language (for an API send, the language you name);
  2. that language's base (et-EE → et);
  3. the agent's primary language — its opening language;
  4. 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

MethodPathScope
GET/v1/agents/{agent_id}/variablesagents:readthe config, its revision and schema_hash
PUT/v1/agents/{agent_id}/variablesagents:writereplace the config
POST/v1/agents/{agent_id}/messagessms:send + agents:readsend 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:

whereThe problem is in
variablesthe declared variables
sms_mode, sms_templates, caller_lookupthat 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

CodeWhat to fix
INVALID_VARIABLES_CONFIGThe config is not an object of variables, sms_mode, sms_templates and caller_lookup.
INVALID_VARIABLESvariables must be {"version": 1, "fields": {...}}.
TOO_MANY_FIELDSMore than 32 variables.
INVALID_NAMEA name is not letters, digits and _ (not starting with a digit, up to 80).
RESERVED_NAMESPACEA name starts with contact., call. or campaign.: those are the built-ins.
UNKNOWN_NAMESPACEA name contains a dot; your own names have none.
INVALID_FIELD, INVALID_FIELD_TYPEA declaration is not an object, or its type is not string, number, boolean or list.
UNKNOWN_FIELD_PROPERTY, INVALID_FIELD_PROPERTYA declaration has a setting that does not exist, or required, protected or before_confirmation is not true/false.
INVALID_MAX_LENGTH, INVALID_MAX_ITEMSmax_length (strings only, 1–512) or max_items (lists only, 1–16) is out of range or on the wrong type.
INVALID_LIST_ITEMA list's item is missing or not {key: "string" | "number" | "boolean"}, or a non-list has one.
INVALID_FORMATformat is not one of the supported formats, or is on a non-string.
PROTECTED_FIELD_WITHOUT_VERIFICATIONA protected variable on an agent without a workflow: nothing could confirm the caller before it is said.
PROTECTED_FIELD_ON_LIVE_ENGINEA protected variable on a GPT-Live or Gemini Live agent, which cannot hold a value back.
REQUIRES_WITHOUT_PROTECTED, INVALID_REQUIRESrequires on an unprotected variable, or not confirmation / knowledge_match.
BEFORE_CONFIRMATION_WITHOUT_LOOKUPbefore_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_WORKFLOWThe agent has a workflow: declare the variables on the workflow instead.
INVALID_CALLER_LOOKUP, CALLER_LOOKUP_LIST_NOT_FOUNDcaller_lookup is malformed, or names a contact list this agent's project does not have.
INVALID_SMS_MODEsms_mode is not free_text or templates_only.
INVALID_SMS_TEMPLATE, TOO_MANY_SMS_TEMPLATESA template id or shape is invalid, or there are more than 20 templates.
SMS_TEMPLATE_PRIMARY_LANGUAGE_MISSINGA template has no text in the agent's primary language.
SLOT_COLLISIONAn SMS template slot has the name of a declared variable or a built-in.
TOO_MANY_SMS_SLOTS, INVALID_SMS_SLOTMore than 8 slots, or a slot that is not {"source": "model", "max_length": 1–120}.
UNDECLARED_VARIABLEA line names a variable that is not declared (or a list item key outside its list).
OPTIONAL_OUTSIDE_SECTIONAn optional variable printed outside its own {{#name}} section.
BOOLEAN_PLACEHOLDER, LIST_PLACEHOLDER, LIST_IN_LISTA 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_NODESA marker is misspelled or unbalanced, sections nest more than two deep, or a line has more than 200 markers and text runs.
PROTECTED_BEFORE_CONFIRMATIONA 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.

On this page