Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ A jambonz application controls phone calls by returning **arrays of verbs** —
- **Webhook (HTTP)**: Your server receives POST requests and returns JSON verb arrays. Stateless and simple.
- **WebSocket**: Persistent bidirectional connection. Required for real-time LLM agents, audio streaming, and TTS token streaming.

**IMPORTANT**: Any application that uses a speech-to-speech verb (`openai_s2s`, `google_s2s`, `deepgram_s2s`, `ultravox_s2s`, `elevenlabs_s2s`, `s2s`, or `agent`) MUST use WebSocket transport.
**IMPORTANT**: Any application that uses a speech-to-speech verb (`openai_s2s`, `google_s2s`, `deepgram_s2s`, `ultravox_s2s`, `elevenlabs_s2s`, `gptlive_s2s`, `qwen_s2s`, `voicelive_s2s`, `s2s`, or `agent`) MUST use WebSocket transport.

## Core Verbs

Expand All @@ -32,7 +32,7 @@ A jambonz application controls phone calls by returning **arrays of verbs** —
- **gather** — Collect speech (STT) and/or DTMF input.

### AI & Real-time
- **openai_s2s** / **google_s2s** / **deepgram_s2s** / **ultravox_s2s** / **elevenlabs_s2s** — Vendor-specific LLM voice conversation.
- **openai_s2s** / **google_s2s** / **deepgram_s2s** / **ultravox_s2s** / **elevenlabs_s2s** / **gptlive_s2s** / **qwen_s2s** / **voicelive_s2s** — Vendor-specific LLM voice conversation.
- **s2s** — Generic LLM voice conversation (use when vendor is determined at runtime).
- **agent** — Higher-level voice AI agent with integrated turn detection.
- **dialogflow** — Google Dialogflow agent.
Expand All @@ -41,7 +41,8 @@ A jambonz application controls phone calls by returning **arrays of verbs** —

### Call Control
- **dial** — Place an outbound call and bridge.
- **conference** — Multi-party conference room.
- **conference** / **room** — Multi-party conference room (`room` is a synonym for `conference`; prefer it in new apps).
- **transfer** — Blind or warm (parked/three-way) transfer with built-in failure handling.
- **enqueue** / **dequeue** — Call queuing.
- **hangup** — End the call.
- **redirect** — Transfer control to a different webhook.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ VerbDef("new_verb", "new_verb", doc="Description.")

## Features

- **All 31 jambonz verbs**: say, play, gather, dial, conference, enqueue/dequeue, hangup, pause, redirect, config, tag, dtmf, dub, message, alert, answer, leave, listen/stream, transcribe, openai_s2s, google_s2s, deepgram_s2s, elevenlabs_s2s, ultravox_s2s, s2s, llm, dialogflow, agent, sip_decline, sip_request, sip_refer
- **All jambonz verbs**: say, play, gather, dial, conference/room, transfer, enqueue/dequeue, hangup, pause, redirect, config, tag, dtmf, dub, message, alert, answer, leave, listen/stream, transcribe, openai_s2s, google_s2s, deepgram_s2s, elevenlabs_s2s, ultravox_s2s, gptlive_s2s, qwen_s2s, voicelive_s2s, s2s, llm, dialogflow, agent, sip_decline, sip_request, sip_refer
- **Fluent chainable API**: `.say(...).gather(...).hangup()`
- **Webhook transport**: `WebhookResponse` for HTTP apps (works with aiohttp, FastAPI, Flask, etc.)
- **WebSocket transport**: `create_endpoint` with `Session`, event handling, `send()`/`reply()`
Expand Down
87 changes: 82 additions & 5 deletions scripts/regen_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,23 @@
NESTED_OVERRIDES: list[tuple[str, str, str, str, str]] = [
("verbs/gather.py", "Gather", "say", ".say", "Say"),
("verbs/gather.py", "Gather", "play", ".play", "Play"),
("verbs/agent.py", "Agent", "llm", ".llm", "LLM"),
# NB: ``Agent.llm`` used to be an untyped object here, but the schema
# now defines it inline, so codegen emits a proper nested ``Llm`` model
# and no override is needed.
]

# Verbs whose ``verb`` property is a multi-value ``enum`` rather than a
# ``const`` (because the verb has a synonym, e.g. ``room`` for
# ``conference``). datamodel-code-generator mishandles this shape: it
# emits a dangling ``Verb(Enum)`` class and a broken field annotation
# (``verb: Literal["conference#"]`` with no default). This table repairs
# the field into a proper multi-value ``Literal`` with the canonical value
# as the default, so the model validates and serializes correctly and the
# verb registry can index it under every synonym.
#
# Format: (module_path, class_name, canonical_verb, all_verbs)
VERB_ENUM_FIXUPS: list[tuple[str, str, str, list[str]]] = [
("verbs/conference.py", "Conference", "conference", ["conference", "room"]),
]

