Metadata-Version: 2.5
Name: dialt-sdk
Version: 0.25.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.

`dialt-sdk` replaces the deprecated `converse-sdk` distribution. New code imports from `dialt`.
Existing applications can install the final `converse-sdk` compatibility release while migrating imports from `converse_sdk` to `dialt`.

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

The session-loop fragment below assumes your media layer supplies `mic_frame` and `play_audio`,
and your application supplies `run_tool`:

```python
import os

from dialt import DialtMode, DialtSession, ToolDefinition

lookup_tool: ToolDefinition = {
    "name": "lookup_order",
    "description": "Look up an order by ID.",
    "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}},
    "read_only": True,
    "expected_duration": "instant",
    "status_label": "order lookup",
    "deferred": True,
    "deferred_timeout": 7200,
    "notify_on_complete": True,
}

session = await DialtSession.connect(
    "wss://dialt.com/ws",
    api_key=os.environ["DIALT_API_KEY"],
    mode=DialtMode(
        instructions="Help callers with their orders.",
        greeting="Hello, how can I help?",
        tools=[lookup_tool],
    ),
)

async with session:
    await session.send_audio(mic_frame)  # PCM16 little-endian mono at 16 kHz
    await session.inject_context(
        "Claude Code finished. Tell the user briefly.", role="context", reply=True)

    async for event in session.events():
        if event.type == "audio":
            play_audio(event.audio)  # Float32 mono at 16 kHz
        elif event.type == "tool_call":
            result = await run_tool(event.data["name"], event.data["args"])
            await session.send_tool_result(
                event.data["id"], result, outcome="succeeded", verified=True)
        elif event.type == "tool_deferred_resume":
            resume_host_job(event.data["handle"])
```

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.

`DialtMode(turn_end_threshold=0.2)` opts one voice session into faster, less conservative
Ink finalization. The accepted range is 0.05 through 0.5; lower values are more conservative.
Leave it unset for the broker's deployment default. Use an override only when the application's
pause distribution has been evaluated, because faster finalization can increase mid-thought ends.

`deferred: True` makes the tool a background job: the call can outlive the voice turn, the
conversation carries on while the host works, and with `notify_on_complete` the agent speaks the
result when it lands — 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.

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://dialt.com/ws",
    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 spoken, `session_end_requested` arrives with the
`farewell`, and the server closes after a short grace unless the user speaks. 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 voice switch, tool swap and wrap-up

`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. With the conversation history intact, the two are enough to hand a call from one
agent persona to another on the same session: start with only a hand-off tool declared, and
when the agent calls it, declare the second persona's tools, switch the voice and return the
handover note as the tool result.

```python
async for event in session.events():
    if event.type == "tool_call" and event.data["name"] == "handoff_to_agent":
        await session.set_tools(SPECIALIST_TOOLS)
        await session.set_voice("southern_us_female")
        await session.send_tool_result(event.data["id"], "Intake complete; continue as the account specialist.",
                                       outcome="succeeded", verified=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/).

### A policy agent beside the call

`DialtMode(policy=...)` declares rules the broker's judge watches the transcript for, each with
the instruction it injects when the rule applies and how it lands: `speak_now` makes the agent
reply at once, `next_turn` lets it act at its next turn. The judge sees only the policy and the
transcript, each rule is raised once per session, and every raised rule arrives as a
`policy_flag` event (`rule`, `action`, `evidence`, `delivered`). The agent's instructions and
tools are untouched; the injected context is the whole effect, so a flag never invalidates the
prompt cache. Contract and timing: `docs/policy-agent.md`.

```python
mode = DialtMode(instructions=..., tools=..., policy={
    "subject": "a clinic appointment call",
    "rules": [
        {"id": "emergency", "action": "speak_now",
         "when": "The caller says they have, right now, symptoms that may need urgent care.",
         "do": "Tell the caller to hang up and call emergency services now, then end the call."},
        {"id": "clinical_advice", "action": "next_turn",
         "when": "The caller asks whether symptoms are serious or whether to change a medicine.",
         "do": "Do not give medical advice. Say a clinician has to answer that; offer a callback."},
    ],
})
async for event in session.events():
    if event.type == "policy_flag":
        log(event.data["rule"], event.data["delivered"])
```

### 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.

### 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.
