Most browser agents start every turn from zero.

New browser.

No cookies.

No tabs.

Log in again.

That is slow, but it is honest. The agent never thinks it is still signed in when it is not.

Persistent browsers change that.

On October 13, 2026, Cloudflare shipped a new persistent browser tool in the Agents SDK. The changelog says:

Your agent can open a website, inspect a page, and come back to it with its tabs, cookies, and login session intact while the browser session remains alive.

That last clause is the part I keep thinking about.

While the browser session remains alive.

The same changelog spells out what happens when it does not:

Its default idle timeout is 10 minutes. If the session expires or crashes, the tool creates a fresh browser and reports restarted: true. The old tabs and login state are gone.

And one more line that matters a lot:

Each Pi conversation gets its own active tab by default. Cookies and logins remain shared within the browser.

This lands one day after Meta's Muse Wonder launch post for developers (October 12, 2026) pitched Muse Wonder Full on "long-running agents, and advanced computer-use capabilities".

Different companies. Same direction.

Agents are getting computers that stick around.

A browser that remembers is a feature. An agent that assumes it remembers is a bug.

So let's build a tiny version of the part around the browser.

By the end, you'll run one command:

npx tsx session.ts 

And watch a session keeper block one conversation from borrowing another's login, notice a restart after an idle sweep, stop a refund that assumed a dead login, and catch a crash the same way.

This is my small model of the idea. It is not how Cloudflare's browser tool works inside. Every site, timing, and grant below is an example input. The 10 minute idle limit in the demo mirrors Cloudflare's documented default, but the fake clock is mine.

Table of Contents

  1. The Problem
  2. Step 1: A Fake Browser and a Clock
  3. Step 2: Ensure the Browser Every Turn
  4. Step 3: Tabs per Conversation, Logins per Browser
  5. Step 4: Scope the Shared Login
  6. Step 5: Sweep Idle Sessions
  7. Step 6: Run It
  8. Where It Breaks Down
  9. The Bigger Idea

1. The Problem

A persistent browser gives an agent three things it did not have before.

Tabs it can come back to.

Cookies that survive the turn.

Logins a human only has to finish once.

It also gives the agent three new ways to be wrong.

It can think a login still exists after the browser restarted.

It can use a login that a different conversation created.

It can leave a signed-in browser running long after anyone needed it.

None of those are browser bugs. They are planning bugs.

The browser can tell you it restarted. Your loop has to listen.

2. Step 1: A Fake Browser and a Clock

We need a browser we can kill on purpose and a clock we can fast forward.

Save the following TypeScript blocks in order as session.ts.

// tiny-session-keeper: a persistent browser session an agent can trust. // Fake browser, fake clock, fake sites. No API key. No network. type Clock = { now: number }; const clock: Clock = { now: 0 }; const MIN = 60_000; const at = () => `t+${String(Math.round(clock.now / MIN)).padStart(2, "0")}m`; type Tab = { conversation: string; url: string }; class FakeBrowser { readonly id: string; cookies = new Map<string, string>(); // site -> session cookie tabs = new Map<string, Tab>(); // conversation -> active tab lastUsed: number; alive = true; constructor(id: string) { this.id = id; this.lastUsed = clock.now; } } 

cookies is keyed by site. tabs is keyed by conversation.

That split is copied from the changelog on purpose. One active tab per conversation. One shared cookie jar per browser.

lastUsed is the only thing the idle timeout cares about.

3. Step 2: Ensure the Browser Every Turn

This is the most important method in the file.

// Every login, with who did it and when. type Login = { site: string; by: string; at: number }; type Grant = { conversation: string; site: string }; type Ensure = { browser: FakeBrowser; restarted: boolean; lost: string[] }; class SessionKeeper { private browser: FakeBrowser | null = null; private count = 0; logins: Login[] = []; constructor( private idleMs: number, private grants: Grant[], ) {} // Call this at the start of every turn. Never assume the old browser. ensure(): Ensure { const b = this.browser; const idle = b ? clock.now - b.lastUsed : 0; if (b && b.alive && idle <= this.idleMs) { b.lastUsed = clock.now; return { browser: b, restarted: false, lost: [] }; } const lost = b ? [...b.cookies.keys()].map((s) => `login:${s}`) : []; if (b) lost.push(...[...b.tabs.keys()].map((c) => `tab:${c}`)); this.count += 1; this.browser = new FakeBrowser(`browser-${this.count}`); if (b) this.logins = []; // the old login state is gone return { browser: this.browser, restarted: b !== null, lost }; } 

Every turn starts with ensure().

If the old browser is alive and inside the idle window, you get it back and restarted is false.

Otherwise you get a fresh browser, restarted: true, and a lost list that names exactly what disappeared: which logins, which tabs.

That list is the point. "Restarted" is a boolean. "You lost your billing login and the billing tab" is something a planner can act on.

Notice that the keeper also clears its own login log. Old evidence about a dead browser is not evidence.

4. Step 3: Tabs per Conversation, Logins per Browser

Two small helpers.

 tab(conversation: string, url?: string): Tab { const b = this.browser!; const existing = b.tabs.get(conversation); if (existing && !url) return existing; const t = { conversation, url: url ?? "about:blank" }; b.tabs.set(conversation, t); return t; } logIn(conversation: string, site: string): void { this.browser!.cookies.set(site, `sess-${site}-${clock.now}`); this.logins.push({ site, by: conversation, at: clock.now }); } 

tab() returns the conversation's existing tab unless you navigate somewhere new.

