- System architecture — the boxes that make up Peachdesk.
- Local dev topology — which processes run where when you
bash scripts/start_services_dev.sh. - Workflow & agent runtime — how a single live call flows through nodes, LLM, STT, and TTS.
System architecture
Peachdesk is a FastAPI backend, a Next.js frontend, a real-time voice pipeline (pipecat), and a set of pluggable telephony providers. Everything is tenant-scoped by organization_id.
Backend subsystems
All API routes are mounted under/api/v1 by api/app.py; the router assembly lives in api/routes/main.py.
Cross-worker sync
FastAPI runs multiple workers in production. If a request updates in-memory state on one worker (cached credentials, provider configs, Langfuse keys, etc.), the other workers won’t see it unless it’s broadcast.WorkerSyncManager (api/services/worker_sync/manager.py) publishes those change events on a Redis pub/sub channel (RedisChannel.WORKER_SYNC, lines 48–50). All workers subscribe, including the one that emitted the change, and re-fetch authoritative state from Postgres. Handlers are registered at app startup (api/app.py:92-97).
Rule: never mutate a cached process-local dict from a route handler without broadcasting.
Frontend → backend contract
ui/src/client/is generated from the backend’s OpenAPI spec. Regenerate withnpm run generate-client(ui/package.json:11); config inui/openapi-ts.config.ts. Never hand-edit the generated files.- Auth on outgoing calls is attached by an interceptor at
ui/src/lib/apiClient.ts:70–107. It readsauth.getAccessToken(), setsAuthorization: Bearer <token>, and retries once with a refreshed token on 401. - The interceptor is registered synchronously during
UserConfigContextrender (ui/src/context/UserConfigContext.tsx:60), not from auseEffect, so that the very first API call already has auth attached. Components must still guard fetches withauthLoading— seeui/AGENTS.md.
Telephony providers
Concrete provider dirs underapi/services/telephony/providers/:
twilio,telnyx,plivo,vonage,cloudonix,vobiz— SIP/PSTN providers driven via each vendor’s webhook + WebSocket protocol.ari— Asterisk REST Interface, for self-hosted PBX / SIP.
api/services/telephony/factory.py. Credentials and default configs are per-organization, not baked in.
MCP server surface
Mounted stateless-HTTP at/api/v1/mcp (see api/app.py:137, session manager set to stateless at line 45). Auth uses the same X-API-Key as the REST API (api/mcp_server/auth.py).
Tools registered in api/mcp_server/server.py:20–43:
- Workflow:
create_workflow,get_workflow,list_workflows,get_workflow_code,save_workflow - Catalog:
list_node_types,get_node_type,list_credentials,list_documents,list_recordings,list_tools - Docs:
search_docs,read_doc
Local dev topology
What happens when you runbash scripts/start_services_dev.sh on the host, with docker-compose-local.yaml providing the stateful infra.
Host processes started by start_services_dev.sh
Re-running the script reads the PID files, kills each process tree, then relaunches. Alembic runs before uvicorn starts (
scripts/start_services_dev.sh:158), and the script blocks on /api/v1/health returning 200 before exiting.
Only uvicorn hot-reloads (--reload --reload-dir api). If you change code touched by arq, ari_manager, or campaign_orchestrator, re-run the start script.
Docker services (local infra only)
Fromdocker-compose-local.yaml:
- postgres —
pgvector/pgvector:pg17, exposes:5432, user/passpostgres:postgres, dbpostgres. - redis —
redis:7with--requirepass redissecret, exposes:6379. - minio —
minio/minioserver,:9000(S3 API) +:9001(console), rootminioadmin:minioadmin, bucketvoice-audiocreated on first API call.
If you have a system Redis (e.g. Homebrew) already listening on
:6379, it will squat the port and the Docker Redis will fail to bind. Sending AUTH redissecret to an unauthenticated Redis produces AUTH called without any password configured — the fix is brew services stop redis, not stripping the password from REDIS_URL.Env loading
scripts/start_services_dev.sh:34-35:
$ENV_FILE is api/.env. set -a marks assignments as exported, so every subprocess (uvicorn, arq, ari_manager, campaign_orchestrator) inherits the vars. No process passes --env-file itself.
The only strictly required vars are DATABASE_URL and REDIS_URL — everything else in api/.env.example is optional / feature-gated.
Logs
- Each run creates
logs/YYYYMMDD_HHMMSS/and updates thelogs/latestsymlink to point at it. - One file per service:
uvicorn.log,arq.log,ari_manager.log,campaign_orchestrator.log. Multi-worker uvicorn also producesuvicorn-worker-N.log. - Rotation via loguru:
LOG_ROTATION_SIZE(default 100 MB),LOG_RETENTION(default 7 days),LOG_COMPRESSION(defaultgz).
Prod stack (docker-compose.yaml) — the diff
For context, the prod compose file also builds and runs:
- api (
api/Dockerfile,FASTAPI_WORKERSworkers) and ui (ui/Dockerfile, published on:3010— not 3000). - nginx (
profiles: remote) fronting the api at:80/:443. - coturn on
:3478 / :5349+ UDP relay range for reliable WebRTC. - cloudflared — named tunnel via
CF_TUNNEL_TOKEN, or an ephemeraltrycloudflare.comURL for public webhooks. - voice-engine (
services/voice-engine/Dockerfile) on:8001. - docs (Mintlify) on
:3333. - peachdesk-init — one-shot that renders nginx/coturn config from env into a shared volume before those services start.
Workflow & agent runtime
This is the interesting part: what happens between the moment a caller speaks and the moment the agent replies.The workflow graph
A workflow is a set of nodes (typed) and edges (typed transitions), defined inapi/services/workflow/dto.py.
Node types (NodeType enum, dto.py:41-57):
- Lifecycle:
startCall,endCall - Conversational:
agentNode,globalNode,qa,note - Structural:
trigger,webhook - Actions:
logicSplit,extractVariable,function,callTransfer,agentTransfer,pressDigit,inCallSms,code
AgentNode (dto.py:453-459, mixins at 129–198) carries:
prompt— system prompt for the LLM turnallow_interrupt— barge-inadd_global_prompt— prepend the global node’s promptextraction_enabled,extraction_prompt,extraction_variables— post-turn LLM extraction into typed variablestool_uuids,document_uuids,mcp_tool_filters— callable tools + knowledge base scope
EdgeDataDTO, dto.py:1569-1582) have a transition_type:
prompt— LLM decides by calling a transition function generated from the edge’sconditionequation— deterministic expression like{{budget}} >= 1000, evaluated against gathered variablesalways— take the edge without waiting for a user turn
transition_speech — text or pre-recorded audio spoken during the transition.
The frame pipeline
At call time,api/services/pipecat/pipeline_builder.py builds the following pipecat frame pipeline (line 28):
STT, LLM, and TTS providers are pluggable (OpenAI, Deepgram, ElevenLabs, Cartesia, Groq, Google, Azure, Sarvam, Speechmatics, Camb…), configured per-organization or via the managed MANAGED_* keys in api/.env.
The transport layer
Two families of transport plug intotransport.input() / transport.output():
- In-browser calls:
SmallWebRTCTransport(api/services/pipecat/transport_setup.py:15). - Telephony calls: provider-specific transports at
api/services/telephony/providers/{provider}/transport.py.
api/routes/agent_stream.py:31:
- URL:
/agent-stream/{workflow_uuid}?provider=<name> - Query params (
from,to,callId, provider credentials) become the initial call context (line 86). db_client.create_workflow_run(...)(line 93) records the run and stampstelephony_configuration_id.- The provider’s
handle_external_websocket()takes the socket and hands it to that provider’s transport.
api/services/telephony/factory.py resolves the workflow’s telephony_configuration_id → provider config → credentials + a from_numbers list, then dials.
The engine: node-to-node traversal
The live driver isPipecatEngine in api/services/workflow/pipecat_engine.py. The core is set_node() (line 1187):
set_node() in detail:
- Entry effects (line 1227) — action nodes run synchronously:
callTransfer,pressDigit,inCallSms,code, etc. - Deterministic transitions (lines 1241–1259) — outgoing
equationandalwaysedges are evaluated first. If one matches,set_node(target)recurses. - Node dispatch (lines 1289–1297):
_handle_start_node()— line 1291_handle_end_node()— line 1294_handle_agent_node()— line 1297. Runs an LLM turn.
prompt edge, the engine generates a transition function (_create_transition_func, lines 323–430) and adds it to the LLM’s tool schema. When the LLM decides to route, it calls that function; the engine then extracts variables, applies filters, and calls set_node(target).
Equation edges never touch the LLM — _first_satisfied_equation_target() (line 1304) evaluates them against gathered variables and returns the first match.
Prompt & tool composition per node
Every timeset_node() lands on an agentNode, the LLM’s system prompt and tool schema are recomposed from scratch:
compose_system_prompt_for_node()inapi/services/workflow/pipecat_engine_context_composer.py:92combines: global prompt (if enabled), handbook/guardrails, node prompt, adherence instructions, recording mode markers.compose_functions_for_node()(line 167) assembles tool schemas from knowledge base tools, custom tools, and the transition functions for outgoing edges._register_transition_function_with_llm()(pipecat_engine.py:430) pushes the updated schema to the LLM.
Post-call work
When the call ends, arq jobs (registered inapi/tasks/arq.py:65-74) run:
QA runs before webhooks so webhook payloads can reference QA results in their render context (
api/tasks/run_integrations.py:220).
Where to go from here
- Adding a node type: extend
NodeTypeand the DTO inapi/services/workflow/dto.py, add a handler inPipecatEngine(_handle_*_node), and wire the frontend node inui/src/components/flow/. - Adding a telephony provider: create
api/services/telephony/providers/<name>/with atransport.pyand register it inapi/services/telephony/factory.py. Follow an existing provider (e.g.twilio/) as a template. - Adding a post-call job: define an async function, register it in
api/tasks/arq.py:WorkerSettings.functions, enqueue it from the call-completion path. - Tenant safety: every read/write of an org-scoped row must filter by
organization_id— see the Organization Scoping section inapi/AGENTS.mdfor the exact rules.

