The tension, and how to resolve it
"100% customizable" and "batteries included" pull in opposite directions. The trap is resolving that with one big configurable component that takes fifty options — it always fails to be either. Resolve it with layers, borrowing the headless-UI philosophy.
Layer 1 — headless (build this first)
A LiveView integration (a use Agentix.Chat macro plus an on_mount hook) that
owns the conversation assigns and renders nothing. It exposes the state and the
verbs; you write your own HEEx.
The mount pattern is snapshot + live tail (see 01), in this order: subscribe
to the live topic first, then fetch the snapshot, then apply. The snapshot is the
canonical log plus the agent's in-progress turn state: the agent already
accumulates the streaming text in order to finalize the message, so a mid-stream
mount (second tab, reconnect) fetches the partial text and seeds the JS hook with
it — otherwise the visible message starts mid-sentence. The subscribe-then-fetch
race is benign because the projection is keyed throughout (stream dom-ids,
tool_call_ids, last-write-wins state): replaying an event already reflected in
the snapshot is a no-op. That idempotence is a property to protect, not an
accident.
Assigns projection (resolved)
%{
messages: <LiveView stream of finalized %ReqLLM.Message{}>,
streaming_message: %{id, thinking} | nil, # text lives in the JS hook mid-stream
state: :idle | :preparing | :streaming | :executing_tools
| :awaiting_input,
streaming?: boolean, # derived from state
in_flight_tools: %{tool_call_id => %{name, executor, progress}},
pending: %{tool_call_id => %{executor, kind, prompt}}
# kind: :approval | :elicitation | :client_exec
}Note what is not an assign mid-stream: streaming text. It lives in the JS hook
(see the streaming path below) and only lands in messages via stream_insert on
finalization. streaming_message carries the in-progress id and any thinking
deltas; streaming? is derived from state and drives both the streaming
indicator and composer-disable.
Helpers it exposes:
- send a user message,
- resolve a pending call (approve/deny, submit an answer),
- cancel the current turn.
This layer is what actually delivers "100% customizable," and it is the one to nail first, because everything else is optional sugar on top of it.
Layer 2 — default function components (ownable)
A set of plain function components rendering a clean default chat against that
state: <.message_list>, <.message>, <.tool_call>, <.composer>,
<.thinking>, plus <.approval> and <.elicitation> for HITL.
Ship them as ownable components — mix agentix.gen.components copies the source
into the user's app (the shadcn / Phoenix core_components.ex model) so people edit
source rather than fighting config. Function components compose and override far
more gracefully than a monolith.
Layer 3 — slots
The default components take slots so someone can override just the message bubble,
or just the tool-call card, without rewriting the whole list — e.g.
<.message_list> with a :message slot that yields the message struct.
The event contract is the real interface
The renderer is a projection of the agent's canonical event stream into assigns.
If the agent publishes a clean, canonical event stream (it does, built on ReqLLM's
Message / ContentPart / StreamChunk types), the renderer is downstream and
swappable. Get the contract right and the UI is trivial to replace; get it wrong and
every renderer leaks provider quirks.
These are the live events (the PubSub plane from 01 — ephemeral, lossy-safe,
never the source of truth). Keep the union closed and small: Elixir 1.20 offers
only inference-based redundant-clause warnings (no user-declared sum types, no
exhaustiveness checking — that's milestone 3, ~1.22), so a checkable declared
union is a future upgrade, not a v0 tool:
{:state_changed, state}
{:turn_started, turn_ref}
{:text_delta, turn_ref, msg_id, chunk} # → JS hook, not assigns
{:thinking_delta, turn_ref, msg_id, chunk}
{:message_completed, turn_ref, %ReqLLM.Message{}} # → stream_insert
{:tool_call_started, tool_call_id, name, executor, args}
{:tool_progress, tool_call_id, payload} # progressive tools
{:tool_call_resolved, tool_call_id, result}
{:tool_call_errored, tool_call_id, reason}
{:suspended, tool_call_id, executor, prompt} # awaiting human/client
{:turn_completed, turn_ref} | {:cancelled, turn_ref}Each event maps cleanly to an assign mutation: :state_changed sets state
(and derives streaming?); :text_delta/:thinking_delta push to the JS hook;
:message_completed does stream_insert; the :tool_call_* events maintain
in_flight_tools; :suspended adds to pending and its resolution clears it.
Executor-aware rendering
The tool executor (see 03) drives what the UI shows for a tool call:
:server— a tool-call card with result, and progress if the tool is progressive.:human— an elicitation form/prompt; the submitted value resolves the call.- gated (
:requires_approval) — a confirm card; approve/deny resolves the gate. :provider— a "searched the web" style affordance; the result arrives in-stream.:client— usually executes invisibly; the result returns over the socket.
The discriminator the components actually switch on is pending[id].kind
(:approval / :elicitation / :client_exec), not the executor — executor alone
can't distinguish a gated :server call from an elicitation, and a gated
:client call suspends twice (approval, then client execution — see 03).
The streaming token path (the one real perf concern)
Appending tokens by re-rendering the streaming message server-side makes LiveView
re-diff a growing string every chunk. The fix: push token deltas to a small JS hook
via push_event and let the client append to the DOM, updating server assigns only
on finalization. Markdown is rendered live, client-side (a settled non-issue), so
the hook owns both append and render during streaming; the server holds the
finalized message.
The message list uses Phoenix.LiveView.stream/3 so the list itself isn't
re-diffed; the single in-progress message is the only thing the JS hook manages
incrementally.
Resolved decisions
- Event union + assigns shape — pinned above. The contract is the live event
union (this doc) plus the canonical log for snapshot/scrollback (
01,04). - Client-tool dispatch (see
03) —:clientis:humanwith JS as the "user." The agent emits{:suspended, id, :client, args}; a registered JS hook maps tool name → client function, executes it, andpushEvents the result back, which calls the sameresolveas any other pending call. Often invisible (no prompt rendered). Security: the server validates:clientresults and never trusts one for a privileged decision. - Approval vs elicitation — two components, one mechanism. The resolution
path is identical, so the headless layer has one
pendingconcept and one resolver, but ship both<.approval>(boolean gate) and<.elicitation>(arbitrary form) — don't force a form abstraction over a yes/no.