Developer Docs
Everything here reflects the real, live system — every command on this page has been run against production and verified to work exactly as shown.
1. Payment and credit
One fixed tier: $28/month. No metering, no custom amounts — fiatAmount is a fixed server-side constant, never read from your request. See Pricing for the checkout form.
Credit unit: 1 credit = 1 US cent. $28 = 2800 credits.
Billing runs in 6-day cycles, not calendar months. Each cycle costs 560 credits (2800 ÷ 560 = 5 cycles ≈ one $28 payment's worth of runway). The first 560-credit charge happens atomically at the moment your instance successfully starts running — not retroactively at the end of the cycle — so there's no free trial period hiding in the billing math. Every cycle after that is checked and charged automatically, in advance, every 6 days.
A free trial may also be available — new accounts can be granted a one-time 560-credit bonus (enough for exactly one 6-day trial instance) via the signup form on the homepage. This is independently toggleable and isn't always open; if it's closed, /signup returns {"status": "trial_closed"} and checkout (paid) is the reliable path in.
Multi-instance: one AWS account can run up to 4 instances at once, sharing one credit pool. If your account has more than one instance due for a cycle charge at the same time and the shared balance can't cover all of them, the oldest instance is protected first — newest instance(s) are the ones deprovisioned if credit runs short.
Standing balance cap: at most 22,400 credits (2 months × 4 instances) can be held prepaid at once — enforced before any payment is even created, so a rejected top-up never becomes a payment you'd need refunded.
Running out of credit: when a cycle's charge can't be covered, that instance is deprovisioned automatically — the underlying server, its static IP, and its DNS record are all torn down. Top up again via Pricing any time before or after.
2. Setting up and installation
Your AWS account is your login — every authenticated call below is signed with your own AWS credentials (SigV4), the same mechanism the aws CLI already uses. No separate API key or password to manage.
Step 1 — Get an account and credit
Submit the checkout form on Pricing (email + 12-digit AWS account number) and pay. This registers your account (if it doesn't exist yet) and, once the payment settles, credits it automatically — no manual step on your end.
Step 2 — Provision your instance
All authenticated calls need a SigV4-signing tool. awscurl is the simplest option:
bash
pip install awscurl
export AWS_ACCESS_KEY_ID=<your access key>
export AWS_SECRET_ACCESS_KEY=<your secret key>
awscurl --service execute-api --region ap-southeast-1 -X POST "https://api.niuty.com/create-instance"Returns once the instance is actually running (usually 15-90 seconds):
json
{"status": "creating", "instance_id": "<id>", "livekit_url": "wss://<id>.rt.niuty.com"}Choosing a region: pass ?region=EU or ?region=SG (case-insensitive) to pick where your instance runs. Omit it and you get the platform default. Any other value returns a 400 listing the currently valid regions - more are planned, but only ones that are actually live are ever accepted.
bash
awscurl --service execute-api --region ap-southeast-1 -X POST "https://api.niuty.com/create-instance?region=SG"Audio-only vs. audio+video: if your agent only ever publishes/subscribes audio tracks, either region works equally well. If you're streaming real video alongside audio (e.g. a camera-equipped robot, or anything using /session?enableCamera=1), choose EU (?region=EU) — it's provisioned with meaningfully more real-time throughput headroom and is the better fit for audio+video workloads today. SG is tuned specifically for audio-only agents and may throttle a video track under sustained real use.
Once your own agent process is up and listening for a job on this instance, you can talk to it directly from your browser — no frontend of your own required yet — via Test Your Agent.
/session is the same underlying flow (audio and video, for an agent that also publishes a video track) as a single shareable link instead of a page you paste a token into - not linked from the site nav, and deliberately not search-indexed, since it's meant to be sent directly to one person rather than browsed to. /test-token's response already includes a ready-to-use sessionUrl field (already URL-encoded) - just copy that, no need to build it by hand. Pass expiresInHours to /test-token if you want the link to stay valid longer than the default 1 hour (up to 7 days) - useful for an evergreen demo link rather than a one-off test.
Opening the link connects immediately, full-screen-capable, no paste step - whoever you send it to just sees/hears the agent right away.
Add &audioOnly=1 to skip video entirely - swaps the fullscreen video stage for a minimal "connected, talk into your microphone" view, same as Test Your Agent but via the single-link flow instead of paste. /session also always exposes a two-way data-channel bridge (window.NiutyBridge/window.AndroidApp, both modes, video or audio-only) - this is what makes it embeddable inside your own native app instead of just a page a human opens directly; see Embedding in a native Android app below.
Tune the browser's own mic processing with &echoCancellation=, &noiseSuppression=, and &autoGainControl= (each 1/0 or true/false) - these map directly to WebRTC's own capture constraints, applied when /session publishes your microphone. Any you omit are left at the browser's own default (normally on). Useful if you're using a mic that already does its own noise reduction (e.g. a lavalier) and don't want WebRTC's processing fighting it - for example, keep echo cancellation on for TV-speaker feedback while turning off noise suppression and AGC so they don't distort the input:
https://niuty.com/session?token=<token>&livekitUrl=<livekitUrl>&noiseSuppression=0&autoGainControl=0Pick a specific mic with &audioDeviceId=<id> — for deterministic device selection on fixed kiosk hardware (e.g. an external lavalier), instead of letting the browser/OS pick whatever it currently calls the default input. Get a device's ID from the browser's own MediaDevices.enumerateDevices() on the same machine.
Publish your own camera, not just receive the agent's, with &enableCamera=1 — /session otherwise never turns your camera on at all (it only ever attaches the agent's video, if it publishes one). This is for a kiosk/embodied-agent setup — a dedicated webcam feeding the agent's own vision pipeline, not a video call — so it's an explicit opt-in, independent of audioOnly: you can combine audioOnly=1&enableCamera=1 to keep the minimal "talk into your microphone" view while still publishing your camera underneath it.
Once enableCamera=1 is set, the same kind of device/quality controls apply to video: &videoDeviceId=<id> (pick a specific camera — the dedicated overhead one, not a laptop's built-in webcam), and &videoWidth=/&videoHeight=/&videoFrameRate= for capture quality (e.g. request 1080p or 4K for better computer-vision input):
https://niuty.com/session?token=<token>&livekitUrl=<livekitUrl>&enableCamera=1&videoDeviceId=<id>&videoWidth=1920&videoHeight=1080&videoFrameRate=30Save instance_id — every other call needs it. This call charges 560 credits atomically on success; if you don't have enough credit, it fails cleanly with {"status": "error", "message": "Insufficient credit."} and nothing is charged.
Step 3 — Check status (optional)
bash
# One specific instance
awscurl --service execute-api --region ap-southeast-1 -X GET "https://api.niuty.com/status?instanceId=<instance_id>"
# Account-wide overview - credit balance + every instance you have
awscurl --service execute-api --region ap-southeast-1 -X GET "https://api.niuty.com/status"The single-instance response includes the region it's actually running in (EU/SG, confirming whatever you picked, or the platform default if you didn't pick one):
json
{"instance_id": "<id>", "status": "active", "tier": 1, "region": "EU", "instance_ip": "1.2.3.4", "livekit_url": "wss://<id>.rt.niuty.com"}If an instance looks unhealthy (status checks failing, unreachable, nothing responding) — reboot it yourself, no need to contact us:
bash
awscurl --service execute-api --region ap-southeast-1 -X POST "https://api.niuty.com/reboot-instance?instanceId=<instance_id>"Takes about a minute to come back. Rate-limited to one request per instance per 2 minutes — a second call before that window passes returns {"status": "error", ...} with a 429, not a second reboot.
Step 4 — Fetch your credential
bash
awscurl --service execute-api --region ap-southeast-1 -X GET "https://api.niuty.com/fetch-credential?instanceId=<instance_id>" > ~/.niuty/credentials.encThis is an encrypted envelope (key_id/nonce/ciphertext), not raw LiveKit credentials — only live-lib (below) can decrypt it. ~/.niuty/credentials.enc is the default path every live-lib API in this doc reads from.
Step 5 — Install live-lib
Run this inside the virtualenv you'll actually use for your agent (python3 -m venv .venv && source .venv/bin/activate first, if you haven't already):
bash
mkdir -p ~/.niuty # if it doesn't already exist
aws codeartifact login --tool pip --domain niuty --domain-owner 355681235310 --repository niuty-python --region ap-southeast-1
# aws codeartifact login always writes the index-url globally (affects every
# venv and your system Python, not just this one) - move it into this venv
# only, then remove the global copy:
pip config --site set global.index-url "$(pip config get global.index-url)"
pip config --user unset global.index-url
pip install "live-lib[livekit]"Use the [livekit] extra — the base live-lib install is schema-only (for tools that just need to author/export flows) and deliberately doesn't pull in the LiveKit/OpenAI/Google SDK stack.
CodeArtifact's own upstream chain (niuty-python → pypi-store → public PyPI) means this same index also transparently serves ordinary public packages (boto3, awscurl, etc.) — you never need a second index configured for anything else this venv installs.
Troubleshooting:
401 Error, Credentials not correcton anypip install, even unrelated public packages: the CodeArtifact login token embedded in your pip config has expired — it's only valid 12 hours. Fix: re-run theaws codeartifact login+pip config --site set/pip config --user unsetblock above (inside the same venv) to get a fresh token.- A different, unrelated venv (or your bare system Python) is also trying to hit CodeArtifact, or fails the same way: the index-url leaked into the global/user-level pip config instead of staying scoped to one venv — most often from running plain
aws codeartifact login --tool pip ...without the--site/--userfollow-up commands above. Fix:pip config --user unset global.index-url(removes it everywhere except whichever venvs already have their own--site-scoped copy), then redo the Step 5 block above inside whichever venv(s) actually need it.
If codeartifact login fails with AccessDeniedException on GetAuthorizationToken: your AWS account is already granted access at the resource-policy level (that happens automatically when your account registers), but if you're calling as a specific IAM user rather than the account root, that user also needs their own identity-based policy allowing it — a resource policy alone isn't sufficient for a non-root caller. Attach this to the IAM user you're using:
json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"codeartifact:GetAuthorizationToken",
"codeartifact:GetRepositoryEndpoint",
"codeartifact:ReadFromRepository"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "sts:GetServiceBearerToken",
"Resource": "*",
"Condition": { "StringEquals": { "sts:AWSServiceName": "codeartifact.amazonaws.com" } }
}
]
}(Or simply call codeartifact login with your account's root credentials for this one step — root isn't subject to identity-based policy restrictions at all.)
3. live-lib API usage
This covers the APIs actually used in a real, production live-lib integration — not the full surface. Two independent halves: FlowGraph (design-time — build your agent's conversation logic in Python) and agent_runtime (run-time — connect that logic to a live LiveKit voice session using your niuty-provisioned instance).
FlowGraph — designing your agent's conversation
python
from live_lib import FlowGraph
graph = FlowGraph(name="support_agent", persona="Friendly, concise support rep.")
@graph.node("MAIN_ROUTING", objective="Figure out what the caller needs")
def main_routing(n):
n.instructions("Greet the caller.", "Ask how you can help.")
n.on(condition="caller wants billing help", to="BILLING")
n.on(condition="caller wants technical support", to="SUPPORT")
@graph.node("BILLING", objective="Resolve a billing question")
def billing(n):
n.instructions("Ask for the account or invoice number.", "Explain the charge or issue a fix.")
n.on(condition="caller's question is resolved", to="MAIN_ROUTING")
n.on(condition="caller needs a human", to="SUPPORT")
graph.entry("MAIN_ROUTING")
flow = graph.compile()Each node is a live conversation state, not a text-generation step — transitions (n.on) are what your caller actually says next, not function calls. Flows are cyclic state machines (see BILLING looping back to MAIN_ROUTING above), not one-way scripts. See FlowGraph on the homepage for the full visual walkthrough.
LiveKitBridgeWorker — connecting a compiled flow to a live session
python
from live_lib.agent_runtime import LiveKitBridgeWorker
worker = LiveKitBridgeWorker(
openai_api_key="<your OpenAI key>",
google_api_key="", # optional, only if using Google's models
agent_name="my-agent-bridge", # must match your dispatch/room-naming setup
room_id_prefix="agent-",
)
worker.start()livekit_url/api_key/api_secret are never constructor parameters — the worker reads them itself, internally, by decrypting ~/.niuty/credentials.enc (or a custom path via credential_path=). There's no other way to supply them; a missing or invalid credential file raises CredentialError and the worker never starts. This is deliberate: it means a real LiveKit connection is only ever possible using an instance you've actually provisioned and paid for through this system, not an ad-hoc URL/key pasted into code.
After construction, worker.livekit_url / worker.api_key are readable (useful for logging) even though they were never passed in.
Two more options (live-lib 1.9.3+) — see Data handling and privacy:
python
worker = LiveKitBridgeWorker(
openai_api_key="<your OpenAI key>",
log_conversation_content=False, # log lengths only, never transcript/text content
)
worker.start()
...
worker.stop() # graceful shutdown: ends active sessions, disconnects, returns True when doneIdentifying the agent in the room (custom clients)
If you build your own client instead of using /session (for example, a receive-only player that subscribes manually), bind it to the agent's participant in the room, never to agent_name:
agent_nameis only the dispatch name. It decides which worker receives the job. It is not the agent's identity in the room.- The agent's room identity is assigned per job by the server, in the form
agent-<job id>. It is new for every dispatch, so it can't be known or configured in advance. - What you can rely on is the participant kind: the agent always joins with kind
AGENT. Clients joining with tokens from/test-tokenorgenerate_participant_token()never do.
Recommended binding, in your client (LiveKit client SDK, JavaScript shown):
js
import { RoomEvent, ParticipantKind } from "livekit-client";
let agent = null; // { identity, sid }
function bindAgent(room) {
const agents = [...room.remoteParticipants.values()].filter(p => p.kind === ParticipantKind.AGENT);
if (agents.length !== 1) return; // fail closed: none yet, or unexpected extra agents
agent = { identity: agents[0].identity, sid: agents[0].sid };
// Tracks it published before we bound (TrackPublished won't fire again for these):
for (const pub of agents[0].audioTrackPublications.values()) pub.setSubscribed(true);
}
// Connect with autoSubscribe off, then bind - the agent may already be in the room:
await room.connect(url, token, { autoSubscribe: false });
bindAgent(room);
room.on(RoomEvent.ParticipantConnected, () => { if (!agent) bindAgent(room); });
room.on(RoomEvent.ParticipantDisconnected, p => {
if (agent && p.sid === agent.sid) agent = null; // job ended: retire this binding permanently
});
// Only accept audio published by the bound participant:
room.on(RoomEvent.TrackPublished, (pub, p) => {
if (agent && p.sid === agent.sid && pub.kind === "audio") pub.setSubscribed(true);
});Behavior to expect:
- One dispatch per room means exactly one agent participant. If you ever see more than one, don't pick one; treat it as an error.
- A new dispatch creates a new participant with a new identity and SID. Treat it as a new binding.
- The agent can restart its voice session inside the same job (for example, after a model reconnect). The participant identity and SID stay the same, but its audio track is published again under a new track SID. Bind to the participant SID, not the track SID.
On your backend (live-lib 1.9.4+). Define either or both hooks on your agent class to get the same participant in Python, for example to issue or revoke an output grant against the exact SID:
python
class MyAgent(LiveKitAgentMixin):
def on_job_started(self, info): # or async def
# info: JobParticipantInfo(room_name, identity, sid, kind="agent", job_id)
grant_output(info.room_name, info.identity, info.sid)
def on_job_ended(self, info, reason): # or async def; exactly once per job
revoke_output(info.sid)on_job_startedruns once per job, after the agent has joined the room and before the voice session starts.on_job_endedruns exactly once per job.reasonis one of:"disconnected": fired as soon as the room disconnects, before the session finishes shutting down;"shutdown": the job was stopped, for example byworker.stop();"error";"ended".
- Restarts inside a job don't count. A voice session restart (for example,
update_livekit_instruction()) does not fire either hook; identity and SID stay the same. - Keep both hooks quick. They run on the job's event loop. An
async defhook is limited to 5 seconds, and a hook that raises is logged without affecting the session. get_livekit_job_info()(onLiveKitAgentMixin) returns the currentJobParticipantInfo, orNonewhen no job is running.
on_session_ready() is unchanged and still takes no arguments.
LiveKitAgentMixin — wiring a flow into your own agent class
python
from live_lib.agent_runtime import LiveKitAgentMixin
class MyAgent(MyBaseAgentClass, LiveKitAgentMixin):
...Mix this into your existing agent class to pick up the LiveKit-side integration (room registration, dispatch pairing) without restructuring your own agent hierarchy.
GPT-Live backend delegation — OpenAI's full-duplex voice model
LLM_PROVIDER on your agent class picks which realtime voice model LiveKitBridgeWorker connects to LiveKit — "gemini", "openai" (the original single-model Realtime API), or now "gpt_live": OpenAI's GPT-Live (gpt-live-1), a full-duplex model built to listen and speak at the same time instead of taking turns, which hands off the actual reasoning and tool-calling to a separate backend model instead of doing that itself.
python
class MyAgent(MyBaseAgentClass, LiveKitAgentMixin):
LLM_PROVIDER = "gpt_live"
VOICE = "marin" # any GPT-Live voice
GPT_LIVE_BACKEND_MODEL = "gpt-6.1-sol" # or "gpt-6-astra" — any OpenAI Responses-API model
SYSTEM_FLOW = flow # or SYSTEM_INSTRUCTION — same as every other providerThat's the entire setup — nothing else about your LiveKitBridgeWorker/LiveKitAgentMixin wiring changes. A few things worth knowing:
- Delegation, not one model doing everything. GPT-Live itself only handles the live conversation — listening, speaking, deciding when to interrupt or wait.
GPT_LIVE_BACKEND_MODELis a separate OpenAI Responses-API model that does the actual reasoning and calls your tools. Contrast withLLM_PROVIDER = "openai", where one model does both. - Backend instructions are optional. Skip
GPT_LIVE_BACKEND_INSTRUCTIONSand the backend model reasons from the exact sameSYSTEM_FLOW/SYSTEM_INSTRUCTIONcontent already driving the conversation — set it only if you want the backend's reasoning instructions to actually differ from your flow's persona/content. - Tools need no extra wiring. Whatever
get_livekit_tools(bridge)returns (seefunction_tool/find_function_toolsbelow) is available to the backend model exactly the way it is for any other provider. - One API key for both halves. GPT-Live and its backend model both authenticate with the same
openai_api_keyyou already pass toLiveKitBridgeWorker— no second key to manage.
Backend tuning, all optional: GPT_LIVE_BACKEND_TOOL_CHOICE, GPT_LIVE_BACKEND_PARALLEL_TOOL_CALLS, GPT_LIVE_BACKEND_REASONING_EFFORT (a plain string — "low"/"medium"/"high"/etc.), and GPT_LIVE_BACKEND_SERVICE_TIER. GPT_LIVE_SERVICE_TIER is different — it's the whole connection's own processing tier, so (unlike the others) it applies regardless of which delegation mode you use.
Client delegation — route to your own backend instead of OpenAI's Responses API
GPT_LIVE_DELEGATION = "client" hands delegated work to your own code — a custom agent/service, a local vision-language model, Gemini Robotics ER, anything you want driving the reasoning, instead of OpenAI's Responses API:
python
class MyAgent(MyBaseAgentClass, LiveKitAgentMixin):
LLM_PROVIDER = "gpt_live"
VOICE = "marin"
GPT_LIVE_DELEGATION = "client"
SYSTEM_INSTRUCTION = "..."
async def on_gpt_live_delegation(self, delegation):
# delegation.id, delegation.pending_transcript — the caller's current turn
answer = await call_my_own_backend(delegation.pending_transcript)
if self.is_current_gpt_live_delegation(delegation.id): # not superseded meanwhile
self.respond_to_gpt_live_delegation(answer, delegation_id=delegation.id)on_gpt_live_delegation can be def or async def — returning a coroutine lets your handler await a real call to your own backend without blocking the voice session. Function tools don't work under client delegation — the delegation handoff above is the only path; use "responses" delegation instead if you need tool-calling.
Two more optional hooks fire automatically once your handler finishes or raises, for orchestration/telemetry: on_gpt_live_delegation_completed(delegation_id), on_gpt_live_delegation_error(delegation_id, exc).
Testing with a synthetic participant instead of a real client
If you're publishing microphone audio from a Python rtc.Room.connect() client — e.g. to test locally without a real browser/mobile participant — the track can show up as published and even subscribed while transcripts, on_gpt_live_delegation, and any raw-audio hooks stay silent. This isn't specific to live-lib or to client delegation — it's livekit-agents' own AgentSession, which only treats a participant as "the user" if its ParticipantKind is STANDARD, SIP, or CONNECTOR. A real browser/mobile client connecting through LiveKit's official SDKs always gets STANDARD automatically, so this normally never comes up — it only affects synthetic/test participants, depending on how their connection code is written. Neither /create-instance-issued tokens nor /test-token set a kind, so a plain rtc.Room.connect() with either should default to STANDARD and just work — if transcripts/delegation go silent for a synthetic test client specifically, check what kind it's actually connecting as before assuming a delegation-mode or audio-pipeline bug.
Stopping in-flight work when a delegation is superseded
is_current_gpt_live_delegation (shown above) checks after your handler finishes, so you don't speak a stale answer. For realtime interaction — e.g. a child changing their answer while your backend is still reasoning about the previous one — you often want to stop that work the moment it's superseded, not pay for it to finish anyway. on_gpt_live_delegation_superseded(old_id, new_id) fires exactly then, and cancel_gpt_live_delegation(delegation_id) actually aborts it:
python
def on_gpt_live_delegation_superseded(self, old_id, new_id):
self.cancel_gpt_live_delegation(old_id)The two aren't linked automatically — you might want the old work to finish anyway (for logging), just not spoken, which is what is_current_gpt_live_delegation already handles. Cancelling raises inside your handler at whatever it's currently awaiting, so a real in-flight call to your backend gets a genuine chance to abort, not just have its result thrown away afterward — and it does not trigger on_gpt_live_delegation_completed/_error, since a cancelled delegation is its own distinct outcome, not a success or a failure.
Injecting context while the conversation is running
Independent of which delegation mode you use, three methods push live context into GPT-Live — not just for answering a delegation, also for proactively informing an ongoing conversation:
python
self.append_gpt_live_thinking("the caller just picked up the red mug") # silent, informs replies
self.append_gpt_live_instructions("confirm the address before booking") # steers behavior, not spoken
self.respond_to_gpt_live_delegation("delivery moves to Friday") # spoken aloudEach accepts an optional delegation_id= (default None = session-wide rather than tied to one delegation).
Camera snapshot — for a vision-capable client-delegation backend
GPT-Live's own realtime session has no image/video input channel — there's nothing to "send a picture into." For a vision use case, get_latest_camera_frame() returns the most recently captured camera frame (raw RGB) for your on_gpt_live_delegation handler to send on to your own vision backend:
ENABLE_VIDEO_STREAM is about video coming into your agent, not video your agent sends out. It watches for a video track published by someone else in the room and captures frames from it. If you're using /session as your client, &enableCamera=1 (see Embedding in a native Android app below for the equivalent on native) is what publishes that track in the first place — turn that on in the browser/app and ENABLE_VIDEO_STREAM = True on your agent picks it up automatically, no extra wiring on either side. This is completely separate from any video your agent itself sends back (e.g. a talking-avatar) — that's the other direction, shown in /session's own video stage, and has nothing to do with ENABLE_VIDEO_STREAM.
python
async def on_gpt_live_delegation(self, delegation):
frame = self.get_latest_camera_frame() # None if video isn't enabled / no frame yet
answer = await call_my_vision_backend(delegation.pending_transcript, frame)
self.respond_to_gpt_live_delegation(answer, delegation_id=delegation.id)Updated automatically whenever your agent has video enabled (ENABLE_VIDEO_STREAM = True) — nothing extra to turn on.
For reasoning about motion rather than a single still — e.g. judging a physical action over a few seconds — get_recent_camera_frames(seconds=3.0, fps=None) returns the same kind of frames over a window instead of just the latest one, oldest to newest:
python
async def on_gpt_live_delegation(self, delegation):
frames = self.get_recent_camera_frames(seconds=3.0, fps=5.0)
answer = await call_my_vision_backend(delegation.pending_transcript, frames)
self.respond_to_gpt_live_delegation(answer, delegation_id=delegation.id)fps, if given, subsamples toward that rate — it can't invent frames faster than your agent actually captures them (set VIDEO_FRAME_INTERVAL_SECONDS = 0.2 for ~5 fps). Up to the last 10 seconds are kept regardless of fps; asking for longer just returns what's still buffered. No video/clip encoding is built in — most vision APIs (including the ones this was built for) take multiple still images per request rather than a video file, so a plain list of frames is the more directly useful shape anyway.
User transcript hooks — run your own ASR/processing in parallel
GPT-Live transcribes the caller's speech automatically, no setup needed. Two optional hooks expose it directly, regardless of which delegation mode you're using — useful for running your own processing alongside GPT-Live's own transcription, e.g. for speech it handles less reliably (young children):
python
class MyAgent(MyBaseAgentClass, LiveKitAgentMixin):
def on_gpt_live_user_transcript_delta(self, transcript, item_id=None, timestamp=None):
... # partial, not yet final
async def on_gpt_live_user_transcript_final(self, transcript, item_id=None, timestamp=None):
await my_own_asr_pipeline(transcript)Either hook can be def or async def — an async one runs as its own task, so it can await real work without blocking the voice session.
Raw user audio — for your own ASR pipeline
Sometimes a transcript isn't enough — if GPT-Live misreads something (young-child speech is a common case), you need the original audio, not just the text it produced. Set ENABLE_USER_AUDIO_CAPTURE = True (off by default — it's a second, independent subscription to the caller's mic track) and either stream it or pull a recent clip:
python
class MyAgent(MyBaseAgentClass, LiveKitAgentMixin):
LLM_PROVIDER = "gpt_live"
ENABLE_USER_AUDIO_CAPTURE = True
USER_AUDIO_SAMPLE_RATE = 16000 # default; a standard ASR-friendly rate
async def on_gpt_live_user_audio_frame(self, pcm, sample_rate, timestamp):
await my_own_streaming_asr(pcm, sample_rate)python
async def on_gpt_live_delegation(self, delegation):
clip = self.get_recent_user_audio(seconds=3.0) # None if nothing captured yet
if clip:
text = await my_own_asr_pipeline(clip.pcm_bytes, clip.sample_rate)Audio is always captured mono; on_gpt_live_user_audio_frame fires for every frame (no throttling, unlike camera snapshots — dropping audio would corrupt your ASR input), while get_recent_user_audio returns the window already concatenated into one clip, ready to hand to a pipeline. This capture works regardless of which LLM_PROVIDER you're using — it's just a second subscriber to the same mic track the framework already feeds to whichever model you're running; only the hook's name follows this GPT-Live-based use case.
Correlating streams — a shared clock
CameraFrame.captured_at, the transcript hooks' timestamp, and the audio hook/clip's timestamps all come from the same clock, stamped at the moment each one is captured — so you can directly compare them (e.g. a transcript at timestamp=12.4 lines up with whatever get_recent_camera_frames() shows around captured_at≈12.4) to correlate speech like "this one" with pointing or object movement in the matching frames. These are not wall-clock/calendar timestamps and aren't comparable across a restart — only within one running session.
function_tool / find_function_tools — exposing Python methods as LLM tools
python
from live_lib.agent_runtime.tools import function_tool, find_function_tools
class MyToolProvider:
@function_tool(description="Forward a complex query to a backing service.")
async def ask_backend(self, query: str) -> str:
return await self._bridge.forward_to_agent(query)
# Later, wherever your worker/agent expects a tool list:
tools = find_function_tools(MyToolProvider())@function_tool marks a method as LLM-callable with a description the model uses to decide when to call it; find_function_tools discovers every decorated method on an instance and returns them as a ready-to-use tool list.
4. Minimal working example (copy, paste, run)
Everything above as one complete, runnable script — a tiny FlowGraph wired into LiveKitBridgeWorker, using Gemini. This is the fastest way to have something real to talk to via Test Your Agent: save it, fill in your Gemini API key, run it, then connect from that page using the same room name.
python
import time
from live_lib import FlowGraph
from live_lib.agent_runtime import LiveKitBridgeWorker
# 1. Design the conversation. A single looping node is enough for a first test -
# see "FlowGraph" above for branching to multiple nodes.
graph = FlowGraph(name="minimal_greeter", persona="Friendly, upbeat voice assistant.")
@graph.node("GREETING", objective="Greet the caller and see how you can help")
def greeting(n):
n.instructions("Greet the caller warmly.", "Ask what they'd like help with today.")
n.on(condition="caller says anything", to="GREETING")
graph.entry("GREETING")
flow = graph.compile()
# 2. Wire the compiled flow into an agent class. LiveKitBridgeWorker reads these
# attributes via getattr - there's no import/registration step beyond this.
# get_livekit_tools is optional (no function tools needed for a minimal
# example) - defining it as a no-op just avoids a harmless but confusing
# "object has no attribute 'get_livekit_tools'" warning in your logs.
class MinimalAgent:
LLM_PROVIDER = "gemini"
VOICE = "Puck"
SYSTEM_FLOW = flow
def get_livekit_tools(self, bridge):
return []
AGENT_ID = "demo"
ROOM_NAME = "demo-room" # must match the roomName you pass to /test-token
# 3. livekit_url/api_key/api_secret come from ~/.niuty/credentials.enc
# (Step 4 above) - never pass them directly.
worker = LiveKitBridgeWorker(
openai_api_key="", # unused for this Gemini-only example
google_api_key="<your Gemini API key>",
agent_name="minimal-test-agent",
)
# 4. Register the agent, then map the room name directly with notify_join -
# _entrypoint's first (and fastest) pairing check is exactly this mapping,
# so this makes pairing instant. Without it, LiveKitBridgeWorker instead
# spends up to 2 real minutes retrying prefix-based matching (default
# room_id_prefix="agent-", which "demo-room" doesn't start with) before
# falling back to the single-registered-agent path - confirmed live: every
# first connection sat silent for ~120s before the agent ever responded.
worker.register(AGENT_ID, agent_ref=MinimalAgent())
worker.notify_join(AGENT_ID, ROOM_NAME)
worker.start()
time.sleep(3) # give the worker a moment to register with LiveKit before dispatching
# 5. Jobs are only ever explicitly dispatched, never automatic on room join -
# this must be the SAME room name you pass as `roomName` to /test-token.
worker.create_agent_dispatch(ROOM_NAME)
print(f"Agent running, dispatched to room '{ROOM_NAME}'. Ctrl+C to stop.")
while True:
time.sleep(3600)Requires pip install "live-lib[livekit]" (Step 5 above) and a Gemini API key. Leave the process running, then go to Test Your Agent and request a token for roomName=demo-room — you should hear MinimalAgent greet you within a few seconds of connecting.
5. Embedding in a native Android app
Instead of integrating LiveKit's Android SDK directly, load /session into a WebView inside your own app — it already handles the WebRTC connection, microphone, and (with &audioOnly=1) skips video entirely. A LiveKit data channel carries structured messages between your Python agent and your native code, through the page.
Architecture: your own backend mints a token server-side (SigV4-signed /test-token call, same as Step 2 above) — never ship your AWS credentials inside the app. Hand only the resulting short-lived token/URL down to the device, then load:
https://niuty.com/session?token=<token>&livekitUrl=<livekitUrl>&audioOnly=1For a kiosk/embodied-agent setup publishing its own camera too (see Camera snapshot above), add &enableCamera=1 to the same URL — everything below already handles both cases, since the permission grant only ever grants what the page actually asked for.
WebView setup (Kotlin):
kotlin
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(NiutyBridgeInterface(webView), "AndroidApp")
// Grant microphone (and, if the URL has &enableCamera=1, camera) access - LiveKit's WebRTC
// connection needs it, and a WebView never prompts for it on its own the way a normal browser
// tab does. The allow-list below covers both cases without needing two separate examples - if
// the page never requested video (no enableCamera=1), RESOURCE_VIDEO_CAPTURE is simply never in
// request.resources, so the filter is a no-op for it.
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
val allowed = setOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE)
request.grant(request.resources.filter { it in allowed }.toTypedArray())
}
}
webView.loadUrl("https://niuty.com/session?token=$token&livekitUrl=$livekitUrl&audioOnly=1")Also request RECORD_AUDIO at the Android permission level as usual (and CAMERA too, if using &enableCamera=1), and consider running the session inside a Foreground Service if it needs to survive your app being backgrounded — both are ordinary Android/WebRTC concerns, not specific to this integration:
xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" /> <!-- only if using &enableCamera=1 -->Receiving messages from your Python agent — implement onDataReceived on the object you registered as "AndroidApp":
kotlin
class NiutyBridgeInterface(private val webView: WebView) {
@JavascriptInterface
fun onDataReceived(jsonString: String) {
val msg = JSONObject(jsonString)
when (msg.getString("type")) {
"MOVE" -> handleMove(msg.getString("direction"))
// ...
}
}
}Called once per inbound data-channel message (topic "logic") — the internal KEEP_ALIVE heartbeat is filtered out before it reaches you, so every call here is real application data.
Sending messages to your Python agent — call window.NiutyBridge.sendData(...) via evaluateJavascript:
kotlin
val payload = JSONObject(mapOf("type" to "BUTTON_PRESSED", "button" to "call_staff"))
webView.post {
webView.evaluateJavascript("window.NiutyBridge.sendData($payload)", null)
}Server side (Python, live-lib) — implement on_rt_data_message to receive, call send_data_to_livekit (from LiveKitAgentMixin) to send:
python
class MyAgent(LiveKitAgentMixin):
def on_rt_data_message(self, msg: dict) -> None:
if msg.get("type") == "BUTTON_PRESSED":
self.handle_button(msg.get("button"))
def notify_status(self, status: str) -> None:
self.send_data_to_livekit({"type": "STATUS", "status": status})Envelope convention: {"type": "<YOUR_TYPE>", ...} — freeform beyond type, entirely yours to define on both ends. See LiveKitAgentMixin above for how this mixin fits into your own agent class.
6. Data handling and privacy
What each part of the system does with your users' audio, video, transcripts and other data, so you can assess it against your own requirements. This describes system behavior only — whether your use case meets a particular law or policy is for your own review. Your responsibilities, including for any use involving children, are set out in the Terms of Service.
live-lib (runs on your machine)
- No recording or upload. live-lib 1.9.3+ starts every session with recording explicitly disabled, so the LiveKit Agents SDK never uploads audio, transcripts, traces or logs anywhere. Don't set
LIVEKIT_OBSERVABILITY_URLin your worker's environment. - Nothing written to disk unless you opt in (
debug_dump_path=, which writes only the system prompt). Camera frames and raw user audio exposed by the GPT-Live APIs are short in-memory buffers only. - Your logs. By default the worker prints conversation content to stdout for debugging (
[TRANSCRIPT IN]/[TRANSCRIPT OUT], spoken text, tool queries/results). Passlog_conversation_content=FalsetoLiveKitBridgeWorkerto log only lengths instead (<redacted len=42>). Takes effect at construction, before any session starts. Output from your own hooks is your own code and isn't affected. - Model providers. Your worker calls OpenAI/Google directly with your own API key. niuty never receives or relays that traffic, so your provider account's own data controls apply as you configure them.
- Shutdown.
worker.stop()ends every active session, disconnects from LiveKit and returnsTrueonce the worker has exited.
To check: confirm LIVEKIT_OBSERVABILITY_URL is unset, and that your worker only connects to your own *.rt.niuty.com instance and your model provider.
Your niuty instance (the LiveKit relay)
- Media and messages are relayed, not stored. Audio, video, data messages, chat and transcriptions pass between participants and are never recorded or written to disk — there is no recording service on the instance.
- Server logs record connection details only: room name, participant identity, IP addresses and client device info (SDK, OS, device model). Display names, metadata, attributes and message content are not logged. Don't put personal data (e.g. a person's name) in room names or participant identities — use opaque IDs.
- Retention. Logs stay on your instance only, and are deleted together with it when the instance is deprovisioned (the server, its IP address and its DNS record are all removed).