Skip to content

Authoring Agents in YAML

Every Aura voice agent can be exported as a single, self-documenting YAML file and imported back. You can edit that file by hand or hand it to an LLM such as Claude or ChatGPT (“add a branch for billing questions”) and re-import the result.

This page is the reference for that file. It covers the format, the rules an import enforces, how secrets are handled, and what happens between Import and a live agent. For the Switchboard screens, see AI Voice Agents and Building & Editing Agents. For what the flow nodes mean in a call, see Flow Builder Concepts.

The file format is published as a JSON Schema, generated from the same definition SIPSTACK validates against:

Every exported file starts with a comment block that summarizes the rules, links the guide, and ends with a # yaml-language-server: $schema=… line. Editors with YAML language support (for example VS Code with the Red Hat YAML extension) use that line for autocomplete and inline validation. When you hand the file to an LLM, keep that header in place.

  1. Export. In Aura AI → Agents → AI Voice Agents, use Export in the agent editor header or Export to YAML in the row menu. Before the download starts, confirm that you understand the file may contain personal data (see Privacy). The file is named agent-<name>-<date>.yml.
  2. Edit. Change the file yourself or with an LLM. Keep to the hard rules.
  3. Import. On the AI Voice Agents page, click Import and choose a .yml / .yaml file (up to 1 MB). The file is validated in a dry run first, and nothing is written yet. You see a summary, any errors, and the unresolved-reference report.
  4. Import as Draft. This creates the agent as a draft. Import never overwrites an existing agent; it always creates a new one.
  5. Resolve, test, activate. Fix what the report flagged, test the agent, then click Activate agent on the draft banner. See Activation.

You can start from a blank file too. You don’t need an export, as long as the file satisfies the schema.

An import is rejected if the file breaks any of these:

  1. auraAgentExportVersion: 1 must be present.
  2. name, voiceName and greeting are required and must be non-empty.
  3. Every node id is unique. flow.entryNodeId and every edge source / target must match an existing node id. Don’t invent ids: an edge that points at a missing node fails validation.
  4. type, mode, flowMode, escalationMode and node type only accept their listed values. Other values are not all rejected: a post-call action with an unknown channel is dropped, and an unknown extraction field type becomes text. Stick to the documented values.
  5. mode: flow requires a flow.
  6. With flowMode: strict, the flow may not use any AI-driven routing or speech. See Strict mode.
  7. Don’t write secret values. Credentials appear as ${credential:Name} placeholders. Keep them as placeholders and bind them after import (see Credentials & Secrets).

The import also enforces the same size and range limits as saving in the editor:

LimitValue
greeting4,000 characters
systemPrompt32,000 characters
globalPrompt8,000 characters
Flow size500 nodes, and 256 KB as JSON
postCallActions10 actions; each target up to 2,048 characters
silenceTimeoutMs500 – 15,000
maxCallDurationMs10,000 – 3,600,000 (one hour)
maxTurnswhole number, 1 – 100

A value over a limit is rejected, never silently truncated.

FieldRequiredMeaning
auraAgentExportVersionYesAlways 1. This is the file-format version, separate from flow.version.
nameYesThe agent’s name.
typeNooverflow, sales, support, general or custom. Defaults to overflow. A starting archetype only; it doesn’t change how a call runs.
voiceNameYesThe voice persona id. If your account can’t use it, the import report flags it for replacement.
greetingYesThe first line spoken in freeform mode. In flow mode, the caller hears the flow’s first Say node instead, but the field is still required.
modeNofreeform (the AI converses from the greeting and prompt) or flow (the agent runs the flow graph). If you leave it out, it is inferred: flow when a flow is present, otherwise freeform. A freeform agent may still carry a dormant flow.
flowModeNoflexible (default) or strict, the routing discipline inside a flow. Not the same as mode.
systemPromptNoPersona and instructions for AI replies.
globalPromptNoAn always-applied scope and refusal instruction (the editor’s Scope & refusals).
responseValidatorNo{ enabled: true } turns on the pre-speech response check.
chimeEnabledNoThinking tone: a short tone after the caller stops speaking. It can’t be on together with barge-in. Defaults to true on import when omitted.
bargeInEnabledNoLet callers talk over the agent. Defaults to false on import when omitted.
silenceTimeoutMs, maxCallDurationMsNoCall-handling timers, in milliseconds (ranges above).
maxTurnsNoPer-agent turn cap (1–100). Omit to use the platform default.
escalationModeNotransfer (default) or ticket.
variableOverridesNoString map of template values, e.g. { company_name: "Acme" }. Overrides the built-in values of the same name.
fallbackDestinationNoWhere to route a dead-end: { type, id?, number? }. Always listed in the import report for review.
extractionPlanNoPost-call data capture: { enabled, promptOverride?, fields: [{ name, description, type?, options? }] }. Field type is text, number, boolean or enum.
postCallActionsNoDeliveries at the end of a call: [{ channel, target, enabled?, skipWhenNoInput? }]. channel is webhook, slack, teams, email or sms. Webhook, Slack and Teams targets must be http(s) URLs without embedded credentials; an email target is exactly one address; an SMS target is a phone number. skipWhenNoInput overrides the empty-call default (email and SMS skip calls with no caller input; webhook, Slack and Teams always fire). The published JSON Schema doesn’t list it yet, so an editor may flag it even though import accepts it.
recordingNo{ record?, consentMode?, disclosureText?, analyze? }. consentMode is announcement or in_greeting. See What import does not carry over.
flowNoThe conversation graph (below). Omit it for a freeform-only agent.
flow:
version: 2
entryNodeId: start
nodes: [ ... ]
edges: [ ... ]
limits: { maxNodeVisits: 200 } # optional, 1–1000
eventHandlers: { ... } # optional, see below

