Let an AI Agent Use Your Web Page

Add WebMCP tools to a page so an AI agent can complete a task without clicking.

Introduction

30 Second Summary

Every web page you have ever used was built for a person with a mouse. AI agents are now showing up as visitors too, and they are stuck squinting at buttons meant for human eyes.

In this project, you will put callable tools directly onto a page you own. Then you will watch an agent finish a task on that page without clicking anything.

What You'll Build

You will type a plain English request, then watch an agent add an item to your live page while a log on screen prints every tool call as it lands.

By the end of this project, you'll have:

  • A live URL you can hand to anyone, serving the page you built and deployed yourself.
  • A page that declares its own tools with WebMCP, so an agent calls them straight up instead of guessing at your buttons.
  • A visible log panel that prints a fresh line every time a tool fires, so you can watch the agent work in real time.
  • Secret Mission: An optional challenge to push what you have built even further.

Are there any prerequisites?

You need a browser and a text editor, and that is it. The whole build runs on your laptop, and the challenge credits cover the agent calls.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Naming the goal in your own words now is what makes every step that follows click into place.

Declare a Read-Only Tool

Your starter page is running, and so far the only way to get anything out of it is to look at it. An agent visiting the page would be in the same position, squinting at the layout and guessing.

WebMCP lets a page hand an agent a list of functions it can call by name. In this step you'll declare your first one, a read-only tool that reports the items currently on your list.

In this step, get ready to:
  • Wire a tools.js file into your starter page.
  • Declare a read-only tool that reports the items on your list.
  • Confirm the tool shows up when you inspect the page.

Why declare a tool instead of letting the agent click?

An agent driving a page by sight has to find the button, work out what it does, and hope the layout hasn't shifted since it last looked. Every one of those is a chance to get it wrong.

A declared tool hands the agent a name, a description, and a schema. The agent calls the function directly and gets a result back.

Wire a tools file into your page
  • Switch back to the editor you used to get the starter page running.
  • Create a new file called tools.js next to your page's index.html file.
  • Prove the file is loading by adding this line to tools.js:
// This file holds every tool your page offers to an agent.
console.log("tools.js loaded");
  • Open index.html in your editor.
  • Link your new file by adding this line just before the closing </body> tag:
<script type="module" src="tools.js"></script>

Why type="module"?

A module script can use await at the top level of the file. You'll need that in a moment, because registering a tool returns a promise.

  • Save both tools.js and index.html.
  • Switch back to the browser tab running your page and refresh it.
  • Open your browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).

You should see tools.js loaded printed in the console.

Nothing in the console?

  • Check that tools.js sits in the same folder as index.html, not in a subfolder.
  • Check that your dev server is still running. A stopped server serves nothing, however many times you refresh.
  • Still stuck? Why isn't my script file loading on the page?
Declare the read-only tool

A tool needs something to report on. You'll keep the list in tools.js so the tool has real data to hand back.

  • Declare your list and your first tool by adding this code below the console.log line in tools.js:
// The list your tool reports on. Two items so there is something to read.
const listItems = ["Buy milk", "Water the plants"];

// Hand the agent a named function instead of making it hunt for a button.
await document.modelContext.registerTool({
  name: "list_items",
  description: "List the items currently on the page's list.",
  inputSchema: {
    type: "object",
    properties: {},
  },
  annotations: {
    readOnlyHint: true,
  },
  execute: async () => listItems.join(", "),
});

What does this code do?

  • name is the handle an agent uses to call your tool.
  • description is how the agent decides your tool is the right one for the job, so it's worth writing plainly.
  • inputSchema is a JSON Schema describing the arguments. Yours has no properties because reading the list needs nothing from the caller.
  • execute is the function that actually runs. It returns the list as a single string.
  • readOnlyHint tells the agent this call reports state without changing it.
  • Save tools.js.
  • Refresh your browser tab.

The console should still show tools.js loaded and no red errors. That tells you the file parsed and the registration ran.

Seeing a red error after saving?

  • An error about await means your script tag is missing type="module". Top-level await only works in a module.
  • Check your braces and commas match the code above. A missing comma between annotations and execute is the usual culprit.
  • Still red? Help me read this JavaScript error in my browser console.

✔️ Awesome, I've got everything!

Great, double check you've saved tools.js before you move on.

