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 |
keypad | strings: the value is keyed on a phone menu and never shown to the agent (see Keypad variables) |
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 |
campaign.call_kind | why the campaign is calling this contact now: first_contact, retry, callback or reopened |
campaign.previous_summary_status, campaign.previous_summary, campaign.previous_follow_up | what was said the last time the campaign reached this person, when the campaign asks for it |
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 |
call.navigation_hint | the navigation_hint you sent in call_context: what the phone menu this call will meet expects (see Phone menus) |
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.
Names the agent listens for
On a call whose speech-to-text is Deepgram, the call's proper-noun values are
also given to the recogniser as key terms, so it hears them as written: the
contact's name and first name, the campaign's name, and the values you
declared. A value counts when it is one to four words of letters (with ',
., - or &), each starting with a capital letter, 3 to 40 characters, with
no digit. Numbers, dates with digits, emails and lower-case phrases never do,
and neither do the other built-ins (the call's own facts, a time zone, a
language or a reference). At most 20 per call. Protected values that are not
yet released, and keypad values, are never sent. A workflow's listener does not
receive key terms yet.
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 Standard or GPT-Live: 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.
On a GPT-Live workflow the live session is only the workflow's voice. It hears no one (the workflow's listener hears the caller), it is given each line as the workflow releases it, and before the confirming step no line it is given names a protected value. So a protected value reaches the model, and the caller, only after the caller is confirmed, exactly as on Standard.
A free-form live agent does not protect values
A GPT-Live or Gemini Live agent without a workflow cannot hold protected variables: the model is given everything the call holds and nothing gates what it says. Gemini Live cannot run workflows yet, so it cannot hold them at all. Declare such an agent's variables as ordinary ones, and give it only data that may be said to whoever answers.
Keypad variables
An automated phone menu can ask for a number: a card, a PIN, a government ID, a
date of birth, a reference. Declare that value with keypad and a class, and
send it with the call like any other value (as text: a number is refused, because it would lose leading zeros):
"card": { "type": "string", "keypad": { "class": "card" } }The class is one of reference, card, pin, government_id,
date_of_birth or other. A keypad variable is a string (a number would lose
its leading zeros), cannot be protected, and is allowed on every conversation
engine, GPT-Live and Gemini Live included.
The value comes only from the call's own data. The agent never invents one, and a menu that asks for something the call was not given gets nothing.
What a keypad variable is not:
- Never text. It is not in the agent's prompt, the
<call_variables>block, a tool request or a webhook'scontext.variables, and the model never sees or says it. When a menu asks for it, the platform enters it on the agent's behalf: on the keypad, or, for an agent on the Standard engine and a prompt that accepts speech only, spoken by the platform's own controller voice while the model is muted. Either way the transcript records onlyEntered {field}. A greeting, instruction, workflow line or SMS template that names it ({{card}}) is refused when you save the config (KEYPAD_FIELD_IN_TEXT) and again when a call is placed. - Never in another text. A call whose instructions, prompts, workflow lines,
SMS templates,
navigation_hintor other variables contain the value's digits is refused withkeypad_value_in_text, naming the field and where, never the value. The check ignores spaces, punctuation and non-ASCII digits (4111 1111and4111both match), counts*and#, and also reads each filled-in line as it will be said or sent and all your other variables together, so a value split between a greeting and a variable, or between two variables, is found. A value of three characters or fewer is only refused where it stands alone as a whole number, so ordinary text with a stray digit does not refuse the call. It cannot catch a value you deliberately cut into pieces and spread across unrelated texts, or digits written out as words. - Not in your reads.
GET /v1/calls/{call_id}, the MCP call tools and the Calls drawer withhold it fromcall_contextand the variables list while the call runs, and a call refused by policy never stores it. The drawer lists the field with its class and the statekeypad, not whether it has a value.
The value has to be dialable. Spaces, -, /, . and parentheses are
dropped (4111 1111 1111 1111 is 4111111111111111, 01/02/1990 is
01021990, in the order you wrote it), and what remains must be 1 to 32
characters of 0-9, * and #. If it is not, a required variable refuses
the call with keypad_value_not_dialable and an optional one is dropped: the
call is placed without it, and a menu that asks for it ends the call.
Two limits worth knowing. A keypad value is entered on the call's own line for whatever menu asks, at any prompt: the platform does not check that the menu is the one you meant, and it cannot see a company phone system that forwards the line elsewhere. And once a person answers, anything that person says is an ordinary conversation, even if they read a number back.
What is kept after a keypad value is entered
The platform holds a keypad value only so the call can key it. When the call
ends it deletes the value from the call record: call_context (as stored and as
GET /v1/calls/{call_id} returns it) no longer lists the field, and neither does
the copy of your request the call was checked against. The Calls drawer and the
timeline show the entry as Entered {field}, with no digits and no count.
The transcript is masked at the source, in three stages:
- From the first key until a person answers, every number the other side says
(digits in any script, and number words in the languages the platform has a
table for) is stored as
█, whether or not it matches your value. The agent is not given what the menu says in that time; it is told which keys the menu offers and what each is for, without numbers. A menu in a language the platform has no number words for is stored as "far end spoke, content withheld". - Once a person answers, the agent hears the call again, and any stretch of
two or more digits that is part of your value is stored as
█, however the digits are spaced or split across consecutive turns, and for the digits 0 to 9 spoken as words ("four one one one", "double four") in those languages. A pair of digits that happens to occur in your value (such as11) is masked too. Numbers such as "twenty four" are not read as digits. - When the call ends the whole transcript is masked once more, across adjacent turns, before it is uploaded.
The platform keeps no copy to retry with. A campaign retry reads the contact's own variables again. For a call you place through the API, send the value again with every retry: a retry without an optional keypad variable is placed, and a menu that asks for it ends the call; without a required one it is refused, as for any missing required variable.
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,
per_call_text_on_workflow (per-call first_message or instructions sent to
a workflow agent, which speaks only its authored lines),
keypad_value_not_dialable (a required keypad variable has
characters a phone keypad cannot press) or keypad_value_in_text (a text names
a keypad variable or carries its digits).
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. - On a workflow call, only the workflow's own steps run tools. A
Read tool step runs the AI agent's knowledge base search, calendar
tools and Integration actions when the workflow declares them with
source: "agent"; the AI agent's model is offered none. A call that carries protected values runs no tool at all (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 an agent whose model would be given it before the caller is confirmed: a GPT-Live or Gemini Live agent without a workflow, or any Gemini Live agent. A GPT-Live agent with a published workflow can hold protected variables. |
REQUIRES_WITHOUT_PROTECTED, INVALID_REQUIRES | requires on an unprotected variable, or not confirmation / knowledge_match. |
INVALID_KEYPAD, KEYPAD_FIELD_NOT_STRING, KEYPAD_FIELD_PROTECTED, KEYPAD_FIELD_RESERVED_NAME | keypad is not {"class": ...} with a known class, or is on a variable that is not a string, on a protected one, or on one named navigation_hint (that name carries the navigation hint). |
KEYPAD_FIELD_IN_TEXT | A greeting, prompt, workflow line or SMS template names a keypad variable. |
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.
What runs on calls
The agent page shows which tools, knowledge bases and calendar tools an AI agent is offered on each engine and kind of call, and the reason when one is not.
Simulations Lab
Author realistic caller scenarios, replay them against one pinned agent version, and read hard verdicts with the exact snapshot each attempt exercised.