Launch bundles — personalize
Personalize a Linky
One Linky, different tabs per viewer. Attach a resolutionPolicyand Linky evaluates it against the viewer's identity on every click to /l/[slug].
Policy shape
type ResolutionPolicy = {
version: 1;
rules: Rule[];
};
type Rule = {
id: string; // minted for you if you omit it
name?: string; // your private label; viewer sees it only if showBadge
when: Condition; // what has to be true for this rule to match
tabs: { url: string; note?: string }[];
stopOnMatch: boolean; // default: true — first match wins and evaluation stops
showBadge: boolean; // default: false — rule name stays private
};Omit id and Linky mints one for you. Omit stopOnMatch and the first match wins; omit showBadge and the rule name stays private to you.
Viewer fields
Single-value fields: email, emailDomain, userId, githubLogin, googleEmail. Each holds one value per viewer.
Plural fields (hold multiple values per viewer): orgIds, orgSlugs. These are the viewer's full organization membership list — not just whichever workspace they have active.
in is the only operator you can use against a plural field. equals, endsWith, and exists against orgIds / orgSlugs are rejected with a 400 — use in with a single-element value array instead.
What each field means and where its value comes from is documented on the Identity page.
Operators
| Op | Example | Notes |
|---|---|---|
always | { "op": "always" } | Matches every viewer. Useful as a catch-all final rule. |
anonymous | { "op": "anonymous" } | Matches viewers who aren't signed into Linky. |
signedIn | { "op": "signedIn" } | Matches any signed-in viewer, regardless of identity. |
equals | { "op": "equals", "field": "email", "value": "alice@acme.com" } | Exact-match on a single-value field. |
in | { "op": "in", "field": "orgSlugs", "value": ["acme", "acme-staging"] } | Matches if the viewer's org membership list overlaps the value list, or — on a single-value field — if the viewer's value is in the list. |
endsWith | { "op": "endsWith", "field": "emailDomain", "value": "acme.com" } | Suffix match on a single-value field. |
exists | { "op": "exists", "field": "githubLogin" } | Matches when the viewer has any value for that field. |
and | { "op": "and", "of": [ { "op": "signedIn" }, { "op": "endsWith", "field": "emailDomain", "value": "acme.com" } ] } | All branches must match. |
or | { "op": "or", "of": [ { "op": "equals", "field": "email", "value": "alice@acme.com" }, { "op": "equals", "field": "email", "value": "bob@acme.com" } ] } | Any branch matches. |
not | { "op": "not", "of": [ { "op": "anonymous" } ] } | Invert a single nested condition. |
How Linky evaluates your rules
- Rules are checked top-to-bottom. With
stopOnMatch: true(the default) the first matching rule fires and the viewer gets that rule's tabs. WithstopOnMatch: falsethe rule's tabs are appended and evaluation continues down the list. - A rule that references a field the viewer doesn't have just fails to match — it never errors.
- An empty or missing policy skips evaluation entirely; every viewer gets the public tab set.
- Rule names are private by default. The viewer only sees the matched rule's
namewhen you setshowBadge: trueon that rule. - When nothing matches, the viewer always gets the public tab set you supplied as
urls.
Limits
- Up to 50 rules per policy.
- Up to 20 tabs per rule.
- Condition nesting depth ≤ 4.
- String values are capped at 512 characters.
The Limits page has the full list alongside plan and rate-limit defaults.
How to author
Dashboard — the Personalize panel at /dashboard/links/[slug] has two modes:
- Structured — canned operator presets (
equals email,endsWith emailDomain,in orgSlugs,anonymous,signedIn) plus a Preview as control that runs your rules against a sample viewer exactly the way/l/[slug]would. - Advanced (JSON) — raw policy with validation on Apply. Use this for compound
and/or/not.
API / CLI / SDK — attach at create time (see Create) or edit later via PATCH /api/links/:slug (see API). The CLI takes a JSON file with --policy (see CLI), and the SDK takes an object under resolutionPolicy (see SDK).
Worked example
Two rules: signed-in Acme engineers get a personal Linear + GitHub queue with a visible badge; members of partner-co get a separate partner dashboard. Everyone else falls through to the public tab set.
{
"version": 1,
"rules": [
{
"name": "Engineering team",
"showBadge": true,
"when": {
"op": "and",
"of": [
{ "op": "signedIn" },
{ "op": "endsWith", "field": "emailDomain", "value": "acme.com" }
]
},
"tabs": [
{ "url": "https://linear.app/acme/my-issues", "note": "Your queue" },
{ "url": "https://github.com/acme/app/pulls?q=author:@me" }
]
},
{
"name": "Partner access",
"when": {
"op": "in",
"field": "orgSlugs",
"value": ["partner-co"]
},
"tabs": [
{ "url": "https://partners.acme.com/dashboard" }
]
}
]
}