boundless-author-7811
02/18/2026, 11:37 AMtall-airline-16756
02/18/2026, 11:37 AMtall-airline-16756
02/18/2026, 11:38 AMload_fnc does and how you can customize it. I’ll explain what it’s for, what it must return, common metrics you can use, and show short examples you can adapt.
Acknowledgement
• You’re asking about the load_fnc used by the LiveKit AgentServer and how to change its behavior.
What load_fnc does
• load_fnc is the function the AgentServer uses to report its current load as a number between 0 and 1.
• If the returned load exceeds load_threshold, the agent server stops accepting new jobs.
• By default, load_fnc reports overall CPU utilization.
What the function must return
• A float (or Promise/async returning a number) in the range 0.0–1.0 that meaningfully represents how busy the server is.
Common ways to customize it
• Use other system metrics instead of CPU, for example:
◦ Active job/session count normalized to a maximum (e.g., active jobs / max_jobs).
◦ Memory usage as a fraction of total available memory.
◦ Combination of metrics (weighted sum of CPU, memory, and job count).
◦ Application-level signals (e.g., number of active WebRTC sessions, encoding queue length).
• Ensure the computed value is clamped to [0, 1].
Examples you can adapt
1) Limit by active jobs (Python)
def compute_load(agent_server: AgentServer) -> float:
# assume max desired concurrent jobs is 10
return min(len(agent_server.active_jobs) / 10, 1.0)
server = AgentServer(load_threshold=0.9)
server.load_fnc = compute_load
2) Limit by active jobs (Node.js / TypeScript)
const computeLoad = async (agentServer: AgentServer): Promise<number> => {
// assume max desired concurrent jobs is 10
return Math.min(agentServer.activeJobs.length / 10, 1.0);
};
const server = new AgentServer({
loadFunc: computeLoad,
loadThreshold: 0.9,
});
3) Combined metric (conceptual)
• Example: load = 0.6 * (cpu_percent / 100) + 0.3 * (memory_percent / 100) + 0.1 * (active_jobs / max_jobs)
• Then clamp to 0..1
Important notes
• Always clamp results to the [0,1] range.
• Pick a metric (or weighted combination) that reflects the resource that becomes the bottleneck for your deployment.
• In LiveKit Cloud deployments, load_fnc and load_threshold cannot be modified.
If you’d like, tell me:
• Which runtime you’re using (Python or Node.js),
• What metric you want to base load on (jobs, memory, CPU, sessions, or a combo),
and I’ll provide a tailored code snippet or a recommended weighting.
Sources: Self-hosted deployments | LiveKit Documentation | Server options | LiveKit Documentationboundless-author-7811
02/18/2026, 12:19 PMtall-airline-16756
02/18/2026, 12:19 PMAgentServer. I’ll explain how load_fnc fits into that setup, what you can do today, and the practical options for making load-aware decisions across multiple pods.
Quick summary of the situation I’m responding to
• load_fnc is part of the AgentServer class and runs per agent process to report a load value (0–1). It only applies if you use AgentServer.
• You’re running multiple agent pods and aren’t using AgentServer, so load_fnc is not currently available in your agent code.
What this means for a multi-pod Kubernetes deployment
• load_fnc would run inside each pod and report that pod’s load. It is a per-pod mechanism — not a cluster-wide coordinator.
• Because you are not using AgentServer, the agent framework’s built-in load reporting/threshold behavior is not active for your pods.
• In Kubernetes, pods are independent; job distribution and scaling are normally handled by the control plane (Service, K8s scheduler, Horizontal Pod Autoscaler) or by LiveKit’s agent registration if you use AgentServer.
Options you can use to get load-aware behavior with multiple pods
• Reintroduce AgentServer (recommended if you want LiveKit to use per-pod load):
◦ Add AgentServer into your agent code, set a load_fnc implementation and load_threshold.
◦ Deploy the same image/config to each pod. Each pod will report its own load and LiveKit will avoid pods over threshold.
• Keep your current flow and let Kubernetes handle scaling:
◦ Use Kubernetes Horizontal Pod Autoscaler (HPA) driven by CPU/memory or custom metrics to scale pods automatically.
◦ This doesn’t control per-pod job acceptance, but it prevents overload by increasing pod count.
• Implement a shared, cluster-aware load check (if you cannot use AgentServer):
◦ Use a small central store (Redis, etcd) to keep active-job counts across pods.
◦ On job requests, each pod checks shared state and accepts/rejects based on cluster-wide limits or per-pod quotas.
• Implement pod-local heuristics in your job handler:
◦ Each pod measures its own load (CPU, memory, active sessions) and rejects new jobs when local thresholds are exceeded. This is similar to load_fnc but implemented by you.
• Hybrid: pod-local load_fnc + K8s autoscaling:
◦ Implement pod-local load checks to refuse new work when busy and configure HPA to scale using the same metrics.
Practical guidance / next steps
• If you want LiveKit to use load_fnc behavior, I can walk you through adding AgentServer back into your agent code and a sample load_fnc. Tell me whether your agents are Python or Node.js.
• If you prefer a Kubernetes-native approach, tell me which metrics you already expose (CPU, memory, active session count) and whether you use HPA. I’ll suggest a concrete pattern (HPA config or Redis-based shared counter) and a sample implementation.
• If you want an example now, tell me:
◦ runtime (Python or Node.js),
◦ whether you can modify the agent code to include AgentServer, and
◦ which metric(s) you want to base load on (CPU, memory, active jobs/sessions, or a combination).
Pick one of the options above and I’ll provide the specific code/config snippet or step-by-step instructions.
Sources: Server options | LiveKit Documentation | Self-hosted deployments | LiveKit Documentationboundless-author-7811
02/18/2026, 12:20 PMimport os
from livekit.agents import cli, WorkerOptions
from lifecycle import entrypoint, prewarm
from shared.utils.logger import get_logger
logger = get_logger("orchestrator.main")
# Prometheus metrics port (default: 9090, configurable via env)
PROMETHEUS_PORT = int(os.getenv("PROMETHEUS_PORT", "9090"))
if __name__ == "__main__":
<http://logger.info|logger.info>("🚀 Starting Orchestrator...")
<http://logger.info|logger.info>("📊 Prometheus metrics will be exposed on port %s", PROMETHEUS_PORT)
# Number of idle worker processes to keep warm
# CRITICAL: Must be 1 to prevent duplicate job dispatch to same room
# LiveKit dispatches room jobs to multiple workers if num_idle_processes > 1
# This causes multiple agents to join the same call
# NUM_IDLE_PROCESSES = int(os.getenv("NUM_IDLE_PROCESSES", "1"))
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
prewarm_fnc=prewarm,
agent_name="outbound-caller",
port=8001,
prometheus_port=PROMETHEUS_PORT
)
)tall-airline-16756
02/18/2026, 12:21 PMWorkerOptions + cli.run_app pattern (Python) so I’ll explain how that affects load_fnc, and give you clear options you can pick from with next steps and code snippets.
What I’m responding to
• You shared your Python entry point that calls cli.run_app(WorkerOptions(...)).
• You want to know how to use load_fnc (or the equivalent behavior) across two Kubernetes pods.
Key fact that matters
• load_fnc is a feature of AgentServer. When you use WorkerOptions and cli.run_app as shown, you are not using AgentServer, so load_fnc is not available out of the box in your current setup.
Options you can choose from (what you can do next)
• Option A — Re-introduce AgentServer (recommended if you want LiveKit-aware per-pod load reporting)
◦ Replace the WorkerOptions approach with an AgentServer setup so each pod runs an AgentServer instance that exposes load_fnc and load_threshold. LiveKit will then use each pod’s reported load to avoid sending jobs to overloaded pods.
◦ If you want, I can provide a minimal example showing how to wire AgentServer, set load_fnc, and start it (Python).
• Option B — Keep WorkerOptions but add pod-local load checks (simpler, no framework change)
◦ Implement a local load check in your entrypoint or job handler that rejects new work when local load is high (based on CPU, memory, or active job/session count).
◦ This mirrors load_fnc behavior per pod, but it’s enforced by your code, not by LiveKit.
◦ I can show how to read CPU/memory or count active jobs and return a boolean/reject logic in the handler.
• Option C — Cluster-aware load coordination (if you want cluster-wide limits)
◦ Keep your current WorkerOptions pattern, but add a small shared state (Redis, etcd) that all pods consult to decide whether to accept a job (e.g., increment a counter on accept, decrement on finish).
◦ This gives cluster-level capacity control (so two pods won’t both accept jobs beyond a global limit).
◦ I can provide a simple Redis-backed pattern for safe increments/decrements and guard conditions.
• Option D — Rely on Kubernetes autoscaling (operational approach)
◦ Use HPA (Horizontal Pod Autoscaler) driven by CPU, memory, or a custom metric (e.g., active session metric exposed to Prometheus). This prevents overload by increasing pod count rather than rejecting work at the app level.
◦ This is complementary to any of the above and works well if your workload scales horizontally.
Which one I recommend
• If you want LiveKit to natively use per-pod load metrics and avoid sending rooms to overloaded pods, use Option A (reintroduce AgentServer).
• If changing framework is difficult and you only need local protection, Option B is the quickest.
• If you need a global cluster-level cap, choose Option C.
• Always consider combining app-level checks (B/C) with HPA (D) for best resilience.
Concrete next steps — pick one and I’ll give code/config:
1. If you want AgentServer: tell me and I’ll provide a minimal Python example showing how to create an AgentServer, set load_fnc, and start it in place of WorkerOptions.
2. If you want a quick pod-local load_fnc equivalent: tell me which metric you prefer (CPU, memory, active jobs/sessions) and I’ll provide a code snippet to integrate into your entrypoint or job handler.
3. If you want shared-state coordination: tell me if Redis is available in your cluster and I’ll give a safe increment/decrement pattern and example usage in your job acceptance path.
4. If you want HPA guidance: tell me whether you already export Prometheus metrics (you exposed Prometheus port) and I’ll suggest an HPA config using CPU or a custom Prometheus metric.
Which option would you like to start with?boundless-author-7811
02/18/2026, 12:23 PMtall-airline-16756
02/18/2026, 12:23 PMWorkerOptions + cli.run_app pattern you have now.
What that means for you
• Older sample code and tutorials used WorkerOptions (and job handlers started via cli.run_app) and did not include AgentServer.
• Newer versions add AgentServer as a higher-level runtime that provides built-in per-process load reporting (load_fnc) and load_threshold behavior so LiveKit can avoid routing jobs to overloaded pods.
• Because your current entry point uses WorkerOptions, the `load_fnc`/`load_threshold` features are not active unless you switch to AgentServer.
How this affects your deployment options
• Staying with `WorkerOptions`: Continue using your current pattern — implement app-level or cluster-level load controls (pod-local checks, Redis-based coordination, or Kubernetes HPA).
• Moving to `AgentServer`: Swap in AgentServer so each pod reports its own load to LiveKit and the platform can prefer healthier pods. This is the cleanest way to get LiveKit-native per-pod load balancing.
If you want, I can:
• Show a minimal migration example in Python that replaces your WorkerOptions usage with AgentServer and a sample load_fnc.
• Or provide a pod-local load-check snippet you can drop into your current entrypoint to mimic load_fnc behavior without changing the runtime.
Which would you prefer?boundless-author-7811
02/23/2026, 7:41 AMtall-airline-16756
02/23/2026, 7:42 AMload_fnc is used to pick the least-busy pod, and give practical steps and a minimal Python example you can adapt from your current WorkerOptions setup.
What AgentServer does (high level)
• Each pod runs its own AgentServer instance. Each instance registers with the LiveKit server and is considered part of a worker pool.
• AgentServer reports that pod’s current load via a load_fnc (must return a float 0.0–1.0).
• When LiveKit dispatches a job, it checks registered agents’ reported loads and prefers agents below load_threshold. This causes jobs to be routed to less-loaded pods rather than overloaded ones.
• AgentServer also manages job lifecycle (spawning per-job processes, graceful draining on shutdown, etc.), so it integrates load reporting with lifecycle behavior.
How load reporting works across 4 pods
• Each pod computes its own load independently using the same load_fnc logic you provide.
• LiveKit’s dispatcher queries those loads and will skip pods whose reported load > load_threshold.
• Because each pod reports a relative load, LiveKit effectively routes new jobs to the least busy pods (those reporting lower values), giving you cluster-wide balancing without shared state between pods.
What to tune so the least-busy pod picks jobs
• Choose a meaningful load_fnc for your bottleneck (active jobs, CPU, memory, queue length, or a weighted combination).
• Clamp/normalize the output to [0,1].
• Pick load_threshold to define when a pod is considered “too busy” (defaults are commonly around 0.7; you might use 0.9 for higher utilization).
• Ensure load_fnc is reasonably stable (avoid very spiky signals) so the dispatcher has consistent data.
• Align autoscaling thresholds to be more conservative than load_threshold (e.g., scale up at 0.5 while load_threshold is 0.7) so you add capacity before pods are excluded from dispatch.
Minimal migration path (Python) — replace WorkerOptions with AgentServer
1. Replace your cli.run_app(WorkerOptions(...)) entry with an AgentServer creation.
2. Implement a load_fnc that returns 0.0–1.0 based on your chosen metric.
3. Start the server so each pod registers and reports load.
Example (conceptual — adapt to your imports and lifecycle functions):
from livekit.agents import AgentServer
def compute_load(agent_server: AgentServer) -> float:
# Example: normalize active job count to [0,1] (assume 10 is "full")
max_jobs = 10
return min(len(agent_server.active_jobs) / max_jobs, 1.0)
server = AgentServer(
agent_name="outbound-caller",
port=8001,
prometheus_port=int(os.getenv("PROMETHEUS_PORT", "9090")),
load_threshold=0.9, # pod stops accepting new jobs above this
)
server.load_fnc = compute_load
# Start the AgentServer (this will register the pod with LiveKit)
server.run()
Notes and practical tips
• Per-pod: load_fnc runs in each pod; there is no implicit cross-pod aggregation — LiveKit uses each pod’s reported value to decide routing.
• If you prefer not to migrate to AgentServer, you can:
◦ Implement pod-local load checks in your current job handler (reject if local metric too high).
◦ Use a shared store (Redis) to implement cluster-wide capacity coordination.
◦ Use Kubernetes HPA driven by CPU/memory or a custom Prometheus metric (you already expose Prometheus) so you scale pods before overload.
• Graceful drain: AgentServer supports draining so active jobs finish on shutdown—match your pod’s terminationGracePeriodSeconds to your drain timeout to avoid dropped sessions.
What I can help with next
• Provide a complete, copy-pasteable AgentServer example that integrates your entrypoint and prewarm functions.
• Help design a load_fnc based on the metric you prefer (CPU, memory, active sessions, queue length, or a weighted combination).
• Provide a Redis or Prometheus + HPA pattern if you want cluster-wide coordination without switching to AgentServer.
Which would you like to do next — migrate to AgentServer, add a pod-local load_fnc equivalent, or implement cluster-wide coordination?
Sources: Server options | LiveKit Documentation | Self-hosted deployments | LiveKit Documentation | Server lifecycle | LiveKit Documentationboundless-author-7811
02/23/2026, 7:44 AMtall-airline-16756
02/23/2026, 7:45 AMload_fnc, how LiveKit chooses pods, and recommended Kubernetes practices to ensure the least-busy pod receives jobs.
How AgentServer behaves across pods
• Each pod runs its own AgentServer instance and registers with LiveKit as part of a worker pool (same agent_name).
• AgentServer calls your load_fnc locally and reports a load value 0.0–1.0 for that pod.
• When dispatching, LiveKit queries registered agents and prefers agents whose reported load <= load_threshold. This effectively routes jobs to less-busy pods.
• Job dispatch is single-assignment and round-robin across available (under-threshold) agents; if one is busy/unresponsive, LiveKit picks another.
Designing load_fnc so the least-busy pod is chosen
• Choose a metric that reflects your real bottleneck:
◦ Active concurrent jobs/sessions (common for connection-limited workloads).
◦ CPU utilization (default).
◦ Memory/GPU usage (if model memory is the limiter).
◦ A weighted combination: e.g., 0.6_CPU + 0.3_mem + 0.1*(active_jobs/max_jobs).
• Always return a value clamped to [0.0, 1.0].
• Keep the signal reasonably stable (avoid short spikes). Smooth or average metrics over a small window (e.g., 5s) to avoid thrashing.
• Pick load_threshold to mark when a pod should stop receiving new jobs (common values: 0.7–0.9 depending on tolerance for fullness).
Example patterns (Python)
• Active-jobs based:
def compute_load(server: AgentServer) -> float:
max_jobs = 10
return min(len(server.active_jobs) / max_jobs, 1.0)
• Combined metric (conceptual):
# cpu_percent and mem_percent are 0..100
load = 0.6 * (cpu_percent / 100) + 0.3 * (mem_percent / 100) + 0.1 * (active_jobs / max_jobs)
load = max(0.0, min(load, 1.0))
Kubernetes best practices to support AgentServer
• Run multiple identical pods with the same agent_name for redundancy and pooling.
• Align autoscaling with `load_fnc`:
◦ Use HPA based on the same metric (CPU or custom Prometheus metric).
◦ Scale up earlier than load_threshold (e.g., HPA target at 50% while load_threshold is 70%) so new pods come up before others are excluded.
• Set a generous terminationGracePeriodSeconds and match AgentServer drain timeout so pods finish active jobs on shutdown.
• Expose Prometheus metrics from AgentServer (you already expose a port) to drive HPA with custom metrics if needed.
Operational tips
• Prewarm heavy resources (models, VAD) in `setup_fnc`/`prewarm_fnc` to reduce per-job latency.
• Test load_fnc under load to verify the dispatcher behavior and avoid overly aggressive rejection.
• If you need cluster-wide limits beyond LiveKit’s per-pod routing, combine AgentServer with a shared coordination store (Redis) or a global admission controller pattern — but usually AgentServer per-pod reporting + HPA is sufficient.
Migration note from WorkerOptions
• Replace your cli.run_app(WorkerOptions(...)) entry with an AgentServer creation, attach a load_fnc, and run the server so each pod registers and reports load.
If you’d like, I can:
• Provide a drop-in Python example that replaces your current entrypoint with AgentServer, wiring your entrypoint and prewarm functions.
• Help design a load_fnc tailored to your environment (tell me whether CPU, memory, active sessions, or a GPU/memory-bound model is your primary bottleneck).
Which would you like me to provide next?
Sources: Self-hosted deployments | LiveKit Documentation | Agents (Python) | livekit/livekit_composite | DeepWiki | Server options | LiveKit Documentation | Introduction | LiveKit Documentation | Gemini Realtime Agent with Live Vision | LiveKit Documentationboundless-author-7811
02/23/2026, 8:49 AMtall-airline-16756
02/23/2026, 8:51 AMload_fnc, and (2) staying on your current WorkerOptions flow and implementing pod-local or shared coordination, the optimal choice is:
Recommendation — Use AgentServer with a custom load_fnc
• Why it’s optimal:
◦ It integrates directly with LiveKit’s built-in job dispatcher so LiveKit will prefer pods that report lower load — you get native, per-pod load-aware routing without extra glue.
◦ Each pod reports its own load; no need for a separate coordination layer or race-prone logic.
◦ AgentServer handles job lifecycle, graceful draining, and per-job isolation for you, which simplifies correctness and reduces operational work.
◦ Works naturally with Kubernetes: you can combine AgentServer load reporting with HPA driven by the same metrics for smooth autoscaling.
• When it’s best:
◦ You need reliable, low-latency routing to the least-busy pod.
◦ Sessions are long-lived or stateful (voice/video agents).
◦ You want minimal custom infrastructure (no Redis locks or extra admission control).
When to consider the other option(s)
• Pod-local checks (stay on WorkerOptions):
◦ Quick to implement and useful as a stopgap if you can’t change the runtime.
◦ Simpler if you only need basic local protection (reject new jobs when local metrics are high).
◦ Downsides: LiveKit won’t be aware of your local thresholds, and you can get uneven cluster-wide distribution.
• Shared coordination (Redis/etc.):
◦ Useful if you require strict global concurrency limits across pods.
◦ Adds extra operational complexity (consistency, failure modes).
◦ Use only if AgentServer + proper load_fnc cannot express your required global constraints.
Practical next step
• I can provide a drop-in Python migration example that replaces your WorkerOptions entry with an AgentServer, wires your entrypoint and prewarm, and includes a production-ready load_fnc (e.g., active jobs + CPU weighting) and recommended load_threshold and kube settings.
Would you like that migration example now (and do you prefer the load_fnc to be based on active sessions, CPU, memory, or a weighted combo)?boundless-author-7811
02/23/2026, 8:57 AMtall-airline-16756
02/23/2026, 8:57 AMload_fnc can be fully customized.
Quick summary (what I’m answering)
• You asked whether load_fnc can be customized. The short answer: yes — you control the logic, as long as it returns a float between 0.0 and 1.0 that meaningfully represents that pod’s load.
What load_fnc must do
• Return a number (or an async/Promise that resolves to a number) in the range 0.0–1.0.
• Lower = more available, higher = busier. When the value exceeds load_threshold, that pod will stop receiving new jobs.
• Be reasonably stable (smooth/average over a short window) to avoid rapid flapping in availability.
Common customization approaches
• Active jobs / sessions:
◦ Useful when you’re limited by concurrent connections or processes.
◦ Normalize: active_jobs / max_expected_jobs and clamp to [0,1].
• CPU-based:
◦ Default behavior. Use if CPU is your bottleneck.
◦ Average CPU% over a short window and divide by 100.
• Memory/GPU usage:
◦ Use if model memory or GPU memory is the limiting factor.
◦ Normalize memory_used / memory_available.
• Composite metric:
◦ Weighted sum of multiple signals (e.g., 0.6_cpu + 0.3_mem + 0.1*(active_jobs/max)).
◦ Clamp to [0,1] after combining.
• Application-level signals:
◦ Queue length, encoding backlog, model inference latency — anything that correlates with inability to accept more jobs.
Examples you can copy (Python)
• Active-jobs based:
def compute_load(server: AgentServer) -> float:
max_jobs = 10
return min(len(server.active_jobs) / max_jobs, 1.0)
• CPU + jobs weighted (conceptual — replace cpu_percent with your method of reading CPU):
def compute_load(server: AgentServer) -> float:
cpu_percent = read_cpu_percent() # returns 0..100
active_jobs = len(server.active_jobs)
max_jobs = 10
load = 0.6 * (cpu_percent / 100.0) + 0.4 * (active_jobs / max_jobs)
return max(0.0, min(load, 1.0))
• Memory/GPU-aware:
def compute_load(server: AgentServer) -> float:
mem_used, mem_total = read_memory()
mem_fraction = mem_used / mem_total
return min(mem_fraction, 1.0)
Practical tips
• Smooth metrics (e.g., short moving average) to avoid rapid toggling.
• Test under realistic load to tune weights and max_jobs.
• Set load_threshold to a value that balances utilization vs. risk of overload (common range 0.7–0.9).
• If using autoscaling, trigger scale-up earlier than your load_threshold (e.g., scale at 0.5 when load_threshold is 0.7).
• Keep load_fnc inexpensive to compute — it runs frequently for availability decisions.
If you want, I can:
• Provide a drop-in AgentServer example that wires your `entrypoint`/`prewarm` and includes a concrete load_fnc based on your preferred metrics.
• Help pick weights and thresholds if you tell me which resource is your bottleneck (CPU, memory/GPU, or concurrent sessions). Which metric do you want to base it on?boundless-author-7811
02/23/2026, 9:04 AMtall-airline-16756
02/23/2026, 9:05 AMload_fnc reflects both the LLM token budget and the phone-number concurrency limits, and explain why it works and what to tune.
Short summary of the approach
• Track two normalized utilizations (each 0.0–1.0):
a. Token utilization = recent LLM tokens/sec ÷ token budget/sec
b. Phone-number utilization = active_calls ÷ (n_numbers × 50)
• The load_fnc returns the higher of the two (the bottleneck): load = max(token_util, phone_util)
• Clamp to [0,1]. Choose load_threshold (e.g., 0.9) to stop accepting new jobs when near capacity.
• Use sliding windows and smoothing to avoid rapid flapping.
Why max() of the two
• A job should be rejected if either constraint would be exceeded. Taking the maximum models “either resource saturated” behavior.
Implementation outline (Python)
• You’ll run this inside each AgentServer pod. The trackers are per-pod; if you need cluster-wide quotas, use a shared store — but per-pod is simplest if token and phone capacity are assigned per pod.
• Steps:
a. TokenRateTracker: sliding-window token accounting (60s window recommended).
b. PhoneCapacityTracker: thread-safe active-call counter.
c. `load_fnc`: compute token_load and phone_load, return max(token_load, phone_load).
Example code (conceptual — adapt to your codebase, integrate where you handle token accounting and job lifecycle):
import time
from collections import deque
import threading
from typing import Deque, Tuple
# 1) Token rate tracker (per pod)
class TokenRateTracker:
def __init__(self, max_tokens_per_minute: int = 100000, window_seconds: int = 60):
self.max_tokens_per_sec = max_tokens_per_minute / 60.0
self.window_seconds = window_seconds
self.history: Deque[Tuple[float, int]] = deque() # (timestamp, token_count)
self.lock = threading.Lock()
def add_tokens(self, tokens: int):
now = time.time()
with self.lock:
self.history.append((now, tokens))
self._evict_old(now)
def _evict_old(self, now: float):
cutoff = now - self.window_seconds
while self.history and self.history[0][0] < cutoff:
self.history.popleft()
def get_token_load(self) -> float:
now = time.time()
with self.lock:
self._evict_old(now)
total_tokens = sum(t for _, t in self.history)
avg_per_sec = total_tokens / max(self.window_seconds, 1)
load = avg_per_sec / max(self.max_tokens_per_sec, 1e-9)
return min(max(load, 0.0), 1.0)
# 2) Phone capacity tracker (per pod)
class PhoneCapacityTracker:
def __init__(self, num_numbers: int, per_number_capacity: int = 50):
self.max_calls = max(1, num_numbers) * per_number_capacity
self.current_calls = 0
self.lock = threading.Lock()
def incr(self) -> int:
with self.lock:
self.current_calls += 1
return self.current_calls
def decr(self) -> int:
with self.lock:
self.current_calls = max(0, self.current_calls - 1)
return self.current_calls
def get_phone_load(self) -> float:
with self.lock:
load = self.current_calls / max(self.max_calls, 1)
return min(max(load, 0.0), 1.0)
Integrating with AgentServer and jobs
• Initialize trackers when the process starts (e.g., in your prewarm or top-level module):
TOKEN_TRACKER = TokenRateTracker(max_tokens_per_minute=100000, window_seconds=60)
PHONE_TRACKER = PhoneCapacityTracker(num_numbers=NUM_NUMBERS, per_number_capacity=50)
• When the agent issues an LLM request, record tokens consumed (you must get token counts from the LLM response or estimate them):
# after a successful LLM call where tokens_used is known:
TOKEN_TRACKER.add_tokens(tokens_used)
• When a call job starts or a session is accepted, increment active calls; decrement on end:
PHONE_TRACKER.incr()
# ... on job finish
PHONE_TRACKER.decr()
Define the load_fnc for AgentServer:
def custom_load_fn(agent_server) -> float:
token_load = TOKEN_TRACKER.get_token_load()
phone_load = PHONE_TRACKER.get_phone_load()
# Optionally add smoothing, hysteresis, or small safety margin
load = max(token_load, phone_load)
return min(max(load, 0.0), 1.0)
Attach to AgentServer:
server = AgentServer(
agent_name="outbound-caller",
load_threshold=0.9,
# other args...
)
server.load_fnc = custom_load_fn
server.run()
Tuning details and recommendations
• Window size: 60 seconds matches your tokens/minute budget. If you want faster reaction, use a smaller window (e.g., 30s) but be careful of noise.
• Smoothing: if token usage is bursty, apply a short EMA to avg_per_sec to avoid flapping.
• Safety margin: consider multiplying measured loads by 1.05 (5% headroom) before comparing to threshold to avoid hitting hard caps.
• Token attribution: ensure you record actual tokens consumed per request (prompt+completion). If you only estimate, bias the estimate conservative (higher).
• Per-pod vs cluster-wide:
◦ If your token budget and phone numbers are global (shared across pods), you must centralize tracking (Redis) so load is computed cluster-wide.
◦ If budgets are allocated per pod (recommended for simplicity), keep per-pod local trackers as shown.
• HPA alignment: if you use HPA, scale up at a lower load (e.g., 0.5) than your load_threshold (0.9) so new pods arrive before others are excluded.
Edge cases & failure modes
• If your LLM tokens are billed and globally limited (not per pod), local trackers will under-report global usage and could oversubscribe. Use shared counter in that case.
• Network partitions or tracker failures: ensure trackers don’t crash the agent; default to conservative load = 1.0 if metrics unavailable.
• Race conditions on job acceptance: LiveKit will query pods and pick one under threshold, but if two pods cross threshold right after the query you might still temporarily oversubscribe. Safety margin helps.
Would you like:
• A copy-pasteable, end-to-end example that wires this into your exact entrypoint (entrypoint, prewarm, and cli.run_app -> replaced by AgentServer)?
• A short example showing how to implement a Redis-backed cluster-wide token counter if your tokens/phone numbers are global?
Tell me whether your tokens and phone numbers are:
• partitioned per pod (each pod has its own allotment), or
• global (shared across pods),
and I’ll provide the appropriate complete example for that scenario.
Sources: Server options | LiveKit Documentation | Self-hosted deployments | LiveKit Documentationboundless-author-7811
02/23/2026, 9:09 AMtall-airline-16756
02/23/2026, 9:09 AMload_fnc.
Why Redis is better for global constraints
• Single source of truth: every pod reads the same counters for tokens and active calls.
• Consistent admission decisions: jobs are accepted/rejected based on cluster-wide usage rather than per-pod estimates.
• Simpler operational model for enforcing global quotas (tokens/minute, total concurrent calls).
Key correctness considerations
• Use atomic Redis operations for updates (INCR/DECR, Lua scripts) to avoid races.
• Track tokens in a sliding window or time-bucketed counters (to respect tokens/minute).
• Ensure recovery on crashes: decrement active call counters when jobs end or on process exit. Consider TTLs or heartbeat-cleanup for stuck counters.
• Be conservative on failure: if Redis is unavailable, the load_fnc should default to a conservative load (e.g., 1.0) to avoid oversubscribing.
• Monitor and alert on Redis latency and errors — they affect admission decisions.
Recommended design (high level)
• Redis keys (examples):
◦ globaltokensused — tokens used in the current minute window (or use per-second buckets)
◦ globaltokenswindow:<epoch_min> — optional per-minute bucketed counts
◦ globalphonesactive — current total active calls across cluster
◦ globalphonesmax — n_numbers × 50 (set at deploy time)
• Use atomic operations:
◦ For calls: INCR globalphonesactive when starting a call; DECR when ending.
◦ For tokens: use Lua to increment token usage and get the new total in the window atomically, returning whether the increment would exceed the budget.
• Compute global load as:
◦ token_load = used_tokens / max_tokens (clamped 0..1)
◦ phone_load = active_calls / max_phone_calls (clamped 0..1)
◦ load = max(token_load, phone_load)
Practical pattern (Python-like pseudocode)
• This shows safe token reservation via a Redis Lua script and phone counters using INCR/DECR. Integrate this into your job acceptance path; your load_fnc can read the current ratios rather than reserving tokens itself (reservation happens when you actually call the LLM or accept a job).
1) Token reservation Lua (atomic):
-- args: KEYS[1]=token_key, ARGV[1]=tokens_to_add, ARGV[2]=max_tokens, ARGV[3]=window_ttl_seconds
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local add = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
if current + add > max then
return {0, current} -- cannot reserve
end
current = redis.call("INCRBY", KEYS[1], add)
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3]))
return {1, current} -- reserved, return new total
2) Python sketch using redis-py:
import redis
from typing import Tuple
r = redis.Redis(...)
# keys/config
TOKENS_KEY = "global:tokens:current" # bucket for current minute (or finer buckets)
TOKENS_MAX = 100000
TOKENS_WINDOW_SECS = 60
PHONES_ACTIVE_KEY = "global:phones:active"
PHONES_MAX = NUM_NUMBERS * 50
def try_reserve_tokens(tokens_needed: int) -> bool:
# Call the Lua script (load it once and reuse)
res = r.evalsha(lua_sha, 1, TOKENS_KEY, tokens_needed, TOKENS_MAX, TOKENS_WINDOW_SECS)
return res[0] == 1
def increment_call_counter() -> int:
return r.incr(PHONES_ACTIVE_KEY)
def decrement_call_counter() -> int:
val = r.decr(PHONES_ACTIVE_KEY)
if val < 0:
r.set(PHONES_ACTIVE_KEY, 0)
return 0
return val
def get_global_load() -> float:
used_tokens = int(r.get(TOKENS_KEY) or 0)
phone_active = int(r.get(PHONES_ACTIVE_KEY) or 0)
token_load = min(used_tokens / TOKENS_MAX, 1.0)
phone_load = min(phone_active / PHONES_MAX, 1.0)
return max(token_load, phone_load)
3) How to use this with AgentServer
• Reservation approach (preferred): When a job is about to be accepted:
◦ Attempt to reserve phone slot: call INCR and check if <= PHONES_MAX. If not, decrement and reject.
◦ If job will call LLM immediately, call the Lua script to reserve tokens for the expected usage. If reservation fails, roll back any phone reservation and reject.
◦ On job completion, decrement phone counter. Tokens are already consumed/recorded in the Redis bucket.
• load_fnc implementation for LiveKit:
◦ Read current used token bucket and phone active count (no mutations) and return the computed load = max(token_load, phone_load).
◦ Keep it cheap — avoid doing costly ops in load_fnc.
• If you cannot reserve tokens at accept-time (e.g., token usage is only known after LLM returns), use conservative estimates at accept-time and adjust.
Edge cases and resilience
• Stale counters: use TTLs or periodic reconciliation jobs to correct counters if pods crash without decrementing.
• Atomicity: use Redis Lua scripts for multi-key operations and checks.
• Performance: load_fnc should be a simple GETs; heavy logic (reservation) belongs in the job acceptance flow.
• Fail-closed: if Redis is unreachable, treat load as 1.0 (or a high value) to avoid overcommit.
Would you like:
• A complete copy-pasteable example that replaces your current entrypoint with AgentServer and uses Redis for global token+phone tracking (including the Lua script and AgentServer wiring)?
• Or a lighter example showing only the Redis Lua script + job-acceptance flow you can plug into your existing code?
Tell me which you prefer and I’ll provide the concrete example.
Sources: Server options | LiveKit Documentation | Universal Egress | Universal Egressboundless-author-7811
02/23/2026, 9:29 AMtall-airline-16756
02/23/2026, 9:29 AMagent_name) and assigns the job. LiveKit uses each pod’s reported load (load_fnc) and load_threshold to prefer less-busy pods.
What actually happens in a typical flow
1. Your scheduler creates the job (e.g., requests a room or starts a SIP/call job) by calling the LiveKit API or creating the job in the LiveKit system.
2. LiveKit queries registered AgentServer instances for availability (each pod returns its per-pod load via load_fnc).
3. LiveKit dispatches the job to an available pod that is under load_threshold. Dispatch uses round-robin among available agents but skips overloaded pods — effectively sending the job to a less-busy pod.
4. That pod accepts and starts the job (AgentServer spawns per-job process, etc.).
When you need to add logic in your scheduler
• You only need extra logic if you want to:
◦ Reserve global resources before job creation (e.g., reserve tokens or phone slots in Redis). If reservation fails, do not create the job.
◦ Provide pre-validation (e.g., check quotas, billing, or business rules) before asking LiveKit to dispatch.
• Do NOT implement your own pod-selection logic. Let LiveKit do assignment; duplicating it risks race conditions and uneven distribution.
If you are using global constraints (tokens / phone lines)
• Recommended pattern:
◦ First, reserve global capacity in Redis (atomic Lua script): reserve tokens and/or a phone slot for this job.
◦ If reservation succeeds, create the job in LiveKit (LiveKit will dispatch it to a pod).
◦ If reservation fails, reject/skip scheduling the job.
◦ On job completion or failure, release the reservations (decrement counters).
• This keeps global quotas consistent and still lets LiveKit choose the best pod.
Practical checklist to ensure smooth dispatch
• All pods run AgentServer with the same agent_name.
• Implement a correct load_fnc (local or Redis-backed) so LiveKit has accurate per-pod load signals.
• Set load_threshold appropriately (e.g., 0.9).
• If global resources exist, perform atomic reservation in your scheduler before creating LiveKit jobs.
• Make load_fnc cheap (simple GETs) and do reservations in the scheduler/job-accept path, not inside load_fnc.
Want an example?
• I can provide:
◦ A short code example showing the scheduler reserving tokens/phone slots in Redis, then creating a LiveKit job.
◦ The AgentServer load_fnc example that reads the same Redis keys (so pods reflect global usage).
Which example would you like first — the Redis reservation + scheduler flow, or the AgentServer load_fnc that reads Redis?boundless-author-7811
02/23/2026, 9:31 AMself.lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=room_name,
metadata=json.dumps({
"trunk_id": trunk_id,
"phone_number": phone_number,
"customer_data": customer_row,
"caller_phone_id": phone_number_id,
"resource_type": "phone"
})
)
),
timeout=10.0
)tall-airline-16756
02/23/2026, 9:31 AMload_fnc that reads the same keys.
Short answer
• Your current call to agent_dispatch.create_dispatch(...) is correct: LiveKit will pick one of the registered AgentServer pods (same agent_name) and assign the job.
• If you have global constraints (LLM tokens and phone capacity), reserve them in Redis before calling create_dispatch. If reservation fails, do not create the dispatch. If reservation succeeds but dispatch fails, roll back the reservation.
• Pods’ load_fnc should read the same Redis keys so LiveKit sees cluster-wide utilization.
Where to put reservation logic
• In your scheduler, immediately before the create_dispatch call:
a. Attempt atomic reservations in Redis (tokens and a phone slot).
b. If both succeed, call create_dispatch.
c. If create_dispatch succeeds, proceed.
d. If create_dispatch fails (or job is cancelled), release the reservations.
• On job start/acceptance you may also want to re-verify reservations (defensive), and always release on job completion.
Concrete example (pattern to adapt)
• This example uses redis-py, a Lua script for atomic token reservation, and simple INCR/DECR for phone slots. It shows reserving, creating dispatch, and rollback on failure. Replace variable names, error handling, and logging to suit your environment.
1) Lua script to atomically add tokens to current window (load into Redis once and reuse sha)
-- token_reserve.lua
-- KEYS[1] = token_bucket_key
-- ARGV[1] = tokens_to_add
-- ARGV[2] = max_tokens_in_window
-- ARGV[3] = window_ttl_secs
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local add = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
if current + add > max then
return {0, current}
end
local new = redis.call("INCRBY", KEYS[1], add)
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3]))
return {1, new}
2) Scheduler-side Python sketch (reservation + dispatch)
import redis
import json
import time
from livekit import CreateAgentDispatchRequest # pseudo-import
r = redis.Redis(host="redis.svc", port=6379, db=0)
# keys / config
TOKENS_KEY = "global:tokens:current" # bucket covering the sliding window
TOKENS_MAX = 100_000
TOKENS_WINDOW_SECS = 60
PHONES_ACTIVE_KEY = "global:phones:active"
PHONES_MAX = NUM_NUMBERS * 50
# load the lua script once
with open("token_reserve.lua", "r") as f:
lua = f.read()
lua_sha = r.script_load(lua)
def try_reserve_tokens(tokens_needed: int) -> bool:
res = r.evalsha(lua_sha, 1, TOKENS_KEY, tokens_needed, TOKENS_MAX, TOKENS_WINDOW_SECS)
# res -> [status (0/1), new_total]
return bool(res[0])
def try_reserve_phone_slot() -> bool:
new_val = r.incr(PHONES_ACTIVE_KEY)
if new_val > PHONES_MAX:
# roll back
r.decr(PHONES_ACTIVE_KEY)
return False
return True
def release_phone_slot():
val = r.decr(PHONES_ACTIVE_KEY)
if val < 0:
r.set(PHONES_ACTIVE_KEY, 0)
def schedule_job(room_name, trunk_id, phone_number, phone_number_id, customer_row, tokens_estimate):
# 1) Reserve phone slot
if not try_reserve_phone_slot():
return {"ok": False, "reason": "no_phone_capacity"}
# 2) Reserve tokens atomically
if not try_reserve_tokens(tokens_estimate):
# rollback phone slot
release_phone_slot()
return {"ok": False, "reason": "no_token_capacity"}
# 3) Create LiveKit dispatch
try:
resp = self.lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=room_name,
metadata=json.dumps({
"trunk_id": trunk_id,
"phone_number": phone_number,
"customer_data": customer_row,
"caller_phone_id": phone_number_id,
"resource_type": "phone"
})
),
timeout=10.0
)
except Exception as e:
# Rollback both reservations on failure
release_phone_slot()
# Optionally reduce token bucket: because tokens were reserved by INCRBY in Lua,
# you should compensating-decrement the token key. Use a safe Lua script or DECRBY.
r.decrby(TOKENS_KEY, tokens_estimate)
return {"ok": False, "reason": "create_dispatch_failed", "error": str(e)}
# on success, return dispatch info
return {"ok": True, "dispatch": resp}
Notes about this sketch
• tokens_estimate: best to estimate conservatively before reservation. If exact tokens used differ, you can adjust after the LLM call (increment by actual tokens used was already done by reservation; if estimate overshot, you might decrement, or accept conservative waste).
• Token accounting: this Lua script increments a single bucket key for the window. For more accurate sliding-window accounting, use per-second buckets (e.g., globaltokensts:YYYYMMDDHHMMSS) and sum last 60 buckets; reservations then act on the current second bucket. That prevents a single large burst early in the minute from blocking rest of minute.
• Ensure your job cleanup path always calls release_phone_slot() and adjusts tokens if necessary.
AgentServer load_fnc that reads Redis
• Keep load_fnc cheap: simple GETs and division. This returns cluster-wide load so LiveKit sees correct availability.
def agent_load_fnc(agent_server) -> float:
try:
used_tokens = int(r.get(TOKENS_KEY) or 0)
phone_active = int(r.get(PHONES_ACTIVE_KEY) or 0)
token_load = min(used_tokens / TOKENS_MAX, 1.0)
phone_load = min(phone_active / PHONES_MAX, 1.0)
return max(token_load, phone_load)
except Exception:
# conservative fallback when Redis is unavailable
return 1.0
Additional recommendations and edge cases
• Atomicity: reservation is atomic for tokens (Lua) and phone slots are best handled with INCR + check + possible DECR rollback. If you need guaranteed multi-key atomicity, do both checks/reservations in a single Lua script.
• Sliding window granularity: per-minute single bucket is simple, but per-second buckets or leaky-bucket rate limiter gives smoother capacity usage and more fairness.
• Failure handling: if your scheduler reserves but dispatch never starts or pod crashes, you must release reservations. Consider setting TTLs on phone slots or using heartbeat/lease patterns to recover stuck reservations.
• Load_fnc frequency: keep it fast and cheap. Don’t perform reservations there.
• Conservative behavior: if Redis errors occur, treat load as high to prevent oversubscription.
Would you like:
• A fuller, copy-pasteable example that includes:
◦ Single-Lua multi-reservation script (tokens + phone slot) to make reservation atomic,
◦ Job creation + rollback flow,
◦ AgentServer wiring with server.load_fnc = agent_load_fnc and where to hook release on job completion; OR
• A simpler example showing per-second token buckets (more accurate sliding window) plus reservation logic?
Pick which example you want and I’ll produce the full code you can drop into your scheduler and agent.boundless-author-7811
02/23/2026, 12:52 PMtall-airline-16756
02/23/2026, 12:52 PMawait job_request.accept(...) or await job_request.reject()), and LiveKit routes the job accordingly.
• There is no single synchronous API call that returns “accepted vs rejected” from create_dispatch. Instead use one of the observation patterns below.
Practical approaches you can implement
1) Observe agent presence in the room (recommended)
• When an agent accepts the job it will join the room. Monitor participant join events:
◦ Use LiveKit webhooks (participant_joined / participant_left) or poll the Room/Participant API to detect the agent participant.
◦ If the agent joins within your expected window, treat the job as “accepted.” If no agent joins after a timeout, treat it as “rejected/failed” and take fallback action.
• This is simple and reliable because an accepted job results in an actual participant connection.
2) Add agent-side signaling / callbacks (recommended for immediate feedback)
• Have your agent code emit an API/webhook event back to your scheduler when it accepts or rejects a job.
◦ On accept: agent calls your scheduler endpoint with dispatch_id, room, agent_id, status=accepted.
◦ On reject: agent calls with status=rejected and reason.
• This gives near-real-time confirmation and is easy to correlate with dispatch metadata.
3) Use job lifecycle events or agent logs
• Instrument agent workers to log accept/reject. Aggregated logs or tracing can be queried by the scheduler for status (less real-time unless you wire alerts).
4) Retry / timeout pattern in scheduler (defensive)
• After create_dispatch, start a short timer (e.g., 5–30 seconds).
• If no agent-joined event or agent-side callback arrives before timeout, consider the job unpicked and:
◦ Retry create_dispatch, or
◦ Release reservations and mark job as failed, or
◦ Trigger fallback routing.
• This pattern is important to handle transient failures or situations where all agents are busy.
5) If you use global reservations (Redis)
• If your scheduler performed a reservation (tokens/phone slots) before dispatch, ensure reservation rollback if job was not accepted or dispatch fails.
• Use agent-side callbacks to release reservations on reject/complete.
Implementation pointers (concrete)
• Put a unique dispatch_id in metadata when calling create_dispatch so you can correlate webhooks or agent callbacks with the original scheduled job.
• Webhook flow:
a. Scheduler creates dispatch with metadata {dispatch_id: "X", ...}.
b. Agent accepts -> agent joins the room.
c. LiveKit emits participant_joined webhook (include metadata or inspect participant identity to map to dispatch_id).
d. Scheduler marks job accepted.
• Agent callback flow:
a. Agent handler receives JobRequest and decides to accept/reject.
b. Immediately call your scheduler endpoint: POST /dispatch-status {dispatch_id, status, reason, agent_id}
c. Scheduler marks the status and proceeds.
Edge cases & recommendations
• There may be a short window between dispatch and worker acceptance; use reasonable timeouts before retrying.
• Log and metric the time between create_dispatch and acceptance to tune timeouts.
• If you need guaranteed atomic behavior (e.g., you reserved tokens/phone slots), perform reservations before create_dispatch and roll them back if no acceptance is observed.
• Treat Redis/unavailable signals conservatively (fail-closed) to avoid oversubscription.
Would you like a small code example for:
• webhook-based detection (scheduler side), or
• agent-side immediate callback (agent code) to notify your scheduler on accept/reject?
Tell me which and I’ll provide a copy-pasteable snippet you can plug into your scheduler and agent.
Sources: Server options | LiveKit Documentation | server-sdk-ruby | livekit/livekit_composite | DeepWiki | SIP Lifecycle Management Agent | LiveKit Documentation | Why do jobs stop after agent rejection? | LiveKitboundless-author-7811
02/23/2026, 1:00 PMtall-airline-16756
02/23/2026, 1:00 PMcreate_dispatch call. Instead, acceptance/rejection is observable via asynchronous signals (participant joins, agent callbacks, or webhooks). You should rely on those signals (or add agent-side callbacks) for scheduler feedback.
Details and recommended approaches
• What LiveKit knows internally
◦ When LiveKit dispatches a job, it sends a JobRequest to an agent worker.
◦ The agent explicitly accepts (job_request.accept(...)) or rejects (job_request.reject()).
◦ LiveKit records that outcome and proceeds to route the job accordingly.
• Why create_dispatch doesn't synchronously return accept/reject
◦ create_dispatch only confirms the dispatch was scheduled in LiveKit. Worker acceptance is asynchronous and can take time (agent selection, network latency, worker decision).
◦ Because acceptance is asynchronous, the scheduler must observe external signals rather than expect an immediate reply from create_dispatch.
• Reliable signals you can use as feedback
a. Participant join events (recommended)
• When an agent accepts, it joins the room as a participant.
• Use LiveKit webhooks (participant_joined/participant_left) or poll the Room/Participant API to detect agent presence.
• Correlate events with a dispatch_id in your metadata so you know which scheduled job was accepted.
a. Agent-side callbacks (recommended for immediate confirmation)
• Have the agent call your scheduler’s webhook or API endpoint immediately upon accept/reject with {dispatch_id, status, reason, agent_id}.
• This gives near-real-time, explicit acknowledgement you can act on.
a. LiveKit job/room state via API (secondary)
• You can periodically check the room or participant list via LiveKit API to confirm an agent connected.
a. Logs/metrics (debugging)
• Aggregate agent logs or tracing to verify accept/reject behavior, but don’t rely on logs for real-time control.
• Practical pattern (robust)
a. Scheduler reserves global resources (Redis) if needed.
b. Scheduler calls create_dispatch with metadata including dispatch_id.
c. Start a timeout (e.g., 5–30s). Within that window:
• If you receive a participant_joined webhook for the dispatch_id → mark accepted.
• Or if you receive an agent callback indicating accept → mark accepted.
• If you receive agent callback indicating reject → mark rejected and release reservations.
a. If timeout expires with no accept signal → consider the job unpicked and retry or fail and release reservations.
b. On job completion, agent or room events trigger reservation release.
• Why this is preferable to trying to read LiveKit internals
◦ Using webhooks/agent callbacks is explicit and robust across failures and restarts.
◦ Relying on internal status (if exposed) would still be asynchronous and require polling; webhooks/callbacks minimize latency and complexity.
If you want, I can provide:
• A webhook-based example (scheduler side) that listens for participant_joined and maps to dispatch_id.
• An agent-side snippet that calls your scheduler endpoint immediately on accept/reject.
• A complete flow tying your Redis reservation + create_dispatch + accept/reject handling together.
Which example would help you most right now?boundless-author-7811
02/23/2026, 1:14 PMtall-airline-16756
02/23/2026, 1:15 PM# agent_load_fn.py
def agent_load_fnc(agent_server) -> float:
try:
used_tokens = int(r.get(TOKEN_KEY) or 0)
active_phones = int(r.get(PHONES_KEY) or 0)
token_load = min(used_tokens / TOKENS_MAX, 1.0)
phone_load = min(active_phones / PHONES_MAX, 1.0)
load = max(token_load, phone_load)
# clamp to [0.0, 1.0] and return
return max(0.0, min(load, 1.0))
except Exception:
# conservative fallback if Redis is unavailable or an error occurs
return 1.0
Final notes and recommendations
• Tune thresholds: pick a sensible load_threshold (e.g., 0.8–0.9) and ensure your HPA scales earlier (e.g., scale up at ~0.5) so new pods are available before others saturate.
• Use TTLs and cleanup: set TTLs or lease patterns for phone-slot keys and periodically reconcile counters to recover from crashed pods or missed decrements.
• Prefer push over poll: rather than polling Redis from the scheduler, use Pub/Sub or an HTTP callback from the agent for immediate confirmation of accept/reject; Redis keys are still useful for authoritative state.
• Atomicity and correctness: perform multi-resource reservations in a single Lua script (as shown) to avoid races. Use Lua also for safe rollback logic where needed.
• Failure mode: treat Redis failures conservatively (load = 1.0) to avoid oversubscription. Monitor Redis latency and errors because they impact admission decisions.
• Observability: emit metrics for time-to-accept, reservation failures, and rejected dispatches so you can tune timeouts and capacities.
If you’d like, I can:
• Provide a fully integrated copy-pasteable repo-style example that wires this into your exact entrypoint (entrypoint, prewarm) and AgentServer startup.
• Convert the scheduler polling to a Pub/Sub or HTTP callback example for lower latency.
Which would you like next?boundless-author-7811
02/23/2026, 1:16 PMtall-airline-16756
02/23/2026, 1:16 PMimport uuid, json
dispatch_id = str(uuid.uuid4())
resp = self.lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=room_name,
metadata=json.dumps({
"dispatch_id": dispatch_id,
"trunk_id": trunk_id,
"phone_number": phone_number,
"resource_type": "phone"
})
),
timeout=10.0
)
# Start a timeout watcher (see below) to handle no-accept cases.
2) Scheduler: HTTP endpoint to receive agent callbacks (Flask-style)
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/dispatch-callback", methods=["POST"])
def dispatch_callback():
data = request.json # { dispatch_id, status: "accepted"|"rejected", agent_id, reason? }
dispatch_id = data["dispatch_id"]
status = data["status"]
# Lookup your scheduled job by dispatch_id, mark accepted/rejected, release/confirm reservations
if status == "accepted":
mark_dispatch_accepted(dispatch_id, data)
else:
mark_dispatch_rejected(dispatch_id, data)
return jsonify({"ok": True})
3) LiveKit webhooks: participant_joined handling (scheduler webhook)
• Configure LiveKit to POST participant_joined events to your webhook endpoint.
• In the webhook handler, inspect participant metadata or room metadata to find dispatch_id. If found, mark job accepted.
@app.route("/livekit-webhook", methods=["POST"])
def livekit_webhook():
ev = request.json
if ev.get("event") == "participant_joined":
participant = ev["participant"]
# Look in participant.metadata or room.metadata for dispatch_id (embed dispatch_id)
dispatch_id = find_dispatch_id_from_event(ev)
if dispatch_id:
mark_dispatch_accepted(dispatch_id, {"agent_joined": True})
return jsonify({"ok": True})
4) Agent: send immediate callback on accept/reject
• In your AgentServer job handler, immediately post to scheduler when you accept or reject.
import requests
def on_job_request(job_request):
dispatch_id = job_request.metadata.get("dispatch_id")
# decide whether to accept
accepted = should_accept_job(job_request)
if accepted:
job_request.accept()
# immediately notify scheduler
<http://requests.post|requests.post>("<https://scheduler.example/dispatch-callback>", json={
"dispatch_id": dispatch_id,
"status": "accepted",
"agent_id": my_agent_id,
}, timeout=1.0)
else:
job_request.reject()
<http://requests.post|requests.post>("<https://scheduler.example/dispatch-callback>", json={
"dispatch_id": dispatch_id,
"status": "rejected",
"reason": "busy_or_quota"
}, timeout=1.0)
5) Scheduler: timeout / reconciliation logic
• Start a short timer when you call create_dispatch (e.g., 10–30s).
• If you get callback/webhook before timeout → done.
• If timeout expires with no acceptance:
◦ Option A: retry dispatch (after re-reserving resources), possibly with backoff.
◦ Option B: mark failed and release reservations and notify business systems.
6) Reservation integration (outline)
• Reserve tokens/phone slot in Redis before create_dispatch.
• If reservation fails → skip create_dispatch.
• If create_dispatch fails → release reservations immediately.
• On agent accept (agent callback or webhook) → confirm reservation (keep).
• On reject or timeout → release reservation.
Tuning and reliability notes
• Use dispatch_id everywhere in metadata for easy correlation.
• Use atomic Redis ops or Lua scripts for reservation to avoid races.
• Make agent callbacks best-effort (non-blocking); webhook is authoritative if callback fails.
• Treat missing Redis or scheduler endpoint conservatively (fail-closed) to avoid overcommit.
• Log and emit metrics: dispatch created → accepted delay, reservation failures, retries.
Would you like a complete copy-pasteable example (scheduler + Lua reservation + AgentServer handler + webhook handlers) wired to your current entrypoint? If so, tell me whether you want per-pod or global reservations and I’ll provide the full code.boundless-author-7811
02/24/2026, 9:37 AMresources:
limits:
memory: 4Gi
requests:
cpu: 500m
memory: 1Gi
now i have to scale it 6x times, what should be the ideal resources that should be allocated and how many concurrent calls can be handled?tall-airline-16756
02/24/2026, 9:37 AMdispatch_id in dispatch metadata so events correlate.
• Scheduler exposes two endpoints:
◦ POST /dispatch-callback — receives immediate agent callbacks
◦ POST /livekit-webhook — receives LiveKit webhooks (participant_joined)
• Agent will call /dispatch-callback on accept/reject.
• Token reservation uses a Lua script (atomic). Phone slot uses INCR/DECR with rollback.
1) Scheduler: reservation + dispatch (Python sketch)
• Reserve resources (atomic token Lua + phone INCR). If successful, call create_dispatch. If create_dispatch fails, roll back reservations.
import uuid, json, time, requests, redis
from livekit import CreateAgentDispatchRequest # pseudo-import for your SDK
r = redis.Redis(host="redis.svc", port=6379, db=0)
TOKENS_KEY = "global:tokens:current"
TOKENS_MAX = 100_000
TOKENS_WINDOW_SECS = 60
PHONES_KEY = "global:phones:active"
PHONES_MAX = NUM_NUMBERS * 50
# load token_reserve.lua once and store sha
with open("token_reserve.lua") as f:
token_lua = f.read()
token_sha = r.script_load(token_lua)
def try_reserve_tokens(tokens_needed: int) -> bool:
res = r.evalsha(token_sha, 1, TOKENS_KEY, tokens_needed, TOKENS_MAX, TOKENS_WINDOW_SECS)
return bool(res[0])
def try_reserve_phone_slot() -> bool:
new = r.incr(PHONES_KEY)
if new > PHONES_MAX:
r.decr(PHONES_KEY)
return False
return True
def release_phone_slot():
val = r.decr(PHONES_KEY)
if val < 0:
r.set(PHONES_KEY, 0)
def release_tokens(tokens: int):
# Use DECRBY safely; if key missing, set to 0
r.decrby(TOKENS_KEY, tokens)
if int(r.get(TOKENS_KEY) or 0) < 0:
r.set(TOKENS_KEY, 0)
def schedule_call(room_name, trunk_id, phone_number, phone_number_id, customer_row, tokens_estimate):
dispatch_id = str(uuid.uuid4())
# 1) Reserve phone slot
if not try_reserve_phone_slot():
return {"ok": False, "reason": "no_phone_capacity"}
# 2) Reserve tokens
if not try_reserve_tokens(tokens_estimate):
release_phone_slot()
return {"ok": False, "reason": "no_token_capacity"}
# 3) Create dispatch
try:
resp = self.lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=room_name,
metadata=json.dumps({
"dispatch_id": dispatch_id,
"trunk_id": trunk_id,
"phone_number": phone_number,
"caller_phone_id": phone_number_id,
"resource_type": "phone"
})
),
timeout=10.0
)
except Exception as e:
# rollback reservations
release_phone_slot()
release_tokens(tokens_estimate)
return {"ok": False, "reason": "create_dispatch_failed", "error": str(e)}
# 4) Start a watcher (background) to reconcile if no accept in time
start_accept_watcher(dispatch_id, tokens_estimate, timeout=20) # implement per your infra
return {"ok": True, "dispatch_id": dispatch_id, "dispatch": resp}
Notes:
• token_reserve.lua is the Lua script that atomically checks and INCRBY tokens for the window (as we discussed earlier).
• start_accept_watcher should trigger after the timeout to release reservations and optionally retry if no accept seen.
2) Agent: accept/reject and immediate callback (Python sketch)
• Inside the agent job handler, immediately notify the scheduler when accepting or rejecting.
import requests
SCHEDULER_CALLBACK_URL = "<https://scheduler.example/dispatch-callback>"
SCHEDULER_TIMEOUT = 1.0
def on_job_request(job_request):
metadata = job_request.metadata or {}
dispatch_id = metadata.get("dispatch_id")
if should_accept_job(job_request):
# Accept the job with LiveKit
job_request.accept()
# Best-effort notify scheduler (non-blocking ideally)
try:
<http://requests.post|requests.post>(SCHEDULER_CALLBACK_URL, json={
"dispatch_id": dispatch_id,
"status": "accepted",
"agent_id": MY_AGENT_ID,
}, timeout=SCHEDULER_TIMEOUT)
except Exception:
# ignore — webhook will still confirm when participant joins
pass
else:
job_request.reject()
try:
<http://requests.post|requests.post>(SCHEDULER_CALLBACK_URL, json={
"dispatch_id": dispatch_id,
"status": "rejected",
"reason": "quota_or_busy",
"agent_id": MY_AGENT_ID,
}, timeout=SCHEDULER_TIMEOUT)
except Exception:
pass
3) Scheduler endpoints: callback + webhook handlers (Flask-style)
• Callback: immediate data from agent (fast path)
• Webhook: authoritative confirmation when agent actually joins room
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/dispatch-callback", methods=["POST"])
def dispatch_callback():
data = request.json
dispatch_id = data["dispatch_id"]
status = data.get("status")
if status == "accepted":
mark_dispatch_accepted(dispatch_id, data)
elif status == "rejected":
mark_dispatch_rejected(dispatch_id, data)
# release reservations
release_phone_slot_for_dispatch(dispatch_id)
release_tokens_for_dispatch(dispatch_id)
return jsonify({"ok": True})
@app.route("/livekit-webhook", methods=["POST"])
def livekit_webhook():
ev = request.json
if ev.get("event") == "participant_joined":
# Extract dispatch_id from room or participant metadata
dispatch_id = extract_dispatch_id(ev)
if dispatch_id:
mark_dispatch_accepted(dispatch_id, {"source": "webhook", "event": ev})
# Keep reservations confirmed; no action needed
return jsonify({"ok": True})
4) Watcher / timeout reconciliation
• If neither callback nor webhook confirms acceptance within the window:
◦ release phone slot and tokens
◦ mark job failed or retry based on policy
def start_accept_watcher(dispatch_id, tokens_reserved, timeout=20):
# schedule background task to run after timeout
def watcher():
time.sleep(timeout)
if not is_dispatch_accepted(dispatch_id):
# release reservations
release_phone_slot_for_dispatch(dispatch_id)
release_tokens(tokens_reserved)
mark_dispatch_timed_out(dispatch_id)
spawn_background_thread(watcher)
5) Additional safety & best practices
• Correlate using dispatch_id everywhere (dispatch metadata, agent callback, webhook).
• Use atomic Redis operations (Lua) for multi-key reservation if you want stronger guarantees.
• Use per-second buckets for tokens if you need smoother sliding-window behavior.
• Implement TTLs and reconciliation job to fix stuck counters from crashes.
• Treat missing scheduler/Redis as conservative failure (don’t oversubscribe).
• Keep agent callback best-effort: webhook is authoritative if callback fails.
• Log metrics: time-to-accept, reservation failures, retries.
Why this is optimal
• Fast confirmation: the agent callback gives near-instant confirmation so the scheduler can proceed quickly (e.g., release optimistic reservation state).
• Reliable truth: webhook (participant_joined) proves the agent actually connected; use it to reconcile and to recover when callbacks are lost.
• Safe resource usage: reservations before dispatch prevent overcommit; watcher + webhook/callback ensure cleanup.
If you’d like, I can:
• Produce a single, runnable example repository layout with:
◦ token_reserve.lua
◦ scheduler (Flask) with reservation + dispatch logic
◦ agent handler snippet integrated with AgentServer
◦ sample LiveKit webhook parsing function
• Or provide the single-Lua atomic reservation script that reserves both tokens and a phone-slot in one operation.
Which would you like next?
Sources: Self-hosted deployments | LiveKit Documentation | Troubleshooting Latency and Timeout Errors with Turn Detection on AWS | Server options | LiveKit Documentation