A node never holds its own routing. All routing is carried by edges and their conditions. Every node has an id, a type, and an optional label. position ({ x, y }) is canvas layout only, and the call runtime ignores it.

Speaking and listening

typeWhat it doesKey fields
startEntry marker, referenced by entryNodeId.none
saySpeak a line, then move on immediately.source: { mode: static, text } (verbatim, templated) or source: { mode: llm, prompt } (AI-written). interruptible (default true).
collectCapture one caller turn into vars.<variable>.variable, prompt?, inputMode (speech default, or dtmf), dtmf: { maxDigits (1–32), finishOnKey, interDigitTimeoutMs (1000–30000) }, silenceTimeoutMs (min 500, default 2000), maxUtteranceMs?, acknowledge?, highAccuracy?, fieldType?
verified-collectCapture a high-stakes value exactly: read it back, ask the caller to spell it on a mismatch, confirm, then commit vars.<variable>.variable, fieldType (default text), prompt?, maxParseRetries (0–5, default 2), maxConfirmRetries (0–5, default 2), spellOnRetry (default true)
converseA bounded open conversation inside the flow, which ends when goal is met, exitCondition matches, or a cap is hit. The transcript lands in vars.<resultVar> and the summary in vars.<resultVar>__summary.systemPrompt, resultVar, goal?, maxTurns (1–50, default 10), maxDurationMs?, exitCondition?
kb-answerAnswer from your Knowledge Base. The grounded reply goes to vars.<resultVar> and the sources to vars.<resultVar>__sources.query, resultVar, topK (1–10, default 3), tagFilter? (defaults to the agent’s KB tags), fallbackText?

fieldType is one of email, phone, code, first-name, last-name, full-name or text.

Routing and state

typeWhat it doesKey fields
branchPure routing. The outgoing edges carry the conditions.none
intent-routerOne AI call picks the best intent. The label goes to vars.<resultVar> and the confidence to vars.<resultVar>__confidence.intents: [{ label, description? }] (1–20), resultVar (default intent), transcriptWindow (0–20, default 6), includeVars?, confidenceThreshold (0–1, default 0 = off), clarifyPrompt?, maxClarifyRounds (1–3, default 1)
set-variableWrite one or more variables, applied in order.assignments: [{ target, value }] (templated string) or [{ target, fromVar }] (copy a context path). Each assignment sets exactly one of value / fromVar. 1–50 assignments.

For an intent-router, key the outgoing edges on the label (condition: { var: "vars.intent", op: eq, value: "billing" }) and add one isDefault edge. none is reserved as the no-match label and is added automatically, so don’t define it. Labels must be unique (case-insensitive). Below confidenceThreshold, the agent asks a clarifying question and re-classifies, at most maxClarifyRounds times. After that the label commits as none and the default edge handles the call.

Integrations and actions

