3way

Install

Add the exchange layer to a WebMCP site.

Serve the bundled widget from your own origin and point it at the service that provides policy, domain data, the site agent, and confirmation endpoints.

The default embed needs no framework or page build step. Put 3way.bundle.js on your origin, add one script tag, and the exchange widget mounts itself.

The whole embed
<script src="/js/3way.bundle.js"
        data-3way-api="https://api.yourstore.com"
        data-3way-store="Yourstore Support"></script>

Measured The live demo uses this tag plus two optional attributes, a display name and the interaction mode — one tag, 18 tools registered, policy fetched at mount.

Where to host it

Your consent gate should not load from someone else's server.

Stripe can tell you to load js.stripe.com because Stripe is the counterparty — you are trusting them by definition. Here you are the counterparty, and this widget enforces your gate. A third-party script tag that someone else can change is a script that can be changed to remove the gate.

So the recommendation is the boring one: download the file and serve it from your own origin. Your gate, your origin, your control.

To try it without hosting anything

There is a versioned copy here. It is fine for a prototype and it is pinned with Subresource Integrity, so a swapped file fails to load rather than running:

Hosted · for trying it out
<script src="https://3way.dev/v1/3way.bundle.js"
        integrity="sha384-VBorJEpYKqZsBc6euBYohgMZku2Chz68VQeFG371baxXC/PvWW5byvxnq1FEenYo"
        crossorigin="anonymous"
        data-3way-api="https://api.yourstore.com"></script>

Versioned on purpose. An unversioned URL means any change silently reaches every page that ever embedded it.

Why the policy isn't in the tag

Because a page must not be able to disagree with its own gate.

The widget needs your returns policy — the prose a person reads and the rules the gate enforces. Those are objects, and an attribute is a string, so the only honest route to one tag is to serve them from your API.

That is where they belonged anyway. Your server is already the authoritative gate's source of truth for the same rules. Keeping one copy is what stops a page showing someone one policy while the action enforces another.

If the config cannot be fetched, nothing mounts and the console says why. A widget that quietly fell back to a default policy would be displaying terms that are not yours.

Attribute
data-3way-apiRequired. Where your API lives. Its absence is how a page opts out of auto-mounting.
data-3way-storeHow your agent is named in the transcript. Defaults to “Store”.
data-3way-userDisplay name for the signed-in person. Nothing security-relevant reads it.
data-3way-stanceWhich personality preset to use. Overrides the server default.
data-3way-tenantWhich store a shared backend should answer as.
data-3way-inputInteraction mode. Omit it (or set anything else) for Keyholder mode: no text box, the person only authorizes. Set to composer to give the person a text box instead.
data-3way-domainWhich tool registry to mount. Set to clinic for the records domain; omit for the default shop.

What your API answers

One endpoint.

GET /api/config, returning the policy the widget will show and the rules its gate will read. Public by design — a returns policy is published anyway.

Everything else is the conventions: the tool names, the origins, and the refusal shape. Read them →

GET /api/config
{
  policy: {
    prose: "…what a person reads",
    rules: { requiresHumanDirect: [ … ] }
  },
  stances: { … },
  storeAgentName: "Yourstore Support"
}

Full control

Or mount it yourself.

A tag with no data-3way-api is inert, so nothing auto-mounts and ThreeWay.mount() takes every option directly — useful when the policy is already in the page, or when you need a custom mount point.

Run locally

Start the Worker and demos on matching origins.

For the site's Realtime agent only, copy the local secret template and enter an OpenAI API key. WebMCP registration, deterministic tools, and tests do not require a live key.

npm ci
cp worker/.dev.vars.example worker/.dev.vars

Set OPENAI_API_KEY in worker/.dev.vars. In terminal one, run:

npm run dev:worker

In terminal two, run:

npm run preview:local

Open:

http://localhost:4173/demo/halden/
http://localhost:4173/demo/clinic/

WebAuthn requires the page origin and the Worker's EXPECTED_ORIGIN to match, which is why the port is fixed.