This page is for contributors. It documents three layers, from wide to narrow:
  1. System architecture — the boxes that make up Peachdesk.
  2. Local dev topology — which processes run where when you bash scripts/start_services_dev.sh.
  3. Workflow & agent runtime — how a single live call flows through nodes, LLM, STT, and TTS.
File and line references are given inline; they are anchors into the code, not stable docs.

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 with npm run generate-client (ui/package.json:11); config in ui/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 reads auth.getAccessToken(), sets Authorization: Bearer <token>, and retries once with a refreshed token on 401.
  • The interceptor is registered synchronously during UserConfigContext render (ui/src/context/UserConfigContext.tsx:60), not from a useEffect, so that the very first API call already has auth attached. Components must still guard fetches with authLoading — see ui/AGENTS.md.

Telephony providers

Concrete provider dirs under api/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.
Selection at call time goes through 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 run bash 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)

From docker-compose-local.yaml:
  • postgres — pgvector/pgvector:pg17, exposes :5432, user/pass postgres:postgres, db postgres.
  • redis — redis:7 with --requirepass redissecret, exposes :6379.
  • minio — minio/minio server, :9000 (S3 API) + :9001 (console), root minioadmin:minioadmin, bucket voice-audio created 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 the logs/latest symlink to point at it.
  • One file per service: uvicorn.log, arq.log, ari_manager.log, campaign_orchestrator.log. Multi-worker uvicorn also produces uvicorn-worker-N.log.
  • Rotation via loguru: LOG_ROTATION_SIZE (default 100 MB), LOG_RETENTION (default 7 days), LOG_COMPRESSION (default gz).

Prod stack (docker-compose.yaml) — the diff

For context, the prod compose file also builds and runs:
  • api (api/Dockerfile, FASTAPI_WORKERS workers) 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 ephemeral trycloudflare.com URL 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.
Local dev deliberately runs none of these — postgres/redis/minio are enough for the app processes on your host.

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 in api/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
An AgentNode (dto.py:453-459, mixins at 129–198) carries:
  • prompt — system prompt for the LLM turn
  • allow_interrupt — barge-in
  • add_global_prompt — prepend the global node’s prompt
  • extraction_enabled, extraction_prompt, extraction_variables — post-turn LLM extraction into typed variables
  • tool_uuids, document_uuids, mcp_tool_filters — callable tools + knowledge base scope
Edges (EdgeDataDTO, dto.py:1569-1582) have a transition_type:
  • prompt — LLM decides by calling a transition function generated from the edge’s condition
  • equation — deterministic expression like {{budget}} >= 1000, evaluated against gathered variables
  • always — take the edge without waiting for a user turn
Optionally: 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 into transport.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.
For inbound telephony, the entry point is 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 stamps telephony_configuration_id.
  • The provider’s handle_external_websocket() takes the socket and hands it to that provider’s transport.
For outbound, 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 is PipecatEngine in api/services/workflow/pipecat_engine.py. The core is set_node() (line 1187): set_node() in detail:
  1. Entry effects (line 1227) — action nodes run synchronously: callTransfer, pressDigit, inCallSms, code, etc.
  2. Deterministic transitions (lines 1241–1259) — outgoing equation and always edges are evaluated first. If one matches, set_node(target) recurses.
  3. Node dispatch (lines 1289–1297):
    • _handle_start_node() — line 1291
    • _handle_end_node() — line 1294
    • _handle_agent_node() — line 1297. Runs an LLM turn.
For each outgoing 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 time set_node() lands on an agentNode, the LLM’s system prompt and tool schema are recomposed from scratch:
  • compose_system_prompt_for_node() in api/services/workflow/pipecat_engine_context_composer.py:92 combines: 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.
The result: the LLM’s “world” changes at every node — different prompt, different callable tools, different routing options.

Post-call work

When the call ends, arq jobs (registered in api/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 NodeType and the DTO in api/services/workflow/dto.py, add a handler in PipecatEngine (_handle_*_node), and wire the frontend node in ui/src/components/flow/.
  • Adding a telephony provider: create api/services/telephony/providers/<name>/ with a transport.py and register it in api/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 in api/AGENTS.md for the exact rules.