Metadata-Version: 2.5
Name: dialt-sdk
Version: 0.34.0
Summary: Python SDK for the Dialt realtime voice and text API.
Project-URL: Documentation, https://dialt.com/docs/api/
Project-URL: Homepage, https://dialt.com/
Author: Dialt
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: dialt,realtime,speech,voice,voice-ai
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: numpy>=1.26
Requires-Dist: websockets>=12.0
Provides-Extra: webrtc
Requires-Dist: aiortc>=1.15.0; extra == 'webrtc'
Description-Content-Type: text/markdown

# dialt-sdk

The headless Python SDK for the
[Dialt realtime voice API](https://dialt.com/docs/api/). Dialt manages
speech recognition, turn-taking, interruption handling, speech generation, reasoning and tool
orchestration through a single API; this SDK connects telephony bridges, services, evaluations
and custom devices to it.

Install the `dialt-sdk` distribution and import its public API from `dialt`.

```sh
uv add dialt-sdk
```

## Register a Python tool

Define an async function once. `ToolRegistry` derives its name, description and JSON Schema;
pass the generated definitions to the ordinary session configuration. The decorator leaves the
function callable from your application.

```python
from dialt import DialtMode, ToolRegistry

tools = ToolRegistry()

@tools.register(read_only=True, expected_duration="instant")
async def lookup_order(order_id: str) -> dict:
    """Look up the current order status.

    Args:
        order_id: The customer's order identifier.
    """
    return await order_store.lookup(order_id)

mode = DialtMode(instructions="Help callers with their orders.", tools=tools.definitions)
# In your application's handler for a broker-dispatched tool_call:
result = await tools.call(event.data["name"], event.data["args"])
# Set verified=True only after your application has established successful completion.
await session.send_tool_result(event.data["id"], result, outcome="succeeded", verified=True)
```

`order_store`, `session` and `event` above belong to your application. For a complete text-session
example with tracked tasks, cancellation and quiet progress, see
[tool_updates.py](examples/tool_updates.py). Run it with `DIALT_API_KEY` in the environment:

```sh
uv run --project sdk/python python -u sdk/python/examples/tool_updates.py
```

Keep the event consumer running while tools execute so audio, cancellation and other calls can
still be handled. The registry does not consume events, schedule tasks, retry operations, infer
success or grant permission. Your host owns these decisions and sends exactly one terminal
result per dispatched call. Only execute a broker-dispatched `tool_call`; declaring a
permissioned tool does not authorize running it directly.

### Registration contract

- `tools.register(function, **options)` and `@tools.register(**options)` register async functions;
  `@tools.register` uses defaults. Bound async methods work too.
- Parameter annotations support `str`, `int`, `float`, `bool`, `None`, `Literal` values (strings,
  integers, booleans, null), unions, `list[T]` and `dict[str, T]`, recursively. Future annotations
  are resolved with Python's `get_type_hints`; referenced types must be resolvable there.
- The cleaned docstring is the tool description, including any parameter explanations. Override
  `name` or `description` if needed. Required parameters have no Python default: `str | None`
  still requires an argument unless a default is provided. Return annotations do not define
  the result envelope.
- Registration rejects duplicate names, missing/unsupported parameter types, non-async functions,
  positional-only parameters, variadic parameters and invalid defaults. `call` rejects missing,
  extra or incorrectly typed arguments before execution, without coercion; booleans are not
  numbers and non-finite floats are rejected. Each invocation receives its own argument copies.
- Existing tool policy options such as `requires_permission` or `deferred` pass through unchanged.
  Safety and duration are never inferred from the function name or annotation.
- `tools.definitions` returns an independent JSON-serializable manifest, usable at session start,
  with `set_tools`, or in eval cases. Custom JSON Schemas still use `ToolDefinition` directly;
  a registry's `parameters` cannot be overridden. No broker frame or dependency is added.

### Choose what the caller hears

Routine updates should normally stay quiet. Use speech for information that helps the caller
at that moment, and send the final result once the operation has finished.

| Intent | Python API | Delivery |
|---|---|---|
| Update status quietly | `send_tool_progress(id, note)` | Context only; no new speech. |
| Add structured facts quietly | `send_tool_partial_result(id, content)` | Context only; no new speech. |
| Offer a useful spoken milestone | `send_tool_partial_result(id, content, reply=True)` | Best effort when no reply or caller speech is in flight. Skipped speech is not queued; facts stay in context. |
| Finish the operation | `send_tool_result(id, content, outcome=..., verified=...)` | Ends the call and informs its answer. For background completion, `notify_on_complete=True` requests delivery when the floor is free. |

Progress and partials never complete the call. Do not send a spoken partial and then repeat the
same information as a terminal result. `reply` must be a boolean, so a string such as `"false"`
cannot accidentally request speech. A request to speak is not a playback receipt.

For a deferred tool, `notify_on_complete=False` keeps its final result in context without a
proactive completion wake. It does not suppress an ordinary call's answer. A partial with
`interaction` is a separate, durable request for a caller decision; use it only when the work
needs an answer. See the [background tool guide](https://dialt.com/docs/api/background-tools/)
for deferral, decision interactions and their acknowledgements.

A string `greeting` is the conversation's first assistant turn, not presentation-only audio. It
enters model context and is bargeable in voice mode; when interrupted, playback stops and only the
heard prefix remains in context. Use `greeting=False` to disable it.

`deferred: True` permits a tool call to become a background job after the host sends
`send_tool_deferred`: the call can outlive the voice turn, the conversation carries on while the
host works, and with `notify_on_complete` the agent delivers the result when the floor is free,
even if the caller has moved on to another topic. For work that should
release the voice turn immediately, call `send_tool_deferred(id, handle, status_label=...)` before
continuing it in the background. The handle identifies that call, not your worker: mint a fresh one
per call (e.g. `f"job-{call_id}"`) and check the returned acknowledgement — re-using a live handle
is rejected as `handle_in_use`, and a rejected defer leaves the call on the ordinary tool timeout,
so it expires while you think it is backgrounded. Keep a handle-to-worker map if several calls
should feed one long-running worker.
For proactive host announcements, call
`await session.inject_context(text, role="context", reply=True, message_id="job-42")`. It returns
the broker's authoritative acknowledgement (`accepted` plus optional `retryable`/`detail`). For a
typed user turn, the same `message_id` and `input_source="text"` appear on the final ASR event;
spoken input uses `input_source="voice"`. The role defaults to `"context"`, `reply` defaults to
`False`, and omitted message IDs are generated by the SDK. Text is limited to 1–2000 characters.
An accepted `message_id` is an idempotency key for the logical session. After an acknowledgement
loss, retry the identical payload and ID (including after resume) to replay delivery proof without
injecting a duplicate turn. Reusing an accepted ID with different content is rejected; the broker
retains up to 512 accepted receipts per logical session.

Save `session.resume_token` from the connected session; after a transport loss, pass it as
`resume_token=...` to the replacement `DialtSession.connect(...)` call so pending jobs are
re-announced as `tool_deferred_resume` events within the broker's bounded resume window.

### Automatic session recovery

`DialtSession.connect(..., auto_reconnect=True)` (the default) handles that transport loss for
you: on any abnormal close (a WS close code other than 1000, e.g. 1006 loss, 1011 upstream lost,
1012/1013 broker drain/handoff), the SDK redials the same URL with the retained start-frame
configuration (voice, instructions, tools, mode, user, timezone, capabilities) plus the latest
`resume_token`, so the broker (possibly a different instance) restores the session from its
Postgres stash. Redials back off exponentially (0.5s base, capped at 5s, up to 12 attempts by
default; tune with `reconnect_base_s`, `reconnect_max_s`, `max_reconnect_attempts`), and
`resume_token` keeps rotating on every accepted `ready`, including a resumed one.

`session.state` reports `"connecting"`, `"live"`, `"reconnecting"`, or `"closed"`, and
`session.events()` surfaces the recovery itself:

- `reconnecting` (`{"attempt": 1, "code": <close code>}`): the connection dropped and a redial
  is under way. `events()` keeps running, it does not end here.
- `reconnected` (`{"attempt": <n>}`): the redial succeeded and the session is live again.
- `resume_failed` (`{"code": "resume_failed", "detail": ..., "retryable": False}`): the broker
  rejected the resume (the window closed, or the token was already consumed). Terminal:
  `resume_token` is cleared and `events()` ends right after, with no `session_end`.
- `error` (`{"code": "reconnect_failed", "detail": ...}`): every redial attempt failed and the
  chain gave up. Terminal, `events()` ends right after.
- A clean server close (WS code 1000, e.g. an idle sign-off or `end_call`) still ends the
  session with `session_end` and never reconnects, same as before.

While `state == "reconnecting"`, `send_audio`/`stream_audio` silently drop the frame instead of
raising or buffering it (buffered stale audio must never flush into the resumed session once it
comes back), and every other control method (`send_text`, `inject_context`, `set_voice`,
`set_tools`, `handoff_agent`, and the rest) raises `DialtError(code="reconnecting",
retryable=True)` so a caller can retry once `reconnected` arrives instead of getting the ambiguous
`connection_closed`. `set_voice`, `set_instructions`, and `set_tools` fold their change into the
retained mode so a later reconnect replays it; `set_tool_choice` folds only a non-one_shot choice,
and only when the retained mode already has a non-empty `tools` list (matching `set_tools`
resetting `tool_choice` to `"auto"` on the wire). Pass `auto_reconnect=False` to get the
pre-recovery behavior instead: any drop just ends `events()`.

The SDK deliberately does not own capture, playback, pacing or echo cancellation. Live playback
integrations must implement the
[playback contract](https://dialt.com/docs/api/websocket/#playback-contract).

For a text session, keep the same conversation configuration and replace the media loop with
committed turns:

```python
async with await DialtSession.connect(
    "wss://api.dialt.com/v1/realtime",
    api_key=os.environ["DIALT_API_KEY"],
    mode=DialtMode(modality="text", instructions="Help customers with their orders."),
) as session:
    await session.send_text("Where is order A123?")
    async for event in session.events():
        if event.type == "utterance":
            print(event.data["text"])
```

Text mode is WebSocket-only and emits no audio. Model behavior, tools, greeting, history and
conversation lifecycle events remain the same.

`DialtMode(end_call=True)` lets the agent end the session itself through the managed
`end_call(farewell)` tool: the farewell is sent, then `session_end_requested` arrives with it.
That advisory does not confirm that the peer heard or acted on the farewell. Stop automatic
new-turn injection but keep any local output already queued; a client that needs post-close audio
drain owns that policy. The server waits a short, cancellable grace for a real user turn before it
closes. Off by default; the host then ends the session with `wrap_up` or by closing.
`DialtMode(end_call_when="the user asks
to end the call")` states your own ending condition instead of Dialt's default (the caller asks
you to end the call); the condition is declared to the agent as part of the tool.

### Mid-session agent handoff, instructions, voice switch, tool swap and wrap-up

`set_instructions(text)` replaces the session instructions from the next reply on; a reply already
in flight, tool answer included, finishes under the old ones, and the platform persona above them
is untouched. `set_voice(key)` switches the roster voice from the next reply on,
confirmed by a `voice` event. `set_tools(tools)` replaces the client tool manifest for an agent
whose capabilities change by call phase.

For an atomic same-session handoff, use `await session.handoff_agent(instructions=...,
tools=..., voice=..., context=..., operation_id=...)`. The SDK generates `operation_id` when
omitted; it only correlates this live request and is not an idempotency or retry token. The call
waits through an optional queued acknowledgement for the final applied or rejected acknowledgement.
On timeout or disconnect its outcome is uncertain and it is never retried. Await it before a mode
setter. After an applied handoff, the caller recipe may use its separately acknowledged
`inject_context(..., reply=True)` call to make the new agent respond first.

The individual setters remain available when you need a narrower change. Send them between
replies (after `done`); with `new_speaker` the server refuses `set_instructions`
(`instructions_busy`, retryable) from the moment a turn is accepted until its reply's `done`.
Passing the caller to a human is a different operation: a permission-gated client tool whose host
moves the call leg, then `request_wrap_up`.

```python
async for event in session.events():
    if event.type == "tool_call" and event.data["name"] == "handoff_to_agent":
        await session.send_tool_result(event.data["id"], {"handoff_complete": True},
                                       outcome="succeeded", verified=True)
    elif event.type == "done" and handoff_pending:
        await session.handoff_agent(
            instructions=SPECIALIST_INSTRUCTIONS, tools=SPECIALIST_TOOLS,
            voice="southern_us_female")
        await session.inject_context(
            f"The caller {name} has just been passed to you by intake; reason: {reason}.",
            role="context", reply=True)
```

`request_wrap_up(reason)` asks the model for a graceful sign-off in persona, for a host-enforced
time limit or a transfer the host is about to make. It never cuts a reply in flight; the session
closes with `session_end` once the sign-off has played.

See the [complete Python reference](https://dialt.com/docs/api/python/#python-sdk)
and the [Twilio bridge quickstart](https://dialt.com/docs/api/twilio/).

### Policy guidance

Define each rule once in `mode.policy` with `id`, `when`, and `do`:

```json
{"include_instructions": true, "background_guidance": true,
 "rules": [{"id": "supervisor", "when": "The caller requests a supervisor.",
            "do": "Call request_supervisor and explain the tool result to the caller."}]}
```

Both switches default true and work independently. Declare and handle the tool normally;
existing permission checks still apply. Background checks quietly inject guidance about
the reviewed caller or assistant turn without requesting another reply or executing tools.
Source revisions, flag updates/retractions, errors and completion events are reported through
the ordinary event interface. Delivery is not proof the agent complied.

Previous action configurations remain accepted temporarily during migration. See the
[policy contract](https://github.com/dialt-ai/dialt/blob/main/docs/policy-agent.md).

### Hosted evals

`dialt.evals` creates cases and starts runs on the Dialt evals dashboard with your
account API key (`dk_...`; existing `ck_...` keys remain valid). Cases are JSON files in your repository; `upsert_case` keeps the
hosted copy in step by name, so a re-push updates rather than duplicates.

```python
from dialt import EvalsClient, load_cases

evals = EvalsClient(api_key=os.environ["DIALT_API_KEY"])
cases = evals.upsert_cases(load_cases("evals/"))          # one case per *.json file
run = evals.start_run([c["id"] for c in cases], modality="text")
print(evals.dashboard_url(run["id"]))
result = evals.wait(run["id"])                            # polls until terminal
print(result["status"], [a["status"] for a in result["attempts"]])
```

A case has `name`, `starter` (or `target.greeting`, when the agent opens the call), `target` and
`simulator` (each a session mode document, the shape `DialtMode.to_wire()` produces, so every
session option is a case option), `fixtures`, `checks` and `limits`; the field reference is in the
evals guide.
`converse-recipes` ships a `converse-evals push evals/` command built on this client.

### Relaying two sessions (simulations)

`dialt.relay` cross-pipes two sessions so one can play the user for the other: the
building blocks behind `converse-recipes`' `converse-sim` and the hosted evals in the Dialt
webapp.

```python
from dialt.relay import TextTurnRelay, VoiceTurnRelay

# text: forward each committed target utterance to the simulator once tool work has settled
relay = TextTurnRelay(simulator.send_text)
relay.utterance(event.data["text"])      # on the target's `utterance`
relay.working(active)                    # on `working`
relay.done()                             # on `done`: forwards after TURN_RELAY_SETTLE_S

# voice: the simulator's virtual microphone, a paced stream that never stops
relay = VoiceTurnRelay(simulator)
relay.start()                            # line noise flows from now on, like a live line
await relay.audio(event.audio)           # on each target `audio` event: queued, played at real time
await target.send_client_event(          # on the target's `interrupted`: what the simulator
    "playback_stopped", **relay.interrupted(event.data))   # never heard, as a real client reports it
relay.canceled()                         # on the target's `canceled`: drop the rescinded audio
await relay.close()                      # at the end; a line that hangs up first just stops the mic
```

The broker measures turn-end silence in received audio, so a simulated caller's mic must keep
streaming between turns and after an interrupted clip, not only while someone speaks; the
receiving side's own endpointer then closes each turn on the trailing silence. Both relays take
`on_error` to surface background failures. An eval harness that expects a simulated caller to
make a decision must leave that caller an opportunity to respond before ending its relay. That is
an eval-harness decision, not a general `done` or `session_end_requested` API guarantee.

### WebRTC transport (experimental)

Experimental: the API is stable, but this transport is newly shipped and still being hardened on
real networks; `ws` remains the default and recommended fallback.

Pass `transport="webrtc"` to `DialtSession.connect(...)` to carry the session over WebRTC (UDP)
instead of the default WebSocket. Requires `uv add "dialt-sdk[webrtc]"`.
`ws` remains the default; most headless callers are fine on it. See the
[Python guide's WebRTC section](https://dialt.com/docs/api/python/#python-webrtc).

Licensed under the [Apache License 2.0](LICENSE). This license applies to the SDK, not to the
hosted Dialt service, its models, or its server-side implementation. Runtime dependencies
remain under their own licenses.

## Application tool approval

The permission source vocabulary is `caller` (the default) or `application`; the old `conversation` and `external` values are not accepted.

Set `requires_permission: True` and `permission_source: "application"` on a tool to require
approval from your application. Omit the source for caller consent verified by AI. Forward
`permission_pending` events whose `source` is `"application"` to your approval application, displaying
the exact `tool`, `arguments`, `status_label` and `expires_at`. Keep the session event consumer running
while the reviewer decides. In your authenticated approval callback:

```python
await session.resolve_tool_permission(request_id, "approve")  # or "decline"
```

The authenticated session host is trusted to submit decisions; authenticate the reviewer in your
application. Observe `permission_resolution` in `session.events()` and check `event.data["accepted"]`
and any rejection `reason`. Sending is not acceptance or completion. An accepted approval releases
the exact stored tool call; handle `tool_call` and return its result as usual. Caller consent does
not satisfy application approval, and the assistant does not ask the caller for it.
