WebMCP Challenge entry ↗

Keep the Agent
Action In Frame

WebMCP puts an agent on your page, working alongside you. Dolly follows the action — showing what it’s doing, what it did, and what it can do.

ChatGPT 5.5
Message ChatGPT
dollycrm.app
DDolly CRM

Contacts

24

Open deals

7

Pipeline

$86.4k

Contacts

ARAlex RiveraFern Labs
PPPriya PatelHalcyon
TOTom OkaforBrightline

Deals

Halcyon expansion$28,000
ProposalPriya Patel
Brightline pilot$9,500
DiscoveryTom Okafor
Dylan Jones

Dylan Jones

Creator of Dolly.dev

SiteLinkedInTwitter

WebMCP unlocks
multiplayer

WebMCP gives agents real controls for your site — ask in plain language and it happens, while you keep everything an interface is good at: click around, see your data, stay oriented

Not everything
should be a chatbox

The interesting shape is multiplayer: you and an agent working the same interface at the same time.

ChatGPT

Move the Acme deal to Proposal

Done — Acme platform rollout is in Proposal now.

Message ChatGPT
dollycrm.app

DollyCRM

Deals · $25,600 open

Northwind renewalQualified$12,000
Acme platform rolloutProposal$8,400
Juniper starter planWon$5,200
You
ChatGPT

WebMCP handles the wiring: a page registers typed tools on document.modelContext and an agent calls them. Every action a menu once hid becomes a sentence — the learning curve shrinks to saying what you want. What WebMCP doesn’t handle is feedback. The agent can call tools and navigate while the page shows nothing: not what’s happening, not what’s possible, not even that an agent is connected.

For headless automation, invisible is fine. In multiplayer, the action has to be felt. Dolly is the rig that follows it — showing what the agent can do, what it’s doing, and what it did — so the work lands on the page instead of happening behind it.

The Open in
ChatGPT button

One click opens the site inside your agent — ChatGPT, Claude, or Gemini — and multiplayer starts: chat on one side, the live app on the other

The learning curve,
one sentence long

I believe buttons like this will be common. Land in an app you’ve never used, describe the outcome, and start working — no tutorial, no hunting through menus. And the agent shows up with context of its own: it already knows your calendar, your contacts, and what you’re trying to get done.

OpenInButton builds the deeplink for you — chatgpt.com/codex/deeplink?url=<your page> — and hides itself when the site is already inside an agent.

By default it links the current URL. Pass url to deeplink a specific page, connectionParam to change the handshake param, and children to replace the button label.

Note: ChatGPT is the only agent with a deeplink like this — I couldn’t find a standard way to open a page in Claude or Gemini yet.

import { OpenInButton } from "@dolly/rig";

// Deeplinks the current page into ChatGPT. Hides itself
// when the site is already running inside an agent.
<OpenInButton agent="openai" />

// Options
<OpenInButton
  agent="openai"
  url="https://example.com/trip/paris"  // defaults to the current URL
  connectionParam="webmcpconnected"     // handshake param on the link
  hideWhenInsideAgent={false}
>
  Plan this trip in ChatGPT
</OpenInButton>

Onboarding, once inside

The deeplink gets you in the door, but I’ve landed in an app I’ve never seen. What can it do? What do I ask? Someone has to say

Treat arrival like a first launch

The way a mobile app greets its first open: show what’s possible, plant a few ideas, point at a first action.

MC
$12,000

Manage contacts & deals

Add contacts, create deals, and move them through the pipeline — just by asking.

Next

The deeplink carries ?webmcpconnected=true; when the provider sees it, it stores the connection, cleans the URL, and opens the onboarding dialog.

The dialog’s content comes from the provider. Each capabilities entry — a title and a one-line description — becomes a row in the default dialog, so the Trip Planner above greets you with “Plan a trip” and “Invite people”.

For a fuller welcome, steps replaces the list with a paged walkthrough — each step an optional image, a title, and a description — and doneLabel names the closing button. rig.openOnboarding() reopens the dialog any time, so a help menu can point back to it.

