docs · concepts

Execution model

Most catalog skills run as one evaluate call. The trick that makes this possible: the skill’s extractor function is cached inside the site’s own localStorage, rebuilt on each call with a synchronous eval. It is stored once per browser; every run after that replays it, until the skill ships a new version.

One bridge call

The only channel a skill really needs is the browser’s evaluate capability — browser_evaluate in Playwright MCP, javascript_tool in claude-in-chrome (the full mapping is in capabilities). We call it the bridge: a snippet goes in, runs inside the page, and a JSON value comes back.

A skill run is a single bridge call with a ~10-line snippet. No page needs to be opened for the data — the open tab only supplies two things: the origin (localStorage is per-origin) and, for auth: session skills, the logged-in sessionthe extractor’s fetch rides on. navigate exists to establish that tab, not to browse.

Store once, call forever

Every extractor-backed skill ships two snippets in its SKILL.md: Call and Store. The rule is call-first: the agent always just calls. The Call itself detects whether the extractor is missing or stale — only then is the Store done, once, and the Call repeated.

the store/call protocolper skill · per browser
Run the skillevaluate(Call snippet) — params onlyin an open tab on the site’s origin
localStorage lookup
extractor cached · every run
Hitthe source is there and the version matches
eval('(' + s + ')') → fn(document, params)the extractor runs in-page: authenticated fetch + parse
not stored yet · first run only
Miss → 'NEEDS_STORE'no extractor under the key, or the version stamp mismatches
Store — oncelocalStorage.setItem(KEY, String(fn))evaluate.js written into the site’s localStorage
Re-run the Callnow a hit — and on every run after
Structured JSONchecked against the skill’s success assertion

The Call — what runs every time

This is the whole snippet the agent evaluates on a run — real example from github.com/get-user, only the params change between skills:

Call · from github.com/get-user · SKILL.md
await (() => {
  const KEY = "github-get-user", VER = "1";
  const s = localStorage.getItem(KEY);
  if (!s || localStorage.getItem(KEY + ":ver") !== VER) return 'NEEDS_STORE';
  const fn = eval('(' + s + ')');
  return fn(document, { username: "torvalds" });
})()

Line by line:

The Store — done once, on NEEDS_STORE

Only when the Call reports 'NEEDS_STORE' does the agent evaluate the Store snippet, splicing the contents of evaluate.js in place of <FN>:

Store · from github.com/get-user · SKILL.md
(() => {
  const KEY = "github-get-user", VER = "1";
  const fn = (<FN>);   // ← the contents of evaluate.js
  localStorage.setItem(KEY, String(fn));
  localStorage.setItem(KEY + ":ver", VER);
  return 'stored';
})()
why it’s cheap

The Store is the onlytime the extractor’s source (a few KB) passes through the model — once per skill per browser, and again only when a new version ships. Every other run, the model handles ~10 lines of Call snippet and the JSON result. The heavy code never travels again.

Where the Store must run depends on auth: an auth: noneskill stores from any tab on the site’s origin; an auth: session skill pins a logged-in page (e.g. linkedin.com/feed/ with the session cookie present), so the very first call also verifies the session works.

What exactly is in localStorage

Two keys per skill, on the site’s own origin. Nothing else:

localStorage · https://github.com
github-get-user      "async (root, params) => { …the extractor source… }"
github-get-user:ver  "1"
KeyValue
<storage_key>The extractor as plain source text — the bare async (root, params) => { … } function from evaluate.js
<storage_key>:verThe version stamp — must equal the version in the skill’s frontmatter for the Call to accept it

Updates: how a new version rolls out

The stored extractor is gated by the version stamp. When a skill’s evaluate.js changes, the catalog bumps version in the frontmatter and VER inside both snippets — they always move together. No browser is notified; each one converges on its own next run:

lazy rolloutversion bump
evaluate.js changescatalog ships version: 2; both snippets now carry VER = "2"
Next Call in any browserstored :ver is still "1" → mismatch → 'NEEDS_STORE'
Store overwrites both keysnew source + new stamp — back on the fast path
↺ each browser re-stores on its own next call — no migration step, no accumulation

The same mismatch also protects the other direction: a stale snippet from an old SKILL.md can never silently run a newer stored extractor — any disagreement between VER and :ver resolves to a fresh Store.

Why the eval is synchronous (CSP)

The Call rebuilds the function with a plain synchronous eval and only ever awaits the result of running it. That shape is deliberate: on sites with a strict Content-Security-Policy, an eval that happens synchronously on snippet entry is allowed, while the same eval placed after an await is blocked. The snippets are written for the strictest site, so they work on all of them.

run verbatim

Never add an await before the eval — only the outer await is allowed. A pre-eval await trips the site’s CSP and the call fails. Every SKILL.md repeats this warning under its Call snippet for a reason: run the snippet exactly as shipped.

What passes through the model

The point of the whole mechanism is token economics: the extractor is model-visible exactly once per browser, then never again.

what the model handlessame skill, same browser
first run · store once
github-get-useronce per browser
$ call { username: "torvalds" }'NEEDS_STORE'store evaluate.js → localStorage~1 KB'stored'call againhit{ login, name, topRepos, … }
every run after · replay
github-get-userforever
$ call { username: "gvanrossum" }Call snippet only~10 lines{ login, name, topRepos, … }

This stacks on top of the catalog-level saving described in first run vs every run after: the catalog remembers the recipe so no agent re-derives the site, and the browser remembers the extractor so no run re-ships the code.

The error contract

Three signals cover the whole lifecycle. Each one names its own recovery, so an agent never has to guess:

SignalRaised byMeaning → what to do
NEEDS_STOREthe Call snippetExtractor missing or version-mismatched in this browser → run the Store once, then re-run the Call
re-auth: …the extractorNo session cookie, or the site answered 401/403 → the user logs in manually, then re-run. Never automate the login
tool-broken: …the extractorThe site changed its API or markup → the skill needs re-deriving; it gets fixed once in the catalog for everyone

Signals travel either as a thrown Error or as an { error: '…' } field on the result — the string prefixes are what matters. Skills also surface their own domain errors the same way (user not found, rate limited); those are listed per-skill in the SKILL.md’s ## Errors section, and they are data to handle, not protocol.

What this means for privacy

nothing secret is cached

What sits in localStorage is catalog-public code — the same evaluate.js anyone can read in the catalog. No credentials, no scraped data, no user content. Sessions and cookies stay where they always were: in your browser profile, never leaving it.

Next: the file format behind all this — Skills & SKILL.md — or the bmem CLI that installs them.