Asks where a link should open — before it opens.
When another application hands a link to your browser — Slack, Outlook, Teams, a terminal — it lands wherever the browser felt like putting it. Usually the wrong identity. linkward stops it first and asks:
- open it in this container,
- open it without one,
- or copy the link and open nothing at all.
On Firefox the request is stopped before it is sent, so the page is never fetched, no cookie is set, and no session is started in the wrong container.
Answer once for a host and tick remember, and it stops asking about that one. Those live in the settings page, where you can move a host to another container or drop it to be asked again — and they follow your browser account, because they are stored by the container's name, not by the id the browser minted for it on one machine. A rule for a container this browser does not have is never guessed at: it asks.
flowchart TD
A["A link arrives<br/>from outside the browser"] --> B{"Could this be<br/>an external link?"}
B -->|"a page opened it,<br/>or you were already<br/>browsing in that tab"| L["Leave it alone"]
B -->|"nothing in the browser<br/>can account for it"| C{"On the<br/>never-ask list?"}
C -->|yes| L
C -->|no| D{"Answered for<br/>this host before?"}
D -->|"yes, and that container<br/>still exists here"| E["Open it there.<br/>No question."]
D -->|"yes, but it said<br/>no container"| L
D -->|"no — or the container<br/>is gone or renamed"| F["Ask"]
F --> G["Open in a container"]
F --> H["Open without one"]
F --> I["Copy it, open nothing"]
F --> J["Close the tab"]
style F fill:#2f6feb,color:#fff
style E fill:#eaf1ff,color:#16181d
style L fill:#8080801f,color:#8a8f98
Every branch that is not the blue one errs towards not interrupting you. The one that matters most is "the container is gone or renamed": a remembered rule that cannot be resolved on this machine asks rather than guessing, because opening the wrong identity is the failure this exists to prevent.
The picker interrupts something, and it is seen many times a day, so it does not insist on the mouse:
| Key | |
|---|---|
1–9 |
open in the nth container |
Enter |
the one offered first — the container used last, or plainly if there are none |
c |
copy the link, open nothing |
Esc |
close the tab |
Anything with ⌘, Ctrl or Alt held is left alone: those belong to the browser, and taking ⌘C from somebody copying the address off the page would be its own small betrayal.
The page says how long ago the tab appeared and what it could not account for:
Asked because this tab was opened 2.4s ago and nothing in the browser accounts for it — no page opened it, and you had not been browsing in it.
This is not decoration. The detection is a process of exclusion, so when it gets one wrong there has to be something to point at — for whoever was interrupted, and for whoever they report it to.
Firefox does not tell an extension that a link came from another
application. It knows — isExternal, in BrowserDOMWindow.sys.mjs — and it
does not expose it. What an extension sees for a link handed over by Slack is
transitionType: "link": byte for byte the same as a click on a web page.
Mozilla's own bug for this
has been open since 2022.
So linkward does not detect external links. It excludes everything it can positively identify as something else — a tab with an opener, a navigation a document started, a tab you have already been browsing in, and a tab that started on one of the browser's own pages rather than on a link — and asks about what is left. Every rule errs towards not asking, because interrupting a link you clicked yourself is the failure that gets an add-on uninstalled.
The rules are one small, pure file — src/lib/candidates.js
— and it is the most heavily tested thing in the repo.
Those exclusions leave four things that look identical to an extension — a
new tab, an http(s) address, no opener, navigating within a few seconds:
| What actually happened | |
|---|---|
| a link handed over by Slack, Outlook, a terminal | the one linkward wants |
| a bookmark | must be left alone |
| an address typed or pasted into a new tab | must be left alone |
| a search from the address bar | must be left alone |
The field that names them is transitionType — auto_bookmark, typed,
link — and it exists only on webNavigation.onCommitted, which fires
after the request has already gone. The two builds therefore answer this
differently, each with the best evidence its browser actually offers:
flowchart TD
S["New tab, http(s) address,<br/>no opener, first navigation"] --> B{"Which build?"}
B -->|Chromium| T{"What does<br/>transitionType say?"}
T -->|"typed, generated, auto_bookmark,<br/>keyword, reload, start_page …<br/>or the from_address_bar qualifier"| IN["Started in here"]
T -->|"link, or anything unknown"| OUT["Handed over by<br/>another application"]
B -->|Firefox| Q{"How long had the<br/>browser been in front<br/>when the tab appeared?"}
Q -->|"longer than 1.5s"| IN
Q -->|"it had just come to the front"| OUT
Q -->|"still behind something else,<br/>or nothing recorded"| OUT
IN --> L["Left alone"]
OUT --> P["Ask where it should open"]
L -.->|"setting: ask about<br/>these too"| P
style P fill:#2f6feb,color:#fff
style L fill:#eaf1ff,color:#16181d
Chromium waits for onCommitted and believes it. Nothing on that side could
hold the request anyway — MV3 removed blocking webRequest, so the earlier
onBeforeNavigate only ever raced the navigation it was reacting to. Waiting
costs a page flash and buys an exact answer, which is a good trade when the
alternative is guessing. The list in candidates.js names what to exclude,
so a Chromium that invents a new transition leaves linkward asking rather than
going quiet on a string nobody has looked at.
Firefox holds the request before it is sent — the reason to use that build —
and at that moment the browser has said nothing about how the navigation
started. So it falls back to the one question that is answerable beforehand
and that differs between the four: was the browser already in front? A
bookmark, the address bar, a history entry are all somebody already in the
browser, using it. A link from another application is by definition somebody who
was not: they were in Slack, and the browser is being brought to the front to
receive it.
windows.onFocusChanged
says which just happened, and needs no permission.
That fallback is a proxy, not the fact, and it is wrong in two places. Both are named here rather than left to be discovered. Neither applies to the Chromium build, which does not ask the question at all:
- A link that arrives without raising the browser —
open -g, a cron job, a script — looks like something you did in here, and is not asked about. - Come to the browser and open a bookmark inside a second and a half and you are asked, as before — including when you only moved between two browser windows. That last one is a choice: not counting a window switch would mean keeping a timestamp across a focus change, and the loss of focus is the half neither browser reports reliably — Firefox does not fire it when you leave a non-browser window for another application, open since 2017, and Chrome has been reported not to fire it on minimise. One missed report and every later hand-off would look like something you did in here and never be asked about again. Bounded and annoying beats unbounded and silent.
On Firefox for Android there are no windows to focus, so the API is absent and none of this applies: linkward asks there exactly as it did before.
Both err towards asking, which is the direction every other rule in that
file errs in too. And if the browser will not say — no windows API, no
storage.session to keep the answer in across an event page the browser tears
down when it likes — the answer is "we do not know", which behaves exactly like
linkward did before any of this: it asks.
The proxy shipped first, on both builds, and the report that followed is why Chromium no longer uses it: copy a link somewhere, switch to the browser, paste it into the address bar — a tab created a second after the browser came to the front, which is exactly what a hand-off looks like. No grace period separates those two, which is what "proxy" means.
Settings → Ask about bookmarks and addresses I type myself turns the whole rule off, for people who would rather be interrupted than miss one. It is off.
sequenceDiagram
participant App as Slack, Outlook, …
participant B as Browser
participant L as linkward
participant S as The server
rect rgba(47, 111, 235, 0.12)
Note over App,S: Firefox — before the request is sent
App->>B: open this link
B->>L: onBeforeRequest (blocking)
L-->>B: hold it
L->>L: ask, or apply a remembered rule
L->>B: open in the chosen container
B->>S: first and only request
end
rect rgba(128, 128, 128, 0.12)
Note over App,S: Chrome — it cannot be held, so it waits to be sure
App->>B: open this link
B->>S: request goes — MV3 has no blocking form,<br/>so nothing here could stop it
B->>L: onCommitted, with transitionType
Note right of L: waits for this on purpose:<br/>it is the only event that says<br/>HOW the navigation started
L->>B: turn the tab around
Note right of L: the page may flash —<br/>and there are no<br/>containers to offer
end
Two things are different, and the picker says both on the page rather than hiding them:
- Chrome MV3 removed blocking
webRequest, so linkward can only turn the tab around once the navigation has begun. The page may flash. Since nothing there can hold a request either way, the Chrome build waits foronCommittedand takes the browser's owntransitionTyperather than guessing — see bookmarks and typed addresses. - No extension can open a tab in another Chrome profile.
tabs.createtakes a window to aim at and no profile, because an extension in one profile cannot see that the others exist. A profile can only be chosen before the browser is handed the link — what actually works.
Containers are a Firefox feature. The Chrome build ships without the container permissions at all.
Nothing is granted at install. Everything below is requested from the options page, in one call, when you switch the feature on — and handed back when you switch it off.
| Permission | Why |
|---|---|
<all_urls> |
The only one you actually see: "Access your data for all websites". It is what lets linkward stop a page before it loads. Without it there is nothing to intercept. |
webRequest, webRequestBlocking |
Firefox only. Silently granted. Stopping the request rather than reacting after it. |
webNavigation |
Chrome only. There is no blocking form there. |
contextualIdentities, cookies |
Firefox only, required by the manifest: reading your containers' names and colours, and honouring a cookieStoreId when opening. |
linkward reads no page content, stores nothing about where you go, and sends nothing anywhere. See PRIVACY.md.
In review at both stores.
npm install
npm run build:firefox # dist-firefox/
npm run build # dist/Firefox: about:debugging#/runtime/this-firefox → Load Temporary Add-on →
dist-firefox/manifest.json.
Chrome: chrome://extensions → Developer mode → Load unpacked → dist/.
Then switch it on. linkward opens its settings page by itself the first time, and it does nothing at all until the switch there is on: the access it needs is asked for at that moment, not at install, and a browser only grants it on a click. Before that, links open exactly as they always did — which looks identical to a broken install, so the page says so in as many words.
Settings → Settings file → Export writes the settings that travel, and the remembered hosts, as JSON — two fields are left out on purpose, see below. Import replaces both. The shape is stable and small enough to edit by hand:
{
"format": "linkward-settings",
"version": 1,
"settings": {
"neverAsk": ["intranet.example", "mail.example"],
"rememberPrompt": "ticked",
"askInternal": false
},
"rules": {
"docs.example.com": {
"container": "Work",
"cookieStoreId": "firefox-container-2"
},
"shop.example": {
"container": null,
"cookieStoreId": "",
"plain": true
}
}
}| Field | Meaning |
|---|---|
format |
Always linkward-settings. A file without it is refused, rather than half-read. |
version |
1. A higher number is refused: a newer linkward may mean something else by these fields. A lower one is read. |
settings.neverAsk |
Hosts to leave alone entirely. Subdomains included. Trimmed, de-duplicated and sorted on import. |
settings.rememberPrompt |
What the picker does with its "remember this host" box: hidden, unticked or ticked. |
settings.askInternal |
true to ask about bookmarks and addresses you type yourself as well — see above. Anything but true reads as off. |
rules |
One entry per remembered host, keyed by the host in lower case. |
rules[host].container |
The container's name. This is what a rule is matched on. |
rules[host].cookieStoreId |
The id that name had on the machine that wrote the file. Only ever used for a rule that carries no name — see below. |
rules[host].plain |
true means "always open this host with no container". Absent otherwise. |
Two fields are deliberately not in the file:
enabledstands for a permission a browser only grants on a click, and a file cannot click. Importing it would produce a page claiming the feature is on while nothing is listening.lastContaineris acookieStoreId, which means a different container on the machine the file arrives at.
flowchart LR
R["rule:<br/>container 'Work'<br/>id firefox-container-2"] --> N{"A container<br/>called 'Work'<br/>on this machine?"}
N -->|yes| U["Open there"]
N -->|no| I{"Does the rule<br/>carry a name<br/>at all?"}
I -->|"yes — so the name<br/>was tried and missed"| A["Ask"]
I -->|"no: written before<br/>names were stored"| C{"Does that id<br/>still exist here?"}
C -->|yes| U
C -->|no| A
style A fill:#2f6feb,color:#fff
style U fill:#eaf1ff,color:#16181d
A cookieStoreId is minted per profile and reused. Syncing one alone would
mean the same rule opening a different container on another machine — a rule
for Admin whose old id now belongs to Work would open Work, silently. So
the id is a fallback for legacy rules only, and a named rule that cannot be
matched by name asks.
A link clicked on a page is deliberately never intercepted, so demonstrating this from inside the browser demonstrates nothing. The script hands links to the operating system instead, the way a mail client does:
node scripts/demo.mjs # one link
node scripts/demo.mjs --all # the tour, one at a time
node scripts/demo.mjs --browser=Firefox # ignore the default browser
node scripts/demo.mjs https://your.intranet/pageNo extension can open a tab in another Chromium profile. Not linkward, not anything — this is worth stating plainly, because "not supported yet" would imply it is coming.
chrome.tabs.create takes a window to aim at and nothing else; there is no
profile parameter, and chrome.windows.create has none either. The reason is
below the API: an extension installed in one profile cannot see that the others
exist. Profiles are the isolation boundary, and extensions live inside one.
The same holds in Vivaldi, and for its Workspaces — Vivaldi's own forum staff put per-workspace extension settings down to "Vivaldi/Chromium core restrictions", and routing a URL to a profile by rule is an open feature request, not an API.
So a profile can only be chosen before the browser is handed the link, by whatever hands it over:
| Route | What it costs |
|---|---|
Command line — vivaldi --profile-directory="Profile 2" <url> |
nothing; works today |
| An OS-level router — Choosy, BrowserOSaurus, Velja | a second app, set as the default browser |
| A native-messaging host | a binary and a manifest per machine, installed outside the browser, and the kind of thing store reviewers read closely |
It can — and that is exactly what the first two routes do. The catch is that
an extension is not a process. It is JavaScript in a sandbox with no way to
start one, so every route to vivaldi --profile-directory=… runs through
something registered with the operating system.
The obvious shortcut is a custom URL scheme: register linkward:// with the OS,
have the picker navigate to it, let the OS launch the handler. It works, and it
saves the native-messaging manifest — but not the installer. In both models
the native application is installed by the OS's own machinery rather than by the
browser, so a user still has to install something.
And it would be worse to use. Chromium's list of schemes that may launch without
asking is compiled in, not built from what is on the machine, so a custom scheme
prompts every single time: linkward asks where the link should go, then
Chromium asks whether it may open linkward. Two dialogs for one link. Only an
enterprise policy (AutoLaunchProtocolsFromOrigins) removes the second, and
that is not something an extension can ship.
So linkward ships none of these on purpose. It is an extension that needs one switch and no installer — a native host would trade that for a feature only Chromium users could ever get, while Firefox already does the whole job inside the browser, with containers, and no helper at all.
scripts/demo.mjs takes the first route:
node scripts/demo.mjs --browser=Vivaldi --profile="Profile 1" https://example.com/
# which is: open -a Vivaldi -n --args --profile-directory="Profile 1" <url>Profile directories are Default, Profile 1, Profile 2, … — the folder
names under the browser's user-data directory, not the names shown in its UI.
Two more things follow from profiles being sealed off, and they surprise people:
- Extensions are per profile. linkward installed in one is not installed in the other; the second profile opens links exactly as it did before.
- Installing it in both does not let it move a link between them. It only means the question gets asked in whichever profile the link lands in.
There are extensions that bring Firefox-style containers to Chromium — SessionBox, Cookie Profile Switcher, and several free imitations of Mozilla's Multi-Account Containers. They are real and some of them are good, but they are not an answer to the question on this page.
What they give you is isolated sessions inside one profile: a per-tab cookie
jar, swapped through the cookies API, so you can be signed in twice to the
same site. What they cannot give you is another profile, because that is the
boundary described above and no extension crosses it.
The isolation is also thinner than Firefox's. Cookies and site storage are separated; the fingerprint, the IP and the browser build are not, so every session still looks like the same machine. Firefox containers have the same ceiling — neither changes your fingerprint — but they are implemented by the browser rather than reconstructed on top of it, and users report the extension kind losing sessions after a while.
If you use one, the two get along by staying out of each other's way: put the hosts it manages on the never-ask list, and linkward never reaches them. There is nothing to wire up and no setting connecting them — those hosts simply never become a question.
That is the whole of the interoperability, and deliberately so. SessionBox has
no externally_connectable surface and no deep-link scheme; its own support
pages say the extension has no API. The one supported route is an npm toolkit
driven by an API key from a local Node process — the helper-process cost again,
with a third party and a key on top.
linkward does not do this and will not. Holding somebody's cookie jars is a different product with a different failure mode: when it goes wrong, you are silently signed in as the wrong person, which is precisely what this exists to prevent.
Firefox has none of this trouble: containers live inside one profile, so the add-on really can open the link in the right one.
Containers are built into Firefox — linkward reads them through
contextualIdentities and needs nothing else. What Firefox does not give you is
an obvious way to make one, which is why almost everybody has
Multi-Account Containers,
Mozilla's own add-on, installed to create, name and colour them.
linkward does not depend on it and does not talk to it. It lists whatever containers exist, whoever made them, and there is no setting connecting the two. Install it, make the containers you want, and they appear in the picker.
The picker says as much when it finds none, rather than reporting an empty list and leaving you to work out why.
npm test # vitest
npm run ci # lint + format + coverage + package, the same gate CI runsNo bundler and no runtime dependencies: the source under src/ is the
artifact. An extension that intercepts navigations should be readable end to end
by whoever reviews it.
Two stores, one release workflow, and a setup script that creates the Chrome Web Store item and writes every secret so nobody has to click through two dashboards. What is left is the part no API can do — the store listing and the permission justifications.
Sebastian Winterberger · MIT