# Session tokens for HTTP MCP
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](./mcp-api.md) 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
```elixir
{: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:
```bash
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`:
```javascript
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
```bash
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](./mcp-api.md) for the complete method list, versioning rules, and error codes.
## Revocation and expiry
- `DELETE /agent/:token` — revoke the grant
- `Dialup.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:
```bash
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](./authentication.md) 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:
```bash
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:
```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:
```json
{"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=TOKEN`
- `browserToken` — the raw join token
- `expiresInMs` — 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_id` is required** on the WebSocket upgrade for join links. `dialup.js` exposes a stable `Dialup.tabId` (stored in `sessionStorage`) for this.
- **`_join` stays 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:
```elixir
def agent_grant(_assigns) do
%{
capabilities: [:add_item, :issue_browser_url],
projections: [:state, :regions, :actions]
}
end
```
From Elixir on the server:
```elixir
{: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 upgrade
- `nonce` — value from the `join_finalize_nonce` WebSocket 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 `browserUrl` like 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](https://dialup-framework.org/agent_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.