3way

Install

One script tag. Serve it yourself.

No build step, no framework, no dependency. Put 3way.bundle.js on your own origin, drop this at the bottom of the page, and the 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 This is the literal embed on the live demo — 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-EQziFGULhOCo6s0mqykIRtol1H95kMc1RzpGJmDzljSWJ0pNrfTotWJYCE/gOq0+"
        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.

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.