typeWhat it doesKey fields
tool-callSynchronous HTTP call. Parsed JSON goes to vars.<resultVar>.url (templated), method (GET default, POST, PUT, PATCH, DELETE), headers?, body?, credentialRef?, resultVar, timeoutMs (100–30000, default 8000)
http-actionSynchronous HTTP action with a fallback edge.Same fields as tool-call.
webhookFire-and-forget event.event, payload?
actionDeliver a message during the call.channel (webhook, slack, teams, email, sms), target (templated), body? (the exact SMS text; ignored by other channels), resultVar?
escalate-ticketOpen a support ticket and continue over email.emailVar (required; must hold a verified email), request, nameVar?, phoneVar?, orgVar?, categoryVar?, resultVar?
calendar-bookOffer open times and book one during the call. See Booking Appointments in a Call.meetingType, resultVar (default booking), emailVar?, nameVar?, phoneVar?, timezoneVar?, maxSlots (1–5, default 3), inputMode?, offerPrompt?, noSlotsText?

Ending the call

typeWhat it doesKey fields
transferHand the call back to the dialplan.target (templated extension)
hangupEnd the call.reason?

Success and failure. After a tool-call, http-action, action, escalate-ticket or calendar-book with a resultVar, a failure sets vars.<resultVar>__error. Give the node a conditional success edge and an isDefault failure edge. A successful calendar-book also sets vars.<resultVar>__when to the confirmed time.

edges:
- { id: e1, source: start, target: greeting }
- { id: e2, source: greeting, target: menu }
- id: e3 # conditional edge
source: menu
target: sales
condition: { var: "vars.intent", op: eq, value: "sales" }
- { id: e4, source: menu, target: support, isDefault: true } # fall-through

An edge is { id, source, target, condition?, isDefault?, sourceHandle? }. An edge without a condition is unconditional. isDefault: true marks the fall-through taken when no conditional edge matches.

A condition is one of:

  • Predicate: { var, op, value? }. var is a context path such as vars.choice or caller.number. op is one of eq, neq, contains, regex, gt, lt, gte, lte, exists or notExists. Omit value for exists / notExists.
  • AI intent: { llmIntent: { prompt, expect } }. Not allowed in strict mode.
  • Combinators: { all: [ … ] }, { any: [ … ] }, { not: … }. These nest.

flow.eventHandlers holds optional, flow-wide handlers. Each one jumps to a node id, which must exist. globalEscape can fire from any node that listens to the caller. noInput and noMatch fire after a collect node captures nothing:

eventHandlers:
globalEscape: # first match wins, up to 20
- { phrases: ["operator", "agent"], target: transfer_front_desk }
noInput: { target: reprompt } # caller was silent
noMatch: { maxRetries: 3, target: transfer_front_desk } # 1–10 empty captures in a row

globalEscape is checked first, on every utterance. On an empty capture, noMatch fires once the run of empty captures reaches maxRetries, and noInput fires otherwise. Matching is deterministic (whole-word, case-insensitive phrases, no AI), so the handlers also work in strict mode.

Text fields marked templated accept {{…}} tokens. A token is a plain dotted path, with no expressions. A missing value renders as an empty string.

TokenValue
{{vars.<name>}}A collected answer, tool result or set-variable value.
{{company_name}}, {{caller_name}}, {{caller_number}}, {{dialed_number}}, {{current_time}}, {{current_date}}, {{call_direction}}Built-in shortcuts. A bare name is looked up in vars first, and variableOverrides can replace these. call_direction is set on live calls only, so it renders empty in the Test agent tab.
{{caller.number}}, {{caller.name}}, {{caller.dialedNumber}}Caller details.
{{call.id}}, {{agent.name}}Call and agent details.

With flowMode: strict, the agent is fully deterministic. The import (and later activation) rejects a strict flow that contains any of:

  • a say node with source.mode: llm
  • an intent-router, converse or kb-answer node
  • an edge condition that uses llmIntent anywhere in its tree

Use static say lines with collect, branch and keypad or regex predicates instead.

Exports never contain secret values. Each secret is replaced with a named placeholder:

credentialRef: ${credential:Stripe (prod)}

These fields are exported as placeholders:

  • credentialRef on tool-call / http-action nodes. The placeholder carries the Connection’s name.
  • Webhook, Slack and Teams targets in postCallActions and on action nodes, because the URL itself is the secret.
  • Sensitive header values on tool-call / http-action nodes: any header whose name contains token, secret, auth, key, password, passwd, credential or signature, plus Cookie / Set-Cookie.
  • URLs that carry a secret on tool-call / http-action nodes: a username or password in the URL, a known token-in-path webhook host (Slack, Microsoft Teams, Zapier, Google Chat, Discord), or a secret-looking query parameter such as token, api_key, key, secret, signature or code.

