Overview
Phoenix is Elixir's premier web framework, built for real-time, fault-tolerant applications. Combined with ModelRiver, you can build AI-powered LiveView UIs, channel-based chat, and webhook-driven processing pipelines.
What you get:
- AI-powered LiveView components with real-time streaming
- Channel-based chat with ModelRiver's WebSocket support
- Webhook receivers for async AI processing
- Fault-tolerant AI pipelines leveraging Elixir's supervision trees
Quick start
Add dependencies
ELIXIR
1# mix.exs2defp deps do3 [4 {:phoenix, "~> 1.7"},5 {:req, "~> 0.5"},6 {:jason, "~> 1.4"},7 ]8endCreate an AI client module
ELIXIR
1defmodule MyApp.AI do2 @base_url "https://api.modelriver.com/v1"3 @api_key System.get_env("MODELRIVER_API_KEY")4 5 def chat(workflow, messages, opts \\ []) do6 body = %{7 model: workflow,8 messages: messages9 }10 |> Map.merge(Map.new(opts))11 12 Req.post!("#{@base_url}/ai",13 json: body,14 headers: [{"authorization", "Bearer #{@api_key}"}]15 )16 |> Map.get(:body)17 end18endUse in a controller
ELIXIR
1defmodule MyAppWeb.ChatController do2 use MyAppWeb, :controller3 4 def create(conn, %{"message" => message}) do5 result = MyApp.AI.chat("my_chat_workflow", [6 %{role: "user", content: message}7 ])8 9 json(conn, result)10 end11endLiveView streaming
Build a real-time chat UI with LiveView:
ELIXIR
1defmodule MyAppWeb.ChatLive do2 use MyAppWeb, :live_view3 4 def mount(_params, _session, socket) do5 {:ok, assign(socket, messages: [], loading: false, response: "")}6 end7 8 def handle_event("send", %{"message" => message}, socket) do9 messages = socket.assigns.messages ++ [%{role: "user", content: message}]10 socket = assign(socket, messages: messages, loading: true, response: "")11 12 # Stream AI response in a task13 send(self(), {:generate, messages})14 {:noreply, socket}15 end16 17 def handle_info({:generate, messages}, socket) do18 task = Task.async(fn ->19 MyApp.AI.chat("my_chat_workflow", messages)20 end)21 22 result = Task.await(task, 30_000)23 content = get_in(result, ["data", "choices", Access.at(0), "message", "content"])24 25 messages = socket.assigns.messages ++ [%{role: "assistant", content: content}]26 {:noreply, assign(socket, messages: messages, loading: false)}27 end28endHEEX
1<div id="chat" class="flex flex-col gap-4">2 <%= for msg <- @messages do %>3 <div class={"p-3 rounded #{if msg.role == "user", do: "bg-blue-100", else: "bg-zinc-100"}"}>4 <strong><%= msg.role %>:</strong> <%= msg.content %>5 </div>6 <% end %>7 8 <.form for={%{}} phx-submit="send">9 <input type="text" name="message" placeholder="Type a message..." class="w-full p-2 border rounded" />10 <button type="submit" disabled={@loading}>Send</button>11 </.form>12</div>Webhook receiver
Process async ModelRiver results via webhooks.
The signature covers the timestamp and the exact JSON text of the body's data value, so the handler needs the raw request body. Plug.Parsers consumes it before your controller runs, so cache it with a custom body_reader:
ELIXIR
1# lib/my_app_web/cache_body_reader.ex2defmodule MyAppWeb.CacheBodyReader do3 # Plug.Parsers consumes the body, so keep a copy of the raw bytes for signature verification4 def read_body(conn, opts) do5 with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do6 {:ok, body, Plug.Conn.assign(conn, :raw_body, body)}7 end8 end9end10 11# lib/my_app_web/endpoint.ex12plug Plug.Parsers,13 parsers: [:urlencoded, :multipart, :json],14 pass: ["*/*"],15 body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},16 json_decoder: Phoenix.json_library()ELIXIR
1defmodule MyAppWeb.WebhookController do2 use MyAppWeb, :controller3 4 def handle(conn, params) do5 # Verify webhook signature6 signature = get_req_header(conn, "x-modelriver-signature") |> List.first()7 timestamp = get_req_header(conn, "x-modelriver-timestamp") |> List.first()8 9 if verify_signature(conn.assigns[:raw_body] || "", timestamp, signature) do10 # The event payload is under "data"11 process_webhook(params["data"])12 json(conn, %{status: "ok"})13 else14 conn |> put_status(401) |> json(%{error: "Invalid signature"})15 end16 end17 18 defp process_webhook(%{"status" => "success"} = event) do19 # Handle completed AI request (standard workflow)20 result = event["data"]21 IO.inspect(result, label: "AI result received")22 end23 24 defp process_webhook(_event), do: :ok25 26 # Signed string: "<timestamp>.<exact JSON text of the raw body's "data" value>" (never re-encode)27 defp verify_signature(raw_body, timestamp, signature)28 when is_binary(timestamp) and is_binary(signature) do29 secret = System.get_env("MODELRIVER_WEBHOOK_SECRET")30 31 with {start, len} <- :binary.match(raw_body, ~s("data":)),32 [_ | _] = matches <- :binary.matches(raw_body, ~s(,"timestamp":)),33 {finish, _} when finish >= start + len <- List.last(matches) do34 data = binary_part(raw_body, start + len, finish - start - len)35 36 expected =37 :crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{data}")38 |> Base.encode16(case: :lower)39 40 Plug.Crypto.secure_compare(expected, signature)41 else42 _ -> false43 end44 end45 46 defp verify_signature(_raw_body, _timestamp, _signature), do: false47endGenServer for background AI tasks
ELIXIR
1defmodule MyApp.AIWorker do2 use GenServer3 4 def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)5 def init(_opts), do: {:ok, %{}}6 7 def generate(workflow, messages) do8 GenServer.cast(__MODULE__, {:generate, workflow, messages, self()})9 end10 11 def handle_cast({:generate, workflow, messages, caller}, state) do12 Task.start(fn ->13 result = MyApp.AI.chat(workflow, messages)14 send(caller, {:ai_result, result})15 end)16 {:noreply, state}17 end18endBest practices
- Use Tasks for AI calls: Never block your LiveView process
- Add timeouts: AI calls can take seconds; set appropriate
Task.awaittimeouts - Leverage supervision trees: Let Elixir's OTP handle failures gracefully
- Use webhooks for heavy processing: Batch jobs should use the async API
- Monitor in Request Logs: Track costs and latencies in Observability
Next steps
- Next.js integration: React-based alternative
- FastAPI integration: Python backend
- Webhooks: Async processing guide