The persona
agent/prompts/instructions.md, one Markdown file with one placeholder.
Editing it is the whole process: no restart, no rebuild. LiveKit runs
entrypoint() per job, Assistant.__init__ calls load_instructions(), and
that reads the file every time, so a save lands on the next call. A call already
in progress keeps the prompt it started with.
{agent_name} is the only placeholder, replaced with the value of AGENT_NAME.
Substitution is str.replace, not str.format, so a prompt containing a JSON
example or any other brace is safe. Everything else in the file is literal text
sent to the model.
Two ways to edit it, same file:
- Your editor. Compose mounts the directory into both containers, so there is nothing to rebuild.
- The console. Go to Agents, open the agent, then Edit on the
Prompt row. It reads and writes over
GETandPUT /api/v1/agents/{slug}/prompt, where{slug}isAGENT_NAMEand any other slug is a 404. The write is atomic, a temp file in the same directory renamed over the target, so a save never leaves the worker reading half a persona.
CONSOLE_WRITES_ENABLED=true (compose sets it, code
defaults to false) and the backend’s read-write mount of ./agent/prompts
(compose sets that too). The agent’s mount of the same directory is :ro,
because one writer is the reason a save can be atomic. Without the mount the
editor still loads and every save answers 409 naming the path.
Swapping a provider
Every provider choice is one constructor inagent/src/agent.py.
agent/src/agent.py
voice are strings on the constructor, so changing a voice or a
model version is a one-word edit. Changing a provider is three edits, all in
agent/:
from livekit.plugins import ... line in src/agent.py, then
swap the one argument (llm=openai.LLM(model="gpt-4o-mini")) and leave the
other two alone. Put the new provider’s key in the root .env, and in
agent/.env too if you run the worker by hand. Then rebuild the worker image:
vad and turn_handling are not provider slots. Silero VAD is loaded once in
prewarm() and shared; turn detection is
livekit.plugins.turn_detector.multilingual.MultilingualModel. Changing speech
to text or the model touches neither.
scripts/doctor.py probes Deepgram, Cerebras and Inworld by name from a
hardcoded list, so after a swap it checks a key the session no longer uses.
Its agent-name, LiveKit and stack checks stay correct.src/agent.py. Run
uv add livekit-plugins-noise-cancellation, import RoomInputOptions from
livekit.agents and noise_cancellation from livekit.plugins, then pass
room_input_options=RoomInputOptions(noise_cancellation=noise_cancellation.BVC())
to session.start.
Environment
docker compose reads the root .env and nothing else. It passes that file to
the backend and agent containers, and the VITE_ values to the frontend as
build arguments. The per-service files are for running one service by hand:
agent/.env for main.py dev and console mode, backend/.env for uvicorn,
frontend/.env for pnpm dev and pnpm build. A value in the wrong file gives
you a stack that works one way and not the other.
Six values are required and nothing runs without them: LIVEKIT_URL,
LIVEKIT_API_KEY and LIVEKIT_API_SECRET from your LiveKit project, plus
DEEPGRAM_API_KEY (console.deepgram.com),
CEREBRAS_API_KEY (cloud.cerebras.ai) and
INWORLD_API_KEY (platform.inworld.ai). Console
mode needs the last three only.
Without the service token pair the worker keeps its own LiveKit credentials and
the Calls page stays empty.
Everything else has a working default.
.env.example is the full list, one
comment per value, and it is already in your clone.
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSL and ENV
default in compose to db, 5432, postgres, postgres, app, disable and
dev, so the root .env needs them only when you point at a Postgres that is
not the bundled one. AGENT_PROMPT_FILE is set literally in
docker-compose.yml and is not overridable there: it has to name the mounted
prompts directory, and pointing it elsewhere writes a persona the worker never
reads.