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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .env
12
+ .DS_Store
@@ -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