# Nested classes the re-export heuristic wouldn't pick up (because they
Expand Down Expand Up @@ -313,6 +329,57 @@ def apply_class_validators(output_dir: Path) -> None:
path.write_text(source)


def apply_verb_enum_fixups(output_dir: Path) -> None:
"""Repair verb fields generated from a multi-value ``enum`` discriminator.

See ``VERB_ENUM_FIXUPS`` for why this is needed. For each target this:

- drops the dangling ``class Verb(Enum): ...`` block the generator emits,
- drops the now-unused ``from enum import Enum`` import,
- rewrites the broken ``verb: Literal[...]`` field into
``verb: Literal["a", "b", ...] = "<canonical>"``.
"""
for rel_path, class_name, canonical, all_verbs in VERB_ENUM_FIXUPS:
path = output_dir / rel_path
if not path.is_file():
print(f" warning: verb-enum-fixup target {rel_path} missing — skipping")
continue
source = path.read_text()

# Drop the dangling ``class Verb(Enum): ...`` block (runs until the
# next blank line separating top-level statements).
source = re.sub(
r"\nclass Verb\(Enum\):\n(?:[ \t]+.*\n)+",
"\n",
source,
count=1,
)
# Drop the now-unused Enum import.
source = re.sub(r"^from enum import Enum\n", "", source, flags=re.MULTILINE)

# Rewrite the verb field inside the target class.
literal = ", ".join(f'"{v}"' for v in all_verbs)
replacement = f' verb: Literal[{literal}] = "{canonical}"\n'
class_start = source.find(f"class {class_name}(")
if class_start < 0:
print(f" warning: class {class_name} not found in {rel_path}")
continue
next_class = re.search(r"^class\s+\w+\(", source[class_start + 1:], re.MULTILINE)
class_end = class_start + 1 + (next_class.start() if next_class else len(source))
head, body, tail = source[:class_start], source[class_start:class_end], source[class_end:]
new_body, n = re.subn(
r"^ verb: Literal\[[^\n]*\]\n",
replacement,
body,
count=1,
flags=re.MULTILINE,
)
if n == 0:
print(f" warning: verb field for {class_name} not found in {rel_path}")
continue
path.write_text(head + new_body + tail)


