Chat (AI)
AI Elements for Phoenix - a composition-first family for streaming chat UIs, the kind you'd reach for assistant-ui or Vercel AI Elements for in React, without a client AI SDK. Tokens stream over the LiveView socket you already have: the model streams to your process, you push each delta to the bubble, and the component owns its own DOM so nothing clobbers the text as it arrives.
Three-minute setup
-
The Chat family is not
pulled in by
use PetalComponents(its generic names likemarkdown/1would clash with your own helpers). Alias it:alias PetalComponents.Chat, then call it namespaced -<Chat.conversation>. -
Register the bundled JS hooks in
app.js(import PetalComponents from "../../deps/petal_components/assets/js/petal_components"thenhooks: { ...PetalComponents }) - they drive token streaming, the composer, and copy buttons. -
For rendered markdown, add the optional
{:mdex, "~> 0.12"}dependency.
<.conversation id="showcase-chat-flagship" class="w-full max-w-xl mx-auto">
<.marker variant="separator">Today</.marker>
<.chat_message role="user">How do I install petal_components?</.chat_message>
<.tool_call name="search_docs" status={:complete} label="Searched the docs">
<div class="flex items-center gap-3 text-sm">
<.icon name="hero-book-open" class="w-8 h-8 text-primary-500" />
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">Installation guide</div>
<div class="text-xs text-gray-500 dark:text-gray-400">hexdocs.pm/petal_components</div>
</div>
</div>
</.tool_call>
<.chat_message role="assistant">
<.markdown
id="showcase-chat-flagship-md"
content={"Add the dep and pull it in:\n\n```elixir\ndef deps do\n [{:petal_components, \"~> 4.5\"}]\nend\n```\n\nThen `use PetalComponents` in your web module and every component is a plain HEEx tag."}
/>
<:actions>
<.message_actions visible="always">
<.copy_button
id="showcase-chat-flagship-copy"
text={"{:petal_components, \"~> 4.5\"}"}
icon
/>
<.action_button icon="hero-hand-thumb-up" label="Good response" phx-click="noop" />
<.action_button icon="hero-hand-thumb-down" label="Bad response" phx-click="noop" />
<.action_button icon="hero-arrow-path" label="Regenerate" phx-click="noop" />
</.message_actions>
</:actions>
</.chat_message>
<:footer>
<.suggestions
class="mb-2"
items={["What makes this different from React AI kits?", "Show me a tool call"]}
on_select="noop"
/>
<.prompt_input
id="showcase-chat-flagship-composer"
placeholder="Ask about petal_components..."
/>
</:footer>
</.conversation>
That's the whole family assembled, rendered statically. For the live version - real token streaming, edit-and-fork, the works - see the streaming-chat recipe . Everything below is one of the pieces it's built from.
Conversation
The default plain variant - full-width turns, the ChatGPT / Claude look. Messages are just slots.
<.conversation id="showcase-chat-plain" class="w-full max-w-xl mx-auto">
<.chat_message role="user">What's the weather in Tokyo?</.chat_message>
<.chat_message role="assistant">
It's 22°C and clear in Tokyo right now, with a light breeze from the south.
</.chat_message>
</.conversation>
Bubbles
Pass variant="bubbles" for the messaging-app layout.
<.conversation id="showcase-chat-bubbles" variant="bubbles" class="w-full max-w-xl mx-auto">
<.chat_message role="user">Can you summarise this in one line?</.chat_message>
<.chat_message role="assistant">
It's a Phoenix component library that ships an MCP server so AI tools use the real API.
</.chat_message>
</.conversation>
Tool calls
Generative UI. The model emits data, you map the tool name to a real Phoenix component and render it in the card - the widget stays visible, it never hides behind a disclosure. state drives the header; the older status attr still works and maps onto the same three values.
<div class="w-full max-w-xl mx-auto space-y-3">
<.tool_call name="search_web" state={:running} label="Searching the web" />
<.tool_call name="get_weather" state={:complete}>
<div class="flex items-center justify-between px-4 py-3 text-white rounded-lg bg-gradient-to-br from-sky-500 to-indigo-600">
<div>
<div class="text-sm font-medium opacity-90">Tokyo</div>
<div class="text-2xl font-bold">21°C</div>
</div>
<div class="text-4xl">☀️</div>
</div>
</.tool_call>
<.tool_call name="charge_card" state={:error} label="Payment failed" />
</div>
Tool call lifecycle
The five states a streaming call moves through, stacked so you can read the progression. state is a plain assign - your LiveView patches it as the model's response arrives, and the card follows. Pending and input_streaming rest on a skeleton, running gets the activity line, complete and error settle.
Input
{
"query": "phoenix liveview streams",
"limit": 3
}
Output
{
"results": [
{
"title": "Phoenix.LiveView",
"url": "https://hexdocs.pm/phoenix_live_view"
}
],
"count": 1
}
Input
{
"invoice": "4471",
"amount_cents": 9900
}
<div class="w-full max-w-xl mx-auto space-y-3">
<.tool_call name="web_search" state={:pending} icon="web_search" />
<.tool_call name="web_search" state={:input_streaming} icon="web_search" />
<.tool_call
name="web_search"
state={:running}
icon="web_search"
label="Searching the web"
duration="0.8s"
/>
<.tool_call
name="web_search"
state={:complete}
icon="web_search"
duration="1.2s"
input={"{\"query\": \"phoenix liveview streams\", \"limit\": 3}"}
output={"{\"results\": [{\"title\": \"Phoenix.LiveView\", \"url\": \"https://hexdocs.pm/phoenix_live_view\"}], \"count\": 1}"}
/>
<.tool_call
name="charge_card"
state={:error}
icon="hero-credit-card"
duration="0.3s"
error="Card token expired before submit."
input={"{\"invoice\": \"4471\", \"amount_cents\": 9900}"}
>
<:error_actions>
<button type="button" class="pc-chat__action" phx-click="noop">Retry</button>
</:error_actions>
</.tool_call>
</div>
Compact tool burst
An agent that fires six tools in a row shouldn't produce six cards. compact renders one dense line per call and consecutive rows stack into a list. Settled rows are the disclosure themselves - click or press Enter on a focused row to open the panels.
read_file Complete 0.1s
Input
{
"path": "lib/app_web/router.ex"
}
Output
{
"lines": 184
}
grep Complete 0.2s
Input
{
"pattern": "live_session"
}
Output
{
"matches": 6
}
query_users Failed relation users does not exist 0.4s
Input
{
"sql": "select * from users limit 5"
}
<div class="w-full max-w-xl mx-auto">
<.tool_call
name="read_file"
compact
state={:complete}
icon="code"
duration="0.1s"
input={"{\"path\": \"lib/app_web/router.ex\"}"}
output={"{\"lines\": 184}"}
/>
<.tool_call
name="grep"
compact
state={:complete}
icon="web_search"
duration="0.2s"
input={"{\"pattern\": \"live_session\"}"}
output={"{\"matches\": 6}"}
/>
<.tool_call
name="query_users"
compact
state={:error}
icon="database"
duration="0.4s"
error="relation users does not exist"
input={"{\"sql\": \"select * from users limit 5\"}"}
>
<:error_actions>
<button type="button" class="pc-chat__action" phx-click="noop">Retry</button>
</:error_actions>
</.tool_call>
<.tool_call name="run_migrations" compact state={:running} icon="database" duration="2.1s" />
<.tool_call name="write_file" compact state={:pending} icon="code" />
</div>
Tool icons
icon takes one of the presets (web_search, code, database) or any hero-* name. For a vendor logo or anything that isn't a heroicon, the tool_icon slot takes over.
<div class="w-full max-w-xl mx-auto space-y-3">
<.tool_call name="web_search" state={:complete} icon="web_search" duration="1.2s" />
<.tool_call name="read_file" state={:complete} icon="code" duration="0.1s" />
<.tool_call name="query_users" state={:complete} icon="database" duration="0.4s" />
<.tool_call name="send_invoice" state={:complete} icon="hero-envelope" duration="0.6s" />
<.tool_call name="stripe_refund" state={:complete} duration="0.9s">
<:tool_icon>💳</:tool_icon>
</.tool_call>
</div>
Reasoning
A collapsible thinking block for reasoning-model output.
Thought for 2s
<div class="w-full max-w-xl mx-auto">
<.reasoning label="Thought for 2s" open>
First I considered the user's location, then looked up the current conditions and picked the most relevant detail.
</.reasoning>
</div>
Markdown
Render a committed assistant reply as sanitized, syntax-highlighted markdown. Needs the optional :mdex dep.
Forecast
Tokyo is 21°C and sunny.
- Light breeze
- UV index moderate
IO.puts("pack light")
<div class="w-full max-w-xl mx-auto">
<.markdown content={"## Forecast\n\nTokyo is **21°C** and sunny.\n\n- Light breeze\n- UV index moderate\n\n```elixir\nIO.puts(\"pack light\")\n```"} />
</div>
Message actions
A row of actions under a reply. copy_button copies text client-side via a bundled hook.
<.message_actions class="max-w-xl mx-auto">
<.copy_button id="showcase-chat-copy" text="The full assistant reply, copied to the clipboard." />
<button type="button" class="pc-chat__action" phx-click="noop">Regenerate</button>
</.message_actions>
Suggestions
Prompt-starter chips for the empty state. Each pushes on_select with phx-value-prompt.
<.suggestions
class="max-w-xl mx-auto"
items={["What is Phoenix LiveView?", "Show me a markdown demo", "Write a haiku"]}
on_select="suggestion"
/>
Error
An error notice with an optional retry button.
<div class="w-full max-w-xl mx-auto">
<.chat_error on_retry="retry">
Something went wrong generating a response.
</.chat_error>
</div>
Markers
Section dividers between turns - a date, a "new messages" line, or a tool-call header.
<.conversation id="showcase-chat-markers" class="w-full max-w-xl mx-auto">
<.marker variant="separator">Today</.marker>
<.chat_message role="user">Pick up where we left off.</.chat_message>
<.marker variant="border" icon="hero-wrench-screwdriver">Running search_docs</.marker>
<.chat_message role="assistant">Found 3 matches. Here's the most relevant one.</.chat_message>
</.conversation>
Sources and citations
Answer grounding for RAG. Prompt the model to cite as [^N]; pass the same source maps to markdown/1 and the markers become chips (hover or tab to one for the preview card). chat_sources renders the deduped list below - native <details>, no JS.
<.conversation id="showcase-chat-sources" class="w-full max-w-xl mx-auto">
<.chat_message role="user">How does LiveView keep the page in sync?</.chat_message>
<.chat_message role="assistant">
<.markdown
content="LiveView holds a **persistent connection** and diffs the rendered tree server-side, pushing only what changed [^1]. Anything the server can't own - focus, clipboard, third-party widgets - drops down to a client hook [^2].\n\nEvery request still enters through the endpoint [^3]."
sources={rag_sources()}
/>
<.chat_sources sources={rag_sources()} />
</.chat_message>
</.conversation>
Sources expanded
expanded opens the row on render; max_visible caps the list and tucks the rest behind a "Show all" reveal. A source with no favicon_url falls back to a letter avatar, and no snippet just means a shorter row.
4 sources
<div class="w-full max-w-xl mx-auto">
<.chat_sources sources={rag_sources()} expanded max_visible={2} />
</div>
Citation chip
The chip on its own, for prose you assemble yourself. It's a real link - Tab reaches it, the preview card opens on hover or focus, and activating it opens the source in a new tab.
<p class="w-full max-w-xl mx-auto text-sm text-gray-700 dark:text-gray-300">
Processes in Elixir are cheap and isolated
<.citation index={4} source={Enum.at(rag_sources(), 3)} />
which is why a LiveView per tab is unremarkable
<.citation index={1} source={Enum.at(rag_sources(), 0)} />.
</p>
Message attachments
What the user sent along with the text. Images tile into a grid, files are download rows with the size on the end. A mixed list puts the images first.
<.conversation id="showcase-chat-attachments" class="w-full max-w-xl mx-auto">
<.chat_message role="user">
<.message_attachments attachments={[
%{kind: :image, url: shot_image(), name: "checkout-error.png", size: 184_320},
%{kind: :image, url: logs_image(), name: "server-logs.png", size: 92_100},
%{kind: :file, url: "#", name: "invoice-4471.pdf", size: 96_400}
]} /> The checkout page throws on submit. Screenshot, logs and the invoice attached.
</.chat_message>
<.chat_message role="assistant">
Thanks - the stack trace in that screenshot points at the card token
expiring before submit. I can see the charge attempt on invoice 4471.
</.chat_message>
</.conversation>
Questionnaire
The model pauses to ask a structured question and the answer lands in the transcript. Server-driven: a plain phx-submit, no client state. Options with descriptions render as radio cards, plain ones as radios.
<.conversation id="showcase-chat-questionnaire" class="w-full max-w-xl mx-auto">
<.chat_message role="user">Scaffold me a starter app.</.chat_message>
<.chat_message role="assistant">
<.questionnaire spec={framework_spec()} allow_skip />
</.chat_message>
</.conversation>
Questionnaire - mixed fields
All four field types in one bubble: multi-select, short text, and a 1-to-5 scale with end captions. Required fields use the native required attribute, so enforcement is the browser's.
Before I scaffold, two things
<div class="w-full max-w-xl mx-auto">
<.questionnaire spec={scoping_spec()} submit_label="Send answers" />
</div>
Questionnaire - resolved and skipped
Once the app has the answer, pass it back as resolved and the form becomes quiet chips - nothing focusable is left behind. :skipped renders the one-line skipped state.
<.conversation id="showcase-chat-questionnaire-resolved" class="w-full max-w-xl mx-auto">
<.chat_message role="assistant">
<.questionnaire
spec={framework_spec("showcase-q-framework-resolved")}
resolved={%{"framework" => "phoenix"}}
/>
</.chat_message>
<.chat_message role="assistant">
<.questionnaire
spec={scoping_spec("showcase-q-scope-resolved")}
resolved={
%{
"features" => ["auth", "billing"],
"team" => "Platform",
"confidence" => "5"
}
}
/>
</.chat_message>
<.chat_message role="assistant">
<.questionnaire spec={framework_spec("showcase-q-framework-skipped")} resolved={:skipped} />
</.chat_message>
</.conversation>
Prompt input
The composer - an autogrowing textarea with the arrow-up send button. Enter submits, Shift+Enter adds a line.
<div class="w-full max-w-xl mx-auto">
<.prompt_input id="showcase-chat-composer" placeholder="Message the assistant..." />
</div>
Properties
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
id
|
string |
defaults to a generated id so multiple threads can coexist | |
rest
|
global |
||
variant
|
string |
"plain"
|
plain is the AI convention (ChatGPT/Claude): assistant text sits on the surface, only the user gets a bubble. bubbles puts both sides in bubbles (messenger style)
one of: "plain", "bubbles"
|
:footer
slot
|
slot |
pinned below the scroll area, e.g. a prompt_input | |
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
rest
|
global |
||
role
|
string |
"assistant"
|
one of: "user", "assistant", "system"
|
:actions
slot
|
slot |
an action bar rendered below the message, outside the bubble - message_actions/1. Works on any role: copy/edit under a user message, copy/feedback/regenerate under an assistant one | |
:avatar
slot
|
slot |
optional leading avatar/icon | |
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
event
|
string |
"pc-chat-token"
|
push_event name the hook listens for |
format
|
string |
"text"
|
"text" appends raw token deltas; "markdown" replaces innerHTML with rendered HTML you push (see `to_html/1`)
one of: "text", "markdown"
|
id*
|
string |
| Attribute | Type | Default | Description |
|---|---|---|---|
accept_hint
|
string |
nil
|
human-readable hint of accepted types and size (e.g. "Images and PDFs up to 10 MB"), used as the paperclip button's title and accessible description |
aria_label
|
string |
"Message"
|
accessible label for the textarea |
class
|
any |
nil
|
|
edit_label
|
string |
"Editing message"
|
label shown in the edit banner |
editing
|
boolean |
false
|
show the edit-mode banner above the field (set while editing a past message) |
id
|
string |
defaults to a generated id so multiple composers can coexist | |
loading
|
boolean |
false
|
|
name
|
string |
"prompt"
|
|
on_cancel_edit
|
string |
nil
|
event pushed when the edit banner's cancel (X) is clicked |
on_cancel_upload
|
string |
"cancel-upload"
|
event pushed by a chip's remove button, with phx-value-ref set to the entry ref (wire it to cancel_upload/3) |
on_stop
|
string |
nil
|
event pushed when the stop button is clicked while loading |
placeholder
|
string |
"Send a message..."
|
|
rest
|
global |
||
submit_label
|
string |
nil
|
text for the send button; the default is the arrow-up icon convention |
upload
|
any |
nil
|
a %Phoenix.LiveView.UploadConfig{} from allow_upload/3. When set the composer renders a paperclip trigger wrapping a visually hidden live_file_input, attachment chips for @upload.entries, becomes a phx-drop-target, and accepts pasted images |
value
|
string |
""
|
initial textarea value. The field is uncontrolled after mount (phx-update=ignore, so keystrokes never re-render and lose focus); set it later - edit, quote, clear - by pushing a `pc-chat-set-input` event (`%{value: text}`, optional `%{id: composer_id}`) to the PetalChatComposer hook |
:actions
slot
|
slot |
extra controls left of the send button |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
compact
|
boolean |
false
|
one dense line per call for multi-tool bursts: state glyph, name, duration. Finished rows expand on click to reveal the panels; consecutive compact calls stack as a list |
duration
|
string |
nil
|
elapsed or total time shown in the header, e.g. "1.2s". You format it — the component never ticks a clock. For a live elapsed while :running, compose `PetalComponents.LocalTime` into the label |
error
|
string |
nil
|
error message rendered inline when the state is :error |
icon
|
string |
nil
|
a preset ("web_search", "code", "database") or any heroicon name ("hero-*"), shown before the tool name. nil shows the state glyph only, and an unrecognised value renders no icon |
input
|
string |
nil
|
tool arguments as a JSON string; pretty-printed into the expandable Input panel, or shown verbatim if it is not valid JSON. Only rendered once the call has settled (:complete or :error) |
label
|
string |
nil
|
human label; defaults to the tool name |
name*
|
string |
||
output
|
string |
nil
|
tool result as a JSON string; pretty-printed into the expandable Output panel, or shown verbatim if it is not valid JSON. For a rendered widget use the default slot instead |
rest
|
global |
||
state
|
atom |
nil
|
lifecycle state, server-driven. Defaults to nil, which falls back to the legacy `status` attr — so a card given neither renders exactly as it always has (a completed call). Set this on new code
one of: nil, :pending, :input_streaming, :running, :complete, :error
|
status
|
atom |
:complete
|
DEPRECATED, use `state`. Kept so existing call sites render unchanged; consulted only while `state` is nil, and its three values map onto the states of the same name
one of: :running, :complete, :error
|
:error_actions
slot
|
slot |
actions rendered beside the error message, e.g. a retry button with phx-click | |
:inner_block
slot
|
slot |
the rendered widget / tool result. Always visible, never collapsed. Wrap bare text in an element - the body hides itself when it contains no element, which is what keeps whitespace-only inner content (a formatted call whose only real content is a named slot) from rendering an empty padded strip | |
:input_panel
slot
|
slot |
custom Input panel content, overriding the `input` attr | |
:output_panel
slot
|
slot |
custom Output panel content, overriding the `output` attr | |
:tool_icon
slot
|
slot |
custom icon markup (a vendor logo, an emoji), overriding the `icon` attr. Named `tool_icon` rather than `icon` because a slot cannot share a name with an attr |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
content*
|
string |
||
id
|
string |
nil
|
pass a unique id to enable per-code-block copy buttons |
sources
|
list |
nil
|
when set, complete `[^N]` markers in the content render as inline citation chips for the matching source (by `id`, falling back to the Nth source). Unmatched markers stay as plain text |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
content*
|
string |
||
render_widget
|
any |
nil
|
fn(name :: String.t(), args :: map) -> rendered | nil |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
label
|
string |
"Reasoning"
|
|
open
|
boolean |
false
|
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
icon
|
string |
nil
|
heroicon name rendered before the text |
loading
|
boolean |
false
|
spinner + role=status for in-progress work |
rest
|
global |
||
variant
|
string |
"inline"
|
inline note, centred labelled separator, or a full-width bordered row
one of: "inline", "separator", "border"
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
visible
|
string |
"always"
|
hover reveals the bar on message-row hover/focus; always shows on touch
one of: "always", "hover"
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
icon
|
boolean |
false
|
icon-only (clipboard -> check feedback) |
id*
|
string |
||
label
|
string |
"Copy"
|
|
text*
|
string |
the text to copy |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
items*
|
list |
||
on_select
|
string |
"suggestion"
|
event pushed with phx-value-prompt |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
on_retry
|
string |
nil
|
|
retry_label
|
string |
"Retry"
|
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
expanded
|
boolean |
false
|
render the list open instead of the collapsed 'N sources' row |
label
|
string |
nil
|
override the collapsed row label; defaults to '{count} sources' / '1 source' |
max_visible
|
integer |
5
|
sources shown when expanded before a 'Show all (N)' control reveals the rest |
rest
|
global |
||
sources*
|
list |
list of source maps: %{id, url, title, snippet, favicon_url}; snippet and favicon_url optional. Deduped by URL before render |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
index*
|
integer |
1-based citation number shown in the chip | |
source*
|
map |
the source map this chip points at: %{url, title, snippet, favicon_url}; every key but `url` is optional |
| Attribute | Type | Default | Description |
|---|---|---|---|
attachments*
|
list |
list of maps: %{kind: :image | :file, url, name, size}. :kind picks the rendering, :size is bytes and is formatted for display or omitted when nil. String or atom keys both accepted | |
class
|
any |
nil
|
|
rest
|
global |
| Attribute | Type | Default | Description |
|---|---|---|---|
allow_skip
|
boolean |
false
|
renders a Skip button that posts on_skip with the spec id |
class
|
any |
nil
|
|
on_skip
|
string |
"questionnaire_skip"
|
phx-click event for the skip button, with phx-value-id set to the spec id |
on_submit
|
string |
"questionnaire_submit"
|
phx-submit event name posted to the parent LiveView |
resolved
|
any |
nil
|
nil while pending. A map of answers keyed by field id renders the resolved summary; the atom :skipped renders the skipped state |
rest
|
global |
||
spec*
|
map |
the question spec: %{id, title, description, fields: [...]}. `id` namespaces the ids inside, so give two questionnaires on one page two ids; `title` labels the form and should be set. Each field is %{id, type, label, required, options, placeholder, min_label, max_label, style}, where type is :single_select | :multi_select | :text | :scale. `required` is browser-enforced everywhere except :multi_select, where it is advisory (marker plus announcement, your server validates). String or atom keys both accepted | |
submit_label
|
string |
"Submit"
|
text on the submit button |
submitting
|
boolean |
false
|
disables every input and both buttons and shows a spinner while the app forwards the answer |