Skip to content

ElevenLabs setup

How the fleet described in The subagents is actually configured in this repository, and how it gets deployed.

One agent, thirteen subagent nodes

There is exactly one agent in the ElevenLabs account: Questionnaire Intake Agent. The subagents are not separate agents — they are override_agent nodes inside that agent's workflow, and the workflow graph is what moves the call from one to the next.

elevenlabs/
├── agents.json                          the one agent, and its ID once pushed
├── tools.json                           custom tools (none yet)
└── agent_configs/
    └── questionnaire-intake-agent.json  the agent, workflow included

That single file holds everything: the shared prompt, the voice and ASR settings, and the fifteen-node workflow. There is no per-subagent config file and no linking step.

Always invoke the CLI through bunx

Not npx, and not a global install. See CLAUDE.md at the repository root. The CLI must also be run from inside elevenlabs/, since that is where agents.json lives.

The workflow

workflow.nodes and workflow.edges are both objects keyed by ID, not arrays. Fifteen nodes:

Node ID Type Label
start_node start
intro override_agent Intro
section_01section_11 override_agent One per section (ten of them)
stop_and_refer override_agent Stop and refer
handover override_agent Handover
end_node end

Each override_agent node carries:

Field Holds
label What shows in the workflow editor
additional_prompt This subagent's own instructions, appended to the shared prompt
additional_knowledge_base The pages this subagent loads — see the load map
entry_behavior wait_for_user for the intro, generate_immediately for the rest
position {x, y} so the editor lays out cleanly

The shared scribe rules live once in the agent's base conversation_config.agent.prompt.prompt, not thirteen times. A node's additional_prompt is appended to it, which is what makes a subagent a specialisation of the interviewer rather than a fresh one.

Edges

Twenty-six of them. Each has a source, a target, and a forward_condition that is either {"type": "unconditional"} or {"type": "llm", "condition": "…"}.

"section_02_to_section_03": {
    "source": "section_02",
    "target": "section_03",
    "forward_condition": {
        "type": "llm",
        "condition": "Section 2 is complete and a break has been offered."
    }
}

The eleven red-flag edges also carry a backward_condition, which is how de-escalation is expressed without a second edge:

"section_02_to_stop": {
    "source": "section_02",
    "target": "stop_and_refer",
    "forward_condition": {
        "type": "llm",
        "condition": "An answer in section 2 matches the stop and refer immediately list…"
    },
    "backward_condition": {
        "type": "llm",
        "condition": "The trigger was checked against the red flags reference and does not qualify, so section 2 resumes where it left off."
    }
}

Every question-asking node has one of these, so a red flag can be caught anywhere in the call and, if it turns out not to qualify, control returns to exactly the section it came from.

Fifteen nodes exceeds the usual guidance

ElevenLabs suggests keeping workflows to four to seven nodes. This one is deliberately larger: one node per section is the context-isolation strategy, and collapsing sections together would undo it. See why a fleet.

Deploying

One pass. There are no cross-agent IDs to resolve, so the two-step deploy the old fleet needed is gone.

bunx @elevenlabs/cli agents push

Preview first with --dry-run. Note that --dry-run does not validate against the server schema — it only reports which agents would be sent.

The knowledge base

Every node carries its own pages explicitly, and inherits nothing. Two fields do that job together:

Field on the node Set to Effect
conversation_config.agent.prompt.knowledge_base [] Inherit nothing from the agent
additional_knowledge_base Its own pages, listed one by one The node's entire knowledge base

The agent's own knowledge_base is [] as well, so there is nothing to inherit even by accident. A subagent sees exactly the pages named against it in the load map — never a neighbouring section's.

"conversation_config": {
    "agent": { "prompt": { "knowledge_base": [] } }
},
"additional_knowledge_base": [
    {
        "type": "file",
        "id": "AOekAr0fqiJvLIxCfWbn",
        "name": "section-02-health-history",
        "usage_mode": "prompt"
    },
    {
        "type": "file",
        "id": "834jHRo5jDGXEUoEnGhh",
        "name": "ref-core-rules",
        "usage_mode": "prompt"
    }
]

Seventeen documents are uploaded — the ten client-facing section pages and the seven reference pages subagents load. Upload with:

curl -X POST "https://api.elevenlabs.io/v1/convai/knowledge-base/file" \
  -H "xi-api-key: $ELEVENLABS_API_KEY" \
  -F "file=@docs/sections/02-health-history.md;type=text/markdown" \
  -F "name=section-02-health-history"

Attach documents through the config, not a PATCH

workflow.nodes replaces rather than merges, so a partial PATCH that sends one node drops the other fourteen — the API rejects it for having no start node. Edit the config file and agents push; the CLI always sends the workflow whole.

usage_mode is prompt, and RAG stays off

rag.enabled is false and every document is attached with "usage_mode": "prompt", so a section page is injected whole rather than retrieved against.

This is a correctness decision. Retrieval optimises for relevance; asking a questionnaire needs completeness — every question, in order, with its conditional logic intact. A question that is not retrieved is silently never asked, and the handover record cannot then tell that apart from skipped — not triggered. The full reasoning, and the one narrow exception, are recorded in CLAUDE.md.

Each node holds one section — 12.8 KB at worst, ~3.5k tokens — which is what makes injecting it whole affordable. That is the context budget doing its job: the workflow split already solved the problem RAG would otherwise be solving.

Safety-critical material never goes in the knowledge base at all. The scribe rules and the stop-and-refer triggers live in the agent's base prompt, where they are present on every turn rather than depending on a retriever.

Re-upload when a question changes

The site stays the source of truth only if the uploaded copy tracks it. Reword a question here, re-upload that section's page, and the next call asks the new wording.

Placing a call

One agent means one entry point — there is no risk of dialling into the middle of the questionnaire.

curl -X POST "https://api.elevenlabs.io/v1/convai/twilio/outbound-call" \
  -H "xi-api-key: $ELEVENLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "<the Questionnaire Intake Agent id>",
    "agent_phone_number_id": "<linked number id>",
    "to_number": "+972500000000",
    "conversation_initiation_client_data": {
      "dynamic_variables": {
        "client_name": "Dana",
        "practice": "the practice",
        "coach": "Inbal",
        "form_version": "2026-08"
      }
    }
  }'

Those four dynamic variables seed the call; everything else accumulates as the workflow runs. They are declared under dynamic_variable_placeholders so a test call still works when they are not supplied.

Language

The agent is set to "language": "he", with the questionnaire pages in English — each subagent reads the English page and asks in Hebrew.

Per CLAUDE.md, all prompt and first_message strings in the config are written in English regardless of the spoken language. Change language in the config, not the strings, if the agent should speak something else.

Voice settings

Set once on the agent and inherited by every node, which is what keeps the call sounding like one interviewer rather than fifteen.

Setting Value Why
voice_id one voice Fifteen nodes, one apparent interviewer
model_id eleven_v3_conversational Built for turn-taking
asr.provider scribe_realtime, high quality Numbers and medication names have to survive
turn_timeout 10s Longer than default: these questions need thinking time
silence_end_call_timeout 60s Ends a dead call without chasing
max_duration_seconds 7200 A full intake runs ~90 minutes, with breaks
temperature 0 A scribe should not be creative

One voice is a presentation choice, not a disclosure choice

The client is told once, by the intro subagent, that they are talking to an automated system. A single voice across the workflow stops the call feeling broken; it must never be used to imply a human is on the line.