def apply_nested_overrides(output_dir: Path) -> None:
"""Rewrite specific ``dict[str, Any]`` fields to reference real models."""
for rel_path, class_name, field_name, target_module, target_class in NESTED_OVERRIDES:
Expand Down Expand Up @@ -395,12 +462,19 @@ def _primary_class_of(path: Path, source: str) -> str | None:
Falls back to the last model-class declared in the file.
"""
stem = path.stem
# Collect every model/root class declared in the module.
keep_bases = ("JambonzModel", "LlmBaseProperties")
# Collect every model/root class declared in the module. A class is
# a model unless it subclasses a non-model base like ``Enum``. This
# keeps classes that extend ``JambonzModel`` directly as well as
# those extending another generated model (e.g. vendor s2s verbs
# extend ``LlmBaseProperties``; ``Transfer``/``Handoff`` extend
# ``TransferOptions``), while dropping generated enums.
non_model_bases = ("Enum", "str", "int", "float", "IntEnum", "StrEnum")
classes: list[str] = []
for name, base in class_re.findall(source):
if base in keep_bases or base.startswith("RootModel"):
classes.append(name)
short_base = base.rsplit(".", 1)[-1]
if short_base in non_model_bases:
continue
classes.append(name)
if not classes:
return None

Expand Down Expand Up @@ -550,6 +624,9 @@ def main() -> None:
postprocess_file(path)
print(f" post-processed {len(py_files)} files")

apply_verb_enum_fixups(OUTPUT_DIR)
print(" applied verb-enum fixups")

apply_nested_overrides(OUTPUT_DIR)
print(" applied nested-object overrides")

Expand Down
2 changes: 1 addition & 1 deletion scripts/sync_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from pathlib import Path

# ── Pin the schema version here ──────────────────────────────────────
SCHEMA_VERSION = "v0.3.8"
SCHEMA_VERSION = "v0.4.23"
# ────────────────────────────────────────────────────────────────────

DEST = Path(__file__).resolve().parent.parent / "src" / "jambonz_sdk" / "schema"
Expand Down
4 changes: 2 additions & 2 deletions src/jambonz_sdk/_models/_generated/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__jambonz_schema_version__ = "v0.2.1"
__jambonz_schema_version__ = "v0.4.23"
# generated by datamodel-codegen:
# filename: schema
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# generated by datamodel-codegen:
# filename: schema
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/agent_turn.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/agent-turn
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/amd.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/amd
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
8 changes: 6 additions & 2 deletions src/jambonz_sdk/_models/_generated/callbacks/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/base
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down Expand Up @@ -86,7 +86,11 @@ class ActionhookBasePayload(JambonzModel):
"""
sip_reason: str | None = None
"""
SIP reason phrase (e.g. 'OK', 'Busy Here').
SIP reason phrase from the SIP status line (e.g. 'OK', 'Busy Here'). Not the RFC 3326 Reason header - see sip_reason_header.
"""
sip_reason_header: str | None = None
"""
RFC 3326 Reason header of the SIP message that caused this status change (e.g. 'Q.850;cause=18'), if it carried one; absent otherwise. Carriers fronting ISDN/E1 PRI trunks put the authoritative disconnect cause here rather than in the status line, and the same SIP status can carry different causes - a 503 may be cause=38 (network out of order) or cause=41 (temporary failure) - so the status code alone cannot classify the outcome. Present on whichever event observed it: a final failure response, a BYE, or a CANCEL. Two caveats: the header is re-serialized in transit, which normalizes the optional whitespace RFC 3326 allows around ';' (a carrier's 'Q.850 ;cause=18' arrives as 'Q.850;cause=18'), so parse tolerantly rather than string-matching; and a message may carry more than one Reason header, in which case they arrive comma-separated - split on commas outside quotes, since a text="..." value may contain one.
"""
trace_id: str | None = None
"""
Expand Down
17 changes: 16 additions & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/call_status.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/call-status
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand All @@ -11,6 +11,15 @@
from .base import ActionhookBasePayload


class Direction(Enum):
"""
Call direction. 'inbound' = A-leg (original incoming call to the application). 'outbound' = B-leg (call placed by the dial verb). Use this field to identify which leg generated the status event, especially when capturing the B-leg's call_sid for mid-call control.
"""

inbound = "inbound"
outbound = "outbound"


class CallTerminationBy(Enum):
"""
Who terminated the call. 'caller' if the remote party hung up, 'jambonz' if the call was ended by the application (e.g. hangup verb, REST API). Present only on the final status event (completed/failed).
Expand All @@ -23,11 +32,17 @@ class CallTerminationBy(Enum):
class CallStatusWebhookPayload(ActionhookBasePayload):
"""
Payload sent to the call status webhook URL whenever the call state changes (e.g. trying, in-progress, completed). The status webhook is configured at the application level in jambonz. Multiple status events are sent over the life of a call. The final event (completed or failed) includes additional fields like duration and termination cause.

**Capturing B-leg call_sid:** When using the dial verb to bridge calls, status events are sent for both legs. The A-leg (original inbound call) has `direction: 'inbound'`. The B-leg (outbound dialed call) has `direction: 'outbound'`. To capture the B-leg's call_sid for later use (e.g., injecting commands to the B-leg), listen for status events where `direction === 'outbound'` and extract the `call_sid` field.
"""

model_config = ConfigDict(
extra="allow",
)
direction: Direction | None = None
"""
Call direction. 'inbound' = A-leg (original incoming call to the application). 'outbound' = B-leg (call placed by the dial verb). Use this field to identify which leg generated the status event, especially when capturing the B-leg's call_sid for mid-call control.
"""
call_termination_by: CallTerminationBy | None = None
"""
Who terminated the call. 'caller' if the remote party hung up, 'jambonz' if the call was ended by the application (e.g. hangup verb, REST API). Present only on the final status event (completed/failed).
Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/conference.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/conference
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/conference-status
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/conference-wait
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/dequeue.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/dequeue
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/dial.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/dial
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/dial_dtmf.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/dial-dtmf
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/dial_hold.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/dial-hold
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/dial_refer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/dial-refer
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/enqueue.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/enqueue
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/enqueue-wait
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/gather.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/gather
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/gather-partial
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/listen.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/listen
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/llm.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/llm
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/message.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/message
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
2 changes: 1 addition & 1 deletion src/jambonz_sdk/_models/_generated/callbacks/play.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/play
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/session-new
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: callbacks/session-reconnect
# timestamp: 2026-05-19T13:39:55+00:00
# timestamp: 2026-10-14T10:55:09+00:00

from __future__ import annotations

Expand Down
Loading
Loading