Skip to main content
Flow agents (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.
A failed gate returns 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.
The 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.
A flow may define at most 127 unique typed extraction contracts. Nodes with the same effective tool, 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:
  1. error_next_step_id fires only on hard failures: network timeout, exception, HTTP redirect or other non-2xx status, integration disconnected, tool deleted/inactive, missing required config. The dispatcher’s error field is set; nothing else is checked.
  2. Otherwise the dispatcher walks custom[] top-to-bottom. First branch whose path extracts a value == its equals wins. Evaluation stops there — success is not also taken.
  3. If no custom matched, success_next_step_id fires.
Exactly one out-edge is traversed per tool fire. The canvas drawing all three lines is just the diagram of possible routes — at runtime only one is taken. The full result dict is injected into the next node’s LLM context as a <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.
For per-call extraction or webhook delivery, configure 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 marked is_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 hatchestransfer to human, or end on do-not-call.
  • Session-level concerns — “callback request from anywhere”.
Validation rules (enforced by the API on POST/PATCH):
  • is_global: true is only valid on conversation, transfer, and end nodes. Setting it on a start or tool_call node returns 400.
  • When is_global: true, global_jump_description must be a non-empty string.
  • The current node never jumps to itself even if it’s global.
Practical guidance:
  • 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_description as 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.
When a global jump fires, the runtime publishes 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_events for unconditional per-call delivery (recommended), or (b) call a webhook-type tool from a tool_call node 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 includes flow_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.