sendly 4.5.1__tar.gz → 4.6.0__tar.gz

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.
Files changed (72) hide show
  1. {sendly-4.5.1 → sendly-4.6.0}/PKG-INFO +106 -10
  2. {sendly-4.5.1 → sendly-4.6.0}/README.md +105 -9
  3. {sendly-4.5.1 → sendly-4.6.0}/pyproject.toml +1 -1
  4. {sendly-4.5.1 → sendly-4.6.0}/sendly/__init__.py +37 -1
  5. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/calls.py +104 -6
  6. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/voice.py +244 -16
  7. {sendly-4.5.1 → sendly-4.6.0}/sendly/types.py +467 -4
  8. {sendly-4.5.1 → sendly-4.6.0}/sendly/utils/http.py +1 -1
  9. {sendly-4.5.1 → sendly-4.6.0}/tests/test_calls.py +305 -0
  10. {sendly-4.5.1 → sendly-4.6.0}/tests/test_voice.py +633 -1
  11. {sendly-4.5.1 → sendly-4.6.0}/.github/header.svg +0 -0
  12. {sendly-4.5.1 → sendly-4.6.0}/.gitignore +0 -0
  13. {sendly-4.5.1 → sendly-4.6.0}/CHANGELOG.md +0 -0
  14. {sendly-4.5.1 → sendly-4.6.0}/examples/async_example.py +0 -0
  15. {sendly-4.5.1 → sendly-4.6.0}/examples/error_handling.py +0 -0
  16. {sendly-4.5.1 → sendly-4.6.0}/examples/send_sms.py +0 -0
  17. {sendly-4.5.1 → sendly-4.6.0}/sendly/client.py +0 -0
  18. {sendly-4.5.1 → sendly-4.6.0}/sendly/errors.py +0 -0
  19. {sendly-4.5.1 → sendly-4.6.0}/sendly/py.typed +0 -0
  20. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/__init__.py +0 -0
  21. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/account.py +0 -0
  22. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/business_upgrade.py +0 -0
  23. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/campaigns.py +0 -0
  24. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/contacts.py +0 -0
  25. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/conversations.py +0 -0
  26. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/drafts.py +0 -0
  27. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/enterprise.py +0 -0
  28. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/labels.py +0 -0
  29. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/links.py +0 -0
  30. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/media.py +0 -0
  31. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/messages.py +0 -0
  32. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/numbers.py +0 -0
  33. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/rcs.py +0 -0
  34. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/rules.py +0 -0
  35. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/templates.py +0 -0
  36. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/tendlc.py +0 -0
  37. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/verify.py +0 -0
  38. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/webhooks.py +0 -0
  39. {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/whatsapp.py +0 -0
  40. {sendly-4.5.1 → sendly-4.6.0}/sendly/utils/__init__.py +0 -0
  41. {sendly-4.5.1 → sendly-4.6.0}/sendly/utils/validation.py +0 -0
  42. {sendly-4.5.1 → sendly-4.6.0}/sendly/webhooks.py +0 -0
  43. {sendly-4.5.1 → sendly-4.6.0}/tests/conftest.py +0 -0
  44. {sendly-4.5.1 → sendly-4.6.0}/tests/test_account.py +0 -0
  45. {sendly-4.5.1 → sendly-4.6.0}/tests/test_async_client.py +0 -0
  46. {sendly-4.5.1 → sendly-4.6.0}/tests/test_campaigns.py +0 -0
  47. {sendly-4.5.1 → sendly-4.6.0}/tests/test_client.py +0 -0
  48. {sendly-4.5.1 → sendly-4.6.0}/tests/test_contacts.py +0 -0
  49. {sendly-4.5.1 → sendly-4.6.0}/tests/test_conversations.py +0 -0
  50. {sendly-4.5.1 → sendly-4.6.0}/tests/test_enterprise.py +0 -0
  51. {sendly-4.5.1 → sendly-4.6.0}/tests/test_errors.py +0 -0
  52. {sendly-4.5.1 → sendly-4.6.0}/tests/test_http_body_kwarg.py +0 -0
  53. {sendly-4.5.1 → sendly-4.6.0}/tests/test_http_retry.py +0 -0
  54. {sendly-4.5.1 → sendly-4.6.0}/tests/test_idempotency.py +0 -0
  55. {sendly-4.5.1 → sendly-4.6.0}/tests/test_media.py +0 -0
  56. {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages.py +0 -0
  57. {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages.py.bak +0 -0
  58. {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages_batch.py +0 -0
  59. {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages_schedule.py +0 -0
  60. {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages_wire.py +0 -0
  61. {sendly-4.5.1 → sendly-4.6.0}/tests/test_numbers.py +0 -0
  62. {sendly-4.5.1 → sendly-4.6.0}/tests/test_path_ids.py +0 -0
  63. {sendly-4.5.1 → sendly-4.6.0}/tests/test_rcs.py +0 -0
  64. {sendly-4.5.1 → sendly-4.6.0}/tests/test_rcs_registration.py +0 -0
  65. {sendly-4.5.1 → sendly-4.6.0}/tests/test_rules.py +0 -0
  66. {sendly-4.5.1 → sendly-4.6.0}/tests/test_templates.py +0 -0
  67. {sendly-4.5.1 → sendly-4.6.0}/tests/test_tendlc.py +0 -0
  68. {sendly-4.5.1 → sendly-4.6.0}/tests/test_validation.py +0 -0
  69. {sendly-4.5.1 → sendly-4.6.0}/tests/test_webhooks.py +0 -0
  70. {sendly-4.5.1 → sendly-4.6.0}/tests/test_webhooks_resource.py +0 -0
  71. {sendly-4.5.1 → sendly-4.6.0}/tests/test_whatsapp.py +0 -0
  72. {sendly-4.5.1 → sendly-4.6.0}/tests/test_whatsapp_extras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 4.5.1
3
+ Version: 4.6.0
4
4
  Summary: Official Sendly Python SDK for SMS messaging
5
5
  Project-URL: Homepage, https://sendly.live
6
6
  Project-URL: Documentation, https://sendly.live/docs
@@ -552,8 +552,8 @@ contents.
552
552
 
553
553
  Write methods that accept `idempotency_key` include `messages.send`,
554
554
  `messages.schedule`, `messages.send_batch`, `messages.send_group`,
555
- `calls.create`, `calls.hangup`, every `voice.*` write, and every
556
- `rcs.brands` / `rcs.agents` write.
555
+ `calls.create`, `calls.hangup`, `calls.transfer`, every `voice.*` write but
556
+ `voice.agents.test`, and every `rcs.brands` / `rcs.agents` write.
557
557
 
558
558
  Full details: https://sendly.live/docs/idempotency
559
559
 
@@ -1874,9 +1874,9 @@ if event.type.startswith('short_code.'):
1874
1874
  ## Voice Calls
1875
1875
 
1876
1876
  Place phone calls that one of your AI agents handles, follow them while they
1877
- ring and after they end, end them early, and download recordings. Requires an
1878
- API key with the `calls:read` / `calls:write` scopes; placing and ending calls
1879
- needs a live key. Voice is enabled workspace by workspace: until it is enabled
1877
+ ring and after they end, transfer callers, end calls early, and download
1878
+ recordings. Requires an API key with the `calls:read` / `calls:write` scopes;
1879
+ placing, transferring and ending calls needs a live key. Voice is enabled workspace by workspace: until it is enabled
1880
1880
  for yours, every call method raises `SendlyError` with code `voice_not_enabled`
1881
1881
  (HTTP 404).
1882
1882
 
@@ -1915,6 +1915,15 @@ for c in page.data:
1915
1915
  if page.pagination.has_more:
1916
1916
  page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
1917
1917
 
1918
+ # Only calls answered by one kind of agent (sendly, custom_llm, sip, websocket)
1919
+ page = client.calls.list(agent_kind='websocket')
1920
+
1921
+ # Transfer the caller on an incoming phone call; without `to`, the agent's
1922
+ # transfer number (tools.transfer_to) is used
1923
+ incoming = client.calls.list(direction='inbound', status='active').data[0]
1924
+ incoming = client.calls.transfer(incoming.id, to='+15551234500')
1925
+ print(incoming.transfer.status) # 'pending', then 'completed' or 'failed'
1926
+
1918
1927
  # End a call early (idempotent: an ended call is returned unchanged)
1919
1928
  client.calls.hangup(call.id)
1920
1929
 
@@ -1938,10 +1947,23 @@ Refusals come back as `SendlyError` with a code that says what to do:
1938
1947
  `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
1939
1948
  `from_number_not_supported` (calls are placed from US and Canadian numbers only),
1940
1949
  `number_not_found`, `destination_not_supported`, `outbound_calls_not_enabled`,
1941
- `live_key_required`. Webhooks `call.started`, `call.completed`
1950
+ `live_key_required`, and `agent_kind_unsupported` (`sip` and `websocket`
1951
+ agents answer incoming calls only). Webhooks `call.started`, `call.completed`
1942
1952
  and `call.recording.ready` carry the same call object in snake_case, including
1943
1953
  `billing` and your `metadata`.
1944
1954
 
1955
+ Each call carries `agent_kind` (the kind of agent that answered, or None),
1956
+ `agent_fallback` (True when your own agent could not be reached, so Sendly
1957
+ said a short line and transferred the caller or took a message) and
1958
+ `transfer` (None, or `to`, `status` and `failure_reason`). A transfer answers
1959
+ at once with `transfer.status` `pending`; once the caller is handed over the
1960
+ call ends `completed` with `hangup_class` `transferred`, and a failed transfer
1961
+ leaves the caller on the call. Transfer refusals: `transfer_to_required`,
1962
+ `outbound_not_enabled` (a number other than the agent's needs calling out
1963
+ switched on), `insufficient_credits`, `call_not_active`,
1964
+ `call_not_transferable` (not an incoming phone call) and
1965
+ `transfer_in_progress`.
1966
+
1945
1967
  ### Configure voice
1946
1968
 
1947
1969
  Everything a call depends on can be set up from code: switch voice on for a
@@ -2025,9 +2047,83 @@ says the number couldn't be found for emergency registration: contact
2025
2047
  support), `voice_unavailable` (HTTP 503),
2026
2048
  `e911_not_applicable` (only US and Canadian numbers take an emergency
2027
2049
  address) and `invalid_address`; on HTTP 422 the closest valid address is in
2028
- `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
2029
- stored, but agents do not transfer calls yet: when a caller asks for a person,
2030
- the agent offers to pass a message on and takes their name and number.
2050
+ `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is the
2051
+ number it transfers a caller to when asked for a person.
2052
+
2053
+ ### Bring your own agent
2054
+
2055
+ An agent's `kind` says who writes the replies: `sendly` (the default, from the
2056
+ agent's `instructions`), `custom_llm` (Sendly listens and speaks, and your chat
2057
+ completions endpoint writes each reply), `sip` (Sendly connects the caller to
2058
+ your SIP address) or `websocket` (Sendly streams the call audio to your
2059
+ `wss://` server). Your own agents need the workspace's bring-your-own-agent
2060
+ feature, which support switches on (`byo_agents_not_enabled` otherwise).
2061
+
2062
+ ```python
2063
+ from sendly import VoiceAgentWebsocketConnectionInput
2064
+
2065
+ agent = client.voice.agents.create(
2066
+ 'Support line',
2067
+ kind='websocket',
2068
+ connection=VoiceAgentWebsocketConnectionInput(
2069
+ url='wss://agent.example.com/calls', auth_token='tok_123'
2070
+ ),
2071
+ )
2072
+ secret = agent.signing_secret # save it now; it is never shown again
2073
+ print(agent.connection.url, agent.connection.has_auth_token, agent.has_signing_secret)
2074
+
2075
+ # Same kind: the connection is merged, so only what you pass changes
2076
+ client.voice.agents.update(agent.id, connection={'custom_parameters': {'tenant': 'acme'}})
2077
+
2078
+ # A new signing secret; the old one stops working at once
2079
+ secret = client.voice.agents.update(agent.id, rotate_signing_secret=True).signing_secret
2080
+ ```
2081
+
2082
+ `connection` is a `VoiceAgentCustomLlmConnectionInput` (`base_url`, `model`,
2083
+ `api_key`, `headers`), `VoiceAgentSipConnectionInput` (`uri`, `transport`,
2084
+ `media_encryption`, `username`, `password`, `headers`),
2085
+ `VoiceAgentWebsocketConnectionInput` (`url`, `audio_format`, `auth_token`,
2086
+ `custom_parameters`) or a dict. A secret you leave out keeps its saved value
2087
+ and None removes it. Secrets are never returned: `agent.connection` reports
2088
+ `has_api_key`, `has_password` or `has_auth_token` instead. Sendly signs every
2089
+ request to your agent with the signing secret
2090
+ (`X-Sendly-Signature: t=<unix>,v1=<hex HMAC-SHA256>`); `signing_secret` is set
2091
+ only on the create or update response that made it. A `connection` that breaks
2092
+ a rule raises `ValidationError` with a message like
2093
+ `connection.url: <reason>`.
2094
+
2095
+ #### Test an agent's connection
2096
+
2097
+ `voice.agents.test` reaches your own agent the way a call would, without
2098
+ placing one: `custom_llm` gets a signed, streamed chat completion with a test
2099
+ message, `websocket` gets a signed connection with a `start` carrying
2100
+ `test: true`, half a second of silence and a `stop`, and `sip` gets an
2101
+ `OPTIONS` request. Pass `kind`, `connection` or `instructions` to test settings
2102
+ before saving them; nothing is saved.
2103
+
2104
+ ```python
2105
+ result = client.voice.agents.test(agent.id)
2106
+ if not result.ok:
2107
+ print(result.outcome, result.message) # e.g. 'auth_failed'
2108
+ for step in result.steps:
2109
+ print(step.step, step.ok, step.detail)
2110
+
2111
+ # Try a new SIP address before saving it
2112
+ client.voice.agents.test(
2113
+ agent.id,
2114
+ kind='sip',
2115
+ connection={'uri': 'sip:+15125550100@sip.example.com', 'transport': 'tls'},
2116
+ )
2117
+ ```
2118
+
2119
+ A failed test is still a successful request, so check `ok`. `outcome` is
2120
+ `ok`, `blocked_destination`, `unreachable`, `timeout`, `tls_failed`,
2121
+ `auth_required`, `auth_failed`, `not_found`, `rejected` or `bad_reply` (see
2122
+ `VoiceAgentTestOutcome`; a value added later comes through as a plain string),
2123
+ and each step is `address`, `connect`, `login` or `reply`. The result also
2124
+ carries `status` (the HTTP or SIP status, or None), `latency_ms` and `signed`.
2125
+ It needs the `calls:write` scope and a live key, takes no `idempotency_key`,
2126
+ and a workspace can run 10 tests a minute (`RateLimitError` after that).
2031
2127
 
2032
2128
  ## Error Handling
2033
2129
 
@@ -517,8 +517,8 @@ contents.
517
517
 
518
518
  Write methods that accept `idempotency_key` include `messages.send`,
519
519
  `messages.schedule`, `messages.send_batch`, `messages.send_group`,
520
- `calls.create`, `calls.hangup`, every `voice.*` write, and every
521
- `rcs.brands` / `rcs.agents` write.
520
+ `calls.create`, `calls.hangup`, `calls.transfer`, every `voice.*` write but
521
+ `voice.agents.test`, and every `rcs.brands` / `rcs.agents` write.
522
522
 
523
523
  Full details: https://sendly.live/docs/idempotency
524
524
 
@@ -1839,9 +1839,9 @@ if event.type.startswith('short_code.'):
1839
1839
  ## Voice Calls
1840
1840
 
1841
1841
  Place phone calls that one of your AI agents handles, follow them while they
1842
- ring and after they end, end them early, and download recordings. Requires an
1843
- API key with the `calls:read` / `calls:write` scopes; placing and ending calls
1844
- needs a live key. Voice is enabled workspace by workspace: until it is enabled
1842
+ ring and after they end, transfer callers, end calls early, and download
1843
+ recordings. Requires an API key with the `calls:read` / `calls:write` scopes;
1844
+ placing, transferring and ending calls needs a live key. Voice is enabled workspace by workspace: until it is enabled
1845
1845
  for yours, every call method raises `SendlyError` with code `voice_not_enabled`
1846
1846
  (HTTP 404).
1847
1847
 
@@ -1880,6 +1880,15 @@ for c in page.data:
1880
1880
  if page.pagination.has_more:
1881
1881
  page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
1882
1882
 
1883
+ # Only calls answered by one kind of agent (sendly, custom_llm, sip, websocket)
1884
+ page = client.calls.list(agent_kind='websocket')
1885
+
1886
+ # Transfer the caller on an incoming phone call; without `to`, the agent's
1887
+ # transfer number (tools.transfer_to) is used
1888
+ incoming = client.calls.list(direction='inbound', status='active').data[0]
1889
+ incoming = client.calls.transfer(incoming.id, to='+15551234500')
1890
+ print(incoming.transfer.status) # 'pending', then 'completed' or 'failed'
1891
+
1883
1892
  # End a call early (idempotent: an ended call is returned unchanged)
1884
1893
  client.calls.hangup(call.id)
1885
1894
 
@@ -1903,10 +1912,23 @@ Refusals come back as `SendlyError` with a code that says what to do:
1903
1912
  `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
1904
1913
  `from_number_not_supported` (calls are placed from US and Canadian numbers only),
1905
1914
  `number_not_found`, `destination_not_supported`, `outbound_calls_not_enabled`,
1906
- `live_key_required`. Webhooks `call.started`, `call.completed`
1915
+ `live_key_required`, and `agent_kind_unsupported` (`sip` and `websocket`
1916
+ agents answer incoming calls only). Webhooks `call.started`, `call.completed`
1907
1917
  and `call.recording.ready` carry the same call object in snake_case, including
1908
1918
  `billing` and your `metadata`.
1909
1919
 
1920
+ Each call carries `agent_kind` (the kind of agent that answered, or None),
1921
+ `agent_fallback` (True when your own agent could not be reached, so Sendly
1922
+ said a short line and transferred the caller or took a message) and
1923
+ `transfer` (None, or `to`, `status` and `failure_reason`). A transfer answers
1924
+ at once with `transfer.status` `pending`; once the caller is handed over the
1925
+ call ends `completed` with `hangup_class` `transferred`, and a failed transfer
1926
+ leaves the caller on the call. Transfer refusals: `transfer_to_required`,
1927
+ `outbound_not_enabled` (a number other than the agent's needs calling out
1928
+ switched on), `insufficient_credits`, `call_not_active`,
1929
+ `call_not_transferable` (not an incoming phone call) and
1930
+ `transfer_in_progress`.
1931
+
1910
1932
  ### Configure voice
1911
1933
 
1912
1934
  Everything a call depends on can be set up from code: switch voice on for a
@@ -1990,9 +2012,83 @@ says the number couldn't be found for emergency registration: contact
1990
2012
  support), `voice_unavailable` (HTTP 503),
1991
2013
  `e911_not_applicable` (only US and Canadian numbers take an emergency
1992
2014
  address) and `invalid_address`; on HTTP 422 the closest valid address is in
1993
- `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
1994
- stored, but agents do not transfer calls yet: when a caller asks for a person,
1995
- the agent offers to pass a message on and takes their name and number.
2015
+ `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is the
2016
+ number it transfers a caller to when asked for a person.
2017
+
2018
+ ### Bring your own agent
2019
+
2020
+ An agent's `kind` says who writes the replies: `sendly` (the default, from the
2021
+ agent's `instructions`), `custom_llm` (Sendly listens and speaks, and your chat
2022
+ completions endpoint writes each reply), `sip` (Sendly connects the caller to
2023
+ your SIP address) or `websocket` (Sendly streams the call audio to your
2024
+ `wss://` server). Your own agents need the workspace's bring-your-own-agent
2025
+ feature, which support switches on (`byo_agents_not_enabled` otherwise).
2026
+
2027
+ ```python
2028
+ from sendly import VoiceAgentWebsocketConnectionInput
2029
+
2030
+ agent = client.voice.agents.create(
2031
+ 'Support line',
2032
+ kind='websocket',
2033
+ connection=VoiceAgentWebsocketConnectionInput(
2034
+ url='wss://agent.example.com/calls', auth_token='tok_123'
2035
+ ),
2036
+ )
2037
+ secret = agent.signing_secret # save it now; it is never shown again
2038
+ print(agent.connection.url, agent.connection.has_auth_token, agent.has_signing_secret)
2039
+
2040
+ # Same kind: the connection is merged, so only what you pass changes
2041
+ client.voice.agents.update(agent.id, connection={'custom_parameters': {'tenant': 'acme'}})
2042
+
2043
+ # A new signing secret; the old one stops working at once
2044
+ secret = client.voice.agents.update(agent.id, rotate_signing_secret=True).signing_secret
2045
+ ```
2046
+
2047
+ `connection` is a `VoiceAgentCustomLlmConnectionInput` (`base_url`, `model`,
2048
+ `api_key`, `headers`), `VoiceAgentSipConnectionInput` (`uri`, `transport`,
2049
+ `media_encryption`, `username`, `password`, `headers`),
2050
+ `VoiceAgentWebsocketConnectionInput` (`url`, `audio_format`, `auth_token`,
2051
+ `custom_parameters`) or a dict. A secret you leave out keeps its saved value
2052
+ and None removes it. Secrets are never returned: `agent.connection` reports
2053
+ `has_api_key`, `has_password` or `has_auth_token` instead. Sendly signs every
2054
+ request to your agent with the signing secret
2055
+ (`X-Sendly-Signature: t=<unix>,v1=<hex HMAC-SHA256>`); `signing_secret` is set
2056
+ only on the create or update response that made it. A `connection` that breaks
2057
+ a rule raises `ValidationError` with a message like
2058
+ `connection.url: <reason>`.
2059
+
2060
+ #### Test an agent's connection
2061
+
2062
+ `voice.agents.test` reaches your own agent the way a call would, without
2063
+ placing one: `custom_llm` gets a signed, streamed chat completion with a test
2064
+ message, `websocket` gets a signed connection with a `start` carrying
2065
+ `test: true`, half a second of silence and a `stop`, and `sip` gets an
2066
+ `OPTIONS` request. Pass `kind`, `connection` or `instructions` to test settings
2067
+ before saving them; nothing is saved.
2068
+
2069
+ ```python
2070
+ result = client.voice.agents.test(agent.id)
2071
+ if not result.ok:
2072
+ print(result.outcome, result.message) # e.g. 'auth_failed'
2073
+ for step in result.steps:
2074
+ print(step.step, step.ok, step.detail)
2075
+
2076
+ # Try a new SIP address before saving it
2077
+ client.voice.agents.test(
2078
+ agent.id,
2079
+ kind='sip',
2080
+ connection={'uri': 'sip:+15125550100@sip.example.com', 'transport': 'tls'},
2081
+ )
2082
+ ```
2083
+
2084
+ A failed test is still a successful request, so check `ok`. `outcome` is
2085
+ `ok`, `blocked_destination`, `unreachable`, `timeout`, `tls_failed`,
2086
+ `auth_required`, `auth_failed`, `not_found`, `rejected` or `bad_reply` (see
2087
+ `VoiceAgentTestOutcome`; a value added later comes through as a plain string),
2088
+ and each step is `address`, `connect`, `login` or `reply`. The result also
2089
+ carries `status` (the HTTP or SIP status, or None), `latency_ms` and `signed`.
2090
+ It needs the `calls:write` scope and a live key, takes no `idempotency_key`,
2091
+ and a workspace can run 10 tests a minute (`RateLimitError` after that).
1996
2092
 
1997
2093
  ## Error Handling
1998
2094
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "4.5.1"
7
+ version = "4.6.0"
8
8
  description = "Official Sendly Python SDK for SMS messaging"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -29,7 +29,7 @@ Async Example:
29
29
  >>> asyncio.run(main())
30
30
  """
31
31
 
32
- __version__ = "4.5.1"
32
+ __version__ = "4.6.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -216,24 +216,42 @@ from .types import (
216
216
  CallRecordingStatus,
217
217
  CallStatus,
218
218
  CallTranscriptLine,
219
+ CallTransfer,
220
+ CallTransferFailureReason,
221
+ CallTransferStatus,
219
222
  CreateCallRequest,
220
223
  ListCallsOptions,
224
+ TransferCallRequest,
221
225
  # Voice configuration types
222
226
  CreateVoiceAgentRequest,
223
227
  DeletedVoiceAgent,
224
228
  EmergencyAddress,
229
+ SipMediaEncryption,
230
+ SipTransport,
231
+ TestVoiceAgentRequest,
225
232
  UpdateVoiceAgentRequest,
226
233
  UpdateVoiceNumberRequest,
227
234
  Voice,
228
235
  VoiceAgent,
236
+ VoiceAgentConnection,
237
+ VoiceAgentConnectionInput,
238
+ VoiceAgentCustomLlmConnectionInput,
239
+ VoiceAgentKind,
229
240
  VoiceAgentListResponse,
241
+ VoiceAgentSipConnectionInput,
242
+ VoiceAgentTest,
243
+ VoiceAgentTestOutcome,
244
+ VoiceAgentTestStep,
245
+ VoiceAgentTestStepName,
230
246
  VoiceAgentTools,
247
+ VoiceAgentWebsocketConnectionInput,
231
248
  VoiceListResponse,
232
249
  VoiceMode,
233
250
  VoiceNumber,
234
251
  VoiceNumberEmergencyAddress,
235
252
  VoiceNumberListResponse,
236
253
  VoiceNumberRates,
254
+ WebsocketAudioFormat,
237
255
  OptInPage,
238
256
  PricingTier,
239
257
  QuotaSettings,
@@ -466,24 +484,42 @@ __all__ = [
466
484
  "CallRecordingStatus",
467
485
  "CallStatus",
468
486
  "CallTranscriptLine",
487
+ "CallTransfer",
488
+ "CallTransferFailureReason",
489
+ "CallTransferStatus",
469
490
  "CreateCallRequest",
470
491
  "ListCallsOptions",
492
+ "TransferCallRequest",
471
493
  # Voice configuration types
472
494
  "CreateVoiceAgentRequest",
473
495
  "DeletedVoiceAgent",
474
496
  "EmergencyAddress",
497
+ "SipMediaEncryption",
498
+ "SipTransport",
499
+ "TestVoiceAgentRequest",
475
500
  "UpdateVoiceAgentRequest",
476
501
  "UpdateVoiceNumberRequest",
477
502
  "Voice",
478
503
  "VoiceAgent",
504
+ "VoiceAgentConnection",
505
+ "VoiceAgentConnectionInput",
506
+ "VoiceAgentCustomLlmConnectionInput",
507
+ "VoiceAgentKind",
479
508
  "VoiceAgentListResponse",
509
+ "VoiceAgentSipConnectionInput",
510
+ "VoiceAgentTest",
511
+ "VoiceAgentTestOutcome",
512
+ "VoiceAgentTestStep",
513
+ "VoiceAgentTestStepName",
480
514
  "VoiceAgentTools",
515
+ "VoiceAgentWebsocketConnectionInput",
481
516
  "VoiceListResponse",
482
517
  "VoiceMode",
483
518
  "VoiceNumber",
484
519
  "VoiceNumberEmergencyAddress",
485
520
  "VoiceNumberListResponse",
486
521
  "VoiceNumberRates",
522
+ "WebsocketAudioFormat",
487
523
  # Links resources
488
524
  "LinksResource",
489
525
  "AsyncLinksResource",
@@ -3,13 +3,15 @@ Calls Resource - Phone calls handled by your AI agents
3
3
 
4
4
  A workspace phone number can take and place phone calls. Over the API you
5
5
  place an outbound call that one of your AI agents handles, list and inspect
6
- calls, end a call, and fetch recordings. Switching voice on for a number,
7
- choosing how it answers, registering an emergency address and creating agents
8
- live in ``client.voice``; ``client.voice.numbers.list()`` reports each
6
+ calls, transfer a caller to another number, end a call, and fetch
7
+ recordings. Switching voice on for a number, choosing how it answers,
8
+ registering an emergency address and creating agents live in
9
+ ``client.voice``; ``client.voice.numbers.list()`` reports each
9
10
  number's ``voice_enabled`` and ``voice_mode`` so you can pick a ``from_``.
10
11
 
11
12
  Reads need an API key with the ``calls:read`` scope, writes ``calls:write``.
12
- Placing and ending calls needs a live key (``live_key_required`` otherwise).
13
+ Placing, transferring and ending calls needs a live key
14
+ (``live_key_required`` otherwise).
13
15
  Voice is enabled workspace by workspace; until it is enabled for yours every
14
16
  method raises ``SendlyError`` with code ``voice_not_enabled`` (HTTP 404).
15
17
 
@@ -103,6 +105,9 @@ class CallsResource:
103
105
  (register an emergency address for the number first with
104
106
  ``client.voice.numbers.register_emergency_address``),
105
107
  ``lines_busy`` (retry shortly), ``daily_call_limit``,
108
+ ``agent_kind_unsupported`` (HTTP 409: the agent's ``kind`` is
109
+ ``sip`` or ``websocket``, which answer incoming calls only;
110
+ ``custom_llm`` agents can place calls),
106
111
  ``outbound_calls_not_enabled`` or ``voice_not_enabled``.
107
112
 
108
113
  Example:
@@ -129,6 +134,7 @@ class CallsResource:
129
134
  direction: Optional[EnumLike] = None,
130
135
  kind: Optional[EnumLike] = None,
131
136
  agent_id: Optional[str] = None,
137
+ agent_kind: Optional[EnumLike] = None,
132
138
  to: Optional[str] = None,
133
139
  from_: Optional[str] = None,
134
140
  ) -> CallListResponse:
@@ -145,6 +151,10 @@ class CallsResource:
145
151
  direction: ``inbound`` or ``outbound``.
146
152
  kind: ``pstn`` or ``internal``.
147
153
  agent_id: Only calls handled by this agent.
154
+ agent_kind: Only calls answered by this kind of agent:
155
+ ``sendly``, ``custom_llm``, ``sip`` or ``websocket`` (see
156
+ :class:`~sendly.types.VoiceAgentKind`). Calls from before
157
+ agent kinds existed count as ``sendly``.
148
158
  to: Exact E.164 match on the called number.
149
159
  from_: Exact E.164 match on the calling number.
150
160
 
@@ -155,7 +165,9 @@ class CallsResource:
155
165
  >>> if page.pagination.has_more:
156
166
  ... page = client.calls.list(status='completed', limit=20, offset=20)
157
167
  """
158
- params = _list_params(limit, offset, status, direction, kind, agent_id, to, from_)
168
+ params = _list_params(
169
+ limit, offset, status, direction, kind, agent_id, agent_kind, to, from_
170
+ )
159
171
  data = self._http.request(
160
172
  method="GET", path="/calls", params=params if params else None
161
173
  )
@@ -197,6 +209,62 @@ class CallsResource:
197
209
  )
198
210
  return _parse(Call, data)
199
211
 
212
+ def transfer(
213
+ self,
214
+ id: str,
215
+ to: Optional[str] = None,
216
+ *,
217
+ idempotency_key: Optional[str] = None,
218
+ ) -> Call:
219
+ """Transfer the caller on an incoming phone call to another number.
220
+ Requires the ``calls:write`` scope and a live API key.
221
+
222
+ Works whatever kind of agent is on the call. Returns at once with
223
+ ``transfer.status`` ``pending``; the outcome shows on :meth:`get`
224
+ (``transfer.status``) and, once the caller is handed over, the call
225
+ ends ``completed`` with ``hangup_class`` ``transferred`` (also in the
226
+ ``call.completed`` webhook). A failed transfer leaves the caller on
227
+ the call, with ``transfer.failure_reason`` saying why.
228
+
229
+ Args:
230
+ id: Call identifier.
231
+ to: Number to transfer the caller to, in E.164 format: US or
232
+ Canadian, not premium-rate, not the number the caller dialled
233
+ and not a number the agent answers. Left out (not sent), the
234
+ agent's transfer number (``tools.transfer_to``) is used. Any
235
+ other number needs calling out switched on for the workspace.
236
+ idempotency_key: Optional key sent as the Idempotency-Key header;
237
+ generated automatically when omitted.
238
+
239
+ Raises:
240
+ ValidationError: ``id`` is empty, or the API answered
241
+ ``invalid_request`` (``to`` is malformed, outside the US and
242
+ Canada, premium-rate, the dialled number or a number the agent
243
+ answers).
244
+ InsufficientCreditsError: The workspace has no credits left.
245
+ SendlyError: With ``code`` ``transfer_to_required`` (HTTP 400, no
246
+ ``to`` and the agent has no transfer number),
247
+ ``outbound_not_enabled`` (HTTP 403, ``to`` is not the agent's
248
+ transfer number and calling out is not switched on),
249
+ ``call_not_found`` (HTTP 404), ``call_not_active`` (HTTP 409,
250
+ the call is not in progress), ``call_not_transferable``
251
+ (HTTP 409, not an incoming phone call), ``transfer_in_progress``
252
+ (HTTP 409, a transfer is already under way, or the call has had
253
+ 3 transfer attempts) or ``voice_internal_error`` (HTTP 500).
254
+
255
+ Example:
256
+ >>> call = client.calls.transfer(call.id, to='+15555550142')
257
+ >>> print(call.transfer.status) # 'pending'
258
+ """
259
+ _require(id, "id")
260
+ data = self._http.request(
261
+ method="POST",
262
+ path=f"/calls/{quote(id, safe='')}/transfer",
263
+ body=_transfer_body(to),
264
+ idempotency_key=idempotency_key,
265
+ )
266
+ return _parse(Call, data)
267
+
200
268
  def recording(self, id: str) -> CallRecording:
201
269
  """Fetch where to download a call's recording. Requires the
202
270
  ``calls:read`` scope.
@@ -254,11 +322,14 @@ class AsyncCallsResource:
254
322
  direction: Optional[EnumLike] = None,
255
323
  kind: Optional[EnumLike] = None,
256
324
  agent_id: Optional[str] = None,
325
+ agent_kind: Optional[EnumLike] = None,
257
326
  to: Optional[str] = None,
258
327
  from_: Optional[str] = None,
259
328
  ) -> CallListResponse:
260
329
  """List calls, newest first. See :meth:`CallsResource.list`."""
261
- params = _list_params(limit, offset, status, direction, kind, agent_id, to, from_)
330
+ params = _list_params(
331
+ limit, offset, status, direction, kind, agent_id, agent_kind, to, from_
332
+ )
262
333
  data = await self._http.request(
263
334
  method="GET", path="/calls", params=params if params else None
264
335
  )
@@ -281,6 +352,24 @@ class AsyncCallsResource:
281
352
  )
282
353
  return _parse(Call, data)
283
354
 
355
+ async def transfer(
356
+ self,
357
+ id: str,
358
+ to: Optional[str] = None,
359
+ *,
360
+ idempotency_key: Optional[str] = None,
361
+ ) -> Call:
362
+ """Transfer the caller on an incoming phone call to another number.
363
+ See :meth:`CallsResource.transfer`."""
364
+ _require(id, "id")
365
+ data = await self._http.request(
366
+ method="POST",
367
+ path=f"/calls/{quote(id, safe='')}/transfer",
368
+ body=_transfer_body(to),
369
+ idempotency_key=idempotency_key,
370
+ )
371
+ return _parse(Call, data)
372
+
284
373
  async def recording(self, id: str) -> CallRecording:
285
374
  """Fetch where to download a call's recording.
286
375
  See :meth:`CallsResource.recording`."""
@@ -323,6 +412,13 @@ def _create_body(
323
412
  return body
324
413
 
325
414
 
415
+ def _transfer_body(to: Optional[str]) -> Dict[str, Any]:
416
+ body: Dict[str, Any] = {}
417
+ if to is not None:
418
+ body["to"] = to
419
+ return body
420
+
421
+
326
422
  def _list_params(
327
423
  limit: Optional[int],
328
424
  offset: Optional[int],
@@ -330,6 +426,7 @@ def _list_params(
330
426
  direction: Optional[EnumLike],
331
427
  kind: Optional[EnumLike],
332
428
  agent_id: Optional[str],
429
+ agent_kind: Optional[EnumLike],
333
430
  to: Optional[str],
334
431
  from_: Optional[str],
335
432
  ) -> Dict[str, Any]:
@@ -341,6 +438,7 @@ def _list_params(
341
438
  "direction": _value(direction),
342
439
  "kind": _value(kind),
343
440
  "agentId": agent_id,
441
+ "agentKind": _value(agent_kind),
344
442
  "to": to,
345
443
  "from": from_,
346
444
  }