WebSocket-first apps and auto-generated HTTP MCP APIs in one file-based Elixir framework.
Links
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_actionFeatures
- WebSocket-first human UI — live DOM updates over
/wswith idiomorph - Auto-generated HTTP MCP API — actions and regions become agent tools automatically
- One event path — browser events and agent
tools/callserialize through the sameUserSessionProcess(command/set/navigate/handle_event) - HTTP MCP request-response —
initialize,tools/list,tools/callatPOST /mcp(Bearer token;POST /agent/:tokenis 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 —
.cssnext 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
endThe 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.
- Events — WebSocket-first UI events and updates
- HTTP MCP API — auto-generated tools, discovery, versioning
- Building agent-native apps — implementation workflow
- Session tokens — reaching a live browser session
- Authentication — HTTP auth, MCP grants, sequence diagrams
- Getting Started — installation and basics
- Fullstack Example — Ecto and PubSub
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
endAgents 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 UserSessionProcessSee Session tokens for token issuance patterns.
Declaration boundary
- Only
<.dialup_action>/declare_action/1and<.dialup_region>/declare_region/1become MCP tools. Rawws-event,ws-href, and other ad-hoc WebSocket hooks are not auto-exposed to agents. - Actions marked
confirm: :humanare 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