Jidoka.AgentView is a surface-neutral UI projection. It is not a Phoenix view, it never renders HTML, and it never owns pids or transcripts. It is a small data struct that LiveView, CLI examples, channel handlers, jobs, and tests can use to keep UI state separate from the durable agent runtime. This guide covers the struct, the use Jidoka.AgentView macro, and the lifecycle hooks for applying turns and streamed events.

When To Use This

  • Use AgentView when a UI needs a stable representation of an agent conversation that survives renders and resumes.
  • Use AgentView when you want to share UI state code between LiveView, CLI, channels, and tests without binding any of them to a specific surface.
  • Do not use AgentView as a durable transcript store. Persist the underlying Jidoka.Session or Jidoka.Turn.Result; AgentView is a projection only.

Prerequisites

mix deps.get
mix test

Quick Example

The smallest view is use Jidoka.AgentView, agent: MyAgent.

defmodule MyApp.SupportAgent do
  use Jidoka.Agent

  agent :support_agent do
    instructions "Answer support questions tersely."
  end
end

defmodule MyApp.SupportView do
  use Jidoka.AgentView, agent: MyApp.SupportAgent
end

{:ok, view} = MyApp.SupportView.initial(%{conversation_id: "case_123"})

view.agent_id
#=> "support_agent-case_123"
view.conversation_id
#=> "case_123"
view.runtime_context
#=> %{session: "case_123"}
view.status
#=> :idle

The view is plain data. Pass it to a LiveView assign, a CLI render, or a test assertion without changing the contract.

Concepts

The view is a projection of three things: the agent identity, the visible messages, and the runtime events that produced them.

╭───────────────────────╮     ╭──────────────────────╮
│ use Jidoka.AgentView, │────▶│ MyAgentView module   │
│   agent: MyAgent      │     ╰──────┬───────────────╯
╰───────────────────────╯            │
                                     ▼
                          ╭──────────────────────╮
                          │ AgentView struct     │
                          │ - agent_id           │
                          │ - conversation_id    │
                          │ - runtime_context    │
                          │ - visible_messages   │
                          │ - streaming_message  │
                          │ - events             │
                          │ - status / error     │
                          ╰──────┬───────────────╯
                                 │
                  ╭──────────────┼──────────────╮
                  ▼              ▼              ▼
            before_turn     apply_event     after_turn
            (user msg)      (Jidoka.Event)  (Turn.Result /
                                            snapshot / error)
  • The struct has agent_id, conversation_id, runtime_context, visible_messages, streaming_message, events, status, error, error_text, outcome, and metadata. Nothing more, nothing less.
  • @statuses is [:idle, :running, :error, :interrupted, :handoff]. Lifecycle helpers move the view through these statuses deterministically.
  • The view never stores a pid, a transcript reference, a provider client, process state, or adapter data. Persistence belongs in a session or store.
  • The use Jidoka.AgentView macro defines a default prepare/1, agent_module/1, conversation_id/1, agent_id/1, and runtime_context/1, plus convenience functions initial/2, before_turn/2, after_turn/2, apply_event/2, run/3, and visible_messages/1. Override any of the behaviour callbacks.

How To

Step 1: Wire The View Module

use Jidoka.AgentView, agent: SomeAgent is the common case. The macro defaults pick a stable agent_id from the spec and a conversation_id from the input.

defmodule MyApp.SupportView do
  use Jidoka.AgentView, agent: MyApp.SupportAgent
end

{:ok, view} =
  MyApp.SupportView.initial(%{conversation_id: "VIP Case!"})

view.agent_id
#=> "support_agent-vip_case"
view.conversation_id
#=> "vip_case"

For a custom runtime context (tenants, roles, locale), override runtime_context/1:

defmodule MyApp.TenantSupportView do
  use Jidoka.AgentView, agent: MyApp.SupportAgent

  @impl true
  def runtime_context(input) do
    %{tenant: Map.fetch!(input, :tenant), session: conversation_id(input)}
  end
end

prepare/1 is a hook for input validation; return {:error, reason} to short-circuit initial/2.

Step 2: Apply A User Message Optimistically

before_turn/2 adds a pending user message and flips the view to :running. Use it immediately before kicking off a turn so the UI shows the message without waiting for the agent.

view = MyApp.SupportView.before_turn(view, "Look up order A1001")

view.status
#=> :running

Enum.map(view.visible_messages, & &1.role)
#=> [:user]

Step 3: Run A Turn Through The View

run/3 is the convenience that ties before_turn, the actual Jidoka.turn/3 call, and after_turn together.

llm = fn _intent, _journal ->
  {:ok, %{type: :final, content: "Order A1001 is in transit."}}
end

view = MyApp.SupportView.run(view, "Look up A1001", llm: llm)

view.status
#=> :idle

view.visible_messages
#=> [%{role: :user, content: "Look up A1001", pending?: false},
#    %{role: :assistant, content: "Order A1001 is in transit."}]

You can also drive the steps manually. This is the right pattern when the turn runs in a Task, a job, or a GenServer.

