WebMCP Kit

Developer quickstart

Your tools. Durable Pi. One shared chat.

Expose actions your app already owns, point Pi at your inference provider, then mount the kit’s chat component.
SetupInstall the kit in your app.
npm install @vampaz/webmcp-kit

This quickstart targets WebMCP Kit 0.6.1 and requires Node 24 or newer for tooling. The kit bundles its internal Pi libraries. Use the framework lifecycle of your application. The kit exports core tools, the Pi harness, and chat separately, and it includes no model service: step 04 configures inference. Existing integrations should apply the 0.5 migration guide.

Run browser setup after mounting the page. Functions such as searchProducts, updateOrder, and showApprovalDialog are your app’s handlers. Keep each registration and call unregister() when its component unmounts.

01

Register the page’s actions

Each tool has a schema and an application handler. Guards read current state before execution. These registered tools are available to Pi and, where supported, native WebMCP browser agents.

import { defineTool, registerTool } from '@vampaz/webmcp-kit'

interface SearchProductsInput {
  query: string
}

const searchRegistration = registerTool(
  defineTool<SearchProductsInput>({
    name: 'search_products',
    description:
      'Search the visible product catalog by keyword. Use when the user asks to find products.',
    annotations: { readOnlyHint: true },
    inputSchema: {
      type: 'object',
      properties: {
        query: { type: 'string', description: 'Product name, category, or keyword.' }
      },
      required: ['query'],
      additionalProperties: false
    },
    guard(input) {
      return input.query.trim().length > 0 || 'Enter a search term.'
    },
    execute(input: SearchProductsInput) {
      return searchProducts(input.query)
    }
  })
)
02

Keep approval in the app

Install the confirmation handler before allowing tool calls. Only the boolean true approves; strings such as 'approve' do not. The dialog must close and resolve false when its signal aborts, including when the user presses Stop. invokeTool() has no confirmation bypass.

import {
  defineTool,
  registerTool,
  setConfirmationHandler,
  type ConfirmationRequest
} from '@vampaz/webmcp-kit'

interface UpdateOrderStatusInput {
  orderId: string
  status: 'processing' | 'shipped' | 'cancelled'
}

registerTool(
  defineTool<UpdateOrderStatusInput>({
    name: 'update_order_status',
    description: 'Update the status of one order. Use when the user asks to change an order state.',
    inputSchema: {
      type: 'object',
      properties: {
        orderId: { type: 'string', description: 'Order ID to update.' },
        status: {
          type: 'string',
          enum: ['processing', 'shipped', 'cancelled'],
          description: 'New order status.'
        }
      },
      required: ['orderId', 'status'],
      additionalProperties: false
    },
    confirmation: {
      reason: 'Changing order status mutates business records.'
    },
    guard(input) {
      const order = getOrder(input.orderId)
      if (!order) return 'Order is unavailable.'
      return order.status !== 'cancelled' ||
        'Cancelled orders cannot change status.'
    },
    execute(input: UpdateOrderStatusInput) {
      return updateOrder(input)
    }
  })
)

const disposeApproval = setConfirmationHandler(function confirmTool(
  { tool, input, reason, signal }: ConfirmationRequest
) {
  return showApprovalDialog({
    title: `Run ${tool.name}?`,
    body: reason,
    preview: JSON.stringify(input, null, 2),
    signal
  })
})

Core validates input before approval and checks current guards again before execution. Remove the handler with disposeApproval() when its owning host is destroyed. Server authorization remains independent.

03 · Durable runtimeRun Pi in the page.

Pi uses IndexedDB and Web Locks in the current page. Open the app over trusted HTTPS, register its tools and approval handler, then create the controller. There is no Pi worker build or watch process. Existing PWA and model inference workers stay independent.

04 · InferencePoint Pi at your model provider.

The kit ships no model service and no default inference endpoint. createPiHarness() requires a configured provider adapter: the resolver option, typed ToolResolver. It receives the page’s tools and current context and returns the next action. The adapter is not a separate mode; every chat turn uses the provider you select.

Choose where inference runs, then pass the resulting object as resolver:

Browser built-in AI
createResolver() with provider: 'chrome-built-in'. No endpoint or key; needs Chrome’s LanguageModel API.
Your OpenAI-compatible endpoint
createResolver() with baseUrl: '/model-proxy/v1'. The kit posts to baseUrl + '/chat/completions'. Use a same-origin HTTPS proxy for local or self-hosted models; a key held in the browser is visible to the page.
Your server endpoint
createResolver() with server mode (auth.mode: 'server') and an endpoint your app owns. The browser posts the command to that route; the server holds the provider key and returns the selected tool and input. Recommended for production.
Hosted OpenAI
createHostedOpenAIResolver() with accessKey, model, endpoint, and sessionEndpoint. A publishable key is exchanged for a short-lived session; provider secrets stay server-side.
Custom adapter
Implement ToolResolver with capabilities.conversation: true, honor single-step requests, purpose: 'summary', cancellation, and request-size limits, and throw on failure.

