Dialup has two equal surfaces: a WebSocket-first browser UI for humans and an auto-generated
HTTP MCP API for agents. This guide covers the agent API side. Dialup derives an MCP-compatible
HTTP JSON-RPC API from the same UI declarations that power the browser interface, so you declare
actions and regions once; agents receive tools/list, tools/call, and read_scene without a
second API layer.
The core idea
<.dialup_action> ─┐
declare_action/1 ─┼─→ tools/list ──→ POST /mcp (Bearer token)
<.dialup_region> ─┘ │
declare_region/1 tools/call ──→ command / set / navigate / handle_eventEach declared action carries _meta.mode:
| Mode | Server path |
|---|---|
command | Build Commanded command → Context.dispatch/1 → remount page |
set | Merge rendered set map into assigns |
navigate | Navigate to declared path |
action | Legacy handle_event/3 |
Human operators use WebSocket (/ws). AI agents use HTTP request-response (POST /mcp with a bearer token).
Both paths serialize through the same UserSessionProcess, so state, versions, and audit logs stay
consistent.
Minimal page
defmodule Dialup.App.Invoice.Page do
use Dialup.Page
alias MyApp.Ordering
def mount(_params, assigns) do
{:ok, assigns |> set_default(%{items: [], order_id: "ord-1"})}
end
def agent_state(assigns), do: %{items: assigns.items}
def render(assigns) do
~H"""
<.dialup_region name={:items} role="list" desc="Invoice line items" data={@items}>
<ul><li :for={i <- @items}>{i.sku} × {i.qty}</li></ul>
</.dialup_region>
<.dialup_action
command={{Ordering, :add_item}}
desc="Add a line item"
params={%{sku: :string, qty: {:integer, default: 1}}}
bind={%{order_id: @order_id}}
errors={%{too_many: "Too many line items"}}
available={length(@items) < 20}
sku="SKU-1"
qty="1"
>
Add item
</.dialup_action>
"""
end
endFor UI-only toggles without Commanded, use set mode inline in HEEx (not via declare_action/1):
<.dialup_action
name={:toggle_sidebar}
desc="Toggle the sidebar"
params={%{}}
set={%{sidebar_open: !@sidebar_open}}
>
Toggle
</.dialup_action>Legacy actions that still use handle_event/3 remain valid — see the action row in the mode table
above.
No separate REST controller. No OpenAPI hand-authoring. The catalog is derived from compile-time
declarations and runtime availability via available={...} / generated __available__/2.
The declaration boundary
<.dialup_action> / declare_action is the single boundary of what an agent can do. Dialup
never auto-exposes raw ws-event, ws-submit, ws-change, or ws-href elements as tools. This is
deliberate: an agent is an untrusted external caller, so each tool needs a validated input schema, a
description, availability and confirmation rules — none of which can be inferred from an arbitrary
handle_event/3 clause. It also keeps internal plumbing events out of the agent's surface.
The principle is parity by declaration, not parity by default: when you write a control with
<.dialup_action> it is human- and agent-operable at once, and that includes navigation — see
Navigating between pages. If you want the agent to do something, declare
it; if a human-facing control should be agent-operable, express it with <.dialup_action>.
Discovery
Every agent-enabled page advertises:
- HTTP
Linkheader →/.well-known/dialup-agent?path=/current/path - Embedded
<script id="dialup-agent-context" type="application/json"> /llms.txt— operator instructions for coding agents
The discovery document includes agent_message/1, static tool schemas, semantic regions, and
HTTP connection instructions. It does not include a live session token.
GET /.well-known/dialup-agent?path=... resolves page mounts the same way as HTTP GET:
auth cookies (_dialup_user_token) seed current_user into layout session before mount/2
runs, so protected pages that return {:redirect, "/log_in"} for guests still advertise the
correct catalog when the request is authenticated. Redirect loops and invalid mount returns
map to JSON errors instead of 500 responses.
Authentication and MCP grants
When auth_accounts is configured on your Application module, unauthenticated live sessions
receive minimal MCP capabilities ([:read_scene, :issue_browser_url]) via
Dialup.Auth.Grants.for_user/1. Logged-in sessions expand to :all or your custom
agent_grant/1.
Important behaviors:
| Topic | Behavior |
|---|---|
| Grant re-validation | Every tools/call re-checks live auth, capability/projection subsets, and an issuance-time auth fingerprint |
| Login upgrade | Guest tokens issued before human login do not gain authenticated read_scene after the user signs in — re-issue handoff |
| Logout / cookie loss | HTTP logout revokes DB session tokens (including tokens held only on live processes) and downgrades MCP grants |
| Browser join | Joiner's auth cookie is applied before the first post-join render |
See Authentication for mix dialup.gen.auth, CSRF, session rotation, and
security regression notes.
Session tokens
To operate a user's live browser session, obtain a bearer token:
- Programmatic —
Dialup.Session.grant(session_pid, opts) - From the open tab —
POST /_dialup/agent-handoff?tab_id=...(uses the tab's registry key) - Agent-first —
POST /_dialup/agent-sessionwith{"path":"/page"}(no browser tab required)
Then call the MCP endpoint. Prefer the canonical bearer form:
POST /mcp
Authorization: Bearer {token}
Content-Type: application/jsonSession descriptors and discovery documents advertise /mcp as the endpoint. A legacy path-token form remains available:
POST /agent/{token}
Content-Type: application/jsonPOST /mcp is the canonical Streamable HTTP
endpoint for off-the-shelf MCP clients: configure the client with the /mcp URL and a bearer
token (the Mcp-Session-Id header is also accepted). Session handoff descriptors return /mcp
as endpoint. Legacy POST /agent/{token} uses the same handler with the token in the URL path;
prefer /mcp to avoid token leakage in access logs and Referer headers. On initialize, the server
returns the token as the Mcp-Session-Id response header, which a standard client echoes on
subsequent requests. A GET to either endpoint returns 405 because no server-initiated SSE stream
is offered — agents poll with read_scene.
MCP lifecycle
Supported JSON-RPC methods:
| Method | Purpose |
|---|---|
initialize | Protocol handshake (2025-11-25) |
notifications/initialized | Client ready (no response body) |
ping | Health check |
tools/list | Generated tool catalog |
tools/call | Invoke read_scene, read_audit_log, lock_ui, unlock_ui, issue_browser_url, or a declared action (including navigation actions) |
Typical flow
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"agent","version":"1"}}}{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_scene","arguments":{}}}{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"add_item","arguments":{"sku":"AI-1","qty":2,"_version":3}}}Every mutating action should include the latest _version from read_scene. A stale _version
returns an isError tool result whose structuredContent.currentVersion is the latest version —
call read_scene again instead of blind retries.
Error model
Following the MCP tools spec, errors fall into two buckets:
- Protocol errors (JSON-RPC
error) — unknown tool (-32602), session/grant problems (-32002/-32003), parse/invalid request (-32700/-32600). The request itself is unanswerable. - Tool execution errors (a normal result with
"isError": true) — stale_version, invalid arguments, unavailable action, unknown semantic target, andconfirm: :human. These carry a human-readablecontentmessage plus astructuredContent.reasonso the model can self-correct and retry.
Human-only actions
Actions marked confirm: :human are not executable over HTTP MCP. The call returns an
isError tool result (structuredContent.confirm = "human"). Use the human browser UI for those
operations.
Navigating between pages
Navigation is not a free-form tool. It follows the same principle as every other capability:
an agent can reach exactly the links your UI declares — no more, no less. Declare a navigable link
with navigate on <.dialup_action>:
<.dialup_action navigate="/docs/concepts">Concepts</.dialup_action>This renders an ordinary ws-href link for humans and, from the same declaration, generates a
navigation tool for agents. The tool name is derived from the path (/docs/concepts →
navigate_docs__concepts; path segments join with __ so /foo-bar and /foo/bar stay
distinct); pass an explicit name={...} to override it. The action takes no
arguments because the destination is fixed at the declaration site:
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"navigate_docs__concepts","arguments":{}}}Calling it runs the same code path as a human clicking the link: the page re-mounts and the human's
browser follows in real time. Each page generates a different tool catalog, so call tools/list
(or read_scene) again afterwards. The destination of each navigation action is reported in
tools/list under _meta.navigate and in read_scene actions under navigate.
Navigation links declared on a layout (for shared chrome like a nav bar) are merged into the
tool catalog of every page under that layout, so an agent gets the same site-wide navigation a human
sees. Because navigation actions are ordinary actions, they are gated by capability under their
derived name (e.g. :navigate_docs__concepts); a :all grant includes them automatically.
Navigation actions respect the same __available__/2 predicates and confirm: :human gates as
mutating actions.
Locking the human UI
While an agent works, it can stop the human from operating the page to avoid conflicting edits.
Two built-in tools control this (gated by the :lock_ui capability):
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"lock_ui","arguments":{"reason":"AIが整理中です"}}}{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"unlock_ui","arguments":{}}}While locked:
- The browser shows a blocking overlay with the optional
reason. - Human
ws-event/ws-submit/ws-changeinteractions are ignored server-side (defense in depth). - Agent tool calls still apply normally, and
read_scenereports"uiLocked": true.
Grant the capability explicitly so agents can use it:
def agent_grant(_assigns) do
%{capabilities: [:add_item, :lock_ui], projections: [:state, :regions, :actions]}
endAlways pair lock_ui with unlock_ui (e.g. in a try/after) so the human regains control even if
the agent errors out. A session timeout also releases the lock when the process is rebuilt.
Inviting a human to join
When an agent starts or operates a session without a human tab open, it can mint a one-time browser
join URL through the issue_browser_url built-in (gated by the :issue_browser_url capability):
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"issue_browser_url","arguments":{}}}The tool result includes browserUrl (for example /invoices?_join=TOKEN), browserToken, and
expiresInMs. Share browserUrl with the person who should join.
Handoff is attach → POST /_dialup/finalize-join (sets cookie, consumes token) → WebSocket
__reconnect. Opening the URL alone does not set a session cookie or complete the join.
Join tokens are single-use and expire quickly. Issue a fresh URL if one is consumed or expires.
Grant the capability explicitly when you scope agent authority:
def agent_grant(_assigns) do
%{capabilities: [:add_item, :issue_browser_url], projections: [:state, :regions, :actions]}
endSee Session tokens for HTTP MCP for the full agent-first and browser-handoff
flow, including POST /_dialup/agent-session.
Scoped grants
{:ok, grant} =
Dialup.Session.grant(session_pid,
capabilities: [:add_item],
projections: [:state],
expires_in: :timer.minutes(5),
require_version: true
)capabilities limits which tools appear in tools/list. projections limits what read_scene
returns (:state, :regions, :actions). Revoke with Dialup.Session.revoke/2 or
DELETE /agent/:token.
Action metadata
Tool entries include _meta for agent decision-making:
mode—command,set,navigate, or legacyactionavailable— live predicate derived fromavailable={...}/ generated__available__/2confirm,risk,effects,reversible,idempotentexamples,success
Put metadata on <.dialup_action> for one-off controls, or hoist with declare_action/1 for
repeated references. Use agent_only: true when an operation has no human button.
What agents do not get
- No agent WebSocket —
/agent/:token/wsis not supported. Use HTTP only. - No push notifications — poll with
read_sceneor rely on request-response results. - No focus tool — semantic regions and
read_scenecarry the structured context. To prevent concurrent human edits, uselock_ui/unlock_ui(above) instead.
Agents can navigate (via declared navigation actions) and lock the UI (lock_ui), mirroring human
capabilities. What an agent cannot do is anything you leave undeclared and ungranted: raw ws-event
/ ws-submit / ws-href elements are never auto-exposed. A page event or a link becomes a tool
only through <.dialup_action> / declare_action, and built-ins like lock_ui require their
capability. The single declaration boundary keeps the agent's surface typed, documented, and
intentional rather than a mirror of every internal event.
Demos and references
- Building agent-native applications — implementation workflow
- Live demo — interactive playground on dialup-framework.org