Components Chat (AI)

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

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 like markdown/1 would 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" then hooks: { ...PetalComponents }) - they drive token streaming, the composer, and copy buttons.
  • For rendered markdown, add the optional {:mdex, "~> 0.12"} dependency.
Today
How do I install petal_components?
Searched the docs Complete
Installation guide
hexdocs.pm/petal_components

Add the dep and pull it in:

def deps do
[{:petal_components, "~> 4.5"}]
end

Then use PetalComponents in your web module and every component is a plain HEEx tag.

heex
<.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.

What's the weather in Tokyo?
It's 22°C and clear in Tokyo right now, with a light breeze from the south.
heex
<.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.

Can you summarise this in one line?
It's a Phoenix component library that ships an MCP server so AI tools use the real API.
heex
<.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.

Searching the web Running
get_weather Complete
Tokyo
21°C
☀️
Payment failed Failed
heex
<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.

web_search Pending
web_search Receiving input
Input
Searching the web Running 0.8s
web_search Complete 1.2s
Input
{
  "query": "phoenix liveview streams",
  "limit": 3
}
Output
{
  "results": [
    {
      "title": "Phoenix.LiveView",
      "url": "https://hexdocs.pm/phoenix_live_view"
    }
  ],
  "count": 1
}
charge_card Failed 0.3s

Card token expired before submit.

Input
{
  "invoice": "4471",
  "amount_cents": 9900
}
heex
<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

relation users does not exist

Input
{
  "sql": "select * from users limit 5"
}
run_migrations Running 2.1s
write_file Pending
heex
<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.

web_search Complete 1.2s
read_file Complete 0.1s
query_users Complete 0.4s
send_invoice Complete 0.6s
stripe_refund Complete 0.9s
heex
<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
First I considered the user's location, then looked up the current conditions and picked the most relevant detail.
heex
<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")
heex
<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.

heex
<.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.

heex
<.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.

heex
<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.

Today
Pick up where we left off.
Running search_docs
Found 3 matches. Here's the most relevant one.
heex
<.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.

How does LiveView keep the page in sync?

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.

4 sources
heex
<.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.

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.

Processes in Elixir are cheap and isolated 4 which is why a LiveView per tab is unremarkable 1.

heex
<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.

The checkout page throws on submit. Screenshot, logs and the invoice attached.
Thanks - the stack trace in that screenshot points at the card token expiring before submit. I can see the charge attempt on invoice 4471.
heex
<.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.

Scaffold me a starter app.

Which framework are you targeting?

This picks the generators I'll reach for.

Framework (required)
Framework
heex
<.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

Which features do you need?
Which features do you need?
Team name
How settled is this scope? (required) How settled is this scope?
Still exploring Locked
heex
<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.

Which framework are you targeting?

This picks the generators I'll reach for.

Framework
Phoenix

Before I scaffold, two things

Which features do you need?
AuthBilling
Team name
Platform
How settled is this scope?
5 · Locked

Which framework are you targeting?

This picks the generators I'll reach for.

Skipped

heex
<.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.

heex
<div class="w-full max-w-xl mx-auto">
<.prompt_input id="showcase-chat-composer" placeholder="Message the assistant..." />
</div>
Properties
<.conversation>
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
<.chat_message>
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
<.streaming_text>
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
<.prompt_input>
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
<.tool_call>
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
<.markdown>
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
<.rich_text>
Attribute Type Default Description
class any nil
content* string
render_widget any nil fn(name :: String.t(), args :: map) -> rendered | nil
<.reasoning>
Attribute Type Default Description
class any nil
label string "Reasoning"
open boolean false
:inner_block slot slot
<.marker>
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
<.message_actions>
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
<.copy_button>
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
<.suggestions>
Attribute Type Default Description
class any nil
items* list
on_select string "suggestion" event pushed with phx-value-prompt
<.chat_error>
Attribute Type Default Description
class any nil
on_retry string nil
retry_label string "Retry"
:inner_block slot slot
<.chat_sources>
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
<.citation>
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
<.message_attachments>
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
<.questionnaire>
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