Email and SMS targets are not redacted, because an address or number isn’t a secret.

How placeholders are named. If a redacted value exactly matches the secret of one of your Connections, the placeholder uses that Connection’s name, so a same-account round trip rebinds by itself. Otherwise SIPSTACK generates a name from where the value was found, such as ${credential:post-call slack target} or ${credential:flow lookup url}. A number is appended if the name is already taken.

Binding on import. A placeholder whose name exactly matches one of your Connections (Aura AI → Agents → Connections) binds automatically. For an unmatched name, create a Connection with exactly that name:

  • For a credentialRef, the Connection holds the API secret, as usual.
  • For a redacted URL, header or target, the Connection’s secret is the original value, for example the full Slack webhook URL.

The agent then stores only a reference to the Connection. The secret itself is never written into the agent, never returned by the API, and is resolved only when the call actually makes the request. The import report and the activation error may describe this step as adding the credential “under Integrations”. That is the same Connections page.

Both the dry run and the import return a report of references your account may not have:

KindBlocks activation?Meaning
VoiceYesvoiceName isn’t available to your account. Pick a replacement in the draft’s settings.
CredentialYesA ${credential:Name} with no matching Connection. Create it, then activate.
DestinationNo (advisory)The fallbackDestination is always listed so you can re-point it to one of your own extensions or numbers.

The report doesn’t stop the import. It lists what must be fixed before the draft can go live.

Activate agent on the draft banner re-checks the agent on the server, then makes it live. Activation is refused, with the full list of reasons, if:

  • the voice isn’t available to your account, or isn’t a voice Aura agents can currently speak with;
  • any ${credential:Name} still has no matching Connection;
  • the agent as it would go live fails any check the editor applies when you save, for example a strict-mode violation.

Activating an agent that is already active changes nothing. Once active, point an inbound route at the agent, and test it in the Test agent tab before sending it real calls.

  • Call recording starts off. Exports omit the recording-consent attestation on purpose. The import keeps consentMode and disclosureText (up to 500 characters), but recording stays off until you re-attest and enable it in the draft. recording.analyze is not applied on import either.
  • Plan access. Importing an agent creates a billable resource, so it needs the same access as creating one in the builder: the Pro plan or higher, or Aura credits on your account. The dry-run validation works without it.
  • Replace in place. Import always creates a new draft. To change an existing agent, edit it in Switchboard, or import the new version and retire the old one.

Every import is recorded in the new agent’s version history as an import entry.

Prompts (greeting, systemPrompt, globalPrompt), variableOverrides, extraction fields and email or SMS targets are exported verbatim and can contain personal data. That’s why Switchboard asks you to acknowledge this before the download. Review the file before you share it or paste it into a third-party AI tool.

auraAgentExportVersion: 1
name: Front Desk
type: general
voiceName: f_us_sky_k
greeting: "Thanks for calling {{company_name}}."
mode: flow
flowMode: flexible
systemPrompt: "You are a friendly receptionist. Keep answers short."
variableOverrides:
company_name: Acme
postCallActions:
- channel: slack
target: ${credential:Front desk Slack}
flow:
version: 2
entryNodeId: start
nodes:
- { id: start, type: start }
- id: hello
type: say
source: { mode: static, text: "Thanks for calling {{company_name}}. Are you calling about sales or support?" }
- id: ask
type: collect
variable: reason
- id: route
type: intent-router
intents:
- { label: sales, description: "Buying, pricing or a quote" }
- { label: support, description: "A problem with an existing service" }
- id: to_sales
type: transfer
target: "200"
- id: to_support
type: transfer
target: "300"
- id: fallback
type: say
source: { mode: static, text: "Let me connect you with our front desk." }
- id: to_desk
type: transfer
target: "100"
edges:
- { id: e1, source: start, target: hello }
- { id: e1b, source: hello, target: ask }
- { id: e2, source: ask, target: route }
- id: e3
source: route
target: to_sales
condition: { var: "vars.intent", op: eq, value: "sales" }
- id: e4
source: route
target: to_support
condition: { var: "vars.intent", op: eq, value: "support" }
- { id: e5, source: route, target: fallback, isDefault: true }
- { id: e6, source: fallback, target: to_desk }

The first say node greets the caller (in flow mode it replaces greeting). The collect node then captures the answer, the router classifies it, and the call transfers. Anything unclear falls through to the front desk. On import, voiceName and the Front desk Slack placeholder are checked against your account. The fallback-destination line appears only if the file sets one.