running = MyApp.SupportView.before_turn(view, "Look up A1001")
result = Jidoka.turn(MyApp.SupportAgent, "Look up A1001", llm: llm)
view = MyApp.SupportView.after_turn(running, result)

after_turn/2 matches on {:ok, Turn.Result}, {:hibernate, snapshot}, and {:error, reason} and sets status, streaming_message, visible_messages, outcome, and error_text accordingly.

Step 4: Consume Streamed Events

For live UIs, pair the view with stream_to: from the Streaming guide. Each event updates the view through apply_event/2.

def handle_info({:jidoka_turn_event, %Jidoka.Event{} = event}, %{view: view} = state) do
  {:noreply, %{state | view: MyApp.SupportView.apply_event(view, event)}}
end

apply_event/2:

  • appends content deltas (via Jidoka.Stream.text_delta/1) to streaming_message;
  • folds reasoning deltas into a "Thinking..." placeholder;
  • appends non-delta events to events as compact debug projections;
  • never reaches into runtime state.

Step 5: Render From The View

The view is plain data. A LiveView template might assign view.visible_messages and view.streaming_message; a CLI might print them. The view never assumes a surface.

def render(assigns) do
  ~H"""
  <div data-status={@view.status}>
    <%= for message <- @view.visible_messages do %>
      <p class={message.role}><%= message.content %></p>
    <% end %>

    <%= if @view.streaming_message do %>
      <p class="assistant streaming">
        <%= @view.streaming_message.content %>
      </p>
    <% end %>
  </div>
  """
end

The same view data can be asserted on in a test, serialized for an agent dashboard, or used to drive a CLI repaint.

Step 6: Persist Beyond The View

The view does not own durability. When the surface session ends, persist the underlying Jidoka.Session (or Jidoka.Turn.Result) and rebuild a fresh view on the next render.

{:ok, view} = MyApp.SupportView.initial(%{conversation_id: session_id})

view =
  Enum.reduce(stored_session.requests, view, fn request, view ->
    {:ok, result} = run_or_lookup_result(request)
    MyApp.SupportView.after_turn(view, {:ok, result})
  end)

Treat the view as cache, not source of truth. The truth lives in the session, snapshot, and store.

Common Patterns

  • One view module per agent. Keep the view thin and override only the callbacks you need. Cross-cutting helpers belong outside the view.
  • Drive UI status from view.status. It is the single field LiveView templates, CLI renderers, and channels need to decide what to show.
  • Filter events with apply_event/2, not by hand. It already knows how to merge deltas and dedupe events by id.
  • Do not put state into runtime_context. It is request context that flows to the agent; use metadata for view-local notes.
  • Tests are the cheapest sanity check. A round-trip initial -> before_turn -> after_turn assertion catches most regressions.

Testing

AgentView was designed to be unit-testable without any LiveView or HTTP machinery.

test "before/after turn keep visible messages and tool events" do
  llm = fn _intent, _journal ->
    {:ok, %{type: :final, content: "Order A1001 is in transit."}}
  end

  {:ok, view} = MyApp.SupportView.initial(%{conversation_id: "case_123"})

  running = MyApp.SupportView.before_turn(view, " Check order A1001 ")
  assert running.status == :running
  assert [%{role: :user, content: "Check order A1001", pending?: true}] =
           running.visible_messages

  view =
    MyApp.SupportView.after_turn(
      running,
      Jidoka.turn(MyApp.SupportAgent, "Check order A1001", llm: llm)
    )

  assert view.status == :idle
  assert Enum.any?(view.visible_messages, &(&1.role == :assistant))
end

For streaming, assert that apply_event/2 collects text deltas into streaming_message and that :turn_finished flips the status back to :idle.

Troubleshooting

SymptomLikely CauseFix
ArgumentError: must pass agent:use Jidoka.AgentView was called without agent: and agent_module/1 was not overridden.Pass the agent on use, or override agent_module/1.
view.agent_id looks weirdConversation id normalizer lower-snakes anything not [a-z0-9_].Pass a clean id, or override conversation_id/1.
Streaming text never appearsCallback did not fold events through apply_event/2.Pipe every {:jidoka_turn_event, _} message through the view.
after_turn shows :error instead of :interruptedThe runtime returned {:error, _} rather than {:hibernate, _}.Confirm the operation control returned {:interrupt, _}; see Human In The Loop.
Duplicate events appear in view.eventsMultiple sources called apply_event/2 with the same event.The view dedupes by id; ensure each event has a stable request_id.

Reference

Key modules touched in this guide:

  • Jidoka.AgentView - struct, @statuses enum, initial/3, before_turn/2, after_turn/2, apply_event/2, run/4, visible_messages/1.
  • Jidoka.AgentView (behaviour) - callbacks prepare/1, agent_module/1, conversation_id/1, agent_id/1, runtime_context/1.
  • Jidoka.Event - the event shape apply_event/2 consumes.
  • Jidoka.Stream - text_delta/1, thinking_delta/1, the helpers apply_event/2 calls.
  • Jidoka.Turn.Result - the result after_turn/2 consumes on success.