WebSocket-first apps and auto-generated HTTP MCP APIs in one file-based Elixir framework.

日本語

Overview

Dialup is an Elixir framework for building live applications with a Next.js-like developer experience. It has two equal promises: the human UI is WebSocket-first from the first render, and the HTTP MCP API is generated from the same page declarations. Each page is one supervised server-side actor. You write the UI once with <.dialup_action> and <.dialup_region>; Dialup derives tools/list, tools/call, and read_scene from those declarations. No duplicate REST layer. No hand-written OpenAPI for agent tools.

Human browser  WebSocket  UserSessionProcess  HTTP JSON-RPC  AI agent
                                      
                               command / set / navigate / handle_event
                                      
                          declare_action / dialup_action

Features

  • WebSocket-first human UI — live DOM updates over /ws with idiomorph
  • Auto-generated HTTP MCP API — actions and regions become agent tools automatically
  • One event path — browser events and agent tools/call serialize through the same UserSessionProcess (command / set / navigate / handle_event)
  • HTTP MCP request-responseinitialize, tools/list, tools/call at POST /mcp (Bearer token; POST /agent/:token is legacy)
  • Agent discovery/.well-known/dialup-agent, embedded page context, /llms.txt
  • Scoped session tokens — least-privilege grants with expiry and projection control
  • File-based routing — file placement maps directly to URLs
  • Server-side state — one tab = one UserSessionProcess
  • Colocated CSS.css next to .ex, auto-scoped at compile time

Quick Start

1. Install the generator

mix archive.install hex dialup_new

2. Create a new project

mix dialup.new my_app
cd my_app
mix deps.get
mix run --no-halt

Then visit http://localhost:4000

Agent-ready page in 30 lines

defmodule Dialup.App.Page do
  use Dialup.Page

  declare_action name: :increment, desc: "Increment counter", params: %{}

  def mount(_params, assigns), do: {:ok, Map.put(assigns, :count, 0)}
  def agent_state(assigns), do: %{count: assigns.count}

  def handle_event(:increment, _, assigns) do
    {:update, Map.update!(assigns, :count, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"""
    <p>Count: {@count}</p>
    <.dialup_action name={:increment}>+1</.dialup_action>
    """
  end
end

The same <.dialup_action> is a WebSocket-backed browser button and a generated MCP tool. Use the page from the browser, or grant a session token and call the API:

# After obtaining a token (see guides/mcp-api.md)
curl -X POST http://localhost:4000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer TOKEN' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_scene","arguments":{}}}'

Documentation

Run mix docs --open to browse the full documentation locally.

See guides/ for routing, state, lifecycle, events, deployment, and more.

Architecture

Every live tab runs one UserSessionProcess. The server-side session actor is the single source of truth for page state, declared actions, and agent grants. Human operators and AI agents reach the same actor through different transports — WebSocket for the browser, HTTP JSON-RPC for MCP.

System overview

flowchart LR
  humanBrowser["Browser (human)"] -->|"WebSocket via dialup.js"| userSessionProcess["UserSessionProcess"]
  aiAgent["AI agent (MCP client)"] -->|"HTTP JSON-RPC (POST /mcp)"| userSessionProcess
  dialupAction["dialup_action / declare_action"] --> toolsList["tools/list"]
  dialupRegion["dialup_region / declare_region"] --> toolsList
  toolsList --> toolsCall["tools/call"]
  toolsCall --> userSessionProcess
  userSessionProcess --> render["render/1 + agent_state/1"]
  render --> humanUI["idiomorph DOM update"]
  render --> agentJSON["JSON response (no push)"]

POST /agent/:token is a legacy path-token transport to the same handler.

Human interaction (WebSocket)

sequenceDiagram
  participant Browser as User / browser
  participant JS as dialup.js
  participant USP as UserSessionProcess

  Browser->>JS: click / input
  JS->>USP: WebSocket event
  USP->>USP: command / set / navigate / handle_event
  USP->>USP: version++
  USP->>JS: render diff
  JS->>Browser: idiomorph morph (DOM update)

Agent interaction (HTTP MCP)

sequenceDiagram
  participant Agent as AI agent
  participant MCP as POST /mcp
  participant USP as UserSessionProcess

  Agent->>MCP: initialize
  MCP->>Agent: server capabilities
  Agent->>MCP: tools/list
  MCP->>Agent: declared actions + built-ins
  Agent->>MCP: tools/call read_scene
  MCP->>USP: read actor state
  USP->>Agent: structuredContent + currentVersion
  Agent->>MCP: tools/call mutation + _version
  alt version stale
    MCP->>Agent: isError
    Agent->>MCP: tools/call read_scene
    Note over Agent: re-fetch, then retry with fresh _version
  else version current
    MCP->>USP: apply mutation
    USP->>Agent: updated structuredContent
  end

Agents have no WebSocket transport and no server push — poll with read_scene.

Agent-first browser handoff

When an agent starts a headless session and a human joins later, joining is a three-phase handshake. Opening the browser URL alone does not complete the join.

sequenceDiagram
  participant Agent as AI agent
  participant API as Dialup HTTP
  participant USP as UserSessionProcess
  participant Browser as Human browser

  Agent->>API: POST /_dialup/agent-session
  API->>USP: start session + grant token
  Agent->>API: tools/call issue_browser_url
  API->>Agent: browserUrl
  Browser->>API: WebSocket attach (tab_id + join_token)
  Note over Browser,USP: cookie not set yet
  Browser->>API: POST /_dialup/finalize-join
  Note over Browser,USP: cookie set, join token consumed
  Browser->>USP: __reconnect
  Note over Agent,Browser: human and agent share one UserSessionProcess

See Session tokens for token issuance patterns.

Declaration boundary

  • Only <.dialup_action> / declare_action/1 and <.dialup_region> / declare_region/1 become MCP tools. Raw ws-event, ws-href, and other ad-hoc WebSocket hooks are not auto-exposed to agents.
  • Actions marked confirm: :human are available in the human UI only; HTTP MCP calls return an isError tool result.
  • Agent-visible state is an allowlisted projection from agent_state/1, not a dump of all assigns.

License

MIT