DEV Community

Cover image for Cloudflare Gave Agents a Browser That Remembers. Let's Build a Tiny Session Keeper in TypeScript.
Bobby Hall Jr
Bobby Hall Jr

Posted on

Cloudflare Gave Agents a Browser That Remembers. Let's Build a Tiny Session Keeper in TypeScript.

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
Enter fullscreen mode Exit fullscreen mode

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 });
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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", site: "billing.example", note: "check refund status", assumesLogin: true },
]);
Enter fullscreen mode Exit fullscreen mode

7. Step 6: Run It

Install and run:

npm install
npx tsx session.ts
Enter fullscreen mode Exit fullscreen mode

Example output (example inputs only):

=== Demo 1: one login, two conversations ===
t+00m billing  [LOGIN] billing.example (human finished sign-in)
t+04m billing  [ACT  ] download September invoice on billing.example (tab kept, browser-1)
t+06m research [OPEN ] docs.example in browser-1
t+07m research [BLOCK] export customer list: research has no grant for billing.example (login belongs to billing)
t+09m billing  [ACT  ] update card on file on billing.example (tab kept, browser-1)

=== Demo 2: the browser does not wait for you ===
t+00m billing  [LOGIN] billing.example (human finished sign-in)
t+03m billing  [ACT  ] download September invoice on billing.example (tab kept, browser-1)
t+15m [SWEEP] browser-1 idle 12m, closed
t+18m billing  [RESTART] browser-2 restarted: true, lost login:billing.example, tab:billing
t+18m billing  [STOP ] submit refund: plan assumed a login that no longer exists. Ask a human to sign in again.
t+19m billing  [LOGIN] billing.example (human signed in again)
t+20m billing  [ACT  ] submit refund on billing.example (tab kept, browser-2)
t+22m [CRASH] browser died (simulated)
t+23m billing  [RESTART] browser-3 restarted: true, lost login:billing.example, tab:billing
t+23m billing  [STOP ] check refund status: plan assumed a login that no longer exists. Ask a human to sign in again.
Enter fullscreen mode Exit fullscreen mode

Demo 1 is the shared login.

billing signs in and downloads an invoice. research opens docs in the same browser, then tries to export the customer list from billing. The cookie is right there. The grant is not. Blocked, with the owner named.

Demo 2 is the browser not waiting for you.

The sweep closes browser-1 after 12 idle minutes. Three minutes later billing wants to submit a refund. ensure() hands back browser-2 with restarted: true and the lost login listed. The refund stops instead of firing into a logged-out page.

A human signs in again. The refund goes through on the new browser.

Then a simulated crash, and the same stop happens one turn later.

That is the whole feature.

8. Where It Breaks Down

The clock is fake. Real idle timeouts depend on the provider, hibernation, and network. Treat the 10 minute value as a default to check, not a promise.

Restart detection depends on the tool telling you. Cloudflare reports restarted: true. Other browser tools might not. If yours does not, compare a session id you stored against the one you get back.

Grants are static. Real systems need grants that expire, grants per action (read invoices but not change the card), and a way for a human to approve a one-time borrow.

Cookies are not the only state. Local storage, open forms, half-finished checkouts, and downloads all vanish on restart too. The lost list here only knows about logins and tabs.

"Stop and ask a human" is the safe default, not the only one. Some steps are fine to retry after a fresh login. Deciding which ones is a policy, and it belongs next to your side-effect confirm gate.

A persistent browser is a bigger blast radius. If a prompt injection lands in a page while the browser holds three logins, all three are in play. Scoping per conversation shrinks that. It does not remove it.

9. The Bigger Idea

turn
  └→ ensure: same browser, or restarted: true
       ├→ restarted: drop old login evidence, list what was lost
       │     └→ step assumed a login? stop and ask a human
       └→ alive: reuse the conversation's tab
             └→ need a login? check the grant, not just the cookie
                  ├→ granted: act
                  └→ not granted: block, name the owner
scheduled sweep
  └→ close browsers idle past the limit
Enter fullscreen mode Exit fullscreen mode

The browser provides the memory.

The restart flag provides the truth.

The login log provides the evidence.

The grant provides the permission.

The sweep provides the cleanup.

The human provides the sign-in.

This part is prediction, not history.

I think "can the agent use a browser" is already the old question. The interesting question is what the agent believes about the browser between turns, and how fast it notices when that belief is wrong.

Cloudflare putting restarted: true right in the tool result is the right instinct. It makes the failure loud.

The rest is on us.

A browser that remembers is a feature. An agent that checks is the product.


Try Roster

I'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. They keep a lane, they know which logins are theirs, and they ask a human when the ground shifts under them instead of guessing.

If the same follow-ups, handoffs, and sign-in loops keep eating your week, give them to an AI employee.

Try Roster →

Top comments (0)