type: "flow") carry a flow_config JSONB on the agent row. This page is the canonical reference for that shape. The same schema is validated server-side by the public API, by the dashboard’s React Flow builder, and by the bot pipeline at call-start time.
Top-level shape
Validation gates (enforced before save)
- Exactly one node with
type: "start". - Every
next_step_id(transitions, custom branches, start) resolves to an existing node id. - Node ids are unique within the graph.
- Every node passes its own per-type schema (see below).
- Every conversation transition has a non-empty
description(transition_description_missing) — the natural-language trigger the voice agent uses to pick this path.
400 with a human-readable error message pointing at the offending field.
Node types
Every node has:start
Entry point. Owns the “who speaks first” decision for flow agents — these settings override the agent-level agent.agent_speaks_first and agent.greeting_message fields when the agent is a flow agent.
Use
auto_advance: false when the greeting should feel neutral and the bot
shouldn’t enter its first scripted step until the user has spoken. Useful for
agents that listen for an open-ended intent (caller’s reason for calling)
before routing — the greeting stays generic, then the first conversation
node’s instructions take effect once the caller actually replies.conversation
Talks to the user, then picks a transition.
instructions text is layered on top of the agent’s global system_prompt as a system message at step entry. Every transition must have a non-empty description — at runtime the voice agent reads the description as the trigger for taking that path. Treat the description as a prompt addressed to the model: describe the user-side signal (what the caller said or implied), not an agent intent. A bare label is not sufficient; the API rejects empty descriptions with transition_description_missing.
tool_call
Deterministic tool execution. References a tool by tool_id (must already exist in the company’s tools table). Tool args are owned by the tool itself via payload_config.static_parameters (literals) and payload_config.extraction_parameters (filled by the runtime from the conversation). A tool_call node carries no args_template field — the same tool used by N flow nodes sends the same shape unless that node supplies a config_override. Stale args_template payloads on a tool_call node are silently dropped at parse time.
config_override shallow-merges over the referenced tool’s config (array replacement, not deep merge). Open-shape: each tool type defines its own valid keys.
The dashboard flow editor shows the shared base configuration, the fields overridden by this node, and the final effective configuration together in the node editor. Because the merge is shallow, overriding payload_config replaces that complete object; include every standard-metadata, static-parameter, and extraction-parameter setting the node needs. Switching a node to another tool clears the previous tool’s override after confirmation.
pre_fire_announcement plays a short platform-controlled hold tone while the webhook is in flight. Recommended for tools that may take more than ~500 ms.
timeout_secs (1–600) is an optional per-node hard cap. When set, the dispatcher cancels the action at that limit and routes to error_next_step_id with tool_timeout_after_<N>s. When omitted on a webhook tool, the controller allows the tool’s effective config.timeout_seconds plus one second of dispatch overhead; other tool and integration nodes default to 30 seconds. Set it only when the flow needs a stricter or longer node-level limit.
Args come from the linked tool, not the node. At call start, Yappr resolves the referenced tool plus that node’s config_override and gives the voice model a flat argument-submission schema: one named string field for each effective extraction_parameters entry. There is no model-facing node_id field and no nested { "args": { ... } } wrapper. The runtime assembles standard call metadata and static parameters; the model-facing submitter exposes only extraction fields.
Payload merge order is standard metadata, then static parameters, then extracted values, so an extracted value wins a deliberate name collision. Keep names unique across all three groups unless that override is intentional.
extraction_parameters[].required controls which fields the model must collect. It defaults to true when omitted. Missing optional fields do not block dispatch; if a required field still can’t be surfaced after 3 attempts, the node routes to error_next_step_id.
Tool schemas are fixed for the duration of a live call. Changes to the referenced
tool or a node’s
config_override are picked up when the next call starts.config_override, and extraction schema share one
contract; integration nodes participate only for arguments in ai_extract
mode. Saves over the limit fail with too_many_extraction_contracts instead of
letting the next call fail during model-session startup.
Tool-call nodes can be token sources for downstream nodes: any extraction_parameters[].name becomes addressable as {{<node_id>.<name>}} in later integration_call.args_template literals or ai_extract.description strings.
How tool-call routing works at runtime — entirely deterministic, no LLM involved:
error_next_step_idfires only on hard failures: network timeout, exception, HTTP redirect or other non-2xx status, integration disconnected, tool deleted/inactive, missing required config. The dispatcher’serrorfield is set; nothing else is checked.- Otherwise the dispatcher walks
custom[]top-to-bottom. First branch whose path extracts a value==itsequalswins. Evaluation stops there —successis not also taken. - If no custom matched,
success_next_step_idfires.
<tool_result> block, so a single success → conversation node usually handles soft-fail bodies ({"available": false}) gracefully via prompt instructions. Use custom[] only when the next node should be structurally different for that shape (different instructions, different downstream tools, different transitions).
JSONPath subset for custom[].jsonpath
The runtime supports a deliberately tiny subset of JSONPath. Root ($) is the tool’s parsed response body:
- Webhook tools —
JSON.parse(body)of the HTTP response. - Integration tools — the typed dict returned by the provider client.
Not supported: recursive descent (
$..foo), wildcards ($.*), filter expressions ($[?(@.x>1)]). If your webhook nests the field, point at the exact path. A missing key, wrong type at any step, or out-of-bounds index → the branch silently doesn’t match (falls through to the next custom, then to success).
Stringification rules for equals
The extracted value is stringified JSON-style before string-comparison to equals. Match the table:
Branches with the wrong stringification (e.g.
equals: "True" against a boolean true) silently never match.
integration_call
Calls an OAuth-backed third-party integration (Google Calendar, Gmail) directly from the flow — no tools table row required. The integration config (provider, credential, action) lives on the node itself and the args go in args_template. Routing is identical to tool_call (success / error / custom JSONPath, mutually exclusive, exactly one out-edge per fire).
For the full per-node schema, action catalog, arg modes (literal / ai_extract with {{node.arg}} and {{metadata.key}} token interpolation), and the Calendar response post-processing rules, see the dedicated reference: Integration call node.
transfer
Hands the call off via SIP transfer. Terminal — no transitions out.
end
Speaks the farewell and hangs up. Terminal.
extraction_parameters and webhook_url / webhook_events at the agent level. Those mechanisms apply uniformly to both prompt and flow agents — the flow does not have a separate post-end pipeline.
Global nodes
Conversation, transfer, and end nodes can be markedis_global: true to make them reachable from any conversation node without an explicit edge. The eval LLM gets every global node as an extra candidate transition on every turn, with a strong “prefer labeled transitions over global jumps when both could plausibly apply” bias.
Use cases:
- Misclassification recovery — “wait, you’re an owner, not a tenant” routing to the owner-branch entry, declared once instead of wired into every node.
- Universal escape hatches —
transferto human, orendon do-not-call. - Session-level concerns — “callback request from anywhere”.
is_global: trueis only valid onconversation,transfer, andendnodes. Setting it on astartortool_callnode returns 400.- When
is_global: true,global_jump_descriptionmust be a non-empty string. - The current node never jumps to itself even if it’s global.
- Recommended max ≤3 globals per flow. Each additional global widens the eval LLM’s candidate space and increases false-positive jump risk.
- Write
global_jump_descriptionas a user-side signal, not an agent intent. Good: “User says they want to speak to a human”; bad: “Transfer the user”. - Globals don’t replace explicit transitions — they’re a fallback. The eval LLM is instructed to prefer labeled transitions when ambiguous.
flow_node_entered with reason: "global jump: <node name>" (visible in flow_trace.steps[].reason on GET /calls/:id).
What’s NOT a node type
By design, the flow data model omits a few patterns you might expect:- No “webhook” node — for outbound webhooks, either (a) configure
agent.webhook_url+agent.webhook_eventsfor unconditional per-call delivery (recommended), or (b) call a webhook-type tool from atool_callnode mid-flow if you need per-path delivery. - No “structured_output” node — for transcript extraction, configure
agent.extraction_parameters. (Per-end-path schemas were considered for v1 but cut to avoid duplicating an agent-level mechanism.) - No separate “branch” / “ifelse” node — branching is built into every conversation node via
transitions[].
Versioning
Every successful save (POST or PATCH that includesflow_config) writes a flow_versions row. Identical re-saves are deduplicated by SHA-256 hash of the canonical JSON. Use GET /agents/:id/flow/versions to list history.
Immutability
agents.type is immutable post-create. To convert a prompt agent into a flow agent (or vice-versa), create a new agent of the desired type. PATCHing type returns 400.
Type discriminator and flow_config requirements
Mismatches return
400 with a clear correction message.