The application

The SDK is an OTP application that starts automatically with your app. Its supervision tree includes a task supervisor for call startup (Guava.CallStarter), a registry of live calls (Guava.CallRegistry), a registry of channels (Guava.ChannelRegistry), and a DynamicSupervisor for call runtimes (Guava.CallSupervisor). Just add :guava as a dependency.

Running channels

Add Guava.Channel child specs to your own supervision tree so they restart if they exit:

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      {Guava.Channel, agent: MyApp.SalesAgent, listen: {:phone, System.fetch_env!("SALES_NUMBER")}},
      {Guava.Channel, agent: MyApp.SurveyAgent, campaign: "camp_abc"}
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
  end
end

Each live call runs in its own process under Guava.CallSupervisor; a crash in one call doesn't affect others. For scripts, the blocking Guava.run/1 and Guava.listen_phone/3 helpers start the same machinery and block.

Configuration

# config/runtime.exs
config :guava,
  api_key: System.get_env("GUAVA_API_KEY")

In containers, the Guava-deploy token at /var/run/secrets/guava/token is picked up automatically.

Graceful shutdown

A voice call can outlive a deploy. Guava.drain/1 closes every channel's listener socket so the server stops assigning work to this node, then waits for the calls already in progress to finish. Live calls are unaffected by the listener closing — each one owns a separate socket.

This runs automatically when the :guava application stops, so a SIGTERM during a rolling deploy no longer cuts callers off mid-conversation. You don't have to wire anything up.

Draining detaches this node — it doesn't disable the campaign, so other replicas keep dialing. That's what you want during a rolling deploy.

# config/runtime.exs
config :guava, drain_timeout: 25_000   # default 30_000

Keep drain_timeout comfortably below your orchestrator's kill deadline — terminationGracePeriodSeconds on Kubernetes — which bounds the whole shutdown no matter what the SDK does. If the budget runs out, Guava.drain/1 returns {:timeout, n} and logs how many calls were still running.

Because draining lets calls end normally, your agent's terminate/3 runs for each of them. A call still running when the budget expires is killed rather than stopped, so terminate/3 does not run for that one — another reason to keep the budget longer than your calls typically last.

Call it yourself to drain early, for instance from a Kubernetes preStop hook that wants to stop taking calls before the grace period even starts:

Guava.drain(timeout: 60_000)

Health probes

Guava.ready?/0 is true once every channel on the node has started listening and none is draining. The SDK deliberately doesn't serve HTTP — your app already has an endpoint — so wire it into your own:

# In a Plug pipeline, or a Phoenix router
get "/ready", fn conn, _ ->
  status = if Guava.ready?(), do: 200, else: 503
  Plug.Conn.send_resp(conn, status, "")
end

That maps onto a Kubernetes readiness probe: it gates rollout, so a deploy won't tear down the old pods until the new one has actually connected and is taking calls, and it flips to 503 as soon as the node starts draining.

ready?/0 is a startup and rollout gate, not a liveness signal — it stays true across transient reconnects, which the socket layer absorbs with buffering, so a network blip won't pull the node out of rotation. A liveness probe generally isn't needed: terminal failure on the BEAM means the supervision tree gives up and the VM exits, which your orchestrator already sees as a dead container.

Fault tolerance

  • Per-call callbacks run serially and are wrapped in try/rescue — a raising handler is logged and answered with a safe fallback, never dropping the call.
  • handle_call_received/1 runs in the channel rather than a call process, so it can't fall back to an answer: a raise there is logged and the call is declined, leaving the channel listening for the next one.
  • The WebSocket transport reconnects with backoff and retransmits unacked messages across reconnects, so transient network blips are transparent.
  • Each call runs as a :temporary child, so a call that ends is never restarted; Guava.CallRegistry is therefore an accurate count of live calls.

Observability

The SDK emits :telemetry events you can attach handlers to:

  • [:guava, :http, :request, :start | :stop | :exception]

  • [:guava, :command, :sent]

Each call process sets Logger.metadata(call_id: ...) for correlated logs.

:telemetry.attach("guava-http", [:guava, :http, :request, :stop], &MyApp.Metrics.handle/4, nil)

Publishing this library

mix docs generates the HTML API reference from the moduledocs and includes these guides as extras.