Server endpoint mode — the browser never sees a provider secret:

import { createResolver } from '@vampaz/webmcp-kit'

// Your server holds the provider key and calls the model.
// The browser only posts the command to this route.
const resolver = await createResolver({
  provider: 'openai',
  model: 'your-approved-model',
  auth: {
    mode: 'server',
    endpoint: '/api/webmcp/resolve'
  }
})

Direct OpenAI-compatible endpoint — for a local model behind your own proxy:

import { createResolver } from '@vampaz/webmcp-kit'

// A same-origin HTTPS proxy in front of LM Studio, Ollama, vLLM, or any
// OpenAI-compatible provider. The kit posts to /model-proxy/v1/chat/completions.
const resolver = await createResolver({
  provider: 'openai-compatible',
  model: 'your-model-id',
  baseUrl: '/model-proxy/v1',
  auth: { mode: 'none' }
})

The server endpoint receives { provider, model, message, tools, context, responseSchema, singleStep, purpose } and returns the selected tool and input as JSON. For purpose: 'summary', use the offered reply tool to return a checkpoint, omit action-selection instructions, and reject resolver outcomes and sequences. Keep input schema descriptions in the model prompt. On failure, return a non-2xx status with a { error } body. The installed kit includes the matching docs/resolver-providers.md and docs/pi.md guides for the endpoint contract and controller lifecycle.

Example: use the demo’s hosted OpenAI service

This optional example connects to the standalone demo’s hosted service, which is separate from WebMCP Kit. Use a publishable key for that service and allow your app’s exact origin in its project policy. This Astro example uses a PUBLIC_ variable; Vite apps normally use VITE_.

import { createHostedOpenAIResolver } from '@vampaz/webmcp-kit/hosted'

// Optional example: the standalone demo's hosted service.
const resolver = createHostedOpenAIResolver({
  accessKey: import.meta.env.PUBLIC_WEBMCP_PAID_ACCESS_KEY,
  model: 'gpt-6-luna',
  endpoint: 'https://webmcp.conekto.eu/api/webmcp/resolve',
  sessionEndpoint: 'https://webmcp.conekto.eu/api/webmcp/session'
})

The kit exchanges the publishable key for a short-lived session. Provider secrets stay on the server, and the endpoint must support Pi’s single-step requests.

Changing provider means creating a new adapter and a new harness. Destroy the previous controller first.

05 · ConversationConnect the actual kit chat.

Mount <webmcp-chat></webmcp-chat>, then call mountAssistant(resolver) below with the adapter from step 04. Configure Vue’s compiler to treat webmcp-chat as a custom element. Supply currentUserId, a safe getCurrentPageContext callback, and dismissPendingApproval.

import type { ToolResolver } from '@vampaz/webmcp-kit'
import { createPiHarness } from '@vampaz/webmcp-kit/pi'
import { defineWebMCPChat, type WebMCPChatElement } from '@vampaz/webmcp-kit/chat'

// Mount <webmcp-chat></webmcp-chat>, register tools and approvals first.
// Call this from your browser lifecycle and catch initialization errors.
async function mountAssistant(resolver: ToolResolver) {
  try {
    defineWebMCPChat()
    const chat = document.querySelector<WebMCPChatElement>('webmcp-chat')
    if (!chat) throw new Error('Mount webmcp-chat before connecting the assistant.')
    if (!resolver.available) throw new Error(resolver.detail)
    const harness = await createPiHarness({
      session: {
        namespace: 'my-app',
        ownerId: currentUserId,
        conversationId: location.pathname
      },
      resolver,
      context: getCurrentPageContext,
      onCancel: dismissPendingApproval
    })
    chat.configure({
      controller: harness,
      mode: 'popup',
      title: 'Ask this page'
    })
    return async function dispose() {
      await harness.destroy()
      resolver.dispose?.()
    }
  } catch (error) {
    resolver.dispose?.()
    throw error
  }
}
Vue custom-element configuration
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// Merge this into your existing vite.config.ts.
export default defineConfig({
  plugins: [vue({
    template: {
      compilerOptions: {
        isCustomElement(tag) { return tag === 'webmcp-chat' }
      }
    }
  })]
})

