Skip to content

What you need on the server

One port, five environment variables and a ticket that lives fifteen minutes. This page is everything between "we have an agent" and "there is a chat on the customer's site".

One port, and only one

The api itself is not what a visitor's browser talks to. Publishing every REST route, Swagger and the tool channel to the open internet in order to serve a chat bubble would be an enormous door opened for a small reason.

Instead the hub listens on a separate port of its own, and that port carries exactly one thing:

the api's PORTBRIDLE_PUBLIC_PORT
what listensthe whole apione WebSocket namespace
routesevery endpoint, Swagger, the tool channelPOST /visitor and nothing else
reachesthe database, the secrets key, the clusterthe turn, and nothing else
publishednoyes, and only this

There is no route on that port to publish by accident. That is the property, and it is why the split exists.

The five variables

bash
# Which half of the pair this process is. `hub` (the default) means browsers
# connect HERE and no second service is needed.
BRIDLE_ROLE=hub

# The ONE port you publish. No default: a port that opened itself because a
# variable was missing is the accident worth one line of configuration.
BRIDLE_PUBLIC_PORT=3334

# The public address of that port. This is what a page's `apiUrl` will say.
BRIDLE_URL=https://hub.example.com

# The secret embed tickets are signed with. NOT the one your operators sign in
# with — embedding has its own, and mixing them is a mistake with real teeth.
BRIDLE_JWT_SECRET=<64 random hex characters>

# Where the widget bundle is served from. Optional; defaults to the stock build.
BRIDLE_SDK_URL=https://bridle.cleanslice.org/sdk/latest.js

Unset means "this installation publishes no agents." Bindings still store, minting a token refuses and names the missing variable, no port is opened and no socket is dialled. Nothing half-opens.

BRIDLE_JWT_SECRET never leaves the server

It is not a value a website's code ever holds. A site that could sign tickets itself could name any visitor as any other visitor. If you find yourself pasting it into a front end, the answer you actually want is on this page under Issuing a ticket.

The other role

BRIDLE_ROLE=runtime is the older topology: browsers talk to a separate Bridle hub, and this api dials out to it with BRIDLE_API_KEY. Exactly one of the two roles is alive at a time. An agent published under one stays published across a switch — the id already pasted into a page keeps working.

Under runtime there is no POST /visitor here, because the public door is somebody else's. Per-visitor tickets in that topology come from your own backend.

Publishing the agent

In the cabinet, on the agent's «Сайт» screen: publish the agent to the site channel. Until then, a valid ticket still buys nothing — whether the agent is published is decided at the handshake and again on every message.

The screen also tells you whether the installation can publish at all. If BRIDLE_PUBLIC_PORT (or, under runtime, BRIDLE_API_KEY) is missing, it says so and names the variable, before anything is pressed.

Issuing a ticket

The ticket is minted by a signed-in member of the agent's team:

POST /agents/:id/channels/bridle/token

There is no unauthenticated mint, and there is no way to mint one for an agent your team does not own.

  • Default life: 15 minutes. Ceiling: 24 hours. Ask for more and you get the ceiling; ask for less and you get what you asked for.
  • It is bound to one agent. A ticket minted for agent A, pasted into a page pointed at agent B, does not open a conversation with B. This is checked twice — once when the socket opens, and again on every single message, because a tab left open for a week outlives the ticket in it.
  • It is not an identity, a session or a permission. It says which agent and until when. Whose turn the conversation runs as is decided from the agent's own record — see Boundaries and rights.

Why it cannot live longer

Because it sits in the page's HTML, in plain text, readable by everyone who opens your site. There is nowhere else to put it: the browser has to present something before it has anything. A credential that public is a session, not a key, and the ceiling is where the difference is enforced.

Two kinds of ticket

The ticket that sits in your page names nobody. If every browser presented it unchanged, every visitor would be the same person: one conversation shared by all of them, one flow allowance, and each other's messages.

So there is a second shape, and one exchange between them:

