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.
'NEEDS_STORE'no extractor under the key, or the version stamp mismatchesThe 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:
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:
localStorage.getItem(KEY)— reads the extractor source text cached under the skill’sstorage_key.- The guard checks two things at once: the source exists and the sibling
KEY + ":ver"stamp equals the snippet’sVER. Either failing returns the string'NEEDS_STORE'— the signal to do the Store step. eval('(' + s + ')')— rebuilds the function from its text, synchronously (that matters — see below).fn(document, params)— invokes the extractor with the run’s params. Everything else — the fetch against the site’s own API, pagination, parsing — happens inside the page, in that one call.
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>:
(() => {
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';
})()String(fn)serializes the function back to source text — the barefunction, without the file’s wrapping parentheses. That text is what lands in localStorage; the Call’seval('(' + s + ')')re-adds the parentheses so eval parses a function expression, not a block.- Both keys are written together, so the version stamp always describes the stored source.
- It returns
'stored'; the agent then re-runs the exact Call and gets the data.
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:
github-get-user "async (root, params) => { …the extractor source… }" github-get-user:ver "1"
| Key | Value |
|---|---|
<storage_key> | The extractor as plain source text — the bare async (root, params) => { … } function from evaluate.js |
<storage_key>:ver | The version stamp — must equal the version in the skill’s frontmatter for the Call to accept it |
- Per-origin. The github.com cache is invisible to linkedin.com and vice versa — standard browser isolation.
- Persistent. localStorage survives navigation, new tabs and full browser restarts. One store serves the profile until the skill ships a new version.
- Fixed footprint. Every Store overwrites the same two keys — old versions never accumulate.
- Disposable. Clearing site data wipes it; the next Call simply reports
'NEEDS_STORE'and the skill re-stores itself. Nothing breaks.
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:
version: 2; both snippets now carry VER = "2":ver is still "1" → mismatch → 'NEEDS_STORE'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.
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.
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:
| Signal | Raised by | Meaning → what to do |
|---|---|---|
NEEDS_STORE | the Call snippet | Extractor missing or version-mismatched in this browser → run the Store once, then re-run the Call |
re-auth: … | the extractor | No session cookie, or the site answered 401/403 → the user logs in manually, then re-run. Never automate the login |
tool-broken: … | the extractor | The 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
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.