logIn() sets the cookie and records who did it and when. In a real system that step is a human finishing a sign-in or MFA. Cloudflare's browser docs describe pausing a run for exactly that, then resuming with the same tabs and cookies.

The record is the part people skip. Without it you cannot answer the next question.

5. Step 4: Scope the Shared Login

Here is the risk hiding in "cookies and logins remain shared within the browser."

If the billing conversation logs in, the research conversation is now logged in too. Same browser. Same cookie jar.

 // Cookies are shared inside the browser. Permission should not be. mayUseLogin(conversation: string, site: string): string | null { if (!this.browser!.cookies.has(site)) return `not logged in to ${site}`; const ok = this.grants.some((g) => g.conversation === conversation && g.site === site); if (!ok) { const owner = this.logins.find((l) => l.site === site)?.by ?? "unknown"; return `${conversation} has no grant for ${site} (login belongs to ${owner})`; } return null; } 

Two checks.

Is there a login at all?

Is this conversation allowed to use it?

Grants are explicit pairs: conversation and site. The demo grants billing access to billing.example and nothing else.

When the check fails, the reason names who owns the login. That turns a quiet permission leak into a line in the log.

Shared cookies are an implementation detail. Shared permission should be a decision.

6. Step 5: Sweep Idle Sessions

A signed-in browser nobody is using is not free. It is a live credential sitting in a datacenter.

 // Run from a scheduled task. Reclaim browsers nobody is using. sweep(): string | null { const b = this.browser; if (b && b.alive && clock.now - b.lastUsed > this.idleMs) { b.alive = false; return `${b.id} idle ${Math.round((clock.now - b.lastUsed) / MIN)}m, closed`; } return null; } crash(): void { if (this.browser) this.browser.alive = false; } } 

sweep() closes any browser that has been idle longer than the limit. Cloudflare's docs also suggest calling connector.sweep() from a scheduled task for the same reason.

crash() exists only so the demo can kill the browser on purpose.

Then the turn runner and the demos:

type Step = { minute: number; conversation: string; action: "open" | "login" | "act" | "sweep" | "crash"; site?: string; note?: string; assumesLogin?: boolean; }; function runTurn(k: SessionKeeper, s: Step): void { clock.now = s.minute * MIN; if (s.action === "sweep") { console.log(`${at()} [SWEEP] ${k.sweep() ?? "nothing idle"}`); return; } if (s.action === "crash") { k.crash(); console.log(`${at()} [CRASH] browser died (simulated)`); return; } const { browser, restarted, lost } = k.ensure(); const head = `${at()} ${s.conversation.padEnd(8)}`; if (restarted) { console.log(`${head} [RESTART] ${browser.id} restarted: true, lost ${lost.join(", ") || "nothing"}`); } const site = s.site!; if (s.action === "open") { k.tab(s.conversation, `https://${site}/`); console.log(`${head} [OPEN ] ${site} in ${browser.id}`); return; } if (s.action === "login") { k.tab(s.conversation, `https://${site}/login`); k.logIn(s.conversation, site); console.log(`${head} [LOGIN] ${site} (${s.note ?? "human finished sign-in"})`); return; } // act: a side effect that needs a logged-in session const why = k.mayUseLogin(s.conversation, site); if (why && restarted && s.assumesLogin) { console.log(`${head} [STOP ] ${s.note}: plan assumed a login that no longer exists. Ask a human to sign in again.`); return; } if (why) { console.log(`${head} [BLOCK] ${s.note}: ${why}`); return; } k.tab(s.conversation); console.log(`${head} [ACT ] ${s.note} on ${site} (tab kept, ${browser.id})`); } function demo(title: string, k: SessionKeeper, steps: Step[]): void { console.log(`\n=== ${title} ===`); for (const s of steps) runTurn(k, s); } 

The rule that matters is in runTurn.

If the browser restarted, the login is missing, and the step was planned on the assumption that it existed, the agent stops and asks a human. It does not try the side effect against a logged-out page and hope the site says something useful.

const grants: Grant[] = [{ conversation: "billing", site: "billing.example" }]; demo("Demo 1: one login, two conversations", new SessionKeeper(10 * MIN, grants), [ { minute: 0, conversation: "billing", action: "login", site: "billing.example" }, { minute: 4, conversation: "billing", action: "act", site: "billing.example", note: "download September invoice" }, { minute: 6, conversation: "research", action: "open", site: "docs.example" }, { minute: 7, conversation: "research", action: "act", site: "billing.example", note: "export customer list" }, { minute: 9, conversation: "billing", action: "act", site: "billing.example", note: "update card on file" }, ]); demo("Demo 2: the browser does not wait for you", new SessionKeeper(10 * MIN, grants), [ { minute: 0, conversation: "billing", action: "login", site: "billing.example" }, { minute: 3, conversation: "billing", action: "act", site: "billing.example", note: "download September invoice" }, { minute: 15, conversation: "ops", action: "sweep" }, { minute: 18, conversation: "billing", action: "act", site: "billing.example", note: "submit refund", assumesLogin: true }, { minute: 19, conversation: "billing", action: "login", site: "billing.example", note: "human signed in again" }, { minute: 20, conversation: "billing", action: "act", site: "billing.example", note: "submit refund" }, { minute: 22, conversation: "ops", action: "crash" }, { minute: 23, conversation: "billing", action: "act", Source:https://dev.to/bobbyhalljr/cloudflare-gave-agents-a-browser-that-remembers-lets-build-a-tiny-session-keeper-in-typescript-230c

Related posts