<RigProvider
  appName="Trip Planner"
  capabilities={[
    {
      title: "Plan a trip",
      description: "Build an itinerary from a sentence.",
    },
    {
      title: "Invite people",
      description: "Add travellers by name.",
    },
  ]}
  onboarding={{
    // Optional multi-step walkthrough instead of the
    // default capability list.
    steps: [
      {
        image: "/onboarding/plan.png",
        title: "Plan trips by talking",
        description: "Ask for a weekend in Lisbon and watch it fill in.",
      },
      {
        title: "Invite anyone",
        description: '"Add Mom to the trip" just works.',
      },
    ],
    doneLabel: "Try it",
  }}
>

Show the connection

New technology has to show it’s working. Before anyone types a prompt, the page should answer the first question: is the agent actually connected?

Connected and ready to work

The header shows the connection and has room to reveal tools, history, and live progress. It tracks the live phase, so “connected” becomes “working” the moment a tool runs.

ChatGPT connectediWhat is this

Detection is two separate signals. document.modelContext means the browser itself speaks WebMCP. detectAgent() identifies which agent injected the bridge, and the ?webmcpconnected handshake covers agents that can’t be detected directly.

How it knows it’s ChatGPT: the bridge leaves fingerprints — a __codexWebMcpModelContext global and codex-prefixed methods on the model context. detectAgent() checks for them; other agents will need their own fingerprints as they ship WebMCP support.

import {
  AgentStatusHeader,
  detectAgent,
} from "@dolly/rig";

// Full-width bar — status, with room for tools,
// history, and live progress while work runs.
<AgentStatusHeader showIndicator showProgress />

// The signals underneath
document.modelContext   // the browser speaks WebMCP
detectAgent()           // which agent injected the bridge

Show the work

This is the heart of it: follow the action. When a tool runs, the page has to show it — today the only feedback is the chat’s own spinner. On the page, nothing moves

An app that feels alive

That’s the difference: a live partner in the work, not dumb HTML being invisibly poked and prodded.

Adding two nights…

Trip itinerary

Lisbon — 3 nights
Hotel Avenida — 2 nightsNew
ChatGPT
Flight home

Dolly gives the tool’s execute() a visible lifecycle. startWork with overlay: true pulses a full-screen glow around the viewport edges. focus() outlines the element being changed and dims the rest of the page into a spotlight. progress() streams updates from inside the call, so long tools narrate themselves. endWork and failWork settle it with a toast.

const rig = useRig();

async function execute({ nights }) {
  // Full-screen glow while the agent works.
  rig.startWork("Booking the hotel…", undefined, {
    overlay: true,
  });

  // Spotlight the element being changed.
  rig.focus("#itinerary", "Adding two nights…");

  // Updates from inside the tool call.
  rig.progress("Confirming dates…");

  rig.endWork("Hotel added to the itinerary");
  // rig.failWork("The dates were unavailable");
}

Show what happened

One agent turn can call several tools: add something, search, navigate somewhere. For the agent to feel like a partner in the work, you need a simple log of what happened. What exactly did it do?

Magic, with a receipt

It has to feel like magic when the agent just does things, and you need a way to audit the trick when it goes wrong — like discovering it deleted your favourite pasta sauce from the shopping cart.

AGENT RECEIPT

WHAT AGENTS DID HERE

Added Maya Chen to contacts

Just now
1 ACTION LOGGEDDOLLY.DEV

Tools declare a log template and Dolly records a readable, timestamped event each time they run. logTask() records anything else by hand. The history drawer shows it all, newest first.

The log is also the natural home for undo, retries, and error recovery. Those controls aren’t in the SDK demo, but this is the place they’d live.

useWebMCPTool({
  name: "remove_item",
  // Auto-logged on success, tokens filled from the input.
  log: "Removed %%name%% from the cart",
  logIcon: "🛒",
  async execute({ name }) { /* … */ },
});

// Or record anything by hand.
rig.logTask(
  "Added %%name%% to the trip",
  { name: "Mom" },
  { icon: <UserIcon /> },
);

// "What agents did here"
<AgentHistoryDrawer open={open} onClose={close} />

Ask for permission