POST https://hub.example.com/visitor
     { "token": "<the page ticket>" }
  →  { "token": "<a ticket naming one visitor>", "expiresAt": "…" }
  • It needs no session — the caller is a stranger's browser on your site.
  • It opens nothing new: whoever holds the page ticket could already talk to the agent. It is the same access, divided.
  • The visitor ticket expires with the page ticket, to the instant.
  • Presenting a visitor ticket back returns the same person, which is what puts a reloaded tab into the same conversation.

The snippet the cabinet gives you does this exchange for you, keeps the result in sessionStorage — scoped to the tab, sent on no request by itself, gone when the tab is — and, deliberately, does not fall back to the page ticket if the exchange fails. A fallback would leave a working chat with two strangers quietly sharing it.

The better way: your backend mints, nothing is pasted

Everything above describes a ticket a person makes by hand. For a real site, don't. Fifteen minutes is fifteen minutes, and a chat that dies overnight looks to everyone like broken software.

The token option accepts a function, so the page can ask your own backend for a ticket instead of carrying one. And your backend gets one with a team key: a credential that lives on your server and may do exactly one thing.

1. Issue the key

A signed-in member of the team, once:

POST /teams/{teamId}/embed-keys
     { "name": "shop backend, production", "expiresAt": "2027-01-01T00:00:00.000Z" }
  →  { "id": "…", "key": "emk.0f3a…c19.Zm9vYmFy…", … }
  • The secret is shown once. Nothing here keeps a copy. Lost it? Revoke and issue another — which is the right remedy anyway.
  • Give it an expiresAt if you can. Revoking needs somebody to remember the key exists; an expiry needs nobody.
  • Revoke with DELETE /teams/{teamId}/embed-keys/{keyId}. Immediate, no grace period, and the record survives so an incident can still be read.

2. Mint per person, server to server

POST /agents/{agentId}/channels/bridle/visitor
     X-Embed-Key: emk.…
     { "externalId": "customer-4471", "name": "Анна", "email": "anna@example.com" }
  →  { "token": "…", "expiresAt": "…" }
  • externalId is the identity; name, email and phone are what is known about it. The same externalId tomorrow is the same lead, not a second one. An email is stored and shown and never matched on — two customers can end up sharing one through a site's own mistake.
  • The person is recorded before the token is handed out, so a live credential never names somebody no row knows about. They appear in the team's lead list with what you sent, instead of an opaque string.
  • The header is X-Embed-Key, not Authorization. Deliberately: a key sent as a Bearer token is refused as a malformed JWT, and a session token sent here is refused as not a key. Neither refusal depends on anybody remembering the difference.

What the key does not open

  • Every other route in this api. The guard is attached by hand to that one handler, so a route added tomorrow is closed to it by default rather than by review — and it attaches no principal, so a route that ever got it by mistake would refuse rather than escalate.
  • Another team. The team comes from the key; the agent from the path; if they disagree the answer is the same "no such agent" an outsider gets, so a key cannot be used to discover which agent ids exist.
  • The cabinet. Issuing and revoking keys still needs a member's session.

Then the page carries no credential at all

ts
init({
  apiUrl,
  agentId,
  token: () => fetch('/api/agent-token').then((r) => r.json()).then((t) => t.token),
})

Your /api/agent-token is your route: it should take the identity from the customer's session on your server, never from anything the browser sent. That is the whole difference between "the site says who this is" and "the browser says who it is".

When a pasted ticket expires

There is one case where a pasted ticket is the only option: a static site with no backend — a documentation build, a landing page on file hosting. There is nobody there to call the mint, and the team key must not go into the bundle, because the bundle is published.

Such a site's chat stops answering when the ticket does, and the widget reports INVALID_TOKEN. That is the expiry doing its job, not a fault. Expect to redo it; 15 minutes is 15 minutes.

The demo runs both modes and says which it is in: with a team key it carries no ticket at all, and with a pasted one it reads its own ticket, shows how long is left, and when the time runs out says that rather than falling silent.

What never goes near your site

  • BRIDLE_JWT_SECRET, or any other secret of the installation.
  • The team key. It lives on your server. In a page's source, or in a bundle a build published, it is a credential for naming any customer of your team as any other.
  • The credentials your operators sign in with. Embedding has its own secret and its own ticket, and the separation is on purpose.
  • The agent's own secrets, keys and connections. A visitor's conversation cannot read them, and no setting changes that.