Dev.to AI 🤖 Ai 👁 0 📖 5 min read

How I built a managed ChatGPT browser for my desktop AI companion: no pairing link, no borrowed tab

How I built a managed ChatGPT browser for my desktop AI companion: no pairing link, no borrowed tab The worst setup flow I shipped was the one that worked. To connect my desktop companion to ChatGPT, you copied a pairi

How I built a managed ChatGPT browser for my desktop AI companion: no pairing link, no borrowed tab

The worst setup flow I shipped was the one that worked. To connect my desktop companion to ChatGPT, you copied a pairing link out of the app, pasted it into your browser, and hoped the two ends agreed about who they were talking to. It worked — and every user who did it had to understand my architecture to do it. A pairing link is a credential the user carries by hand, and a hand-carried credential is a credential that gets pasted into the wrong place, screenshotted into a bug report, or lost between two windows.

So I rebuilt the connection as a managed browser: the app owns the browser end to end. Two modes, no link to copy. ANKITA window opens a compact window inside the app itself, with your sign-in saved. My Chrome uses the Chrome profile you are already signed into, with the bundled companion extension installed once. The old explicit-pairing flow still exists as a legacy mode, but it is no longer the way in.

The decision first: the app finds the browser, never the other way round

The core question was who discovers whom. If the browser extension has to find the app, it needs an address, and an address the user types is an address the user gets wrong. So the companion extension discovers the app instead: it greets every candidate loopback port and picks the one that answers with the right app id, the right protocol version, and a host id.

That is the whole of desktop/chatgpt-companion/discovery.mjs — 33 lines. Each greeting gets a 1000 ms timeout, and the candidates are greeted concurrently, so trying several ports costs no more than trying one. A preferred address and host id are tried first, so reconnects are one request. If more than one app answers, discovery refuses: "More than one ANKITA app is waiting. Close the extra app, then connect again." If nobody answers, it returns null instead of guessing.

Then comes the provision step, and this is where the ownership rule lives. The extension posts its browser id, extension id, and the host id it just verified, and the app answers with a key — but only if the version and host id match exactly, and only if the browser is not already owned. A 409 means "this connection is stopped or belongs to another browser," and the code never silently replaces the owner. The key itself is generated fresh per connection: randomBytes(GATEWAY_TOKEN_BYTES) encoded as base64url, in startConnection.

A port is not an identity

The subtle bug this design is built around: a loopback port is not an identity. Ports get reused, apps restart, and two profiles of the same app can both be listening. So the managed browser carries a stable host id, minted once with randomUUID() and stored in the app's own settings, with a comment that says it plainly: "an OS port alone cannot identify the intended local app."

Sessions are stored per owner, not per app. In builtin mode, openCompanion reads the saved session for that teammate's owner from the app settings; if the session is marked desired, it reuses the same browser id and opening id, so reconnecting lands you back in the same conversation instead of a fresh one. In Chrome mode, the same saved session drives an auto-connect, and the app launches Chrome itself if it is absent — openChromeIfAbsent — rather than asking you to open the right profile by hand.

And the sign-in itself is fenced. The builtin window only allows navigation to a small allowlist of login hosts; everything else is the companion's business, not a general browser. That window is a ChatGPT window, not a browser you happen to have open.

The rule I am proudest of: a saved sign-in never launches anything on startup

This is the one I would have got wrong a year ago. On startup, the app reads the saved pairing — and then, deliberately, does almost nothing with it. If the mode is builtin, it returns immediately: "Saved sign-in does not authorize a browser/action launch on app startup." If a saved Chrome pairing exists, recovery opens only its remembered loopback reader. The comment in init() is explicit: recovery "never Chrome, a helper or an action transport."

The reasoning is simple once you say it out loud: a credential surviving a restart is not consent to act. The user signed in last Tuesday; that does not mean the companion may start driving their ChatGPT account on Thursday morning before they have even looked at the screen. Saved state authorizes recovery of the connection, not the resumption of work. Work resumes when the user sends something, and even then the send path waits for the bridge to report ready, with a deadline after which it tells you to open your ChatGPT window and finish signing in, then send again.

The same refusal runs through the rest of the connection lifecycle. Switching browser or teammate while a reply is pending is refused until you resume the saved reply or confirm Stop. A setup that is cancelled mid-flight closes everything it opened rather than leaving a half-connected bridge behind. A connection that changes owner mid-setup throws "The ChatGPT connection was closed or changed during setup" instead of attaching the new window to the old conversation.

What this does not solve

Honest limits, because the managed browser has sharp edges. Discovery only works on loopback — if the companion cannot reach the app on the same machine, there is no fallback, and the failure message is just "could not connect." The Chrome mode depends on the companion extension staying installed in exactly one profile; the setup guide's warning is blunt: "The companion cannot discover the internal-only ANKITA window," and the reverse is equally true. Two apps waiting at once is a hard error, not a chooser UI — I picked a clear failure over a picker that could bind your conversation to the wrong app. And the builtin window is a single-purpose window: it will never be your general browser, and sign-in hosts are allowlisted, so an unexpected login redirect fails closed instead of opening a door.

The code lives in desktop/electron/chatgpt-browser.mjs (488 lines, the DesktopChatGptBrowser class), desktop/chatgpt-companion/discovery.mjs, and desktop/electron/chatgpt-window.mjs. The modes are one frozen object in desktop/shared/chatgpt-browser.mjs: builtin, chrome, legacy.

Ankita is open source (MIT) and local-first — your models, your machine, your sign-in. The repo is at https://github.com/akyourowngames/A.N.K.I.T.A, and the site is https://ankita-sable.vercel.app.

Tell me I am wrong: the "saved sign-in never launches anything" rule costs a click on every restart. Is that the right price for never acting without the user in the room — or would you rather the companion quietly reconnect and only ask before it sends? And where exactly would this break first: the loopback discovery, the host-id check, or the per-owner session store?

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.