callva-livekit 0.1.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.
- callva_livekit-0.1.0/.gitignore +12 -0
- callva_livekit-0.1.0/DESIGN.md +346 -0
- callva_livekit-0.1.0/PKG-INFO +223 -0
- callva_livekit-0.1.0/README.md +195 -0
- callva_livekit-0.1.0/callva/livekit/config/__init__.py +21 -0
- callva_livekit-0.1.0/callva/livekit/config/models.py +104 -0
- callva_livekit-0.1.0/callva/livekit/config/resolver.py +163 -0
- callva_livekit-0.1.0/callva/livekit/config/template.py +67 -0
- callva_livekit-0.1.0/callva/livekit/core/__init__.py +40 -0
- callva_livekit-0.1.0/callva/livekit/core/env.py +35 -0
- callva_livekit-0.1.0/callva/livekit/core/envelope.py +77 -0
- callva_livekit-0.1.0/callva/livekit/core/identity.py +146 -0
- callva_livekit-0.1.0/callva/livekit/core/log.py +10 -0
- callva_livekit-0.1.0/callva/livekit/core/state.py +101 -0
- callva_livekit-0.1.0/callva/livekit/core/transport.py +278 -0
- callva_livekit-0.1.0/callva/livekit/core/version.py +1 -0
- callva_livekit-0.1.0/callva/livekit/webhook/__init__.py +22 -0
- callva_livekit-0.1.0/callva/livekit/webhook/payload.py +113 -0
- callva_livekit-0.1.0/callva/livekit/webhook/service.py +276 -0
- callva_livekit-0.1.0/callva/livekit/webhook/storage.py +95 -0
- callva_livekit-0.1.0/pyproject.toml +55 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
Decided contracts for `callva-livekit`. This is the reference the implementation
|
|
4
|
+
follows; every number and API name here was verified against `livekit-agents` 1.5.7,
|
|
5
|
+
`livekit-protocol` 1.1.7 and the `livekit/livekit` server sources.
|
|
6
|
+
|
|
7
|
+
## 1. Scope
|
|
8
|
+
|
|
9
|
+
Two independent capabilities that any LiveKit agent can opt into:
|
|
10
|
+
|
|
11
|
+
- **Webhook** — emit a `call.started` webhook when the call goes live and a `call.ended`
|
|
12
|
+
webhook when it finishes, carrying the transcript, usage, recording and session data.
|
|
13
|
+
- **Config** — resolve per-call configuration (prompt, greeting, variables) before the
|
|
14
|
+
session starts, from agent dispatch metadata or from an external endpoint.
|
|
15
|
+
|
|
16
|
+
Each is armed by its own call. Installing the package activates nothing.
|
|
17
|
+
|
|
18
|
+
### Non-goals
|
|
19
|
+
|
|
20
|
+
- **Dispatch.** Placing a call is a server-API conversation between the platform and
|
|
21
|
+
LiveKit, and it happens before the agent process exists. Out of scope by construction.
|
|
22
|
+
- **Provider configuration.** The public config schema carries no speech-stack, model or
|
|
23
|
+
voice settings. Vendor-specific material travels in the opaque `extra` field.
|
|
24
|
+
- **Call lifecycle management** (duration caps, reminder prompts, transfer). A later
|
|
25
|
+
module; see §13.
|
|
26
|
+
|
|
27
|
+
## 2. Names
|
|
28
|
+
|
|
29
|
+
The distribution is `callva-livekit` and it imports as `callva.livekit`, following the
|
|
30
|
+
convention LiveKit itself uses: `livekit-agents` imports as `livekit.agents`,
|
|
31
|
+
`livekit-plugins-openai` as `livekit.plugins.openai`. The distribution name is the import
|
|
32
|
+
path with dashes, so `pip install` and `import` never disagree. A later split keeps the
|
|
33
|
+
property: `callva-livekit-webhook` would import as `callva.livekit.webhook`.
|
|
34
|
+
|
|
35
|
+
Environment variables carry no vendor prefix — `WEBHOOK_URL`, `CONFIG_URL`,
|
|
36
|
+
`RECORDING_S3_BUCKET`. This is an extension to the Agents SDK; that a URL points at CallVA
|
|
37
|
+
is configuration, not identity. The same reasoning applies to the delivery headers
|
|
38
|
+
(`X-Webhook-Signature`, `X-Webhook-Event`) and to the event names themselves
|
|
39
|
+
(`call.started`, `call.ended`).
|
|
40
|
+
|
|
41
|
+
The one deliberate exception is the `callva` key inside `ctx.job.metadata`. That key exists
|
|
42
|
+
to keep our block from colliding with the host application's own metadata, and being
|
|
43
|
+
distinctive is the entire job it does. A generic name there would defeat it.
|
|
44
|
+
|
|
45
|
+
## 3. Layout
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
callva/ PEP 420 namespace, no __init__.py
|
|
49
|
+
livekit/ PEP 420 namespace, no __init__.py
|
|
50
|
+
core/ identity, per-job state, HTTP transport, logging
|
|
51
|
+
config/ config resolution and templating
|
|
52
|
+
webhook/ event delivery, recording upload
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
One distribution, three modules. Because `callva` and `callva.livekit` are namespace
|
|
56
|
+
packages, a module can later be split into its own distribution without changing a single
|
|
57
|
+
user-facing import. That makes the single-distribution choice reversible; starting with
|
|
58
|
+
three and merging would not be.
|
|
59
|
+
|
|
60
|
+
`core` holds what more than one module needs: the resolved call identity, the resolved
|
|
61
|
+
config, the HTTP client and the logger. Modules never import each other — they read and
|
|
62
|
+
write the per-job state that `core` owns, keyed off the ambient `JobContext`. This is how
|
|
63
|
+
configuration reaches future modules without being passed by hand.
|
|
64
|
+
|
|
65
|
+
## 4. Integration surface
|
|
66
|
+
|
|
67
|
+
Primary form, documented first:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from livekit.agents import AgentServer, AgentSession
|
|
71
|
+
from callva.livekit import config as callva_config
|
|
72
|
+
from callva.livekit import webhook as callva_webhook
|
|
73
|
+
|
|
74
|
+
server = AgentServer()
|
|
75
|
+
|
|
76
|
+
@server.rtc_session(on_session_end=callva_webhook.on_session_end)
|
|
77
|
+
async def entrypoint(ctx):
|
|
78
|
+
await ctx.connect()
|
|
79
|
+
cfg = await callva_config.load()
|
|
80
|
+
session = AgentSession(...)
|
|
81
|
+
callva_webhook.attach(session)
|
|
82
|
+
await session.start(agent=Agent(instructions=cfg.prompt), room=ctx.room)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
No monkey-patching. Neither call takes a `JobContext`: both resolve it through the public
|
|
86
|
+
`livekit.agents.get_job_context()`, a contextvar set for the duration of the job.
|
|
87
|
+
|
|
88
|
+
Minimal form, for agents still built on `WorkerOptions`, where `on_session_end` is not
|
|
89
|
+
reachable:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
cfg = await callva_config.load()
|
|
93
|
+
callva_webhook.attach(session)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`attach()` falls back to `ctx.add_shutdown_callback()`. That path works, but see §5 for
|
|
97
|
+
why it is the secondary recommendation.
|
|
98
|
+
|
|
99
|
+
Nothing is substituted behind the caller's back. `load()` returns an object; using the
|
|
100
|
+
prompt is the caller's decision.
|
|
101
|
+
|
|
102
|
+
## 5. Lifecycle and ordering
|
|
103
|
+
|
|
104
|
+
Verified in `livekit/agents/ipc/job_proc_lazy_main.py:360-411`:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
shutdown signalled
|
|
108
|
+
entrypoint task finishes (15s grace, then cancelled)
|
|
109
|
+
session.aclose() RecorderIO finalizes the OGG
|
|
110
|
+
on_session_end(ctx) ← primary hook, budget = session_end_timeout (300s)
|
|
111
|
+
ctx._on_session_end() SDK uploads its own report to LiveKit Cloud
|
|
112
|
+
room.disconnect()
|
|
113
|
+
shutdown callbacks ← fallback hook, gathered concurrently
|
|
114
|
+
_on_cleanup() session directory deleted
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The end webhook and the recording upload run in `on_session_end`. Reasons:
|
|
118
|
+
|
|
119
|
+
- **Budget.** `session_end_timeout` defaults to 300s. Shutdown callbacks are bounded by
|
|
120
|
+
`shutdown_process_timeout` (default **10s**), after which the supervisor sends a dump
|
|
121
|
+
signal and kills the process (`ipc/supervised_proc.py:279-301`). A recording upload does
|
|
122
|
+
not reliably fit in 10s.
|
|
123
|
+
- **Ordering.** It runs before `room.disconnect()`, so room and participant state are
|
|
124
|
+
still readable.
|
|
125
|
+
- `shutdown_process_timeout` is set by the host application when it builds the worker. The
|
|
126
|
+
plugin runs inside the job process and cannot raise it.
|
|
127
|
+
|
|
128
|
+
When `attach()` has to use the fallback path it logs one warning at WARNING level naming
|
|
129
|
+
the risk and both fixes. Silent truncation is not acceptable here.
|
|
130
|
+
|
|
131
|
+
`ctx.make_session_report()` raises if `RecorderIO` is still recording. Both hook points sit
|
|
132
|
+
after `session.aclose()`, so both are safe.
|
|
133
|
+
|
|
134
|
+
## 6. Call identity
|
|
135
|
+
|
|
136
|
+
Derived by the package, not supplied by the config.
|
|
137
|
+
|
|
138
|
+
- **`call_id`** — assigned by the package as a UUID, stable across both webhooks, and used
|
|
139
|
+
as the object key for the recording and transcript. Overridable through the dispatch
|
|
140
|
+
metadata envelope when the platform wants to pin its own identifier.
|
|
141
|
+
- **`direction`** — resolved as: the dispatch metadata envelope → an explicit argument or
|
|
142
|
+
environment variable → `"inbound"`. The default is sound rather than a guess: an outbound
|
|
143
|
+
call is always dispatched by someone, so it always carries metadata. Never inferred from
|
|
144
|
+
participant state.
|
|
145
|
+
- **`from` / `to`** — read from the SIP participant attributes: `sip.phoneNumber` is the
|
|
146
|
+
remote party, `sip.trunkPhoneNumber` the local one, swapped by direction. Absent for
|
|
147
|
+
non-SIP sessions.
|
|
148
|
+
|
|
149
|
+
The full `sip.*` attribute set is forwarded verbatim; it is never reshaped.
|
|
150
|
+
|
|
151
|
+
## 7. Config
|
|
152
|
+
|
|
153
|
+
### Channel
|
|
154
|
+
|
|
155
|
+
**Agent dispatch metadata only**, read as `ctx.job.metadata`. Room metadata is not used.
|
|
156
|
+
|
|
157
|
+
`job.metadata` is available in the entrypoint before `ctx.connect()`, is addressed to one
|
|
158
|
+
specific dispatch rather than shared across the room, is capped at 512 KiB, and — unlike
|
|
159
|
+
room metadata and participant attributes — is delivered over the worker's own websocket and
|
|
160
|
+
is **not broadcast to other participants**. Putting a prompt in room metadata exposes it to
|
|
161
|
+
every client in the room, including browsers.
|
|
162
|
+
|
|
163
|
+
Requires explicit dispatch: the worker must have `agent_name` set. With automatic dispatch
|
|
164
|
+
`job.metadata` arrives empty.
|
|
165
|
+
|
|
166
|
+
### Envelope
|
|
167
|
+
|
|
168
|
+
Job metadata is a free-form string the host application may already be using. The envelope
|
|
169
|
+
is therefore looked for under a ``callva`` key, and a top-level object is only claimed when
|
|
170
|
+
it carries keys that are unambiguously ours:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{ "callva": { "call_id": "…", "direction": "outbound",
|
|
174
|
+
"config": { }, "config_url": "…", "webhook": { } } }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Metadata that is not JSON, or is JSON that belongs to someone else, is left alone and
|
|
178
|
+
forwarded to a configuration endpoint unchanged.
|
|
179
|
+
|
|
180
|
+
### Resolution order
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
job.metadata carries a body → use it, returns before connect()
|
|
184
|
+
job.metadata carries a pointer → follow it
|
|
185
|
+
job.metadata empty, env URL set → follow that
|
|
186
|
+
otherwise → no config
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A pointer is an endpoint, or a local file: `file://…` or a plain path, distinguished by the
|
|
190
|
+
absence of a scheme. A file is read as it is — no request, no waiting — which makes it the
|
|
191
|
+
shortest development loop. It cannot answer per caller, so it is not the production channel.
|
|
192
|
+
|
|
193
|
+
Only the endpoint path needs the SIP envelope to build its request, so only it awaits the
|
|
194
|
+
participant. `load()` returns immediately for a body in metadata and for a file, and after
|
|
195
|
+
participant join for an endpoint. Documented, not hidden.
|
|
196
|
+
|
|
197
|
+
### Request payload
|
|
198
|
+
|
|
199
|
+
The request is itself the question — the responder decides what to return from it. This is
|
|
200
|
+
the main inbound scenario: choose the agent by the number that was dialled.
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"room": "...", "job_id": "...", "dispatch_id": "...", "agent_name": "...",
|
|
205
|
+
"direction": "inbound",
|
|
206
|
+
"from": { "number": "..." }, "to": { "number": "..." },
|
|
207
|
+
"sip": { ... },
|
|
208
|
+
"participant_identity": "...",
|
|
209
|
+
"metadata": "<raw job.metadata, if any>"
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Response
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"prompt": "...",
|
|
218
|
+
"greeting": "...",
|
|
219
|
+
"variables": { "name": "Anna", "attempt": 2, "vip": true },
|
|
220
|
+
"webhook": { "url": "...", "secret": "..." },
|
|
221
|
+
"extra": { }
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Five fields, deliberately. `language` is a variable. Duration caps and voice belong to
|
|
226
|
+
other concerns. Call identity is derived, not declared.
|
|
227
|
+
|
|
228
|
+
`webhook` here overrides the environment, because multi-tenancy is resolved per call while
|
|
229
|
+
the environment is a deployment default.
|
|
230
|
+
|
|
231
|
+
`extra` is opaque and never interpreted.
|
|
232
|
+
|
|
233
|
+
### Templating
|
|
234
|
+
|
|
235
|
+
`{{ name }}` placeholders are substituted into **both** `prompt` and `greeting` from
|
|
236
|
+
`variables`. A missing key is left in place verbatim and logged at WARNING. Rendering never
|
|
237
|
+
raises and never evaluates code. The object exposes the raw and the rendered form of each.
|
|
238
|
+
|
|
239
|
+
JSON types in `variables` are preserved — a number stays a number — and typed accessors are
|
|
240
|
+
provided. Substitution uses the string form at render time.
|
|
241
|
+
|
|
242
|
+
### Failure
|
|
243
|
+
|
|
244
|
+
A failed or non-2xx config request **terminates the call** with the reason logged. An agent
|
|
245
|
+
without its prompt is a broken call either way; failing loudly beats failing quietly. The
|
|
246
|
+
response status and body are logged. Overridable for callers who prefer to continue.
|
|
247
|
+
|
|
248
|
+
## 8. Webhooks
|
|
249
|
+
|
|
250
|
+
One thin envelope; everything LiveKit produces is nested verbatim so that new SDK fields
|
|
251
|
+
reach consumers without a release here.
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{
|
|
255
|
+
"event": "call.started",
|
|
256
|
+
"id": "<idempotency key>",
|
|
257
|
+
"timestamp": 0,
|
|
258
|
+
"call": {
|
|
259
|
+
"id": "...", "direction": "inbound",
|
|
260
|
+
"from": { "number": "..." }, "to": { "number": "..." },
|
|
261
|
+
"started_at": 0, "ended_at": 0, "duration": 0, "status": "..."
|
|
262
|
+
},
|
|
263
|
+
"livekit": {
|
|
264
|
+
"room": {}, "job": {}, "participant": {}, "sip": {},
|
|
265
|
+
"session_report": {}
|
|
266
|
+
},
|
|
267
|
+
"recording": {},
|
|
268
|
+
"tags": {}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`livekit.session_report` is `ctx.make_session_report().to_dict()` unmodified — chat history
|
|
273
|
+
with timestamps, per-provider usage, recorded events, session options, SDK version. The key
|
|
274
|
+
is present on every `call.ended`, null when the report could not be built, so a consumer
|
|
275
|
+
never has to handle two shapes of the same event. It is absent from `call.started`.
|
|
276
|
+
|
|
277
|
+
`tags` carries `ctx.tagger` outcome and tags.
|
|
278
|
+
|
|
279
|
+
Delivery: `POST`, HMAC signature over timestamp and body when a secret is configured, an
|
|
280
|
+
idempotency key per event, retries on 5xx and network errors with backoff, fail-fast on 4xx.
|
|
281
|
+
|
|
282
|
+
## 9. Recording
|
|
283
|
+
|
|
284
|
+
The SDK records locally to OGG/Opus, stereo, via PyAV, then reads the whole file into memory
|
|
285
|
+
and sends it to LiveKit Cloud in a single multipart POST with no chunking and no size guard
|
|
286
|
+
(`telemetry/traces.py:406`). Roughly 700 KB per minute at the default bitrate.
|
|
287
|
+
|
|
288
|
+
This package:
|
|
289
|
+
|
|
290
|
+
- **Default: object storage.** One S3-compatible client, configurable endpoint, so S3 and
|
|
291
|
+
R2 are the same path. Recording and transcript are written under the same `call_id`.
|
|
292
|
+
Requires the `s3` extra.
|
|
293
|
+
- **Fallback: multipart to the webhook endpoint**, for zero-configuration use. No size
|
|
294
|
+
ceiling, matching the SDK's own behaviour.
|
|
295
|
+
- The webhook is posted before the upload, so the call is closed out with a terminal status
|
|
296
|
+
even if the process dies mid-upload.
|
|
297
|
+
|
|
298
|
+
## 10. Logging
|
|
299
|
+
|
|
300
|
+
Module-named loggers obtained from `logging.getLogger`. The package never sets a level,
|
|
301
|
+
never attaches a handler and never configures the root logger. Whatever the host has
|
|
302
|
+
configured is what applies.
|
|
303
|
+
|
|
304
|
+
## 11. Constraints worth knowing
|
|
305
|
+
|
|
306
|
+
- **Metadata limits** are server config, not constants: 512 KiB for metadata, 64 KiB for
|
|
307
|
+
attributes summed across keys and values. Servers older than 2026-06-17 cap both at
|
|
308
|
+
64000 bytes. SIP dispatch rules are not size-checked at all — an oversized rule saves
|
|
309
|
+
silently and fails later when the participant joins.
|
|
310
|
+
- **`room_config`** on a SIP dispatch rule applies only when the SIP participant creates the
|
|
311
|
+
room. Against an existing room it is silently ignored. Use a unique room per call or an
|
|
312
|
+
explicit `CreateAgentDispatch`.
|
|
313
|
+
- **`AgentServer.update_options()`** declares `shutdown_process_timeout` and
|
|
314
|
+
`session_end_timeout` with plain defaults instead of sentinels while testing them with
|
|
315
|
+
`is_given`, so any call to it silently resets both to 10s and 300s.
|
|
316
|
+
- **`RoomAgentDispatch.attributes`** exists in the protocol on main but not in 1.1.7. Only
|
|
317
|
+
`metadata` is safe to rely on.
|
|
318
|
+
- **`core.state` must stay bound to the submodule.** Re-exporting `state()` from
|
|
319
|
+
`core/__init__.py` under that name shadows it, and every internal
|
|
320
|
+
`from ..core import state` then binds a function instead — a failure that only surfaces
|
|
321
|
+
at call time. The public alias is `core.call_state`.
|
|
322
|
+
- **Shutdown callbacks are gathered concurrently**, not run in registration order. Nothing
|
|
323
|
+
may depend on one running before another.
|
|
324
|
+
- **A simulated job — console mode — has a mock room nobody joins**, so the participant
|
|
325
|
+
entrypoint never fires. `attach()` detects it through `ctx.is_fake_job()` and reports the
|
|
326
|
+
call as started when the session starts instead. Such a call has no parties and no SIP
|
|
327
|
+
envelope.
|
|
328
|
+
|
|
329
|
+
## 12. Versioning
|
|
330
|
+
|
|
331
|
+
Semantic versioning, not CalVer. A date says when a release happened; it says nothing about
|
|
332
|
+
whether a receiver written against the last one still parses this one, and that question is
|
|
333
|
+
the entire product here. LiveKit itself is on SemVer, and this package declares a range
|
|
334
|
+
against it, so the schemes match.
|
|
335
|
+
|
|
336
|
+
The bump follows a rule rather than taste: additive payload fields and fixes are patches,
|
|
337
|
+
anything a receiver could choke on is a minor. In `0.x` the minor is the breaking position —
|
|
338
|
+
`^0.1.0` admits `0.1.x` and not `0.2.0` — so `0.2.0` is not a big release, it is one the
|
|
339
|
+
consumers need to hear about.
|
|
340
|
+
|
|
341
|
+
## 13. Later
|
|
342
|
+
|
|
343
|
+
- Call lifecycle management — duration caps, reminder prompts, transfer — as a fourth
|
|
344
|
+
module reading the same `core` state.
|
|
345
|
+
- Splitting a module into its own distribution, if dependencies diverge. Import paths are
|
|
346
|
+
already shaped for it.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: callva-livekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in call webhooks and per-call configuration for any LiveKit agent.
|
|
5
|
+
Project-URL: Homepage, https://github.com/callva-io/callva-livekit
|
|
6
|
+
Project-URL: Source, https://github.com/callva-io/callva-livekit
|
|
7
|
+
Author: CallVA
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: agents,livekit,sip,telephony,voice,webhook
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10.0
|
|
18
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
19
|
+
Requires-Dist: livekit-agents>=1.5.7
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
25
|
+
Provides-Extra: s3
|
|
26
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# callva-livekit
|
|
30
|
+
|
|
31
|
+
Drop-in call webhooks and per-call configuration for any LiveKit agent.
|
|
32
|
+
|
|
33
|
+
Two things most voice agents need and the LiveKit SDK does not provide: a webhook when a
|
|
34
|
+
call starts and ends, and a way to get the prompt for *this* call from somewhere other than
|
|
35
|
+
your source code.
|
|
36
|
+
|
|
37
|
+
Both are opt-in. Installing the package activates nothing, patches nothing, and changes
|
|
38
|
+
nothing about how your session behaves.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install callva-livekit # webhooks + config
|
|
44
|
+
pip install callva-livekit[s3] # + recording upload to S3 or R2
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from livekit.agents import Agent, AgentServer, AgentSession
|
|
51
|
+
from callva.livekit import config as callva_config
|
|
52
|
+
from callva.livekit import webhook as callva_webhook
|
|
53
|
+
|
|
54
|
+
server = AgentServer()
|
|
55
|
+
|
|
56
|
+
@server.rtc_session(agent_name="my-agent", on_session_end=callva_webhook.on_session_end)
|
|
57
|
+
async def entrypoint(ctx):
|
|
58
|
+
await ctx.connect()
|
|
59
|
+
|
|
60
|
+
config = await callva_config.load()
|
|
61
|
+
|
|
62
|
+
session = AgentSession(...)
|
|
63
|
+
callva_webhook.attach(session)
|
|
64
|
+
|
|
65
|
+
await session.start(agent=Agent(instructions=config.prompt), room=ctx.room, record=True)
|
|
66
|
+
await session.generate_reply(instructions=config.greeting)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Neither call takes a `JobContext` — it comes from the SDK's own contextvar. `load()` hands
|
|
70
|
+
you an object; what you do with the prompt is your decision.
|
|
71
|
+
|
|
72
|
+
A complete runnable agent is in [examples/agent.py](examples/agent.py), with a local
|
|
73
|
+
receiver that prints what arrives in [examples/receiver.py](examples/receiver.py).
|
|
74
|
+
|
|
75
|
+
## What arrives
|
|
76
|
+
|
|
77
|
+
`call.started` when someone is on the other end, `call.ended` when it is over.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"event": "call.ended",
|
|
82
|
+
"id": "8f1c…:call.ended:1757...",
|
|
83
|
+
"timestamp": 1757600000.12,
|
|
84
|
+
"call": {
|
|
85
|
+
"id": "8f1c…",
|
|
86
|
+
"direction": "inbound",
|
|
87
|
+
"from": { "number": "+37255512345", "identity": "sip_+37255512345", "name": null },
|
|
88
|
+
"to": { "number": "+3726001234", "identity": null, "name": null },
|
|
89
|
+
"started_at": 1757599940.5, "ended_at": 1757600000.1, "duration": 59.6,
|
|
90
|
+
"status": "completed"
|
|
91
|
+
},
|
|
92
|
+
"livekit": {
|
|
93
|
+
"room": { "name": "call-1", "sid": "RM_…", "metadata": null },
|
|
94
|
+
"job": { "id": "AJ_…", "dispatch_id": "AD_…", "agent_name": "my-agent", "…": "…" },
|
|
95
|
+
"participant": { "identity": "sip_…", "attributes": { "sip.callID": "…" } },
|
|
96
|
+
"sip": { "callID": "…", "phoneNumber": "…", "twilio": { "callSid": "…" } },
|
|
97
|
+
"session_report": { "chat_history": {}, "usage": [], "options": {}, "…": "…" }
|
|
98
|
+
},
|
|
99
|
+
"recording": { "url": "https://cdn.example/8f1c….ogg" },
|
|
100
|
+
"tags": { "tags": ["lk.success"], "outcome": "success", "reason": null }
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Everything LiveKit produces is nested under `livekit` **verbatim** — including
|
|
105
|
+
`session_report`, which is `ctx.make_session_report().to_dict()` untouched: full chat
|
|
106
|
+
history with timestamps, per-provider token usage, recorded events, session options. A
|
|
107
|
+
field the SDK adds tomorrow reaches you without a release here.
|
|
108
|
+
|
|
109
|
+
The `call` block is the only thing reshaped, because it is the only thing LiveKit does not
|
|
110
|
+
model: a stable id across both events, a direction, and a `from` and a `to`.
|
|
111
|
+
|
|
112
|
+
Requests carry `X-Webhook-Idempotency-Key`, and `X-Webhook-Signature` when a secret is set —
|
|
113
|
+
`sha256` HMAC over `{timestamp}.{body}`, with `X-Webhook-Timestamp` alongside. Delivery
|
|
114
|
+
retries on 5xx and network errors and fails fast on 4xx.
|
|
115
|
+
|
|
116
|
+
## Configuration for a call
|
|
117
|
+
|
|
118
|
+
Configuration reaches the agent through **agent dispatch metadata**, read as
|
|
119
|
+
`ctx.job.metadata`:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{ "callva": { "call_id": "…", "direction": "outbound", "config": { "prompt": "…" } } }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Put a `config_url` there instead of a `config`, or set `CONFIG_URL`, and the agent
|
|
126
|
+
follows that instead. The request is the question — it carries who is calling, which number
|
|
127
|
+
they reached and the whole SIP envelope — so the endpoint can answer "this number belongs to
|
|
128
|
+
that customer, here is their prompt". That is the inbound case in one hop.
|
|
129
|
+
|
|
130
|
+
A pointer can also be a local file — `file:///etc/agent.json` or a plain `./agent.json`. It
|
|
131
|
+
is read as it is, with no request and no waiting for anyone to join, which makes it the
|
|
132
|
+
shortest development loop there is. It cannot answer per caller, so it is not the production
|
|
133
|
+
channel.
|
|
134
|
+
|
|
135
|
+
The response:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"prompt": "You are speaking with {{ name }}.",
|
|
140
|
+
"greeting": "Hi {{ name }}, how can I help?",
|
|
141
|
+
"variables": { "name": "Anna", "attempt": 2, "vip": true },
|
|
142
|
+
"webhook": { "url": "https://tenant.example/hook", "secret": "…" },
|
|
143
|
+
"extra": { "anything": "you like" }
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`{{ name }}` is substituted into both `prompt` and `greeting`. A placeholder with no
|
|
148
|
+
variable is left exactly as it was and logged — one missing key must not take down a call
|
|
149
|
+
that is already ringing. JSON types survive: `config.variables.get_int("attempt")` is `2`.
|
|
150
|
+
`extra` is never interpreted.
|
|
151
|
+
|
|
152
|
+
`webhook` in the response overrides the environment, which is what lets one worker serve
|
|
153
|
+
many tenants.
|
|
154
|
+
|
|
155
|
+
When configuration cannot be resolved the call is **terminated** and the reason logged. An
|
|
156
|
+
agent without its prompt is a broken call either way. Pass `on_error="continue"` if you
|
|
157
|
+
would rather carry on.
|
|
158
|
+
|
|
159
|
+
Room metadata is deliberately not used as a channel: it is broadcast to every participant
|
|
160
|
+
in the room, so a prompt placed there is readable by any connected client.
|
|
161
|
+
|
|
162
|
+
## Environment
|
|
163
|
+
|
|
164
|
+
| Variable | Purpose |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `WEBHOOK_URL` | Where call events are sent |
|
|
167
|
+
| `WEBHOOK_SECRET` | HMAC signing secret |
|
|
168
|
+
| `WEBHOOK_TIMEOUT` | Per-attempt timeout, seconds (default 30) |
|
|
169
|
+
| `CONFIG_URL` | Endpoint asked for per-call configuration |
|
|
170
|
+
| `CONFIG_API_KEY` | Sent to it as a bearer token |
|
|
171
|
+
| `CONFIG_TIMEOUT` | Per-attempt timeout, seconds (default 10) |
|
|
172
|
+
| `CALL_DIRECTION` | Default direction when nothing declares one |
|
|
173
|
+
| `RECORDING_S3_BUCKET` | Enables recording upload |
|
|
174
|
+
| `RECORDING_S3_ENDPOINT_URL` | Set this for R2 or any S3-compatible store |
|
|
175
|
+
| `RECORDING_S3_REGION`, `RECORDING_S3_ACCESS_KEY_ID`, `RECORDING_S3_SECRET_ACCESS_KEY` | Credentials |
|
|
176
|
+
| `RECORDING_S3_PUBLIC_BASE_URL` | Turns the object key into the URL sent in the webhook |
|
|
177
|
+
| `RECORDING_S3_PREFIX` | Key prefix inside the bucket |
|
|
178
|
+
|
|
179
|
+
Every value has a constructor argument that takes precedence.
|
|
180
|
+
|
|
181
|
+
## Recording
|
|
182
|
+
|
|
183
|
+
With a bucket configured, the recording and the session report are written under the same
|
|
184
|
+
call id — `<call_id>.ogg` and `<call_id>.json` — and the webhook carries the URL, which is
|
|
185
|
+
known before the bytes move. Needs `record=True` on `session.start()` and the codecs extra
|
|
186
|
+
(`pip install "livekit-agents[codecs]"`).
|
|
187
|
+
|
|
188
|
+
Without a bucket, and only if a webhook target is set, the recording follows the webhook as
|
|
189
|
+
a multipart `call.recording` request. Convenient for getting started; object storage is the
|
|
190
|
+
answer for long calls.
|
|
191
|
+
|
|
192
|
+
The `call.ended` webhook is always sent **before** the upload, so a call is closed out with
|
|
193
|
+
a terminal status even if the process does not survive the transfer.
|
|
194
|
+
|
|
195
|
+
## Versioning
|
|
196
|
+
|
|
197
|
+
Semantic versioning, and the compatibility promise is about **what a receiver has to
|
|
198
|
+
parse**, not about the size of the diff:
|
|
199
|
+
|
|
200
|
+
- **0.1.x** — fixes, and fields *added* to a payload. Adding is not breaking: a receiver
|
|
201
|
+
ignores keys it does not know, and because everything LiveKit produces is nested verbatim,
|
|
202
|
+
fields the SDK adds arrive without a release here at all.
|
|
203
|
+
- **0.2.0** — anything a receiver could choke on: a field renamed or removed, an event name
|
|
204
|
+
changed, a header changed, an environment variable renamed, a public function changed.
|
|
205
|
+
- **1.0.0** — when the contract is worth freezing.
|
|
206
|
+
|
|
207
|
+
In `0.x` the digits are shifted one place: the middle number is the breaking one, which is
|
|
208
|
+
what `^0.1.0` means to every resolver. So a `0.2.0` here is not a large release — it is a
|
|
209
|
+
release that someone's receiver has to be told about.
|
|
210
|
+
|
|
211
|
+
## Two things worth knowing
|
|
212
|
+
|
|
213
|
+
**Your agent needs `agent_name` set and explicit dispatch.** With automatic dispatch
|
|
214
|
+
`job.metadata` arrives empty, and nothing can be addressed to this call.
|
|
215
|
+
|
|
216
|
+
**If you cannot use `on_session_end`** — you are still on `WorkerOptions` — `attach()` falls
|
|
217
|
+
back to a shutdown callback and says so in the log. That path is bounded by
|
|
218
|
+
`shutdown_process_timeout`, 10 seconds by default, after which the worker kills the
|
|
219
|
+
process mid-upload. Raise it, or move to `AgentServer`.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
Apache-2.0
|