# HTTP MCP API
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 /agent/{token}
<.dialup_region> ─┘ │
declare_region/1 tools/call ──→ handle_event/3 (same session process)
```
Human operators use WebSocket (`/ws`). AI agents use HTTP request-response (`POST /agent/:token`).
Both paths serialize through the same `UserSessionProcess`, so state, versions, and audit logs stay
consistent.
## Minimal page
```elixir
defmodule Dialup.App.Invoice.Page do
use Dialup.Page
declare_action name: :add_item,
desc: "Add a line item",
params: %{sku: :string, qty: {:integer, default: 1}}
declare_region name: :items, role: "list", desc: "Invoice line items", data: :items
def mount(_params, assigns), do: {:ok, Map.put(assigns, :items, [])}
def __available__(:add_item, _assigns), do: true
def agent_state(assigns), do: %{items: assigns.items}
def handle_event(:add_item, params, assigns) do
item = %{sku: params["sku"], qty: params["qty"]}
{:update, Map.put(assigns, :items, assigns.items ++ [item])}
end
def render(assigns) do
~H"""
<.dialup_region name={:items} role="list" desc="Invoice line items">
<ul><li :for={i <- @items}>{i.sku} × {i.qty}</li></ul>
</.dialup_region>
<.dialup_action name={:add_item} sku="SKU-1" qty="1">Add item</.dialup_action>
"""
end
end
```
No separate REST controller. No OpenAPI hand-authoring. The catalog is derived from compile-time
declarations and runtime availability via `__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](#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.
## Session tokens
To operate a user's **live** browser session, obtain a bearer token:
1. **Programmatic** — `Dialup.Session.grant(session_pid, opts)`
2. **From the open tab** — `POST /_dialup/agent-handoff?tab_id=...` (uses the tab's registry key)
Then call the MCP endpoint. There are two equivalent ways to present the token:
```
POST /mcp
Authorization: Bearer {token}
Content-Type: application/json
```
```
POST /agent/{token}
Content-Type: application/json
```
`POST /mcp` is the canonical [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
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). `POST /agent/{token}` is the same handler with
the token in the path. 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`, or a declared action (including navigation actions) |
### Typical flow
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"agent","version":"1"}}}
```
```json
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
```
```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_scene","arguments":{}}}
```
```json
{"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](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling),
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.
## 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>`:
```elixir
<.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:
```json
{"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):
```json
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"lock_ui","arguments":{"reason":"AIが整理中です"}}}
```
```json
{"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:
```elixir
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.
## Scoped grants
```elixir
{: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:
- `available` — live predicate from `__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
- [Building agent-native applications](./agent-native-app-development.md) — implementation workflow
- [Live demo](https://dialup-framework.org/agent_demo) — interactive playground on dialup-framework.org