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_event

Each declared action carries _meta.mode:

ModeServer path
commandBuild Commanded command → Context.dispatch/1 → remount page
setMerge rendered set map into assigns
navigateNavigate to declared path
actionLegacy 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
end

For 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 Link header → /.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:

TopicBehavior
Grant re-validationEvery tools/call re-checks live auth, capability/projection subsets, and an issuance-time auth fingerprint
Login upgradeGuest tokens issued before human login do not gain authenticated read_scene after the user signs in — re-issue handoff
Logout / cookie lossHTTP logout revokes DB session tokens (including tokens held only on live processes) and downgrades MCP grants
Browser joinJoiner'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:

  1. ProgrammaticDialup.Session.grant(session_pid, opts)
  2. From the open tabPOST /_dialup/agent-handoff?tab_id=... (uses the tab's registry key)
  3. Agent-firstPOST /_dialup/agent-session with {"path":"/page"} (no browser tab required)

Then call the MCP endpoint. Prefer the canonical bearer form:

POST /mcp
Authorization: Bearer {token}
Content-Type: application/json

Session descriptors and discovery documents advertise /mcp as the endpoint. A legacy path-token form remains available:

POST /agent/{token}
Content-Type: application/json

POST /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:

MethodPurpose
initializeProtocol handshake (2025-11-25)
notifications/initializedClient ready (no response body)
pingHealth check
tools/listGenerated tool catalog
tools/callInvoke 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, and confirm: :human. These carry a human-readable content message plus a structuredContent.reason so 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.

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/conceptsnavigate_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-change interactions are ignored server-side (defense in depth).
  • Agent tool calls still apply normally, and read_scene reports "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]}
end

Always 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]}
end

See 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:

  • modecommand, set, navigate, or legacy action
  • available — live predicate derived from available={...} / generated __available__/2
  • confirm, risk, effects, reversible, idempotent
  • examples, 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/ws is not supported. Use HTTP only.
  • No push notifications — poll with read_scene or rely on request-response results.
  • No focus tool — semantic regions and read_scene carry the structured context. To prevent concurrent human edits, use lock_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