agents-go
Deploying

Architecture

Two planes — a worker fleet and a dispatcher — with a transport-oblivious session.

agents-go separates what runs a conversation from what decides where it runs. A session drives one conversation and knows nothing about the network; a worker runs sessions as jobs; a dispatcher places jobs onto the fleet. That split is what lets one agent scale from a laptop to a horizontally-scaled fleet without rewriting it.

Why it matters

Voice agents are long-lived, stateful, and bursty. You need to add capacity, deploy new versions, and lose a machine — all without dropping live calls. Baking the transport or the placement logic into your agent makes every one of those operations a rewrite. agents-go keeps the agent code identical whether it's a console pipe in a test or one of hundreds of workers behind a dispatcher.

The two planes

  • Control plane — dispatcher + worker. A worker registers with a dispatch source, reports load, answers availability, and runs assigned jobs (goroutine-per-job). The dispatcher does load-aware selection and session pinning.
  • Data plane — session + transport. An AgentSession binds to a transport's audio/text I/O and drives the turn loop. The session is transport-oblivious: the same session runs over a raw WebSocket, a LiveKit/WebRTC room, a Twilio media stream, or a console PCM pipe. Swapping transports never touches agent code.

The single-binary MVP

The MVP is one binary with the dispatcher embedded in-process — no second service, no nginx.

Two seams are designed so nothing about the agent changes when you scale:

  • worker ↔ dispatch is message-passing, no shared mutable state. Even in-process, the worker and dispatcher only exchange messages. Extracting the dispatcher into its own service (worker↔dispatch over the wire) is a transport swap, not a code change.
  • the session is transport-oblivious (above).

Keep the single binary until scale forces the issue; extraction to a standalone dispatch service happens only when one process can't hold the fleet, and the message-passing seam makes that migration mechanical.

The scaling ladder

The same agent and session code runs at every tier — you climb only when scale forces it:

  1. One process, no dispatcher. For a single-process ingress (one Twilio number, one box), you don't need the dispatcher at all: websocket.Handler runs a session per connection directly. This is the simplest deployment and handles many concurrent calls.
  2. One binary, embedded dispatcher. A worker fleet inside a single process (the MVP above) — adds load-aware placement, session pinning, and graceful drain, still with no second service.
  3. Multi-node. An external dispatch source (e.g. a LiveKit server) or an extracted dispatch service places jobs across machines.

Move up a rung only when you need what it adds — cross-process capacity, load-aware placement, or graceful drain on deploy — never as a starting point.

LiveKit-optional

The worker speaks a LiveKit-compatible protocol, so one binary can run behind its own embedded dispatcher or behind a LiveKit server — you choose the dispatch source at startup. LiveKit is an option, not a dependency: nothing in the core imports it.

Where the heavy stuff lives

The core stays small and pure. Provider SDKs, WebRTC, and telephony live in plugin subpackages (plugins/openai, plugins/livekit/roomio, plugins/twilio, …), so importing the core never pulls a media stack.

No ML in the Go binary. VAD and silence detection run in Go, but heavier end-of-turn / interruption ML models run in an external sidecar over HTTP — the agent process stays free of ONNX/CGo.

On this page