ⓧ I'd like to double check the full code

Here's your whole tools.js file so far:

// This file holds every tool your page offers to an agent.
console.log("tools.js loaded");

// The list your tool reports on. Two items so there is something to read.
const listItems = ["Buy milk", "Water the plants"];

// Hand the agent a named function instead of making it hunt for a button.
await document.modelContext.registerTool({
  name: "list_items",
  description: "List the items currently on the page's list.",
  inputSchema: {
    type: "object",
    properties: {},
  },
  annotations: {
    readOnlyHint: true,
  },
  execute: async () => listItems.join(", "),
});
Check that your tool is discoverable

Registering a tool is only half the story. An agent has to be able to find it, and the page exposes a discovery call for exactly that.

Before you run the next line, what do you think comes back? A list of every tool on the page, or something else?

  • Ask the page for its tools by running this in your browser console:
await document.modelContext.getTools();

You'll get back an array holding a single object. Expand it and you'll see name set to list_items, alongside the description and schema you wrote.

What's in the object you got back?

This is the exact view an agent gets of your page. It reads the description to decide whether your tool answers the question it's been asked.

The annotations object carries your readOnlyHint, so the agent knows calling this is safe to do freely. There's also an origin field showing which page the tool came from.

Getting an error about modelContext?

  • If document.modelContext is undefined, your browser doesn't have the API switched on. Check you're on the browser and version the build docs from the challenge page call for.
  • If the array comes back empty, refresh the page once more. The tool registers when the module loads, so a stale tab shows nothing.
  • Need a hand? Why is document.modelContext undefined in my browser?

Your page now offers something an agent can call by name. Next up, you'll add a panel to the page that logs every call as it lands, so you can watch it happen instead of taking the console's word for it.

Add a Live Tool-Call Log

Your page already declares a read-only tool, and you've seen it show up in the tool list. What you haven't seen is a call actually happen.

A WebMCP tool call leaves no trace on the page. When an agent runs one in the next step, nothing on screen moves, so you have no way to tell a working tool from a silent failure.

A log panel fixes that. Every call appends a line you can watch arrive in real time.

In this step, get ready to:
  • Add a log panel to your page that lists tool calls as they arrive.
  • Write a logToolCall function that appends one line per call.
  • Trigger your read-only tool by hand and watch the first line appear.
Add the log panel to your page
  • Switch back to the page file you were editing in the last step, the one where you declared your read-only tool.
  • Add the log panel by pasting this markup just above the closing </body> tag:
<section id="tool-log">
  <h2>Tool call log</h2>
  <ul id="tool-log-list">
    <li class="tool-log-empty">No tool calls yet.</li>
  </ul>
</section>

What does this markup do?

  • The tool-log section is the panel itself, kept separate from the rest of your page so it never interferes with your tool.
  • The tool-log-list list is where your code appends a new line on every call.
  • The No tool calls yet. line proves the panel is alive before any call arrives.
  • Save the page file.
  • Refresh your browser. You should see a Tool call log heading with No tool calls yet. underneath it.

Panel not showing up?

  • Check the markup landed inside <body> and not inside <head>.
  • Check you saved the file before refreshing. The browser only shows the version it last loaded.

My log panel markup isn't rendering on the page.

Write the function that appends a line

The panel is static right now. It needs one function that any tool handler can call to add a line.

  • Add the logging function by pasting this script directly below the panel you just added:
<script>
  // Adds one line to the on-page log every time a tool is called
  function logToolCall(toolName) {
    const list = document.getElementById("tool-log-list");

    // Drop the placeholder line the first time a real call arrives
    const placeholder = list.querySelector(".tool-log-empty");
    if (placeholder) placeholder.remove();

    const entry = document.createElement("li");
    entry.textContent = new Date().toLocaleTimeString() + " called " + toolName;
    list.appendChild(entry);
  }

  // Expose it globally so your tool handler can reach it
  window.logToolCall = logToolCall;
</script>

What does this code do?

  • logToolCall takes the tool's name and adds one list item to the panel, stamped with the time.
  • The placeholder line is removed on the first real call, so the panel never claims to be empty once it isn't.
  • window.logToolCall puts the function in the page's global scope, so code anywhere in the DOM can call it.
  • Save the page file.
  • Refresh your browser.
  • Open your browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Fake a call by running this in the console:
