Phoenix production UI companion (v0.7)
View SourceThis is a Phoenix 1.8 companion application for Erlang ADK. It runs Phoenix,
erlang_adk, the authenticated gateway, and supervised agent runs on the same
BEAM. The dependency in mix.exs is deliberately a path dependency on the
repository root; this is a reference integration, not a separately published
package.
The UI provides OIDC login, a server-owned agent catalog, scoped authorization,
text runs, bounded event rendering, credit/ack backpressure, reconnect by
server-side cursor, cancellation, and typed human approval. The v0.7 /live
console adds principal-scoped discovery of server-owned ADK Live sessions,
future-only attach/detach, realtime text input, bounded Live event credit/ack,
an authenticated binary full-duplex voice bridge, read-only observability
snapshots, and pure evaluation report and baseline-comparison panels. Browser
input never selects an ADK user ID,
application name, runner, provider, model, evaluator module, or filesystem
path.
Locked setup and tests
Use Erlang/OTP 27 and Elixir 1.17 or newer. The checked .tool-versions
pins the verified development toolchain to Elixir 1.19.5 on OTP 27 and Node.js
24.3.0 for dependency-free asset tests. Then run:
cd examples/phoenix_adk_ui
mix deps.get
mix assets.setup
mix assets.build
mix assets.test
mix test
mix precommit
mix hex.audit
mix assets.test runs dependency-free resampler and browser-hook tests.
mix test rebuilds both the JavaScript and CSS assets, runs those Node tests,
and then runs ExUnit; the
suite verifies that /assets/css/app.css is served with the expected content
type. mix precommit checks formatting, compiles with warnings as errors, and
runs the deterministic suite. The suite uses fake identity, agent, and Live gateway
providers; it does not spend Gemini quota. The final 2026-07-15 gate passes all
101 ExUnit tests and 31 browser-audio tests;
production asset compilation and release assembly also pass. The assembled
release boots in both test-only trusted-proxy and direct-TLS configurations,
serves GET /health with HTTP 200 on loopback (with the repository test CA for
TLS), and stops cleanly. mix hex.audit remains non-zero only for the explicit
Cowlib exception documented below.
Phoenix LiveView is temporarily pinned to the official upstream fix commit
86165533e311469a1b62093fd182d9d874de8106 for CVE-2026-58228. Replace that Git
pin with a Hex requirement only after LiveView 1.2.7 or newer is available and
the suite passes.
Local development without an OIDC provider
The explicit local authentication mode is available only in MIX_ENV=dev and
binds Phoenix to the exact IPv4 loopback address. It uses one fixed,
server-owned development identity and the normal opaque server-side session;
the browser cannot select its subject, issuer, principal, or scopes. Login is
a CSRF-protected POST rather than a state-changing GET.
cd examples/phoenix_adk_ui
export GEMINI_API_KEY='...'
export ADK_UI_LOCAL_AUTH=true
mix setup
iex -S mix phx.server
Open http://127.0.0.1:4000/auth/login and click Continue as local
developer. No OIDC_* variables are read in this mode. The header displays a
local-authentication badge. ADK_UI_LOCAL_AUTH=true is rejected during
production configuration, and malformed values fail startup rather than
falling back silently.
Unset the flag, or set it to the exact value false, to exercise real OIDC.
OIDC development setup
Register this exact development redirect URI with the identity provider:
http://127.0.0.1:4000/auth/callbackSet deployment-specific values without committing literal secrets:
export GEMINI_API_KEY='...'
export OIDC_ISSUER='https://identity.example.com'
export OIDC_CLIENT_ID='...'
export OIDC_CLIENT_SECRET='...'
export OIDC_REDIRECT_URI='http://127.0.0.1:4000/auth/callback'
export OIDC_SCOPES='openid adk.agents.read adk.run.start adk.run.read adk.run.control adk.live.read adk.live.control adk.observability.read adk.evaluation.read'
mix phx.server
For a public PKCE client, omit OIDC_CLIENT_SECRET and set
OIDC_PUBLIC_CLIENT=true. The provider must support authorization code flow
with S256 PKCE and issue an ID token acceptable for the configured issuer,
client audience, and signing algorithm. The default model is fixed server-side
to gemini-3.1-flash-lite.
Open http://127.0.0.1:4000. With local authentication disabled, the browser
is redirected to the configured identity provider. /health is an
unauthenticated liveness endpoint.
Use /agent for ordinary Runner jobs and /live for server-owned Live
sessions, realtime voice, metadata-only observability, and configured
evaluation reports.
The opaque browser cookie does not contain state, nonce, PKCE verifier, OIDC tokens, identity claims, ADK user IDs, or run state. Login transactions are one-time, bounded, expiring entries; callbacks consume them before token exchange. Authenticated sessions are bounded, expiring entries in an unnamed private ETS table accessed only by its owning GenServer.
Authorization setup and code exchange execute outside Cowboy/Phoenix request
heaps in monitored workers. Each worker has a 15-second timeout and a
1,000,000-word shared-binary-aware maximum heap by default. Process aliases
suppress late replies, completion timestamps reject queued post-deadline
results, and a bounded watchdog reaps the worker if its request owner dies.
Crashes, timeouts, heap termination, and provider exception details collapse
to secret-safe public errors. Tune
:auth_provider_call only through trusted release configuration.
Production assets and release
Build immutable assets and the release:
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
At runtime set PHX_SERVER=true, PHX_HOST, SECRET_KEY_BASE, the OIDC values
above with an HTTPS callback, and GEMINI_API_KEY. PORT is the listener port;
PHX_URL_PORT is the external HTTPS port and defaults to 443. Generate the
Phoenix secret with mix phx.gen.secret; do not reuse an OIDC or Gemini secret.
Choose exactly one transport boundary:
- Direct TLS: set both
TLS_CERT_PATHandTLS_KEY_PATH. - Trusted terminating proxy: set
PHX_BEHIND_HTTPS_PROXY=trueand expose the Phoenix listener only to that proxy through network policy or a firewall. The proxy must overwrite client-suppliedX-Forwarded-Host,X-Forwarded-Port, andX-Forwarded-Proto; this setting explicitly trusts those headers. Never expose this HTTP listener directly to the internet.
Production enforces secure, encrypted, HTTP-only, SameSite=Lax cookies; an exact connection scheme/host/port origin check; CSRF protection; HSTS/HTTPS rewriting; bounded parsers; a same-origin Content Security Policy; and a microphone policy restricted to the same origin. Camera access remains disabled. Add edge rate limits for login, callback, and voice upgrade routes.
Runtime boundary
The browser sends only a fixed catalog ID and message. On every mount, browser
action, streamed event, terminal result, reconnect, and replay-gap transition,
the UI obtains current identity/session state from the private store and uses
adk_web_gateway for authorization and run ownership. Runs remain independent
supervised Erlang processes, so a dropped LiveView socket does not cancel work.
The browser receives one event at a time and returns credit only after bounded,
safe processing.
The Live console has a separate, explicit boundary in
ErlangAdkUi.LiveGateway. Its adapter module is trusted release configuration,
not request input. ErlangAdkUi.LiveGateway.Local performs a bounded,
timeout-limited concurrent scan of only the fixed adk_live_session_sup during
discovery/attach, then returns a server-only opaque attachment handle. LiveView
keeps that handle out of rendered data and uses it for O(1) routing of text,
acknowledgement, and detach calls. Every direct ADK operation still receives the
exact server-side principal and applies capability-specific scopes:
adk.live.readfor discovery, attachment, acknowledgement, detach, and opening a voice bridge;adk.live.controlfor realtime text and microphone/control frames; voice frames re-check both Live scopes;adk.observability.readfor metric/export-delivery snapshots; andadk.evaluation.readfor checked reports and baseline comparisons.
The browser can attach only an identifier returned by its current discovery
result; it can neither see nor submit the opaque attachment handle. Credit is
fixed in trusted configuration as %{messages: 8, bytes: 262_144}. Attach is future-only and carries no cursor: no historical Live
event replay is claimed or attempted. Closing the LiveView explicitly detaches,
and the ADK session also monitors the subscriber process as a cleanup backstop.
Detaching the UI does not close the independently supervised Live session.
Raw audio/video media is never assigned to or rendered by the LiveView. The projection boundary turns audio into format/rate/channel/byte-count metadata, strips thought signatures, omits generic binary payload fields, bounds text and collection sizes, then acknowledges only after projection and bounded event admission.
Voice uses a separate GET /live/voice/:session_id WebSocket upgrade. The
controller requires the normal opaque authenticated session and an exact
scheme/host/port Origin; absent and cross-origin requests fail before the
upgrade. The WebSocket accepts binary frames only, re-fetches the server-side
session on every inbound, outbound, ping, and pong frame, and also performs a
15-second server-driven revalidation so logout, expiry, or revocation releases
an otherwise idle exclusive lease. It owns one ephemeral core
adk_live_voice_bridge process. The bridge keeps the principal and opaque Live
session PID out of browser data and holds each forwarded ADK event's credit
until the browser ACKs that exact sequence. The fixed windows are eight events,
262,144 output bytes, and at most 64,000 bytes per microphone frame. Closing,
logout, expiry, owner death, malformed framing, or backpressure tears down the
bridge without closing the independently supervised Live session.
A monitored core lease permits only one bidirectional browser bridge for a
given Live session, preventing two tabs from interleaving audio while unrelated
sessions remain concurrent. A provider reconnect changes a session-owned
continuity capability and independently notifies the bridge, so an old bridge
cannot write after a fast resume or retain its lease behind exhausted event
credit. An ambiguous timed-out input/ACK terminates the bridge rather than
permitting a duplicate retry. Refresh after the session returns to active,
then start a new voice bridge.
The browser captures through an AudioWorklet, applies a streaming anti-alias
filter, resamples 44.1/48/96 kHz device contexts to 16 kHz mono PCM s16le, and
sends exact 20 ms chunks. A 16-chunk worklet-to-main credit window bounds a
stalled browser thread; overflow closes the generation instead of silently
dropping speech. Web Audio plays the native
PCM response at its declared sample rate with a bounded scheduling queue.
The player establishes a short jitter buffer once per response/underrun and
then schedules PCM frames sample-contiguously; WebSocket arrival jitter is not
converted into silence between chunks.
Model audio is split into at most 100 ms Web Audio sources with no more than
two seconds scheduled at once. A FIFO pending window mirrors the bridge's eight
event/262,144-byte credit bound, and an audio event's exact ACK is withheld
until all of its PCM has entered that scheduled window. Source completion
drains the FIFO, so a bursty or long reply applies backpressure instead of
canceling already audible content. Interruption deliberately cancels and
acknowledges older pending audio before its own ACK; terminal/error teardown
discards pending audio without a stale ACK and closes the bridge, releasing its
credit.
Gemini interruption code 4 immediately stops already scheduled sources. The
checked browser pipeline is offered only when trusted discovery reports an
active session with automatic activity detection; configure the Live session
with automatic_activity_detection => true. Muting disables the media
track and sends only silence so turn detection can finish without exposing
microphone samples. Camera/video capture remains disabled.
Interim transcription updates remain visual; a separate polite live region
announces only finalized user/model utterances to assistive technology.
The v1 browser projection gives transcription frames explicit directional
semantics: input frames contain the complete latest hypothesis and replace the
currently displayed input, while output frames are ordered deltas appended to
the current model utterance. The final flag controls visual/assistive completion
and does not change those replacement-versus-append rules.
Starting a Live session for the UI
Create sessions from trusted server orchestration, never from browser
parameters. The session ID must be unique on the node, and Principal must be
the exact principal derived from the authenticated server-side identity:
{:ok, %{principal: principal}} = ErlangAdkUi.Auth.LocalDev.identity()
config = %{
provider: :adk_live_gemini,
provider_config: %{
model: "gemini-3.1-flash-live-preview",
response_modalities: [:audio],
input_audio_transcription: true,
output_audio_transcription: true,
automatic_activity_detection: true,
session_resumption: true
},
transport: :adk_live_gun_transport,
transport_opts: %{api_key: System.fetch_env!("GEMINI_API_KEY")},
max_subscribers: 64,
max_subscriber_messages: 64,
max_subscriber_bytes: 8_388_608
}
{:ok, live_pid} =
:erlang_adk.start_live_session("web-live-01", principal, config)The Phoenix console discovers web-live-01 only for that exact Principal.
LocalDev.identity/0 is appropriate only for the explicit loopback mode above;
an OIDC deployment passes the principal from its validated server-side
identity through trusted orchestration instead.
The API key is handed to the fixed Google WebSocket transport and is not
returned through status or UI data. The production transport fixes the Google
origin/path and verifies TLS; the model is explicitly the Live preview model,
not the REST default gemini-3.1-flash-lite.
Set the core application environment live_session_limit before startup when
the default node-wide ceiling of 1024 sessions is not appropriate; accepted
values are 1 through the hard maximum of 16384. Each session independently
defaults to 64 subscribers with a hard maximum of 4096, and the UI attachment
relay and active voice bridge each consume one subscriber slot.
Testing full-duplex voice
Start the development server with
ADK_UI_LOCAL_AUTH=trueusingiex -S mix phx.server, then createweb-live-01in that same IEx prompt with the code above.Open http://127.0.0.1:4000/auth/login, continue as the local developer, and select Live and operations.
Click Refresh sessions until
web-live-01reportsactive, then attach it. Do not uselocalhostin one place and127.0.0.1in another; the authenticated voice upgrade enforces the exact browser origin.Prefer headphones, click Start voice, and allow microphone access for
127.0.0.1. Speak normally and pause. Input/output transcription appears in the voice panel and the model's 24 kHz PCM response plays automatically.Speak while the model is responding to verify interruption; queued playback must stop immediately. Exercise Mute microphone, Unmute microphone, and Stop voice, then detach the Live session.
The UI intentionally does not own or terminate the independently supervised Live session. When the manual test is complete, close it explicitly in IEx so the paid provider WebSocket is not left running:
:ok = :erlang_adk.close_live_session( live_pid, principal, :manual_voice_test_complete )
Browser microphone permission is a user/device decision and is not bypassed by
the application. 127.0.0.1 and HTTPS are secure contexts in supported
browsers; production must use the direct-TLS or trusted-proxy boundary described
above. The Live model is gemini-3.1-flash-live-preview; the ordinary agent
model remains gemini-3.1-flash-lite.
Observability and evaluation panels
The observability panel reads bounded snapshots from the fixed
adk_observability_metrics and optional adk_observability_bus processes. It
exposes aggregation/delivery metadata only and declares content attributes
disabled. It is not an arbitrary telemetry query or exporter configuration
surface.
Evaluation reports are immutable checked maps in trusted application configuration. No route accepts a file path or module name:
config :erlang_adk_ui,
evaluation_reports: %{
"release-baseline" => %{label: "Release baseline", report: baseline_result_map},
"release-current" => %{label: "Release current", report: current_result_map}
}report values must be persisted evaluation result maps accepted by
:adk_eval_dev_view. Rendering and comparison are pure and output-bounded;
invalid or mismatched reports fail closed. Populate the map at trusted boot
configuration time. Do not add a browser-controlled loader.
Gateway authorization itself uses independent caller-monitored workers with
bounded admission, timeout, heap, input, and normalized results. The defaults
are 64 concurrent checks, one second, and 100,000 heap words. A custom catalog
provider can tighten max_authorizations, authorizer_timeout_ms, and
authorizer_max_heap_words in its trusted gateway_options/0; a callback
failure denies that operation without wedging unrelated LiveViews.
Unknown pause types are displayed but never resumed. The UI only emits the typed payloads required by human approval and tool confirmation. Final output is escaped by HEEx and truncated on grapheme boundaries.
The bundled catalog contains one Gemini assistant. Replace
ErlangAdkUi.AgentCatalog.Gemini through trusted application configuration to
add a larger immutable catalog; do not build runners or providers from request
parameters.
Current deployment scope
The login, web session, reconnect cursor, gateways, adk_run, ADK Live,
observability snapshot, and configured evaluation catalog are node-local. A
single-node release is supported. A multi-node deployment must
use sticky routing for the complete login/session/run lifetime or replace these
stores and run routing with an explicitly distributed design. A node restart
invalidates web sessions and in-flight login transactions, which fails closed.
Logout revokes the local UI session; it does not perform identity-provider single logout or token revocation. The UI intentionally does not expose the developer inspector. Keep Erlang ADK's developer tooling on a separate, authenticated, loopback/private interface as documented in the repository root.
The operations panel is deliberately not a session creator, media recorder, tool-response editor, arbitrary trace browser, evaluation runner, or report loader. Live media remains ephemeral; the browser view is not durable storage. ADK Live resumption does not imply replay of unacknowledged media, text, or tool responses.
The locked dependency tree currently resolves Cowlib 2.18.0. mix hex.audit
reports EEF-CVE-2026-43969
(also GHSA-g2wm-735q-3f56) and
EEF-CVE-2026-43966. The
companion does not configure Gun's cookie store, and the locked Cowboy 2.17.0
and Gun 2.4.1 enable their invalid-header rejection defaults, but vulnerable
Cowlib routines remain present. Treat a non-zero audit as a visible release
exception, track an official fixed Cowlib release, and rerun the full suite
before upgrading; do not silently waive the advisories. As of 2026-07-15,
2.18.0 is the latest official Cowlib release
and both EEF advisory ranges are still open. The available
preliminary fork patch
addresses only EEF-CVE-2026-43969, so it is not a safe replacement for an
official release that resolves both findings.