For Astro + Vue, pass the same template.compilerOptions to the @astrojs/vue integration in astro.config.mjs.

Catch setup errors in the host and show a retry or configuration message beside the launcher. Call the returned disposer on navigation, unmount, account changes, and provider changes. If mounting finishes after unmount, dispose the late controller too.

The component owns the transcript, suggestions, composer, Stop, and New chat. This example uses popup mode, which adds the launcher, movement, and resizing. Use mode: 'inline' to embed it in your layout. Model configuration belongs to the host: choose the provider in application code and pass it to the controller. Changing it recreates the controller.

To offer model choices inside chat, pass the optional settings configuration with your providers, selected model, and change callback. The kit adds a settings icon to the chat header. The app still owns model connections, stored preferences, and approvals. This demo uses that module and adds developer diagnostics through its settings slot. See the settings integration example.

Pi requests one next action, receives its real tool result, then decides what to do next. Committed conversation history lives in IndexedDB. Reloading interrupts unfinished work without replaying actions. Stop cancels pending work; it does not roll back an action that already ran.

Use a stable, explicit owner and conversation partition. The owner ID separates local history; it does not authorize actions. Destroy the old controller before switching partitions. Clicking the New chat plus button immediately clears the current conversation and draft without confirmation; it does not reset app state. A tool marked “outcome unknown” may have run before interruption, so inspect app state before requesting it again.

06

Verify the tool boundary

Enable this bridge only in development and tests. The Playwright helpers exercise schema validation, guards, and approval. Test model interpretation separately. Set Playwright’s baseURL to your trusted HTTPS app origin.

import { installWebMCPKitTestBridge } from '@vampaz/webmcp-kit/testing'

// Browser entry point, in development and test builds only.
if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
  installWebMCPKitTestBridge()
}

This example invokes the tool registered in step 01:

import playwrightTest from '@playwright/test'
import {
  invokeWebMCPTool,
  waitForWebMCPTool
} from '@vampaz/webmcp-kit/testing/playwright'

const test = playwrightTest
const { expect } = playwrightTest

test('searches products and rejects invalid input', async function ({ page }) {
  await page.goto('/')
  await waitForWebMCPTool(page, 'search_products')

  const result = await invokeWebMCPTool(page, {
    toolName: 'search_products',
    input: { query: 'coffee' },
    source: 'fallback'
  })
  expect(result.status).toBe('success')

  const blocked = await invokeWebMCPTool(page, {
    toolName: 'search_products',
    input: { query: 42 },
    source: 'fallback'
  })
  expect(blocked.code).toBe('invalid_input')
})
Optional

Call a server action

The endpoint receives { toolName, input, source } as JSON. Return JSON on success or HTTP 204. Implement authentication, authorization, and input validation on the server. The kit does not implement the endpoint.

import { defineServerTool, registerTool } from '@vampaz/webmcp-kit'

interface SendReceiptInput {
  orderId: string
}

registerTool(
  defineServerTool<SendReceiptInput>({
    name: 'send_receipt',
    description: 'Send a receipt email for an order. Use when the user asks to email a receipt.',
    endpoint: '/api/tools/send-receipt',
    annotations: { consequentialHint: true },
    inputSchema: {
      type: 'object',
      properties: {
        orderId: { type: 'string', description: 'Order ID to email the receipt for.' }
      },
      required: ['orderId'],
      additionalProperties: false
    },
    confirmation: {
      reason: 'Sending a receipt emails a customer.'
    }
  })
)
Optional

Expose an existing form

Register after the form exists. Named controls and native constraints define the input schema. Wire ordinary form submission to the same app handler separately and unregister on teardown.

<form id="support-ticket-form">
  <label>Account <input name="account" required /></label>
  <label>Subject <input name="subject" required /></label>
  <label>Details <textarea name="body" required></textarea></label>
  <button type="submit">Create ticket</button>
</form>
import { registerFormTool, type FormInput } from '@vampaz/webmcp-kit'

const form = document.querySelector<HTMLFormElement>('#support-ticket-form')

if (form) {
  registerFormTool({
    form,
    name: 'create_support_ticket',
    description: 'Create a support ticket from the visible form. Use when the user asks to open a ticket.',
    confirmation: {
      reason: 'Creating a support ticket changes app state.'
    },
    execute(input: FormInput) {
      return createTicket({
        account: String(input.account ?? ''),
        subject: String(input.subject ?? ''),
        body: String(input.body ?? '')
      })
    }
  })
}