logToolCall("hand test");

A new line appears in the panel with the current time and hand test. The placeholder line is gone.

Console can't find your function?

If the console reports a reference error mentioning logToolCall, the page you're looking at doesn't have the script yet.

  • Check the <script> block is in the page file you saved, not in a different file.
  • Refresh the page after saving. The console only sees the version currently loaded.

Why can't my console see the function I just defined?

Call the log from inside your tool handler

The panel works, but only when you call it by hand. The last wire to connect runs from your tool.

  • Scroll to the read-only tool you declared in the last step.
  • Add this as the first line inside the handler, above the code that returns the current items:
logToolCall('[[TOOL_NAME="the name you gave your tool"]]');

Why log before the work, not after?

Logging on the first line means a call that crashes halfway still leaves a trace. You learn that the call arrived, even when it never produced a result.

A tool that fails silently is the hardest kind to debug. The log is your only witness.

  • Save the page file.
  • Refresh your browser.

Before you trigger anything, what do you expect the panel to show after one call? And what would it show if the call never reached your handler?

  • Trigger your read-only tool by hand, the same way you checked it was discoverable in the last step.

One new line appears in the panel with the time and your tool's name. Your page now keeps a live record of every call it serves.

No line after the trigger?

  • Check the tool name you passed to logToolCall is wrapped in quotes.
  • Check the call sits inside the handler body, not beside the tool declaration where it would run once on page load.
  • Run the console test from the last substep again. If that still works, the problem is the handler wiring, not the panel.

My tool runs but nothing appears in the log panel.

✔️ Awesome, I've got everything!

Great. Double check your page file is saved and your browser is showing the refreshed version.

ⓧ I'd like to double check the full code

Scroll up and confirm all three additions are on your page, in this order:

  • The tool-log panel markup, just above </body>.
  • The <script> block defining logToolCall, directly below the panel.
  • The logToolCall call as the first line inside your read-only tool's handler.

Your page can now show its work. Next up, you'll hand the wheel to an agent and watch the log fill in without touching the page yourself.

Connect an Agent to Your Page

Your page declares a tool, and the log panel proves a call shows up the moment that tool runs. Every call so far has come from you.

The whole point of WebMCP is that something other than a person can make that call. In this step you hand the wheel to an agent and watch it work your page without touching the interface.

You write no new code here. The page stays exactly as it is, which is what makes the result convincing.

In this step, get ready to:
  • Connect an agent to the page you have running locally.
  • Ask the agent, in plain language, for the items in your list.
  • Watch the call log fill in as the agent calls your tool.
Connect an agent to your running page

An agent can only call a tool it can discover. Discovery happens through the agent client you connect to the page, so that connection is the first thing to get right.

  • Switch back to the browser tab where your page is running.
  • Note the local address your page is served from: your local page address.
  • Open the WebMCP Challenge page in a second browser tab.
  • Follow the agent connection instructions in the build docs linked from that page.
  • Point the agent client at the address you noted above.
  • Confirm the agent client lists your read-only list tool among the tools it can call.

Why the challenge docs are the source of truth

This is a very young spec attached to a time boxed contest. The exact way an agent attaches to a page is defined by the build docs the challenge page links to.

A connection recipe copied from anywhere else is likely to be out of date. Take the connection path from those linked docs every time.

Agent can't see your tool?

Check the agent is pointed at the same local address your page is served from. A different port serves a different page, and that page has no tools on it.

If the page reloaded after the agent attached, reconnect the agent so it picks up the tool declaration again.

Why can't my agent discover the tool my page declares?

Ask the agent for your list in plain language

A person reads the list off the screen. An agent should get the same answer by calling your tool, and the log panel is how you tell those two apart.

Before you send anything, make a prediction. Will the agent read the items off the rendered page, or call the tool you declared?

  • Ask the agent for your list by sending this message:
What items are in the list on this page?

What's doing the work here?

You never named the tool and you never passed it an argument. The agent read the tool's description, decided it matched the question, and called it.

That is the shift. Your page stopped being something to click and became something to call.

  • Watch the call-log panel while the agent works.

A new line appears in the log the moment the agent calls your tool. The agent's reply names the same items your tool returns.

No new line in the log?

The agent may have answered from the visible page text without calling anything. Ask it for the current items instead, which pushes it toward a tool call.

