Agent pitfalls (Dialup)

Copy Markdown View Source

This guide records common misunderstandings coding agents make when working on Dialup apps. Add a new section here whenever a review or production issue reveals a pattern agents get wrong.


1. layout.css classes already apply on every page

The mistake

An agent sees page-specific CSS such as agent_demo/page.css and assumes that each page only has access to its own page.css. It then reimplements shared UI (buttons, code blocks, shadows) with new selectors like .mcp-handoff-btn or .mcp-add button.

The reality

Dialup colocation CSS works like this:

  1. layout.css (next to layout.ex) defines styles shared by all pages under that layout.
  2. page.css (next to page.ex) adds page-specific rules only.
  3. At render time, the framework injects both into one <style data-dialup-css> block.

DOM structure:

<div class="d-layout">      <!-- layout.css scope -->
  <nav>...</nav>
  <main>
    <div class="d-page">  <!-- page.css scope -->
      ... page content ...
    </div>
  </main>
</div>

Layout wraps the page. Classes from layout.css — for example .btn, .btn-primary, .btn-ghost — match elements inside any page as long as you put the class on the element.

There is no separate import step and no UI component module required. Reuse is class-based:

<.dialup_action navigate="/docs" class="btn btn-ghost">Get Started</.dialup_action>
<button type="submit" class="btn btn-ghost">Submit</button>
<button type="button" class="btn btn-ghost btn-block" data-mcp="handoff">Hand off</button>

What belongs where

FileUse for
layout.cssSite-wide tokens, buttons (.btn*), nav, docs chrome, shared utilities (.btn-block, .btn-sm)
page.cssLayout and content unique to that page (grids, cards, domain-specific regions) — not another copy of button styles

How to avoid it

Before adding button CSS to page.css, check layout.css for existing .btn variants. Prefer:

  • .btn.btn-ghost — purple CTA
  • .btn.btn-primary — light background
  • .btn.btn-accent — accent2 highlight
  • .btn.btn-sm — compact control
  • .btn.btn-block — full width

Add a new variant in layout.css only when the design system genuinely needs one, not per page.

Reference implementation

See site/lib/app/layout.css (definitions) and site/lib/app/agent_demo/page.ex (usage on MCP demo buttons).


2. ws-change inputs should not return {:update, assigns}

The mistake

A text field uses ws-change + ws-debounce, but handle_event/3 returns {:update, assigns}. Every debounced keystroke triggers a full #dialup-root morph. The input has value={@field} from the server, so the cursor jumps and characters disappear — especially on deployed sites with higher WebSocket latency.

The reality

For live typing, follow Events — ws-change:

  • Human input over ws-change{:noreply, assigns} (state only, no DOM replace)
  • Optional feedback elsewhere → {:patch, id, html, assigns} on a different element (see /demo draft_change)
  • Agent-only updates that must refresh the field → {:patch, input_id, render_input(assigns), assigns}

Reference implementation

site/lib/app/agent_demo/page.exset_project/3
site/lib/app/demo/page.exdraft_change/3


3. Browser join is not complete when the URL opens

The mistake

Documentation or UI copy says that opening browserUrl (with ?_join=TOKEN) immediately sets the session cookie and attaches the human to the agent session. Agents or integrators skip /_dialup/finalize-join or omit tab_id on the WebSocket upgrade.

The reality

Browser handoff has one completion point:

  1. Attach — WebSocket /ws?tab_id=…&join_token=… reserves the token and streams live HTML plus join_finalize_nonce. No dialup_session cookie yet.
  2. CompletePOST /_dialup/finalize-join?tab_id=…&nonce=… sets the cookie and consumes the token (single-use).
  3. Sync__reconnect on the WebSocket (or cookie-only reconnect if the socket dropped after finalize).

dialup.js implements this sequence. Custom clients must too.

How to avoid it

  • Do not set the session cookie on the initial GET for join links.
  • Require tab_id on join WebSocket upgrades.
  • Treat issue_browser_url tokens as consumed only after finalize-join succeeds.

See Session tokens for HTTP MCP.


4. A command action shadows legacy handle_event/3 for the same name

The mistake

A page keeps handle_event("increment", ...) and adds <.dialup_action command={{Ordering, :increment}}>. The agent and human UI call the command path; the handle_event/3 clause is never reached for that event name.

How to avoid it

  • Remove the legacy handler when migrating to command mode, or use a different action name.
  • Prefer command for Commanded-backed mutations so dispatch and remount stay in the framework.

5. bind={...} and set={...} are evaluated at render time

The mistake

Docs or mental models treat bind={%{order_id: @order.id}} as compile-time metadata. The bind map is recorded when the page renders, using current assigns — same as set={%{sidebar_open: !@sidebar_open}}.

The reality

  • bind values come from the latest render (via BindActions); dispatch reads that snapshot.
  • If a command button is not rendered (:if={false}), bind may fall back to empty compile-time metadata.
  • Keep available={...} aligned so agents do not call tools for off-screen actions.

See Building agent-native applications.