{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://jambonz.org/schema/verbs/agent",
  "minVersion": "10.1.0",
  "title": "Agent",
  "description": "Configures a complete voice AI agent by wiring together STT → LLM → TTS with integrated turn detection. Provides a higher-level abstraction than manually orchestrating the individual components. Optimized for building voice AI agents with proper turn-taking behavior.",
  "type": "object",
  "properties": {
    "verb": {
      "const": "agent"
    },
    "id": {
      "type": "string",
      "description": "An optional unique identifier for this verb instance."
    },
    "stt": {
      "$ref": "../components/recognizer",
      "description": "Speech-to-text configuration for the agent."
    },
    "tts": {
      "$ref": "../components/synthesizer",
      "description": "Text-to-speech configuration for the agent."
    },
    "autoLockLanguage": {
      "oneOf": [
        { "type": "boolean" },
        { "type": "string", "enum": ["always"] }
      ],
      "description": "Switch the TTS to the language the caller speaks, using the matching languageConfig entry. Works with any STT that reports the detected language: Deepgram (language 'multi', or Flux Multilingual), AssemblyAI (languageDetection), Google and Azure (altLanguages), Soniox (enableLanguageIdentification), Gladia and Modulate. Values: false (disabled), true (lock on the first utterance whose language is detected), 'always' (re-evaluate every turn). With Deepgram Flux, true also locks the STT language hints to the detected language. Default: false.",
      "default": false
    },
    "languageConfig": {
      "type": "object",
      "description": "Per-language TTS overrides, used when autoLockLanguage detects a language. Keys are language codes such as 'es' or 'es-MX'. A detected language matches an exact key first (case-insensitive), then a key with the same primary language, so 'es' matches a detected 'es-US'. A detected language with no entry keeps the current TTS. The entry's tts is merged over the agent's tts; set its language to the code the TTS vendor expects.",
      "additionalProperties": {
        "type": "object",
        "properties": {
          "tts": {
            "$ref": "../components/synthesizer",
            "description": "TTS config override for this language. Merged with default tts."
          }
        },
        "additionalProperties": false
      }
    },
    "turnDetection": {
      "oneOf": [
        {
          "type": "string",
          "enum": ["stt", "krisp"],
          "description": "Turn detection strategy shorthand. 'stt' uses the STT vendor's native signals (silence-based for most vendors; acoustic+semantic for deepgramflux, assemblyai, speechmatics). 'krisp' uses the Krisp acoustic end-of-turn model with default settings."
        },
        {
          "type": "object",
          "description": "Turn detection configuration with tunable parameters.",
          "properties": {
            "mode": {
              "type": "string",
              "enum": ["krisp"],
              "description": "Turn detection mode. Currently only 'krisp' supports object-form tuning."
            },
            "threshold": {
              "type": "number",
              "minimum": 0,
              "maximum": 1,
              "description": "Krisp end-of-turn confidence threshold (0.0–1.0). Lower values trigger earlier turn transitions. Default: 0.5"
            },
            "model": {
              "type": "string",
              "description": "Optional Krisp model name override."
            }
          },
          "required": ["mode"],
          "additionalProperties": false
        }
      ],
      "default": "stt",
      "description": "Turn detection strategy. Controls when the agent decides the user has finished speaking. STT vendors with native turn-taking (deepgramflux, assemblyai, speechmatics) always use their built-in detection regardless of this setting."
    },
    "bargeIn": {
      "type": "object",
      "description": "Controls whether and how the user can interrupt the assistant while it is speaking.",
      "properties": {
        "enable": {
          "type": "boolean",
          "description": "Allow the user to interrupt the assistant while it is speaking. Default: true.",
          "default": true
        },
        "strategy": {
          "type": "string",
          "enum": ["vad", "interruptPrediction"],
          "default": "vad",
          "description": "How interruptions are detected. 'vad' (default): speech onset tentatively interrupts the assistant and is confirmed after minSpeechDuration of sustained speech, otherwise the assistant resumes. 'interruptPrediction': an ML model scores whether caller speech is a genuine interruption attempt vs backchannel (e.g. 'uh-huh'), so the assistant is never tentatively paused and backchannel does not cut it off; requires a provisioned vendor API key."
        },
        "vendor": {
          "type": "string",
          "description": "Detection vendor for strategy 'interruptPrediction'; defaults to 'krisp' (currently the only option). Not used with strategy 'vad'."
        },
        "threshold": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Probability threshold for interruptPrediction (0-1). Higher values require stronger evidence before interrupting the assistant. Default: 0.5."
        },
        "minSpeechDuration": {
          "type": "number",
          "minimum": 0,
          "description": "Seconds of detected speech required before confirming an interruption. Prevents brief noises from cutting off the assistant. Applies to strategy 'vad' only. Default: 0.5",
          "default": 0.5
        },
        "sticky": {
          "type": "boolean",
          "description": "If true, once the user interrupts the assistant does not resume speaking. Default: false.",
          "default": false
        }
      },
      "additionalProperties": true
    },
    "noResponseTimeout": {
      "type": "number",
      "minimum": 0,
      "description": "Seconds to wait after the assistant finishes speaking before prompting the user to respond. 0 disables. Default: 12.",
      "default": 12
    },
    "noResponseMaxPrompts": {
      "type": "integer",
      "minimum": 0,
      "description": "End the agent after this many no-response prompts in a row get no reply. The actionHook then fires with completionReason 'no_response', so the application decides what happens next. The count resets whenever the user speaks. 0 or unset: no limit (the agent keeps prompting). Requires feature-server with jambonz/feature-server#259.",
      "default": 0
    },
    "llm": {
      "type": "object",
      "description": "LLM configuration for the agent. Optional: when omitted, or when `vendor`/`model` are omitted, they fall back to the application's configured default LLM (like `stt`/`tts`).",
      "properties": {
        "vendor": {
          "type": "string",
          "description": "LLM vendor id. Must match a `@jambonz/llm` registered adapter. Optional: falls back to the application's default LLM vendor."
        },
        "model": {
          "type": "string",
          "description": "Vendor-specific model id (e.g. 'gpt-4o', 'claude-sonnet-4-5-20250929'). Optional: falls back to the application's default LLM model."
        },
        "label": {
          "type": "string",
          "description": "Optional label to disambiguate when the account has multiple credentials for the same vendor."
        },
        "auth": {
          "type": "object",
          "description": "Optional inline credentials. When omitted, feature-server looks up credentials by (vendor, label) from the database.",
          "properties": {
            "apiKey": { "type": "string" }
          },
          "additionalProperties": true
        },
        "connectOptions": {
          "type": "object",
          "description": "SDK-level client options.",
          "properties": {
            "timeout": { "type": "number", "minimum": 0 },
            "maxRetries": { "type": "integer", "minimum": 0 },
            "endpoint": { "type": "string" },
            "baseURL": { "type": "string" }
          },
          "additionalProperties": false
        },
        "llmOptions": {
          "type": "object",
          "description": "Per-call LLM configuration.",
          "properties": {
            "systemPrompt": {
              "type": "string",
              "description": "System prompt for the model. Placed vendor-appropriately (top-level for Anthropic/Bedrock, config.systemInstruction for Gemini, role:'system' for OpenAI-compatibles)."
            },
            "messages": {
              "type": "array",
              "description": "Seed conversation history. A role:'system' entry is extracted into systemPrompt internally.",
              "items": { "$ref": "#/$defs/llmMessage" }
            },
            "initialMessages": {
              "type": "array",
              "description": "Alias of 'messages' (historical).",
              "items": { "$ref": "#/$defs/llmMessage" }
            },
            "maxTokens": {
              "type": "integer",
              "minimum": 1,
              "description": "Maximum tokens the model may generate per turn."
            },
            "temperature": {
              "type": "number",
              "minimum": 0,
              "description": "Sampling temperature."
            },
            "reasoningEffort": {
              "type": "string",
              "enum": ["minimal", "low", "medium", "high"],
              "description": "Vendor-neutral thinking/reasoning effort. Mapped per-vendor by the LLM adapter (Gemini thinkingLevel, OpenAI reasoning_effort, Anthropic extended thinking); ignored by vendors without a native equivalent. 'minimal' minimizes thinking for lowest TTFT on latency-sensitive voice turns."
            },
            "tools": {
              "type": "array",
              "description": "Tool / function definitions available to the model. The MCP-flat shape `{name, description, parameters}` is canonical; the OpenAI-wrapped form `{type:'function', function:{...}}` is also accepted.",
              "items": {
                "type": "object"
              }
            }
          },
          "additionalProperties": false
        }
      },
      "additionalProperties": false
    },
    "actionHook": {
      "$ref": "../components/actionHook",
      "description": "A webhook invoked when the agent ends."
    },
    "eventHook": {
      "$ref": "../components/actionHook",
      "description": "A webhook invoked for agent events. Receives event types: 'user_transcript' (user speech recognized), 'llm_response' (assistant reply), 'user_interruption' (barge-in detected), and 'turn_end' (end-of-turn summary with transcript, response, and latency metrics)."
    },
    "toolHook": {
      "$ref": "../components/actionHook",
      "description": "A webhook invoked when the LLM requests a tool/function call. The payload includes the tool name and arguments; the response provides the tool result."
    },
    "greeting": {
      "type": "boolean",
      "description": "Whether the LLM should generate an initial greeting before the user speaks. Default: true.",
      "default": true
    },
    "earlyGeneration": {
      "type": "boolean",
      "description": "Enable speculative LLM prompting before end-of-turn is confirmed. When using Krisp turn detection, set this to true to speculatively prompt the LLM before Krisp confirms the turn has ended. If the transcript matches when turn ends, buffered tokens are released immediately — reducing response latency. Note: Deepgram Flux performs early generation automatically via its native EagerEndOfTurn signal regardless of this setting. Default: false.",
      "default": false
    },
    "speak": {
      "type": "boolean",
      "description": "Whether the agent's text is spoken. With false the agent never talks (for example while it drives a DTMF-only phone menu with its tools): the LLM's text is still kept in conversation history, llm_response events and the transcript, but is never sent to TTS, and toolFiller and earlyGeneration are turned off. Each reply ends when the LLM finishes, as for a reply that only called tools. Default: true.",
      "default": true
    },
    "speechCleanup": {
      "type": "boolean",
      "description": "Keep text the model was not meant to say out of TTS: a paragraph that is only a stage direction in (), [] or ** (such as '(silence)' or '[No response needed]'), and <think>, <thinking> or <reasoning> blocks. Asides inside a sentence are still spoken. Applied as the reply streams; ordinary speech is not delayed. A reply with nothing left to say ends without speaking. History, llm_response events and the transcript keep the model's raw text. Default: true.",
      "default": true
    },
    "noiseIsolation": {
      "oneOf": [
        {
          "type": "string",
          "enum": ["krisp", "rnnoise", "kugelaudio"],
          "description": "Shorthand — enable noise isolation with the specified vendor using default settings."
        },
        {
          "type": "object",
          "description": "Detailed noise isolation configuration.",
          "properties": {
            "mode": {
              "type": "string",
              "description": "Noise isolation vendor/mode (e.g. 'krisp')."
            },
            "label": {
              "type": "string",
              "description": "Label of the speech credential to use, for vendors that need one (kugelaudio)."
            },
            "auth": {
              "type": "object",
              "description": "Optional inline credentials for vendors that need them (kugelaudio). When omitted, feature-server looks up the speech credential by (vendor, label).",
              "properties": {
                "apiKey": {
                  "type": "string"
                },
                "apiUri": {
                  "type": "string",
                  "description": "Vendor API host, e.g. api.eu.kugelaudio.com."
                }
              },
              "additionalProperties": true
            },
            "level": {
              "type": "number",
              "minimum": 0,
              "maximum": 100,
              "description": "Suppression level 0–100. Default: 100."
            },
            "direction": {
              "type": "string",
              "enum": ["read", "write"],
              "description": "Audio direction to apply noise isolation. 'read' filters caller audio, 'write' filters outbound audio. Default: 'read'."
            },
            "model": {
              "type": "string",
              "description": "Optional model name override."
            }
          },
          "required": ["mode"],
          "additionalProperties": false
        }
      ],
      "description": "Enable server-side noise isolation to reduce background noise on call audio. Defaults to filtering inbound (caller) audio; set direction to 'write' for outbound. Useful for improving STT accuracy in noisy environments."
    },
    "mcpServers": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The URL of the MCP server."
          },
          "auth": {
            "type": "object",
            "description": "Authentication for the MCP server.",
            "additionalProperties": true
          },
          "roots": {
            "type": "array",
            "items": { "type": "object" },
            "description": "MCP root definitions."
          }
        },
        "required": ["url"]
      },
      "description": "External MCP servers that provide tools to the LLM. The agent connects at startup via SSE, discovers available tools, and makes them callable by the LLM."
    },
    "handoff": {
      "$ref": "../components/handoff",
      "description": "Declarative transfer-to-human config. When present, the runtime injects a 'transfer_to_human' tool into the agent's toolset and runs the packaged transfer choreography (blind/warm) when the LLM calls it."
    },
    "hangup": {
      "$ref": "../components/hangup",
      "description": "Enable the built-in hangup tool. When present, the runtime injects a 'hangup' tool into the agent's toolset; when the LLM calls it, the call is ended with a reason placed in an X-Reason SIP header (LLM-supplied reason wins, falling back to hangup.reason)."
    },
    "toolFiller": {
      "oneOf": [
        { "type": "boolean", "const": false },
        { "$ref": "#/$defs/toolFillerConfig" }
      ],
      "description": "Configuration for playing filler audio while tool calls are in progress. Prevents silence during long-running tool executions."
    }
  },
  "$defs": {
    "llmMessage": {
      "type": "object",
      "description": "A conversation-history message. The library normalizes content to a string; adapters may carry vendor-native shapes internally.",
      "required": ["role", "content"],
      "properties": {
        "role": {
          "type": "string",
          "enum": ["system", "user", "assistant", "tool"]
        },
        "content": {}
      },
      "additionalProperties": true
    },
    "toolFillerConfig": {
      "type": "object",
      "required": ["type"],
      "properties": {
        "type": {
          "type": "string",
          "enum": ["audio", "backchannel"],
          "description": "Filler mode. 'audio' plays a looping audio file (killed immediately when tool returns). 'backchannel' uses TTS to speak short phrases like 'one moment...' or 'checking that...' (allowed to complete before next action)."
        },
        "startDelaySecs": {
          "type": "number",
          "minimum": 0,
          "description": "Seconds to wait after tool invocation before playing filler. Prevents filler on fast tools. Default: 2",
          "default": 2
        },
        "url": {
          "type": "string",
          "format": "uri",
          "description": "Audio file URL (for type='audio'). Should be loopable. Playback stops immediately when tool returns."
        },
        "style": {
          "type": "string",
          "description": "Free-form style hint for LLM-generated phrases (for type='backchannel'). Examples: 'casual', 'professional', 'friendly and patient'. The LLM interprets this when generating filler phrases at call start."
        },
        "escalationSecs": {
          "type": "number",
          "minimum": 0,
          "description": "Seconds from tool invocation before generating a longer explanatory response (for type='backchannel'). Useful for tools that may take 10+ seconds. Default: 10",
          "default": 10
        }
      },
      "allOf": [
        {
          "if": { "properties": { "type": { "const": "audio" } } },
          "then": { "required": ["url"] }
        }
      ]
    }
  },
  "examples": [
    {
      "verb": "agent",
      "stt": {
        "vendor": "deepgram",
        "language": "en-US"
      },
      "tts": {
        "vendor": "cartesia",
        "voice": "sonic-english"
      },
      "llm": {
        "vendor": "openai",
        "model": "gpt-4o",
        "llmOptions": {
          "messages": [
            {
              "role": "system",
              "content": "You are a helpful voice assistant."
            }
          ]
        }
      },
      "turnDetection": "stt",
      "actionHook": "/agent-complete"
    },
    {
      "verb": "agent",
      "stt": {
        "vendor": "deepgram",
        "language": "en-US"
      },
      "tts": {
        "vendor": "cartesia",
        "voice": "sonic-english"
      },
      "llm": {
        "vendor": "anthropic",
        "model": "claude-opus-4-6",
        "llmOptions": {
          "messages": [
            {
              "role": "user",
              "content": "You are a helpful voice assistant."
            }
          ]
        }
      },
      "turnDetection": {
        "mode": "krisp",
        "threshold": 0.3
      },
      "bargeIn": {
        "enable": true,
        "minSpeechDuration": 0.3,
        "sticky": false
      },
      "actionHook": "/agent-complete"
    }
  ]
}
