Website Personalization Recipes
Copy-paste snippets for the team that manages a Bryn customer's website (often
non-engineers). Each snippet is paste-ready: drop it in a <script> alongside the Bryn
pixel snippet, or hand it to a developer.
Before you start: the pixel must be live on your site. See Install the pixel.
How it works: Bryn announces, your code reacts
When Bryn recognizes a visitor, it announces it on the page; your code listens and reacts. Bryn never changes your page for you: you decide what to show.
It announces in three ways (use whichever suits you):
- A JavaScript event,
bryn:personalize: the most flexible option (Recipes 1 to 4). - A global,
window.bryn: read the latest at any time (Recipe 6). data-bryn-*attributes: no JavaScript needed (Recipe 5).
Also available: bryn:updated. It fires at the same moments as bryn:personalize,
just after it, and also tells you which fields changed:
window.addEventListener("bryn:updated", (event) => {
const { payload, changedKeys } = event.detail;
// payload: the latest payload, the same object as window.bryn (and bryn:personalize's event.detail)
// changedKeys: the fields that changed, e.g. ["entity.fitLabel"] when a score lands
});
Both events fire every time the payload changes, not only the first time: Bryn keeps
checking while the visitor is on the page, so a field that arrives later (a score, an
enrichment field, the person's name) fires them again. On the first payload, changedKeys
lists every field present. A check that brings nothing new fires neither. Use
bryn:updated when you care about what changed (Recipe 10).
What you get back
{
status: "resolved", // "pending" | "resolved" | "suppressed" (see below)
entity: { // the COMPANY (always present once resolved)
entityId: "39dbb553-...", // stable id for this company: echo it back on server-side events
companyName: "Acme Corp",
domain: "acme.com",
industry: "...", country: "...",
employees: "38", // exact headcount
employeeRange: "21-50", // headcount bucket: the one to show visitors
stage: "$10M - $20M", // revenue band
website: "acme.com", // enrichment field (see the table below)
fitLabel: "strong", // score fields (see the table below)
fitScore: "92", intentScore: "71", timingScore: "64", compositeScore: "80",
signals: ["identified_account", "your_signal_name"] // what the company has done
},
individual: { // the PERSON, or null if Bryn only knows the company
individualId: "5f2b8c1e-...", // stable id for this person: echo it back on server-side events (see Recipe 7)
firstName: "Alex",
title: "VP Marketing",
functionalArea: "Marketing",
email: "...", lastName: "...", seniority: "...", linkedin: "...",
signals: ["identified_account", "your_signal_name"] // what this person has done
}
}
You choose which fields come back (see below), so your payload
may carry fewer or more of them than this example. Every payload field is a string,
scores included ("92", not 92). identified_account is the one signal
every resolved visitor carries; the rest are the names you
configured in Bryn. The examples on this page
(pricing_page_viewed, demo_abandonment) stand in for yours.
status is one of three values:
pending: Bryn is still working. Show the page as shipped. A pending payload can already carry some company fields, such as the domain, enrichment fields or scores, but don't treat it as a recognized visitor yet.resolved: Bryn knows the visitor.entityis filled in;individualis filled in ornull.suppressed: this visitor matches one of your workspace's suppression rules. It is terminal for the visit: the pixel stops asking and remembers it for the browser session. Show the page as shipped, exactly as forpending. Suppression is enforced before anything reaches the page; see How Bryn works.
Three rules:
individualisnullwhen Bryn knows the company but not the specific person. Always check for it.- Any field can be missing. A field comes back only when your workspace returns it and Bryn has a value for this visitor. Check before you use it, and keep your default.
entityIdandindividualIdare stable identity keys. Capture them and send them back on your server-side events so both sources stitch to the same records: see Server-side events & signing.
individualId is a stable identifier for the resolved person. Hand it to your own analytics
tool to connect this visit to your records (see Recipe 7). It's never shown to the visitor.
Enrichment and score fields
On top of the company and person basics, the pixel can return what Bryn learned about the
company from its enrichment sources, and how Bryn scores the account
(Fit, Intent, Timing, and their Composite). All of them sit under
entity:
| Field | What it holds |
|---|---|
website | The company's website, as a bare host such as acme.com. |
description | A short description of what the company does. |
logoUrl | An https link to the company's logo image. |
companyLinkedin | The company's LinkedIn page URL. |
companyType | The kind of organization, such as Privately held or Public company. |
countryCode | The two-letter code of the company's country, such as US. country stays the full name. |
fitLabel | How well the company matches your ICP: strong, moderate, weak, out_of_icp or unknown. |
fitScore | Fit, as a whole number from 0 to 100. |
intentScore | Intent, as a whole number from 0 to 100. |
timingScore | Timing, as a whole number from 0 to 100. |
compositeScore | The overall score blending fit, intent and timing, from 0 to 100. |
How they behave:
- They often arrive after the first payload. Enrichment and scoring run after Bryn first
recognizes the company, so these fields usually land on a later check. Both events fire
again when they do, and
bryn:updatedlists the new fields inchangedKeys(Recipe 10). - An account Bryn hasn't scored yet has no score fields, and an enrichment field is
present only when a source found a value for it.
fitLabelisunknownwhen Bryn had no usable data to grade fit;fitScoreis then missing. - They can ride a
pendingpayload, but they never make a visitorresolvedon their own.
Choose what comes back
Your workspace decides which fields the pixel returns. Pick them in the Bryn app under
Integrations, then Pixel, then Returned fields: one checkbox per field, grouped
into company, person and scores, with a sample payload that follows your choice. Owners and
admins can change the list; everyone else sees it read-only. You can also read and set it
through the API (GET and PATCH /pixel-settings, see
Get pixel settings) or from an AI assistant with the MCP
tools get_pixel_settings and set_pixel_serve_fields.
- Always returned:
status,entity.entityId,entity.domain,individual.individualIdand bothsignalslists. They aren't settings. - Starting list: new workspaces start with the company and person basics, plus
websiteand the five score fields. Workspaces created before website and scores joined the defaults kept the fields they already returned. Fields Bryn adds later start off until you turn them on. Check Returned fields to see yours. - A field you turn off never leaves Bryn. It isn't in
window.bryn, the events, thedata-bryn-*attributes or your PostHog. Changes reach visitors on their next check. - Anything you turn on is readable by the visitor in their own browser, scores included. Only return what you're comfortable showing.
Recipe 1: Greet a returning person by name
Best when you want a personal touch and only when Bryn actually knows the person.
<script>
window.addEventListener("bryn:personalize", (event) => {
const { status, individual } = event.detail;
if (status !== "resolved" || !individual) return; // not resolved, or company-only
// Example: show a banner element you already have on the page
const banner = document.querySelector("#welcome-banner");
banner.textContent = `Welcome back, ${individual.firstName}!`;
banner.style.display = "block";
});
</script>
Recipe 2: Account-level greeting (company, no name)
Fires far more often than Recipe 1 (Bryn resolves the company for most visits). Keep the tone light: you inferred the company, the visitor didn't tell you who they are.
<script>
window.addEventListener("bryn:personalize", (event) => {
const { status, entity, individual } = event.detail;
if (status !== "resolved" || individual) return; // handle the company-ONLY case here
const banner = document.querySelector("#welcome-banner");
banner.textContent = `Exploring Bryn for the team at ${entity.companyName}? We can help.`;
banner.style.display = "block";
});
</script>
Recipe 3: Person if known, otherwise company (the common combined pattern)
<script>
window.addEventListener("bryn:personalize", (event) => {
const { status, entity, individual } = event.detail;
if (status !== "resolved") return;
const banner = document.querySelector("#welcome-banner");
banner.textContent = individual
? `Welcome back, ${individual.firstName}!` // we know the person
: `Someone from ${entity.companyName} is here. Say hello.`; // company only
banner.style.display = "block";
});
</script>
Recipe 4: Only react when there's buying intent
Being recognized isn't the same as being interested. Gate on a signal so you only act
on visitors doing something meaningful. Your active signals are the ones you
configured in Bryn (e.g. pricing_page_viewed, demo_requested):
use your own names here.
<script>
window.addEventListener("bryn:personalize", (event) => {
const { status, entity } = event.detail;
if (status !== "resolved") return;
// Show a "Talk to sales" nudge only if the company has shown pricing intent
if (entity.signals.includes("pricing_page_viewed")) {
document.querySelector("#talk-to-sales").style.display = "block";
}
});
</script>
Recipe 5: No JavaScript at all, the data-bryn-* attributes
Personalize purely in your HTML, with no script at all. Often the best fit for non-engineers.
Every attribute takes a bryn path into the payload (entity.companyName,
individual.firstName, signals, status, …; a bare companyName checks both sections,
person first).
Fill in text. data-bryn-field sets an element's text; your fallback stays if the
field isn't known for this visitor:
<h1>Welcome back, <span data-bryn-field="entity.companyName">your team</span>!</h1>
<p>Nice to see you, <span data-bryn-field="individual.firstName"></span>.</p>
Show / hide. data-bryn-show / data-bryn-hide reveal or hide an element on a
condition (a path that's present/true, or path=value). Author the default state in
your markup; Bryn toggles the standard hidden attribute once it resolves, so the page is
never broken while cold:
<!-- greet the person only when Bryn knows who they are -->
<div hidden data-bryn-show="individual.firstName">
Welcome back, <span data-bryn-field="individual.firstName"></span>!
</div>
<!-- show a pricing CTA only when the company viewed pricing (use your own signal names) -->
<div hidden data-bryn-show="signals.pricing_page_viewed">Talk to sales about pricing →</div>
<!-- a loading line that disappears once resolved -->
<div data-bryn-hide="status=resolved">Personalizing…</div>
Recipe 6: Read the latest at any time (window.bryn)
If your code runs after Bryn has already resolved (e.g. on a button click), read the global instead of waiting for the event:
<script>
document.querySelector("#book-demo").addEventListener("click", () => {
const visitor = window.bryn; // latest payload, or undefined if not resolved yet
if (visitor?.status === "resolved") {
// e.g. prefill a form or tag your analytics
analytics.track("demo_click", { company: visitor.entity.companyName });
}
});
</script>
Recipe 7: Pass the recognized person into your analytics
PostHog users: Bryn already does this. If PostHog runs on your page and PostHog sync
is on (Integrations, then Pixel, then Setup; on by default), the pixel writes
the payload onto the visitor's PostHog person itself, under a single bryn property. It
attaches to whoever PostHog already thinks the visitor is and never changes their identity.
Only the fields your workspace returns are written.
Write code only when you want more than that: to identify the person in PostHog by Bryn's
stable identifier, individual.individualId, or to send the payload to another analytics
tool. Add it alongside your existing analytics snippet:
<script>
window.addEventListener("bryn:personalize", (event) => {
const { status, individual } = event.detail;
if (status !== "resolved" || !individual) return; // only when Bryn knows the person
// Hand the Bryn identifier and the full payload to your analytics tool, e.g. PostHog:
posthog.identify(individual.individualId, { bryn: event.detail });
});
</script>
Your listener runs before the pixel's own PostHog write, so that write lands on the person you just identified.
Only act when there's a person: when individual is null, Bryn knows the company but not
who the visitor is, so there's nothing to identify.
Recipe 8: Follow up on an abandoned demo
A worked example of one signal, end to end. When someone starts your product demo and leaves before finishing, you can offer to pick up where they left off on their next visit.
First, tell Bryn about the abandon. Your analytics sends a "demo abandoned" event (for
example demo_abandoned) to PostHog; deciding what counts as abandoned is up to your
product. In Bryn, go to Configuration, then Signal Mapping, and
map it: when demo_abandoned happens, raise demo_abandonment.
That event now becomes a demo_abandonment signal on the visitor, and it rides the pixel
like any other signal.
Then react to it on your site. Gate a call-out on the signal, just like Recipe 4:
<script>
window.addEventListener("bryn:personalize", (event) => {
const { status, entity, individual } = event.detail;
if (status !== "resolved") return;
if (!entity.signals.includes("demo_abandonment")) return; // only unfinished demos
const banner = document.querySelector("#pick-up-banner");
banner.textContent = individual
? `Welcome back, ${individual.firstName}. Want to finish the demo?`
: `Looks like your team started the demo. Want a hand finishing it?`;
banner.style.display = "block";
});
</script>
Prefer no JavaScript? The data-bryn-* attributes work too: data-bryn-show accepts a
signals.<type> membership check:
<!-- shown only for visitors with an active demo_abandonment signal -->
<div hidden data-bryn-show="signals.demo_abandonment">
Pick up where you left off in the demo →
</div>
Bryn can also stage a reviewed follow-up for these visitors: the "Follow up when a demo is left unfinished" Play on your Plays screen. Nothing sends until you turn it on and approve.
Recipe 9: Read delivery status
bryn:personalize tells you about the visitor; bryn:delivery tells you about the pixel
itself. Listen to it (or read window.__brynPixelDiagnostics) to confirm the beacon left
the browser and to watch the personalization leg poll, pause and stop:
<script>
window.addEventListener("bryn:delivery", (event) => {
const { publish, serve } = event.detail;
console.log(`beacon: ${publish.state}, serve: ${serve.loopState}`, serve.stopReason ?? "");
});
</script>
Field values are listed in Check it in the browser.
Recipe 10: React when a score or enrichment field lands
Scores and enrichment fields often arrive a little after the visitor is recognized (see
Enrichment and score fields). Use changedKeys to act at
the moment they land. Turn on Fit grade in Returned fields
first.
<script>
window.addEventListener("bryn:updated", (event) => {
const { payload, changedKeys } = event.detail;
if (!changedKeys.includes("entity.fitLabel")) return; // only when the fit grade changes
// Show the enterprise call-out only to strong-fit companies
const callout = document.querySelector("#enterprise-callout");
callout.style.display = payload.entity?.fitLabel === "strong" ? "block" : "none";
});
</script>
The no-JavaScript version, with the same data-bryn-show condition as Recipe 5:
<!-- shown only when Bryn grades the company a strong fit -->
<div hidden data-bryn-show="entity.fitLabel=strong">
Talk to us about an enterprise plan →
</div>
The visitor can read their own fit grade and scores in the browser, so use them to choose what to show, not as text on the page.
Good habits
- Always keep a sensible default. A cold or unknown visitor gets
status: "pending", and a suppressed one getsstatus: "suppressed": your page must look right before Bryn does anything. Never hide content behind personalization. - Check
individualfornull. Company-only is the common case; don't assume a name. - Personalize, don't startle. For company-only (inferred from network, not self-identified), keep it helpful and low-key rather than "we know exactly who you are."
- Return only what you use. Every field your workspace returns, scores included, is readable by the visitor in their own browser, and written to their PostHog person while PostHog sync is on. Turn off what your pages don't need in Returned fields.