Trigger the tool by hand the way you did in the last step. If the log still appends for a manual call, the panel is fine and the problem is the agent connection.

The agent answered me but my call log stayed empty, why?

Prove the answer came from your tool

One matching answer could be a coincidence of wording. A second request, phrased nothing like the first, settles it.

  • Count the entries currently sitting in your call-log panel.

Predict before you send the next message. Does a differently worded question still reach the same tool, or does the agent need your original phrasing?

  • Send this second message to the agent:
Summarise what's on my list right now.
  • Compare the log entry count against the number you counted a moment ago.

One more entry has appeared. Same tool, completely different wording, which means the agent picks the tool from its description rather than matching your phrasing.

Second question got no new entry?

Agents often reuse an answer they already have in the conversation. Start a fresh conversation with the agent and send the second message again.

If it still skips the call, the tool's description is probably too narrow to match a summary request. Widen it so it describes reading the list rather than one exact phrasing.

How should I word a tool description so an agent picks it for related questions?

Your page now answers an agent, not just a person. Next up, you'll add a tool that changes what's on the page and let the agent do the changing.

Add a Tool That Changes State

Your page can already answer an AI agent. You asked in plain language, the agent found your read-only tool, and the call-log panel proved it ran.

Reading is the safe half of WebMCP. The half that changes how you think about a page is letting the agent write to it.

A writing tool needs something your read-only tool never did. The agent has to hand your page a value, so the tool has to describe the value it expects.

In this step, get ready to:
  • Add a function that appends an item to your list and writes to the call log.
  • Declare a second tool so an agent can call that function.
  • Ask your agent to add an item and watch the page change in front of you.
Add a function that changes your list

The agent should never touch your list directly. Give the page one function that owns the change, and the tool becomes a thin wrapper around it.

  • Switch back to the file where you declared your read-only tool.
  • Read your existing tool declaration and note two names it already uses: the array holding your list items, and the function that appends a line to the call-log panel.
  • Add this function above your existing tool declaration by pasting the code below:
// Owns every change to the list, so the tool never touches state directly
function addItem(name) {
  items.push(name);
  renderItems();
  logCall("add_item", name);

  return { added: name, total: items.length };
}

What does this code do?

  • items.push(name) adds the new value to the same array your read-only tool reads from.
  • renderItems() and logCall() are the functions your starter page already uses. Swap in your own names if your file spells them differently.
  • The returned object gives the agent something to report back, so it can confirm the change instead of guessing.
  • Save the file you just edited.
  • Reload the page in your browser.
  • Press Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) to open the browser Console.

Before you press Enter on the next line, predict which parts of the page change: the list, the call-log panel, or both.

  • Test your new function by running this in the Console:
addItem("Espresso machine")

Both change. A new Espresso machine row appears in your list, a fresh line lands in the call-log panel, and the Console prints an object with added and total.

Seeing a ReferenceError?

A ReferenceError naming items, renderItems, or logCall means your starter page spells one of them differently. Scroll up to your read-only tool and copy the exact names from there.

If the row appears but the log line does not, your call-log function takes different arguments than the two passed above.

Why is my addItem function throwing a ReferenceError?

Declare the add_item tool

Your function works, but only you can reach it. The agent needs a declared tool, and this one has to describe the value it expects.

  • Declare your second tool by pasting this code directly below your existing tool declaration:
await document.modelContext.registerTool({
  name: "add_item",
  description: "Adds a single item to the list shown on the page.",
  inputSchema: {
    type: "object",
    properties: {
      name: { type: "string", description: "The item to add to the list." }
    },
    required: ["name"]
  },
  execute(input) {
    // The tool never touches state itself, it delegates to the page's own function
    return addItem(input.name);
  }
});

What does this code do?

  • inputSchema is a JSON Schema describing what the agent must send. This is how the agent knows to supply an item name rather than guessing.
  • required: ["name"] marks the value as mandatory, so the agent asks you for it instead of inventing one.
  • execute(input) receives the agent's arguments and hands them straight to your function.

Why this tool has no readOnlyHint

Your first tool carried annotations: { readOnlyHint: true }. That flag tells an agent the tool only reads data, so calling it is always safe.

This tool changes your list, so the flag is left off. The absence is the signal that the call has consequences.

  • Save the file you just edited.
  • Reload the page and check the Console. You should see no red errors, because a malformed schema fails loudly at registration.