Some things an agent shouldn’t do on its own. For those, the tool pauses mid-execute and waits: the page pops an are-you-sure, and it resumes or aborts with the answer

A human stays in the loop

Keep destructive confirmations click-only, and let the countdown default through the safe ones.

delete_trip()

Delete the Paris trip?

ChatGPT wants to delete this trip and its 12 bookings. This can’t be undone.

CancelDelete
You

confirm() returns a promise that only settles when a person decides — the agent’s tool call simply waits on the other end.

By default the dialog waits indefinitely: nothing happens until Continue or Cancel is clicked. For routine confirmations, autoContinueMs approves after a countdown — ticking down on the button with a progress bar along the dialog edge, and pausing while the pointer hovers, so a hand reaching for Cancel is never raced by the timer.

async function execute({ tripId }) {
  // The tool pauses here until a human decides.
  const ok = await rig.confirm({
    title: "Delete the Paris trip?",
    description: "The agent wants to delete this trip.",
    tone: "destructive",   // red Continue button
    confirmLabel: "Delete",

    // Optional: approve automatically after a
    // countdown (shown on the button, paused on
    // hover). Omit to wait for a click, forever.
    // autoContinueMs: 5000,
  });

  if (!ok) {
    return {
      content: [{ type: "text", text: "Cancelled by the user" }],
    };
  }
  await deleteTrip(tripId);
}

Show what’s possible

The tools drawer shows the full range of what you and the agent can do together. Keyboard shortcuts, but for the agent

Set expectations up front

To a user the agent is a black box — ask for something the site can’t do and it fails quietly, reading as broken. Listing the tools heads that off, and the example prompts double as suggestions for what to try.

What agents can do here

3 tools

Add a contact

Add Maya from Northwind

Create a deal

Start a $12k deal for Acme

Move a deal

Move Northwind to Negotiation

Every tool registered with useWebMCPTool can carry an example prompt, and the drawer lists them all under “Try asking”.

AgentToolsDrawer renders the list — open it from anywhere, or let the status header reveal it.

useWebMCPTool({
  name: "add_traveller",
  description: "Add a person to the current trip",
  // Shown in the drawer under "Try asking".
  example: "Add Mom to the trip",
  inputSchema: { /* … */ },
  async execute(input) { /* … */ },
});

// "What agents can do here"
<AgentToolsDrawer open={open} onClose={close} />

A simple API

Adding all of this is one <RigProvider> around the app, one useWebMCPTool per tool, and a handful of imperative calls — startWork, confirm, logTask — dropped into code you already have

The core is what the agent sees: name and description tell it what the tool does, inputSchema types the arguments it must send, and execute() is plain async code — call your own functions, return a result.

The rest wires up the primitives above: example feeds the tools drawer’s “Try asking” list, log writes the receipt when the tool succeeds, and startWork/endWork inside execute() narrate the run on the page. One registration touches every surface.

useWebMCPTool registers against document.modelContext, unregisters on unmount, and does nothing when no bridge exists — the site works exactly the same without an agent.

import {
  useRig,
  useWebMCPTool,
} from "@dolly/rig";

const rig = useRig();

useWebMCPTool({
  name: "add_to_cart",
  description: "Add a product to the active cart",
  example: "Add the field jacket to my cart",
  log: "Added %%productId%% to the cart",
  inputSchema: {
    type: "object",
    properties: { productId: { type: "string" } },
    required: ["productId"],
  },
  async execute({ productId }) {
    rig.startWork("Adding the product…", "#cart", {
      overlay: true,
    });
    await addToCart(productId);
    rig.endWork("Added to the cart");
    return { content: [{ type: "text", text: "Added to cart" }] };
  },
});

This page is rigged

This site runs the SDK it describes. Every button below drives the real provider, and the same actions are registered as WebMCP tools an agent can call

Dolly: the wheeled rig a camera rides to follow the action without losing the frame. Same job here.

Other ideas for how WebMCP can evolve

Agent responsive design

Like mobile design — a CSS media query for when an agent is present, so designers can make the site feel native beside a chat box

Suggested prompt triggers

An API to drop a suggested prompt straight into the agent’s chat box, ready to send