Ground truth is the Python guava-sdk
(v0.36.0). This Elixir port keeps the same
concepts and wire protocol but adapts the public API to idiomatic Elixir
(there are no users, so this was a deliberate design choice, not a constraint).
Mapping
| Python | Elixir | Notes |
|---|---|---|
guava.Client | Guava.Client | new/1/new!/1; all ops return {:ok, _} | {:error, %Guava.Error{}} with ! bang variants |
guava.Agent (decorators) | Guava.Agent behaviour | use Guava.Agent; implement @impl callbacks; per-call threaded state |
guava.Call | Guava.Call | handle passed to callbacks; imperative actions (set_task, transfer, get_field, …); reads are ETS-backed |
guava.Runner | Guava.Channel + Guava.run/1 | supervised channel child specs; blocking helpers on Guava |
decorators (on_question, on_action, …) | callbacks (handle_question/3, handle_action/3, …) | per-key handlers are one pattern-matched callback |
Agent.test / test_roleplay | Guava.Testing.session/3 / roleplay/3 | module-based |
guava.testing.MockCall | Guava.Testing.MockCall | offline handler unit tests; a %Guava.Call{} wired to a recorder process + seeded ETS, not a Call subclass |
guava.Field/Say/Todo | Guava.Field/Guava.Say/Guava.Todo | |
CallInfo, IncomingCallAction, SuggestedAction | same under Guava.* | handle_call_received returns bare :accept/:decline |
| events / commands | Guava.Events.* / Guava.Commands.* | byte-identical wire format |
GuavaSocket | Guava.Socket + Guava.Socket.Reliable (pure state machine) + Conn + Protocol | unchanged from the first port |
campaigns.py | Guava.Campaigns / Campaign / Contact | tuple + bang; every function takes client first, and covers Client.get_campaign / Client.list_campaigns too |
helpers/llm.py | Guava.LLM, Guava.IntentRecognizer, Guava.DatetimeFilter, Guava.DateRangeParser | tuple + bang |
helpers/rag.py | Guava.RAG, Guava.RAG.ServerRAG, Guava.DocumentQA (+ behaviours) | ask tuple + bang; server mode default |
auth.py (AuthStrategy) | Guava.Auth.{APIKey,Deploy,CLI} | + config :guava resolution |
telemetry.py (usage upload) | — | not ported; :telemetry spans instead |
Intentional deviations
API shape and conventions
Agent is a behaviour with threaded state. The Python
Agentregisters callbacks on a mutable object; the ElixirGuava.Agentis ause-able behaviour whose callbacks thread a per-callstate(likeGenServer/LiveView). This is compile-time-checked, needs no external per-call store, and lets you implement only the callbacks you use.Errors are
{:ok, _} | {:error, %Guava.Error{}}with!variants across theClient, campaigns, and LLM/RAG helpers — the Elixir norm.ArgumentErroris used instead for caller bugs (e.g. passing a campaign code where an id is required), which should raise even from the tuple-returning variant — asFile.read/1does for a non-binary path.Campaigns are identified by code only; the legacy id is not public. Per the platform team, the campaign code is the identifier going forward and the id exists for backwards compatibility, so no
Guava.Campaignsfunction accepts one — each takes a code or a%Guava.Campaign{}. The struct still carries:id(documented as internal) because a few endpoints remain keyed on it; given a bare code the SDK resolves it with one extra lookup. Both the field and the lookup delete cleanly once those endpoints move to v2.Campaigns take the client explicitly, not embedded in the handle. Upstream's
Campaignholds a_client; here%Guava.Campaign{}stays plain data and every function takesclientfirst, matching Ecto/Req/Tesla. Single-field upstream result models (UploadContactsResult,CampaignStatus) are unwrapped to a bare integer and a plain map, asClient.create_outbound!already returns a bare call id.ReachPersonOutcomeis likewise a plain map (:key,:description,:next_action_preview) rather than a struct — it is data the caller supplies, and all three fields behave as upstream's do.Callbacks run serially per call, reads are ETS-backed. Guarantees
statecan't race and lets a handler read fields without deadlocking. Long work is offloaded via aTask+handle_info/3.Config via Application env (
config :guava, api_key:/base_url:) in addition to env vars.:telemetryspans ([:guava, :http, :request, …],[:guava, :command, :sent]) for standard observability.Vendor usage reporting is not ported. Upstream's
telemetry.pydecoratesAgent/Client/Call/Campaignswithtrack_class, queueing amethod-callorexception-raisedevent per public method and uploading batches tov1/upload-telemetry. Reproducing that feed in Elixir means either macro-wrapping every public function — invasive, and fragile as the surface grows — or inventing event shapes for an analytics endpoint whose contract lives on the server. The port emits:telemetryspans instead and leaves the decision to the host, which is the Elixir norm. A faithful uploader existed here (Guava.Usage: same envelope, same 100-event queue, same 10s interval) but nothing ever fed it, so opting in silently uploaded nothing; it was removed in favour of documenting the gap. Add it back deliberately if the data is wanted, alongside the backend contract.Socket errors fold into
Guava.Error— upstream'sGuavaSocketClosedError/GuavaSocketConnectionFailedare:closedand:transporterror types rather than separate exception structs.
Transport, retry and reconnect
Transient GET failures are retried; upstream retries nothing. The HTTP layer passes
retry: :safe_transient, so safe methods retry with backoff and honourRetry-After. The SMS polling loop opts out withretry: false— it is already a retry loop.httpxhas no equivalent. Both phases of the per-attempt budget matchhttpx'sTimeout(5.0), which covers connect and read alike:@receive_timeout 5_000bounds the wait for response data and@connect_timeout 5_000the TCP/TLS connect, the latter because Mint's default is 30_000. So four attempts cost up to ~27s against upstream's 5s. RAG and LLM calls pass their own longer timeouts, exactly where upstream passes an explicit one.A
Retry-Aftervalue Req cannot parse —0.5, or anything non-numeric — used to raise out of its retry step, and the rescue inrequest!/4turned that into%Guava.Error{type: :transport}, hiding the real status and body. A response step prepended ahead of Req's own drops such a header, so backoff falls back to the exponential schedule and the caller sees the actual status. Upstream ignoresRetry-Afterentirely and raises with the status, so this only differs in still honouring well-formed values.A stray
open-ackoutside the handshake is dropped, not fatal. Upstream leavesGuavaOpenAckout of its mid-session frame union, so the validation error kills the socket permanently. Ignoring it is deliberately gentler: that outcome is a side effect of how upstream validates rather than a designed policy, and tearing down a socket carrying a live call because one duplicated frame arrived is the worse failure. What the port refuses to do is act on it — re-running the open path mid-session retransmitted past a stale sequence, re-emitted readiness, and cleared the reconnect budget, which let a server looping acks mask a failing connection indefinitely. Symmetrically, amessageframe arriving before theopen-ackis still delivered, where upstream's single-frame handshake read rejects it; both cases require a non-conforming server.A mid-session drop costs one unit of the reconnect budget — 9 attempts where upstream gets 10, and the 5s backoff tier one attempt earlier. Upstream re-initialises its attempt counter on every entry to
_establish_socket, so an established connection dropping is free. An off-by-one in a retry budget, on a port that also retries at the channel level.The open-rate circuit breaker trips when reconnecting, not on a successful open. Upstream checks
_open_counter.count() >= 15immediately after establishing, so a 15th connection that is healthy and stays up is killed anyway. Here the check sits in the reconnect path — it fires only when the client is about to open again, which is when a flapping loop is actually still running. The breaker exists to stop a client hammering a bad server; a settled connection means the loop ended.Sockets close abruptly rather than sending a close frame. Upstream passes
close_timeout=10and completes the WebSocket close handshake (peers see 1000); teardown here exits the connection process, so peers see 1006. Visible only in server-side connection metrics, and avoiding a second teardown path is worth more than the cosmetic difference.The opening handshake gets upstream's 10s as a real overall deadline, enforced by
Guava.Socketrather than the transport.ws_connect(open_timeout=10)is a single budget over TCP + TLS + request + response. WebSockex offers only per-step timeouts and re-armssocket_recv_timeouton everyrecv, andopen_connection/3waits on its handshake task with no timeout at all — so a peer trickling upgrade bytes could extend a connect without bound. It used to do that whileGuava.Socketwas blocked insideconnect/1, so the socket counted no failure, never escalated backoff, never reported"reconnection-failed", and could not service:client_close— meaningGuava.drain/1was silently ignored against such a peer. The connect now runs in a short-lived linked helper with a 10s deadline: on overrun the helper is killed and the link takes any half-open transport with it, and on success its:normalexit does not propagate, so the established connection survives and the socket adopts it on{:conn_up, pid}. The link never reaches the socket, so a later transport crash still cannot take it down.A socket that gives up is retried at the channel level, and what happens next depends on how fast it fails. Upstream raises
GuavaSocketConnectionFailedand its listen loop exits, ending the process. Here the worker stops andGuava.Channel's supervisor rebuilds it, and there are two regimes worth knowing:- An unreachable peer is retried indefinitely. The socket spends its full attempt budget first (~53s), so worker restarts land far outside the supervisor's 5s intensity window and never exhaust it. A node self-heals whenever the server returns.
- A peer that closes immediately on connect produces sub-second restart cycles,
exhausts the window, and the failure propagates to whatever supervises
Guava.Channel. That surfaces a permanent misconfiguration rather than hiding it.
Backoff escalates within each socket's life. A peer-initiated
closeframe is treated as reconnectable here, where upstream treats an explicit peer close as terminal.A dead peer is detected by read inactivity, not a protocol-level keepalive. Upstream leaves the
websocketskeepalive at its defaults (ping_interval/ping_timeoutof 20s each), which fails the connection on a missing pong. HereGuava.Socketsends its application-level%Ping{}on an idle timer and treats a second idle interval with nothing inbound as a dead peer, reconnecting. Detection lands in ~20s against upstream's ~40s, and unlike upstream it costs one unit of the reconnect budget — consistent with how a mid-session drop is already charged here.Auth headers are recomputed on every connection attempt, matching upstream's
_get_headers()call inside its retry loop, so a reconnect picks up a refreshed CLI access token or a rotated deploy token file.
Call and channel lifecycle
Guava.Channelresolves its mode inside the supervisor'sinit/1, which means the campaign lookup (and WebRTC code creation) is HTTP done at start-up. A transient network failure there fails the child spec rather than just that channel. Kept deliberately: it fails fast on a bad campaign code exactly as the adjacentClient.new!()fails fast on missing credentials.A command issued from a process other than the call's is not ordered against commands issued inside a handler. Handler commands are written directly (so they keep their order relative to the runtime's own replies); a command cast from a spawned
Taskis queued and may follow them. Cross-process emission ordering was never guaranteed, and restoring it would mean an ordering queue. A command cast during the terminal event's handler is dropped rather than merely reordered: the runtime stops when that handler returns, and the queued cast dies with the mailbox. Upstream's separate drain thread still sends it, because_shutdown_queue()only runs after the dispatch loop breaks. Flushing them would mean selectively receiving GenServer's internal{:"$gen_cast", …}shape out of the mailbox before stopping — reaching into another module's private representation for work the call has already finished with.An
on_actionhandler's return value is not turned into a follow-up instruction. Upstream interpolates a truthy return intosend_instruction("Responding to action execution <key>: <response>").handle_action/3returns{:noreply, state}; callGuava.Call.send_instruction/2yourself when you want that, which is explicit rather than a templated string.The call socket opens before
handle_start/2finishes. Upstream runson_call_startbefore opening, which it can do because its commands go through a queue drained by a background thread. Here emission is synchronous andGuava.Socketalready buffers frames sent before the handshake, so the difference is not observable.A raising
handle_validate/4is treated as passing validation, where upstream lets the exception end the call. Consistent with keeping a call alive through handler bugs, but note the consequence: a validator that crashes accepts the field it was meant to reject.A raising
handle_start/2leaves the call running half-initialised, where upstream never connects the call socket at all. Same trade — the caller is already on the line, so the port would rather run a degraded call than drop one.Events already off the wire are dispatched after a
closeframe, where upstream discards whatever it had not yet consumed. Deterministic here by design.reach_personcarries no_voicemail_handlervariable. Upstream sets it there and reads it inset_voicemail_actionto detect a conflict between the two across a call. This port has noset_voicemail_action— voicemail handling is folded intoreach_person's:voicemail_message/:voicemail_hangupoptions, which raiseArgumentErrorwhen combined — so with one entry point there is no cross-call conflict to coordinate and the variable would be bookkeeping nothing reads.Call-state reads after the call has ended return the default, not the collected value. The per-call ETS table is owned by the call runtime and dies with it, so a
Taska handler spawned that readsGuava.Call.get_field/3afterwards seesnilrather than raising — and rather than the value, which is what upstream's in-memoryCallwould give. The failure is silent, so read call state while the call is live and send results back viahandle_info/3. Writes after the call has ended raise instead: the asymmetry is deliberate, since a read has a sensible fallback and a write cannot be honoured at all.A raising
handle_call_received/1declines the call. Upstream logs the exception and answers nothing, leaving the caller on a call it already claimed until the server times out. Declining tells the server immediately so it can route elsewhere. Either way the listener stays up.The
Guava.Testingtranscript orders agent speech by arrival, upstream by consumption. Affects the transcript's ordering only.
Not ported
Legacy
CallControllernot ported — deprecated in Python;Guava.Agentsupersedes it (maintainers confirmed out of scope).call_local/ curseschat/webrtc_helperomitted — platform-specific local-dev tools.webrtc_helperin particular downloads a per-OS/arch binary (ManifestEntry, sha256-pinned) and runs it as a subprocess. UseGuava.Testing.Edge / on-device wake features omitted —
edge_wake.py,on_wakeword/on_wake/on_press_enter,listen_for_wake, and the bundled wakeword ONNX models are gated behindGUAVA_EDGEand marked "unavailable for public use" upstream. Same class as the local-dev tools above; no wire impact.Health-check HTTP server omitted, but readiness and drain are provided (
HealthServer,HealthContext,MultiHealthContext). What's dropped is specifically the listener: Plug/Bandit are test-only deps here, and starting an HTTP server inside the host's supervision tree isn't the Elixir norm when the host already has an endpoint. The state upstream'shealth.pytracks is exposed as functions instead —Guava.ready?/0for a readiness probe you mount yourself, andGuava.drain/1, which runs automatically on shutdown. The four upstream states are derivable from channel status, so they aren't modelled explicitly. Elixir's drain goes further than upstream'sHealthContext.draining(), which only flips a flag: it actually waits for in-flight calls, generalizing what upstream's campaign loop does for Ctrl-C. Liveness has no equivalent and needs none — terminal failure on the BEAM exits the VM, which an orchestrator already detects.Guava.ready?/0reflects intake channels only: listen and campaign channels register, outbound ones don't. This matches upstream, whereRunnerbuilds aHealthContextfor each listener butcall_phonebuilds none, so placing a call can't affect/ready. It also has to be this way here — every channel used to register as:connecting, so one outbound call turned the whole node unready for the duration of itscreate-outboundrequest and a readiness probe would pull a pod whose listener was perfectly healthy. Shutdown is unaffected:drain/1waits onGuava.CallRegistry, so an outbound call in progress is still awaited.One narrower gap remains, and it follows from where channels live. A channel becomes visible to
ready?/0only once its worker starts, which is after theGuava.Channelsupervisor has resolved a campaign code over HTTP. During that window the channel is invisible rather than not-ready, so a node with one ready listener and one still starting reports ready, where upstream builds everyHealthContextat registration time in state "starting" and answers 503.Upstream can do that because
Runnerholds the list of channels it is about to start. Here channels are children of the host's supervision tree, so the SDK has no record of intended-but-not-yet-started ones — which is the point of that design, but it means readiness can only ever reflect channels that exist. A placeholder registration by the supervisor is not available either:Registry.unregister/2only affects the calling process's keys, so nothing could retract it once the worker registered. Resolving the mode inside the worker would close the window at the cost of turning a typo'd campaign code from an immediatestart_link/2error into a:permanentrestart loop. The zero-channel case is already handled, so this is confined to partial startup on a multi-channel node.Upstream internals with no public counterpart.
CommandQueueEndis a sentinelCallControllerpushes to unblock its command queue; the port has no such queue, since aGenServermailbox closes when the process stops.InboundTunnelCommandandInboundTunnelEventare defined but never constructed or dispatched anywhere upstream, so there is no behaviour to match — they are listed here only so a parity census doesn't keep surfacing them.Local RAG vector-store backends are expressed as the
Guava.RAG.VectorStore/GenerationModelbehaviours; server-mode RAG is fully ported.
Keeping in sync with the Python SDK
The Python version this port currently matches is recorded in
.upstream-sync.json. Two Claude skills (in
.claude/skills/) drive the update workflow:
- Report — run
check-upstream-parity. It diffs the tracked version against the latestguava-sdkon PyPI (handling being several releases behind), empirically checks for wire-protocol drift by regenerating fixtures, and writes a prioritized, read-only report undersync/. It never edits the SDK. - Reconcile — make the actual
lib//test/changes, guided by the report and the mapping above (manually or interactively with an agent). This is the deliberate, human-in-the-loop step: decide what applies, adapt it to idiomatic Elixir, and add tests. - Release — run
release. It bumps the version everywhere, regenerates fixtures, runs a blocking verification gate (compile/test/docs/hex.build), commits, tags, and cuts the GitHub release — then hands off the Hex publish (mix hex.publishis run by a human because it needs an interactive 2FA OTP). This step is what bumps.upstream-sync.json.