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.
<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:
<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-api | Required. Where your API lives. Its absence is how a page opts out of auto-mounting. |
data-3way-store | How your agent is named in the transcript. Defaults to “Store”. |
data-3way-user | Display name for the signed-in person. Nothing security-relevant reads it. |
data-3way-stance | Which personality preset to use. Overrides the server default. |
data-3way-tenant | Which store a shared backend should answer as. |
data-3way-input | Interaction 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-domain | Which 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 →
{
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.