dial-sdk 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dial_sdk/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ from .client import DialClient
2
+ from .events import EventsConnection
3
+ from .self_hosted import (
4
+ CallConnected,
5
+ Interrupt,
6
+ PingPong,
7
+ ReminderRequired,
8
+ Response,
9
+ ResponseRequired,
10
+ TranscriptItem,
11
+ TranscriptUpdate,
12
+ parse_dial_message,
13
+ serialize_server_message,
14
+ verify_dial_signature,
15
+ )
16
+ from .types import (
17
+ Call,
18
+ CallEvent,
19
+ CallStatus,
20
+ CallTranscribed,
21
+ DialConfig,
22
+ DialEvent,
23
+ MakeCallParams,
24
+ Message,
25
+ MessageEvent,
26
+ PhoneNumber,
27
+ PurchaseNumberParams,
28
+ SendMessageParams,
29
+ )
30
+
31
+ __all__ = [
32
+ "DialClient",
33
+ "EventsConnection",
34
+ "DialConfig",
35
+ "PurchaseNumberParams",
36
+ "SendMessageParams",
37
+ "MakeCallParams",
38
+ "PhoneNumber",
39
+ "Message",
40
+ "Call",
41
+ "CallStatus",
42
+ "CallEvent",
43
+ "MessageEvent",
44
+ "CallTranscribed",
45
+ "DialEvent",
46
+ # Self-Hosted protocol
47
+ "TranscriptItem",
48
+ "CallConnected",
49
+ "TranscriptUpdate",
50
+ "ResponseRequired",
51
+ "ReminderRequired",
52
+ "Response",
53
+ "Interrupt",
54
+ "PingPong",
55
+ "parse_dial_message",
56
+ "serialize_server_message",
57
+ "verify_dial_signature",
58
+ ]
dial_sdk/client.py ADDED
@@ -0,0 +1,203 @@
1
+ from typing import Any, Optional
2
+
3
+ import httpx
4
+
5
+ from .events import EventsConnection
6
+ from .types import (
7
+ Call,
8
+ DialConfig,
9
+ MakeCallParams,
10
+ Message,
11
+ PhoneNumber,
12
+ PurchaseNumberParams,
13
+ SendMessageParams,
14
+ )
15
+
16
+ DEFAULT_BASE_URL = "https://getdial.ai"
17
+
18
+ # Sentinel distinguishing "argument not provided" from None (which clears the field).
19
+ _UNSET: Any = object()
20
+
21
+
22
+ class DialClient:
23
+ def __init__(self, config: DialConfig):
24
+ self._base_url = config.base_url or DEFAULT_BASE_URL
25
+ self._api_key = config.api_key
26
+ self._client = httpx.AsyncClient(
27
+ base_url=self._base_url,
28
+ headers={
29
+ "Authorization": f"Bearer {config.api_key}",
30
+ "Content-Type": "application/json",
31
+ },
32
+ )
33
+
34
+ async def _request(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ body: Optional[dict] = None,
39
+ params: Optional[dict] = None,
40
+ headers: Optional[dict] = None,
41
+ ):
42
+ response = await self._client.request(method, path, json=body, params=params, headers=headers)
43
+ if response.is_error:
44
+ raise Exception(f"Dial API error {response.status_code}: {response.text}")
45
+ return response.json()
46
+
47
+ @staticmethod
48
+ def _filters(number_id, direction, since) -> dict:
49
+ return {
50
+ k: v
51
+ for k, v in {"numberId": number_id, "direction": direction, "since": since}.items()
52
+ if v is not None
53
+ }
54
+
55
+ # ── Numbers ──────────────────────────────────────────────────
56
+
57
+ async def list_numbers(self) -> list[PhoneNumber]:
58
+ """`dial number list` — list the account's phone numbers."""
59
+ data = await self._request("GET", "/api/v1/numbers")
60
+ return [PhoneNumber.from_api(n) for n in data["numbers"]]
61
+
62
+ async def purchase_number(self, params: PurchaseNumberParams) -> PhoneNumber:
63
+ """`dial number purchase` — provision a new phone number."""
64
+ body: dict = {
65
+ "inboundInstruction": params.inbound_instruction,
66
+ "country": params.country,
67
+ }
68
+ if params.area_code:
69
+ body["areaCode"] = params.area_code
70
+ data = await self._request("POST", "/api/v1/numbers", body)
71
+ return PhoneNumber.from_api(data["number"])
72
+
73
+ async def set_number_properties(
74
+ self,
75
+ number_id: str,
76
+ *,
77
+ inbound_instruction: Optional[str] = None,
78
+ nickname: Optional[str] = _UNSET,
79
+ ) -> PhoneNumber:
80
+ """`dial number set` — update a number's properties (any subset; at least one).
81
+
82
+ ``nickname=None`` or ``nickname=""`` clears the nickname; omit the
83
+ argument to leave it unchanged.
84
+ """
85
+ body: dict = {}
86
+ if inbound_instruction is not None:
87
+ body["inboundInstruction"] = inbound_instruction
88
+ if nickname is not _UNSET:
89
+ body["nickname"] = nickname
90
+ if not body:
91
+ raise ValueError(
92
+ "Provide at least one property to update (inbound_instruction or nickname)."
93
+ )
94
+ data = await self._request("PATCH", f"/api/v1/numbers/{number_id}", body)
95
+ return PhoneNumber.from_api(data["number"])
96
+
97
+ async def set_inbound_instruction(
98
+ self, number_id: str, inbound_instruction: str
99
+ ) -> PhoneNumber:
100
+ """Deprecated: use :meth:`set_number_properties` instead."""
101
+ return await self.set_number_properties(
102
+ number_id, inbound_instruction=inbound_instruction
103
+ )
104
+
105
+ # ── Messages ─────────────────────────────────────────────────
106
+
107
+ async def send_message(self, params: SendMessageParams) -> Message:
108
+ """`dial message` — send an SMS."""
109
+ data = await self._request(
110
+ "POST",
111
+ "/api/v1/messages",
112
+ {
113
+ "to": params.to,
114
+ "fromNumberId": params.from_number_id,
115
+ "body": params.body,
116
+ "channel": params.channel,
117
+ },
118
+ )
119
+ return Message.from_api(data["message"])
120
+
121
+ async def list_messages(
122
+ self,
123
+ *,
124
+ number_id: Optional[str] = None,
125
+ direction: Optional[str] = None,
126
+ since: Optional[str] = None,
127
+ ) -> list[Message]:
128
+ """`dial message list` — list messages, optionally filtered."""
129
+ data = await self._request(
130
+ "GET", "/api/v1/messages", params=self._filters(number_id, direction, since)
131
+ )
132
+ return [Message.from_api(m) for m in data["messages"]]
133
+
134
+ # ── Calls ────────────────────────────────────────────────────
135
+
136
+ async def make_call(self, params: MakeCallParams) -> Call:
137
+ """`dial call` — place an outbound AI voice call."""
138
+ body = {
139
+ "to": params.to,
140
+ "fromNumberId": params.from_number_id,
141
+ "outboundInstruction": params.outbound_instruction,
142
+ }
143
+ # Omitted → the server auto-detects from the destination number's country.
144
+ if params.language is not None:
145
+ body["language"] = params.language
146
+ # Same key across retries → the server returns the already-placed call.
147
+ headers = (
148
+ {"Idempotency-Key": params.idempotency_key}
149
+ if params.idempotency_key is not None
150
+ else None
151
+ )
152
+ data = await self._request("POST", "/api/v1/calls", body, headers=headers)
153
+ return Call.from_api(data["call"])
154
+
155
+ async def list_calls(
156
+ self,
157
+ *,
158
+ number_id: Optional[str] = None,
159
+ direction: Optional[str] = None,
160
+ since: Optional[str] = None,
161
+ ) -> list[Call]:
162
+ """`dial call list` — list calls, optionally filtered."""
163
+ data = await self._request(
164
+ "GET", "/api/v1/calls", params=self._filters(number_id, direction, since)
165
+ )
166
+ return [Call.from_api(c) for c in data["calls"]]
167
+
168
+ async def get_call(self, call_id: str) -> Call:
169
+ """`dial call get <id>` — fetch a single call by id."""
170
+ data = await self._request("GET", f"/api/v1/calls/{call_id}")
171
+ return Call.from_api(data["call"])
172
+
173
+ # ── Events (wait-for) ────────────────────────────────────────
174
+
175
+ def new_events_connection(self) -> EventsConnection:
176
+ """Open a long-lived connection to the account's event stream.
177
+
178
+ SDK equivalent of ``dial wait-for``: rather than returning a single
179
+ event, it yields every ``message.received`` / ``call.ended`` /
180
+ ``call.transcribed`` event on the account channel. The PubNub token is
181
+ re-minted automatically before it expires. Every event shares one
182
+ envelope; read values from ``event["data"]``. No I/O happens until the
183
+ connection is entered::
184
+
185
+ async with client.new_events_connection() as conn:
186
+ async for event in conn:
187
+ if event["type"] == "message.received":
188
+ print(event["data"]["from"], event["data"]["body"])
189
+ """
190
+
191
+ async def _mint() -> dict:
192
+ return await self._request("POST", "/api/v1/listen/subscribe")
193
+
194
+ return EventsConnection(_mint)
195
+
196
+ async def close(self):
197
+ await self._client.aclose()
198
+
199
+ async def __aenter__(self) -> "DialClient":
200
+ return self
201
+
202
+ async def __aexit__(self, exc_type, exc, tb) -> None:
203
+ await self.close()
dial_sdk/events.py ADDED
@@ -0,0 +1,155 @@
1
+ import asyncio
2
+ import secrets
3
+ from typing import AsyncIterator, Awaitable, Callable, Optional
4
+
5
+ from pubnub.callbacks import SubscribeCallback
6
+ from pubnub.pnconfiguration import PNConfiguration
7
+ from pubnub.pubnub_asyncio import PubNubAsyncio
8
+
9
+ from .types import DialEvent
10
+
11
+ # A coroutine that calls POST /api/v1/listen/subscribe and returns the grant dict
12
+ # ({ "subscribeKey", "channel", "token", "ttlSeconds" }). Called on open and again
13
+ # before each TTL expiry.
14
+ MintGrant = Callable[[], Awaitable[dict]]
15
+
16
+ # Renew the token at this fraction of its TTL, leaving headroom before expiry.
17
+ _RENEW_FRACTION = 0.8
18
+ # On a failed renewal, retry this soon (seconds) rather than waiting a full TTL.
19
+ _RENEW_RETRY_SECONDS = 5.0
20
+
21
+ # Sentinel pushed onto the queue by close() to wake a parked consumer.
22
+ _CLOSED = object()
23
+
24
+
25
+ class _QueueListener(SubscribeCallback):
26
+ """Funnels PubNub messages on our channel into an asyncio.Queue."""
27
+
28
+ def __init__(self, queue: "asyncio.Queue", channel: str):
29
+ self._queue = queue
30
+ self._channel = channel
31
+
32
+ def message(self, pubnub, message):
33
+ if message.channel == self._channel:
34
+ self._queue.put_nowait(message.message)
35
+
36
+ def presence(self, pubnub, presence):
37
+ pass
38
+
39
+ def status(self, pubnub, status):
40
+ pass
41
+
42
+
43
+ class EventsConnection:
44
+ """A long-lived subscription to the account's event stream, consumed as an
45
+ async iterator of event dicts.
46
+
47
+ This is the SDK's idiom for what the CLI does one-shot with ``dial wait-for``:
48
+ instead of returning a single event, it yields every ``message.received`` /
49
+ ``call.ended`` event on the account channel until the consumer stops iterating
50
+ or the connection is closed. The PubNub token is silently re-minted before it
51
+ expires, so a connection can stay open indefinitely. PubNub specifics
52
+ (subscribe key, channel, token, TTL) are never surfaced to the caller.
53
+
54
+ Usage::
55
+
56
+ async with client.new_events_connection() as conn:
57
+ async for event in conn:
58
+ handle(event)
59
+ """
60
+
61
+ def __init__(self, mint: MintGrant):
62
+ self._mint = mint
63
+ self._pubnub: Optional[PubNubAsyncio] = None
64
+ self._listener: Optional[_QueueListener] = None
65
+ self._channel = ""
66
+ self._queue: "asyncio.Queue" = asyncio.Queue()
67
+ self._renew_task: Optional["asyncio.Task"] = None
68
+ self._opened = False
69
+ self._closed = False
70
+
71
+ async def open(self) -> "EventsConnection":
72
+ """Mint the first token and establish the subscription. Idempotent."""
73
+ if self._opened:
74
+ return self
75
+ self._opened = True
76
+
77
+ grant = await self._mint()
78
+ self._channel = grant["channel"]
79
+
80
+ config = PNConfiguration()
81
+ config.subscribe_key = grant["subscribeKey"]
82
+ config.user_id = f"dial-sdk-{secrets.token_hex(8)}"
83
+ self._pubnub = PubNubAsyncio(config)
84
+ self._pubnub.set_token(grant["token"])
85
+
86
+ self._listener = _QueueListener(self._queue, self._channel)
87
+ self._pubnub.add_listener(self._listener)
88
+ self._pubnub.subscribe().channels([self._channel]).execute()
89
+
90
+ self._renew_task = asyncio.create_task(self._renew_loop(grant["ttlSeconds"]))
91
+ return self
92
+
93
+ async def _renew_loop(self, ttl_seconds: float) -> None:
94
+ try:
95
+ while not self._closed:
96
+ await asyncio.sleep(max(1.0, ttl_seconds * _RENEW_FRACTION))
97
+ if self._closed:
98
+ return
99
+ try:
100
+ grant = await self._mint()
101
+ if self._pubnub:
102
+ self._pubnub.set_token(grant["token"])
103
+ ttl_seconds = grant["ttlSeconds"]
104
+ except Exception:
105
+ # Transient mint failure — keep the (still-valid) token, retry soon.
106
+ await asyncio.sleep(_RENEW_RETRY_SECONDS)
107
+ except asyncio.CancelledError:
108
+ pass
109
+
110
+ def __aiter__(self) -> AsyncIterator[DialEvent]:
111
+ return self
112
+
113
+ async def __anext__(self) -> DialEvent:
114
+ if self._closed and self._queue.empty():
115
+ raise StopAsyncIteration
116
+ item = await self._queue.get()
117
+ if item is _CLOSED:
118
+ raise StopAsyncIteration
119
+ return item
120
+
121
+ async def close(self) -> None:
122
+ """Tear down the subscription and end iteration. Idempotent."""
123
+ if self._closed:
124
+ return
125
+ self._closed = True
126
+
127
+ if self._renew_task:
128
+ self._renew_task.cancel()
129
+ try:
130
+ await self._renew_task
131
+ except (asyncio.CancelledError, Exception):
132
+ pass
133
+ self._renew_task = None
134
+
135
+ if self._pubnub:
136
+ if self._listener:
137
+ self._pubnub.remove_listener(self._listener)
138
+ try:
139
+ self._pubnub.unsubscribe_all()
140
+ except Exception:
141
+ pass
142
+ try:
143
+ await self._pubnub.stop()
144
+ except Exception:
145
+ pass
146
+ self._pubnub = None
147
+
148
+ # Wake any consumer parked in __anext__.
149
+ self._queue.put_nowait(_CLOSED)
150
+
151
+ async def __aenter__(self) -> "EventsConnection":
152
+ return await self.open()
153
+
154
+ async def __aexit__(self, exc_type, exc, tb) -> None:
155
+ await self.close()
@@ -0,0 +1,151 @@
1
+ """Self-Hosted protocol — the WebSocket contract your server speaks when Dial
2
+ drives a call through your own LLM.
3
+
4
+ These pydantic models are the executable form of
5
+ https://docs.getdial.ai/api-reference/self-hosted-protocol, plus
6
+ ``verify_dial_signature`` for the ``X-Dial-Signature`` header. Configuration of
7
+ Self-Hosted mode happens in the dashboard / REST API — this module is purely the
8
+ protocol types + signature verification for building a conformant server.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import time
16
+ from typing import Annotated, Literal, Optional, Union
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
19
+
20
+ Role = Literal["agent", "user"]
21
+
22
+
23
+ class TranscriptItem(BaseModel):
24
+ role: Role
25
+ content: str
26
+
27
+
28
+ # --- Dial -> your server ----------------------------------------------------
29
+
30
+
31
+ class CallConnected(BaseModel):
32
+ # Sent when Dial connects — once per connection, so you also get it on a
33
+ # reconnect (Retell auto_reconnect). `from` is a keyword → `from_` + alias.
34
+ model_config = ConfigDict(populate_by_name=True)
35
+ type: Literal["call_connected"]
36
+ call_id: str
37
+ direction: Literal["inbound", "outbound"]
38
+ from_: str = Field(alias="from")
39
+ to: str
40
+ # The instruction Dial ran the call with (system_prompt + general context).
41
+ instruction: Optional[str] = None
42
+ # The call's primary BCP-47 language (e.g. en-US, he-IL).
43
+ language: Optional[str] = None
44
+
45
+
46
+ class TranscriptUpdate(BaseModel):
47
+ type: Literal["transcript_update"]
48
+ transcript: list[TranscriptItem]
49
+
50
+
51
+ class ResponseRequired(BaseModel):
52
+ type: Literal["response_required"]
53
+ response_id: int
54
+ transcript: list[TranscriptItem]
55
+
56
+
57
+ class ReminderRequired(BaseModel):
58
+ type: Literal["reminder_required"]
59
+ response_id: int
60
+ transcript: list[TranscriptItem]
61
+
62
+
63
+ class PingPong(BaseModel):
64
+ # Keepalive, sent both directions. Dial pings ~every 2s; echo it back.
65
+ type: Literal["ping_pong"]
66
+ timestamp: int
67
+
68
+
69
+ DialServerMessage = Annotated[
70
+ Union[CallConnected, TranscriptUpdate, ResponseRequired, ReminderRequired, PingPong],
71
+ Field(discriminator="type"),
72
+ ]
73
+ _dial_server_adapter: TypeAdapter[DialServerMessage] = TypeAdapter(DialServerMessage)
74
+
75
+
76
+ def parse_dial_message(raw: Union[str, bytes, dict]) -> DialServerMessage:
77
+ """Parse + validate a frame received from Dial. Raises ``ValidationError``."""
78
+ if isinstance(raw, (str, bytes, bytearray)):
79
+ return _dial_server_adapter.validate_json(raw)
80
+ return _dial_server_adapter.validate_python(raw)
81
+
82
+
83
+ # --- your server -> Dial ----------------------------------------------------
84
+
85
+
86
+ class Response(BaseModel):
87
+ type: Literal["response"]
88
+ response_id: int
89
+ content: str
90
+ content_complete: bool
91
+ end_call: Optional[bool] = None
92
+
93
+
94
+ class Interrupt(BaseModel):
95
+ type: Literal["interrupt"]
96
+ content: str
97
+ content_complete: bool
98
+ end_call: Optional[bool] = None
99
+
100
+
101
+ ServerDialMessage = Union[Response, Interrupt, PingPong]
102
+
103
+
104
+ def serialize_server_message(message: ServerDialMessage) -> str:
105
+ """Validate + serialize an outbound frame to a JSON string (omitting unset
106
+ ``end_call``)."""
107
+ return message.model_dump_json(exclude_none=True)
108
+
109
+
110
+ # --- signature verification -------------------------------------------------
111
+
112
+
113
+ def verify_dial_signature(
114
+ secret: str,
115
+ header: str,
116
+ data: str,
117
+ *,
118
+ now: Optional[int] = None,
119
+ tolerance_seconds: int = 300,
120
+ ) -> bool:
121
+ """Verify a Dial ``X-Dial-Signature: t=<unix_seconds>,v1=<hex>`` header.
122
+
123
+ The signature is ``HMAC-SHA256(secret, "<t>.<data>")``, where ``data`` is
124
+ the value the signature covers — the ``call_id`` for the self-hosted
125
+ WebSocket protocol, or the raw request body for a webhook delivery. Returns
126
+ ``False`` (never raises) on a malformed header, a stale/future timestamp, or
127
+ a mismatch. Matches Dial's server-side signer byte-for-byte.
128
+ """
129
+ if not isinstance(header, str):
130
+ return False
131
+ t: Optional[int] = None
132
+ v1: Optional[str] = None
133
+ for part in header.split(","):
134
+ key, _, value = part.partition("=")
135
+ if key == "t":
136
+ try:
137
+ t = int(value)
138
+ except ValueError:
139
+ return False
140
+ elif key == "v1":
141
+ v1 = value
142
+ if t is None or not v1:
143
+ return False
144
+
145
+ if now is None:
146
+ now = int(time.time())
147
+ if abs(now - t) > tolerance_seconds:
148
+ return False
149
+
150
+ expected = hmac.new(secret.encode(), f"{t}.{data}".encode(), hashlib.sha256).hexdigest()
151
+ return hmac.compare_digest(expected, v1)
dial_sdk/types.py ADDED
@@ -0,0 +1,224 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, Optional, TypedDict, Union
3
+
4
+
5
+ @dataclass
6
+ class DialConfig:
7
+ api_key: str
8
+ base_url: str = "https://getdial.ai"
9
+
10
+
11
+ # ── Request params ──────────────────────────────────────────────
12
+
13
+
14
+ @dataclass
15
+ class PurchaseNumberParams:
16
+ inbound_instruction: str # system prompt for inbound calls to this number
17
+ country: str = "US" # ISO-3166 alpha-2
18
+ area_code: Optional[str] = None
19
+
20
+
21
+ @dataclass
22
+ class SendMessageParams:
23
+ to: str
24
+ from_number_id: str
25
+ body: str
26
+ channel: str = "sms"
27
+
28
+
29
+ @dataclass
30
+ class MakeCallParams:
31
+ to: str
32
+ from_number_id: str
33
+ outbound_instruction: str # system prompt for the AI voice agent during this call
34
+ # BCP-47, e.g. "en-US". None → the server auto-detects from the destination
35
+ # number's country prefix and the agent handles both it and en-US.
36
+ language: Optional[str] = None
37
+ # Unique key (e.g. a UUID) making the placement idempotent. Pass the same
38
+ # key when retrying a failed or ambiguous make_call and the server returns
39
+ # the already-placed call instead of dialing again.
40
+ idempotency_key: Optional[str] = None
41
+
42
+
43
+ # ── Resources ───────────────────────────────────────────────────
44
+
45
+
46
+ @dataclass
47
+ class PhoneNumber:
48
+ id: str
49
+ number: str
50
+ sid: str
51
+ nickname: Optional[str] # human-readable label; None when unset
52
+ country: str
53
+ capabilities: str
54
+ inbound_instruction: Optional[str] # system prompt for inbound calls; None for legacy numbers
55
+ account_id: str
56
+ created_at: str
57
+
58
+ @classmethod
59
+ def from_api(cls, d: dict) -> "PhoneNumber":
60
+ return cls(
61
+ id=d["id"],
62
+ number=d["number"],
63
+ sid=d["sid"],
64
+ nickname=d.get("nickname"),
65
+ country=d["country"],
66
+ capabilities=d["capabilities"],
67
+ inbound_instruction=d.get("inboundInstruction"),
68
+ account_id=d["accountId"],
69
+ created_at=d["createdAt"],
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class Message:
75
+ id: str
76
+ sid: str
77
+ from_: str
78
+ to: str
79
+ body: str
80
+ direction: str
81
+ channel: str
82
+ status: str
83
+ phone_number_id: str
84
+ created_at: str
85
+
86
+ @classmethod
87
+ def from_api(cls, d: dict) -> "Message":
88
+ return cls(
89
+ id=d["id"],
90
+ sid=d["sid"],
91
+ from_=d["from"],
92
+ to=d["to"],
93
+ body=d["body"],
94
+ direction=d["direction"],
95
+ channel=d["channel"],
96
+ status=d["status"],
97
+ phone_number_id=d["phoneNumberId"],
98
+ created_at=d["createdAt"],
99
+ )
100
+
101
+
102
+ class CallStatus(TypedDict):
103
+ """Server-derived, client-facing call status (camelCase, as returned)."""
104
+
105
+ state: str # "Queued" | "Ringing" | "In-Progress" | "Terminated" | "Unknown"
106
+ terminationType: Optional[str]
107
+ cancelRequested: bool
108
+ cancelPending: bool
109
+ label: str
110
+
111
+
112
+ @dataclass
113
+ class Call:
114
+ id: str
115
+ phone_number_id: str
116
+ from_: str
117
+ to: str
118
+ direction: str
119
+ duration: int
120
+ transcript: Optional[str]
121
+ instruction: Optional[str] # the system prompt the agent ran with (outbound or inbound snapshot)
122
+ queued_at: Optional[str]
123
+ started_ringing_at: Optional[str]
124
+ call_started_at: Optional[str]
125
+ cancel_requested_at: Optional[str]
126
+ terminated_at: Optional[str]
127
+ termination_type: Optional[str]
128
+ status: CallStatus
129
+ created_at: str
130
+
131
+ @classmethod
132
+ def from_api(cls, d: dict) -> "Call":
133
+ return cls(
134
+ id=d["id"],
135
+ phone_number_id=d["phoneNumberId"],
136
+ from_=d["from"],
137
+ to=d["to"],
138
+ direction=d["direction"],
139
+ duration=d["duration"],
140
+ transcript=d.get("transcript"),
141
+ instruction=d.get("instruction"),
142
+ queued_at=d.get("queuedAt"),
143
+ started_ringing_at=d.get("startedRingingAt"),
144
+ call_started_at=d.get("callStartedAt"),
145
+ cancel_requested_at=d.get("cancelRequestedAt"),
146
+ terminated_at=d.get("terminatedAt"),
147
+ termination_type=d.get("terminationType"),
148
+ status=d["status"],
149
+ created_at=d["createdAt"],
150
+ )
151
+
152
+
153
+ # ── Events (account stream payloads, raw camelCase JSON) ────────
154
+ # Every event shares one envelope; `type` selects the shape of `data`. These
155
+ # TypedDicts are advisory — events are yielded as the raw dicts the backend
156
+ # publishes (not transformed), so read values from event["data"][...].
157
+
158
+
159
+ class RelatedObject(TypedDict):
160
+ id: str
161
+ type: Literal["call", "message"]
162
+ url: Optional[str]
163
+
164
+
165
+ class CallEndedData(TypedDict):
166
+ callId: str
167
+ from_: str # NOTE: actual key on the wire is "from"
168
+ to: str
169
+ direction: str
170
+ durationSeconds: Optional[int]
171
+ status: str
172
+ canceled: bool
173
+ transcriptAvailable: bool
174
+
175
+
176
+ class CallEnded(TypedDict):
177
+ id: str
178
+ object: Literal["event"]
179
+ type: Literal["call.ended"]
180
+ version: int
181
+ createdAt: str
182
+ relatedObject: Optional[RelatedObject]
183
+ data: CallEndedData
184
+
185
+
186
+ class MessageReceivedData(TypedDict):
187
+ messageId: str
188
+ from_: str # NOTE: actual key on the wire is "from"
189
+ to: str
190
+ channel: str
191
+ body: str
192
+ source: str # "external" | "internal"
193
+
194
+
195
+ class MessageReceived(TypedDict):
196
+ id: str
197
+ object: Literal["event"]
198
+ type: Literal["message.received"]
199
+ version: int
200
+ createdAt: str
201
+ relatedObject: Optional[RelatedObject]
202
+ data: MessageReceivedData
203
+
204
+
205
+ class CallTranscribedData(TypedDict):
206
+ callId: str
207
+
208
+
209
+ class CallTranscribed(TypedDict):
210
+ id: str
211
+ object: Literal["event"]
212
+ type: Literal["call.transcribed"]
213
+ version: int
214
+ createdAt: str
215
+ relatedObject: Optional[RelatedObject]
216
+ data: CallTranscribedData
217
+
218
+
219
+ # Keep the public names CallEvent / MessageEvent for back-compat with imports.
220
+ CallEvent = CallEnded
221
+ MessageEvent = MessageReceived
222
+
223
+ # Events are yielded as the raw dicts published by the backend (not transformed).
224
+ DialEvent = Union[CallEnded, MessageReceived, CallTranscribed, dict]
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: dial-sdk
3
+ Version: 0.6.2
4
+ Summary: Official Dial SDK — phone numbers, SMS, WhatsApp, and voice calls for AI agents
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: pubnub>=7
8
+ Requires-Dist: pydantic>=2
9
+ Description-Content-Type: text/markdown
10
+
11
+ # dial-sdk
12
+
13
+ Official Python SDK for [Dial](https://getdial.ai) — phone numbers, SMS, WhatsApp, and voice calls for AI agents.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install dial-sdk
19
+ # or
20
+ uv add dial-sdk
21
+ ```
22
+
23
+ Requires Python 3.11+.
24
+
25
+ ## Quickstart
26
+
27
+ The client is async. Construct it with a `DialConfig`, then call its methods inside an `async with` block:
28
+
29
+ ```python
30
+ import asyncio
31
+ from dial_sdk import DialClient, DialConfig, SendMessageParams, MakeCallParams
32
+
33
+ async def main():
34
+ async with DialClient(DialConfig(api_key="sk_live_...")) as dial:
35
+ # List your numbers
36
+ numbers = await dial.list_numbers()
37
+ from_id = numbers[0].id
38
+
39
+ # Send an SMS
40
+ await dial.send_message(SendMessageParams(
41
+ to="+15551234567",
42
+ from_number_id=from_id,
43
+ body="Hello from Dial",
44
+ ))
45
+
46
+ # Place an AI voice call
47
+ call = await dial.make_call(MakeCallParams(
48
+ to="+15551234567",
49
+ from_number_id=from_id,
50
+ outbound_instruction="You are a friendly assistant confirming an appointment.",
51
+ ))
52
+ print(call.id, call.status)
53
+
54
+ asyncio.run(main())
55
+ ```
56
+
57
+ `DialConfig.base_url` defaults to `https://getdial.ai`. Override it for local or self-hosted setups.
58
+
59
+ ## Live events
60
+
61
+ Incoming messages and call transcripts stream via an async generator:
62
+
63
+ ```python
64
+ async with DialClient(DialConfig(api_key="sk_live_...")) as dial:
65
+ async with dial.new_events_connection() as events:
66
+ async for event in events:
67
+ print(event) # MessageEvent / CallEvent / CallTranscribed / ...
68
+ ```
69
+
70
+ ## Client methods
71
+
72
+ | Method | Description |
73
+ | --- | --- |
74
+ | `list_numbers()` | List the phone numbers on your account |
75
+ | `purchase_number(params)` | Buy a new number (`PurchaseNumberParams`) |
76
+ | `set_number_properties(...)` | Update a number's nickname / inbound instruction |
77
+ | `send_message(params)` | Send an SMS or WhatsApp message (`SendMessageParams`) |
78
+ | `list_messages(...)` | List messages, optionally filtered |
79
+ | `make_call(params)` | Place an outbound AI voice call (`MakeCallParams`) |
80
+ | `list_calls(...)` | List calls, optionally filtered |
81
+ | `get_call(call_id)` | Fetch a single call |
82
+ | `new_events_connection()` | Open a live event stream |
83
+
84
+ ## Related
85
+
86
+ - [`dial-langchain`](https://pypi.org/project/dial-langchain/) — these capabilities as LangChain tools.
87
+ - [Documentation](https://docs.getdial.ai)
@@ -0,0 +1,8 @@
1
+ dial_sdk/__init__.py,sha256=i6dPfAqI-gu5bz6v7fgz9cD6QSDG7VMjGIsCg8YdFQ4,1128
2
+ dial_sdk/client.py,sha256=lJKVRs6MivTY4yojR5c2G6_XJ9ziVoBjUcsicaKjyYI,7734
3
+ dial_sdk/events.py,sha256=uZTjo99n_gmKGfaDe3x_sBeVIyK8LaHog9UXFgl5SRc,5347
4
+ dial_sdk/self_hosted.py,sha256=xFBlDJSsWSEAp2Ql6cfMuzIDBYug-QSh8HTLu6mb7fQ,4600
5
+ dial_sdk/types.py,sha256=VYK_GAHy9PUNH9YFqPcvq5NJScWAgk3GJt1OklGokok,6219
6
+ dial_sdk-0.6.2.dist-info/METADATA,sha256=SMcRT71lnUB4hSmgv2MDz-4DkAiIvlGWm4TTL1bdFeE,2691
7
+ dial_sdk-0.6.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ dial_sdk-0.6.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any