This guide covers how an external agent reaches a live Dialup browser session over HTTP MCP.
For the full API reference — discovery, tools/list, tools/call, grants, and metadata — read
HTTP MCP API first.
Ordinary URL vs session token
| URL | Purpose |
|---|---|
https://app.example.com/invoices | Describes the app and static tool catalog via discovery |
POST /agent/{token} | Operates one specific live session (bearer credential) |
Opening the ordinary URL in an agent's own browser creates that browser's session. It does not attach to work already open in the user's tab.
Obtaining a token
From server code
{:ok, descriptor} =
Dialup.Session.grant(session_pid,
capabilities: :all,
projections: [:state, :regions, :actions],
expires_in: :timer.minutes(15),
require_version: true
)
token = descriptor["token"]
endpoint = descriptor["endpoint"] # "/agent/{token}"From the user's open tab
When the user already has the app open, issue a token tied to that tab's registry key:
curl -X POST 'https://app.example.com/_dialup/agent-handoff?tab_id=TAB_ID' \
-H 'Cookie: dialup_session=SESSION_ID'
The response includes token, endpoint, and grant metadata. Pass endpoint to your MCP client.
In browser JavaScript (same origin), use Dialup.tabId from dialup.js:
const res = await fetch(`/_dialup/agent-handoff?tab_id=${encodeURIComponent(Dialup.tabId)}`, {
method: "POST",
credentials: "same-origin",
});
const { token, endpoint } = await res.json();Calling the API
curl -X POST "https://app.example.com/agent/TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_scene","arguments":{}}}'
See HTTP MCP API for the complete method list, versioning rules, and error codes.
Revocation and expiry
DELETE /agent/:token— revoke the grantDialup.Session.revoke(session_pid, token)— same from Elixir- Expired tokens return JSON-RPC error
-32002
Ask the user to issue a fresh token from their still-open tab if a grant expires mid-task.
Login upgrade and guest tokens
When an agent starts before the human logs in, the initial handoff grant is bound to the
guest auth fingerprint. After the user signs in (same tab via /log_in, or browser join
with the human's cookies), that old token is rejected for operations that no longer match the
current auth context — even though guest capabilities are a subset of :all.
Re-issue handoff after login when the agent needs elevated capabilities:
curl -X POST 'https://app.example.com/_dialup/agent-handoff?tab_id=TAB_ID' \
-H 'Cookie: dialup_session=SESSION_ID; _dialup_user_token=USER_TOKEN'
From Elixir after observing an auth upgrade on the session process, call Dialup.Session.grant/2
again with the desired capabilities.
See Authentication for the full login → MCP upgrade flow.
Agent-first session (no browser tab yet)
Sometimes the agent starts work before a human opens the app. The endpoint is disabled by
default; enable it explicitly with use Dialup, agent_session: true and protect it with auth
plugs before exposing a public deployment. Start a headless session from the page URL:
curl -X POST 'https://app.example.com/_dialup/agent-session' \
-H 'Content-Type: application/json' \
-d '{"path":"/invoices"}'
The response includes token, endpoint, grant, path, and sessionId. Use the token with
POST /mcp or POST /agent/{token} exactly like a handoff token.
From Elixir:
{:ok, descriptor} = Dialup.Session.start(MyApp, "/invoices")
token = descriptor["token"]
endpoint = descriptor["endpoint"]Headless sessions stay alive for up to 15 minutes while waiting for a browser to join. Each MCP call resets that idle timer. After a human connects and finalize-join completes, the normal WebSocket disconnect timeout applies.
Inviting a human to join (browser handoff)
When an agent has already started or taken over a session, it can mint a one-time browser URL for a human to open:
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"issue_browser_url","arguments":{}}}The tool result includes:
browserUrl— relative URL such as/invoices?_join=TOKENbrowserToken— the raw join tokenexpiresInMs— time until the token expires (default five minutes)
Share browserUrl with the person who should join. The handoff completes in three server phases:
| Phase | What happens |
|---|---|
| Attach | Browser opens the URL (cookie not set yet). dialup.js connects to /ws?tab_id=…&join_token=…. The server reserves the token, attaches the tab to the agent's UserSessionProcess, and sends the live HTML plus a join_finalize_nonce. |
| Complete | The client POSTs /_dialup/finalize-join?tab_id=…&nonce=… with credentials: "same-origin". The server sets the dialup_session cookie, consumes the join token (single-use), and clears pending handoff state. |
| Sync | The client sends __reconnect on the WebSocket (or reconnects with the cookie only if the socket dropped after finalize). |
Requirements and client behavior:
tab_idis required on the WebSocket upgrade for join links.dialup.jsexposes a stableDialup.tabId(stored insessionStorage) for this._joinstays in the URL until finalize succeeds so a reload can retry the handoff.- If finalize does not complete within about 30 seconds, the server rolls back the attach and releases the token for a fresh attempt.
Issue a fresh URL if a token 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]
}
endFrom Elixir on the server:
{:ok, %{"browserUrl" => url}} = Dialup.Session.browser_url(session_pid)POST /_dialup/finalize-join
Called by dialup.js during browser handoff (not by agents directly). Query parameters:
tab_id— same value as the WebSocket upgradenonce— value from thejoin_finalize_nonceWebSocket field
On success, responds with 200 and sets the dialup_session cookie. This is the only
completion point for a join token.
Security notes
- Treat
browserUrllike a short-lived login link. Do not log it or paste it into public channels. - A consumed or expired join token cannot be reused.
- The human regains full UI control unless the agent has called
lock_ui.
Live example
See the agent handoff demo on dialup-framework.org.
The demo page mints a token via Dialup.Session.grant/2. Production apps typically combine
programmatic grants with the handoff endpoint for user-initiated access.