Console showing an error on reload?

Check that properties and required sit inside inputSchema and not beside it. A misplaced brace is the usual cause.

Confirm your two tools use different name values. Two tools sharing a name leaves the agent unable to tell them apart.

Why is my registerTool call failing?

✔️ Awesome, I've got everything!

Great, double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both additions are present in your file, in this order:

  • The addItem function, above your tool declarations.
  • Your original read-only tool declaration, unchanged.
  • The add_item registration, directly below it.
Ask the agent to add an item

This is the moment the page stops being something you click. You describe an outcome, and the page changes because the agent called a tool you wrote.

  • Return to the agent you connected to your page in the previous step.
  • Keep the page visible next to the agent so you can watch the list and the call-log panel.

Before you send the message, predict what the agent does with the item name: does it read the name out of your sentence, or ask you for it separately?

  • Ask the agent to change your list by sending this message:
Add "Espresso machine" to the list on this page.

The agent reads the name straight out of your sentence, because your inputSchema told it exactly what shape to fill in.

A new line naming add_item appears in your call-log panel, and a new row appears in the list without anyone clicking anything.

What you just proved

The agent never simulated a click, typed into a field, or parsed your layout. It read your tool declarations and called the one that matched the request.

That is the whole shift. Your page now has an interface for people and an interface for agents, and you control exactly what the second one can do.

Agent didn't call your new tool?

Reload the page and reconnect the agent. A tool registered after the agent connected may not be in the list it discovered.

If the agent answered in words without touching the list, sharpen your description so it names the action plainly.

Why is my agent ignoring my add_item tool?

Secret mission

Let the Agent Remove Items Too

Your agent can read your list and add to it. It has no way to take anything off. Add a third tool that removes an item, then give the agent a job it can only finish by reading the list first and deciding for itself what to delete.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your page runs entirely on your own machine, so there are no ongoing costs.

Resources you used:

  • The local project folder holding your starter page.
  • The read-only tool declaration that returns the current list items.
  • The state-changing tool declaration that adds an item to the list.
  • The local server serving your page in the browser.
  • The connection between your AI agent and your page.

Keep everything running

No action needed. Choose this if you're still actively building on your page and want the agent to keep driving it.

  • Leave your local server running so the page stays available in your browser.
  • Leave both tool declarations in place so your agent can keep calling them.
  • Check your remaining credit balance before your next long session with the agent.

Watch who can drive your page

Every tool you leave declared is an action an agent can take without you clicking anything. Keep the state-changing tool declared only while you're happy for the agent to add items on your behalf.

Pause - I'll come back to this later

Shut down running processes to free up memory, and stop the agent from reaching your tools. Your files stay exactly where they are.

  • Press Ctrl+C in the terminal running your local server to stop it.
  • Disconnect the agent from your page so it can no longer call your tools while you're away.
  • Close the browser tab showing your page and its call-log panel.

Your project folder and both tool declarations stay on disk, ready for you to start the server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Choose this if you don't want an agent driving the page after you're done.

  • Press Ctrl+C in the terminal running your local server to stop it.
  • Disconnect the agent from your page so it has no route to your tools.
  • Delete your project folder on macOS or Linux by running this command:
rm -rf [[PROJECT_PATH="path to your project folder"]]
  • Delete your project folder on Windows by running this command in PowerShell:
Remove-Item -Recurse -Force [[PROJECT_PATH="path to your project folder"]]
  • Confirm the folder is gone by listing its parent directory and checking it no longer appears.

What this removes

Deleting the folder removes the page, both tool declarations, and the call-log panel in one go.

Nothing was provisioned in the cloud, so there is nothing left running once the folder and the server are gone.

Nice Work!

Nice Work

Nice work. You just turned an ordinary web page into something an AI agent can operate directly, and you watched it happen live in your call log.

Most pages assume a person with a mouse. Yours does not anymore.

You've learned how to:

  • Declare a tool on a page you own so an agent can read your list items without touching the interface.
  • Build a visible call-log panel that appends a line every time a tool fires, so you can prove a call actually happened.
  • Connect an agent to your page and hand it a plain-language task, then watch it add an item and update the list in front of you.
  • Secret Mission: Take on the optional challenge that extends what your tools can do.

Ready to quiz yourself?