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
AgentViewwhen a UI needs a stable representation of an agent conversation that survives renders and resumes. - Use
AgentViewwhen 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
AgentViewas a durable transcript store. Persist the underlyingJidoka.SessionorJidoka.Turn.Result;AgentViewis a projection only.
Prerequisites
- A Jidoka agent module or
Jidoka.Agent.Specyou want to project. - Familiarity with
Jidoka.turn/3andJidoka.Event; see Getting Started and Streaming.
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
#=> :idleThe 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, andmetadata. Nothing more, nothing less. @statusesis[: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.AgentViewmacro defines a defaultprepare/1,agent_module/1,conversation_id/1,agent_id/1, andruntime_context/1, plus convenience functionsinitial/2,before_turn/2,after_turn/2,apply_event/2,run/3, andvisible_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
endprepare/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)}}
endapply_event/2:
- appends content deltas (via
Jidoka.Stream.text_delta/1) tostreaming_message; - folds reasoning deltas into a "Thinking..." placeholder;
- appends non-delta events to
eventsas 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>
"""
endThe 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; usemetadatafor view-local notes. - Tests are the cheapest sanity check. A round-trip
initial -> before_turn -> after_turnassertion 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))
endFor streaming, assert that apply_event/2 collects text deltas into
streaming_message and that :turn_finished flips the status back to
:idle.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
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 weird | Conversation id normalizer lower-snakes anything not [a-z0-9_]. | Pass a clean id, or override conversation_id/1. |
| Streaming text never appears | Callback did not fold events through apply_event/2. | Pipe every {:jidoka_turn_event, _} message through the view. |
after_turn shows :error instead of :interrupted | The runtime returned {:error, _} rather than {:hibernate, _}. | Confirm the operation control returned {:interrupt, _}; see Human In The Loop. |
Duplicate events appear in view.events | Multiple 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,@statusesenum,initial/3,before_turn/2,after_turn/2,apply_event/2,run/4,visible_messages/1.Jidoka.AgentView(behaviour) - callbacksprepare/1,agent_module/1,conversation_id/1,agent_id/1,runtime_context/1.Jidoka.Event- the event shapeapply_event/2consumes.Jidoka.Stream-text_delta/1,thinking_delta/1, the helpersapply_event/2calls.Jidoka.Turn.Result- the resultafter_turn/2consumes on success.
Related Guides
- Streaming - the event channel
apply_event/2is built to consume. - Tracing And Events - post-hoc projection of the same event data.
- Sessions And Stores - durable backing for the conversation the view projects.
- Getting Started - the agent definition the view wraps.