Execution target: background
// @match background is a reserved word for @match: the app is not injected into any web page — instead it runs persistently inside the extension's shared background sandbox. Ideal for polling, scheduled jobs, and CLI commands that do not depend on a page.
| Declaration | executionTarget | Behavior |
|---|---|---|
URL-style @match only (or none) | page | Status quo: injected into matching pages |
// @match background only | background | One persistent instance in the background sandbox; never injected into pages |
| Both | both | Two parallel instances: a persistent background instance plus page instances per match rules, isolated from each other |
background ≠ the extension's background service worker
Background apps run in a sandbox iframe (opaque origin) inside an offscreen document. They have no chrome.* extension APIs whatsoever (no chrome.tabs, chrome.storage, …). The capability set matches page apps' LFRuntime built-ins (minus page DOM/location semantics) — switching environments grants no extra privileges.
Environment differences
- No page DOM:
documentexists but is invisible andquerySelectorusually returns null; do not treat DOM mounting as deliverable UI — LFDOM page capabilities are unavailable - Opaque origin: direct
localStorage/IndexedDBaccess throws SecurityError; persist withLFStore(extension storage, shared across environments) - Lifecycle: persistent from browser startup, independent of any tab; automatically restored after extension restarts
@include/@exclude/@exclude-match/@intercept/@run-atonly constrain the page branch and have no effect on the background branch
Built-in library availability
The background sandbox has no chrome.* of its own, but it does get a minimal runtime Port shim: every outbound message is relayed by the offscreen broker and re-authorized per message by the extension background, so the built-ins' existing channel code works unchanged. In other words, everything except capabilities that genuinely need a page or a tab is available in the background branch.
| Capability | Background branch | Notes |
|---|---|---|
LFStore (incl. watch) | Available | Shares one namespace with the page branch — the recommended channel for keeping a both app's two branches in sync |
LFModel (incl. chatStream) | Available | Streaming goes over a Port, same as the page branch |
LFUser | Available | Login state and user info read normally |
LFHttp | Available | request / stream / websocket / client.* / server.* all work; page.* needs a tab and returns TAB_UNAVAILABLE |
LFRuntime.cli | Available | Registrations show up under plugincmd list --source background and do not depend on any tab |
LFRuntime.sidebar | Partly available | getState / getAgentState / input / setModel / close work; open() needs a user gesture and a caller window, which the background has neither of, so it is rejected |
Core LFRuntime | Available | sleep / registerCleanup / print, etc. |
Workspace bridge (readFile / writeFile / execTerminalCommand) | Lease only | Same as page apps: a direct call in app source fails the compile; use ctx.workspace.* inside a LFRuntime.cli.register({ workspace: [...] }) handler — the lease only lasts for that plugincmd call. Temporary run --target background scripts may call them directly |
LFDOM | Unavailable | The library loads, but there is no page DOM to read — queries come back empty rather than throwing; visibility-gated actions such as click / fill / waitFor time out (hint: no web page in the background environment), and waitForResponse / waitForRequest reject with LF_NETWORK_NO_TAB |
LFNetwork | Unavailable | It attaches CDP to a tab, so start() throws LF_NETWORK_NO_TAB outright |
LFRuntime.input | Unavailable | It drives only the tab the script runs in; with no tab it throws RUNTIME_INPUT_NO_TAB outright |
LFRuntime.runInMainWorld | Unavailable | There is no page to inject into; calls reject with MAIN_WORLD_NO_TAB |
Leave websocket at its default mode
The background sandbox is an opaque origin, so CSP blocks native WebSocket and mode: "direct" cannot connect. Keep the default mode: "auto" — it selects background mode automatically, opening the socket in the extension background and relaying events back.
The design consequence: put data, polling, CLI, and outward-facing interfaces in the background branch, and anything that touches a page in the page branch. Pass state between a both app's two branches through LFStore (with LFStore.watch) rather than shared memory.
Detecting the current execution environment
A both app's background and page branches execute the SAME code, so branch at the entry point on LFRuntime.executionEnvironment. It is baked into the artifact at generation time (not a runtime probe), so it is safe to use at the very top level. Do not infer the environment by sniffing for missing chrome.* APIs, checking whether document.querySelector always returns null, or catching localStorage SecurityErrors — those are internal implementation details.
| Value | Meaning |
|---|---|
page | Injected into a web page: the page branch of an enabled/published app, liveDebug, run --tab. Page DOM and location available |
background | Shared offscreen sandbox: a background app branch, run --target background. LFRuntime.isBackground is the shortcut |
editor-sandbox | run WITHOUT --tab: a hidden iframe on the editor page with no target web page at all |
Share state between the two branches through LFStore + LFStore.watch (background writes, page subscribes and renders). Note that LFRuntime.liveDebug distinguishes development from production and says nothing about the environment — do not conflate the two.
Example: the two branches of a both app
// ==UserScript==
// @name Rate Panel
// @description Background polling and page rendering from one shared entry
// @match background
// @match https://example.com/*
// @require store
// @require http
// ==/UserScript==
// executionEnvironment is a compiled constant; branch directly in the entry
if (LFRuntime.isBackground) {
// Background branch: fetch and store data, without a page DOM
const pull = async () => {
const data = await LFHttp.json("https://open.er-api.com/v6/latest/USD");
await LFStore.set("usd-cny", { rate: data.rates.CNY, at: Date.now() });
};
pull();
LFRuntime.interval(pull, 10 * 60 * 1000);
} else if (LFRuntime.executionEnvironment === "page") {
// Page branch: render data and sync with the background via LFStore.watch
LFRuntime.mountHot("rate-panel", (hot) => {
const el = LFRuntime.trackNode(document.createElement("div"));
document.body.appendChild(el);
const render = (value) => {
el.textContent = value ? `1 USD = ${value.rate} CNY` : "Waiting for background data…";
};
const unwatch = LFStore.watch("usd-cny", ({ value }) => render(value), { immediate: true });
hot.onDispose(unwatch);
});
}Development & testing
run --target background <entry>executes test scripts in the exact same sandbox environment as production background apps (mutually exclusive with--tab/--focus/--record/--world user-script)liveDebug onworks for background / both apps: background hot updates use whole-instance replacement; a both app keeps its background branch running while the page branch waits for a matching tab (waiting-for-target)plugincmdprefers commands registered by the background instance; use--source background|pageto pick explicitly- The TARGET column of
app listand the "Background app / Background + Web" tags on the management page show the execution target. In the chat-mode "Apps on this page" popover, background apps — and both apps whose page branch does not match the current page — appear in the always-visible "Running in background" group (badges: "Background + Page" when the page matches, "Background + Web" otherwise)
Example: persistent price watcher
main.js
// ==UserScript==
// @name Price Watcher
// @description Poll prices in the background without an open webpage
// @match background
// @require store
// @require http
// ==/UserScript==
async function checkOnce() {
const data = await LFHttp.json("https://open.er-api.com/v6/latest/USD");
await LFStore.set("usd-cny", { rate: data.rates.CNY, at: Date.now() });
}
checkOnce();
LFRuntime.interval(checkOnce, 10 * 60 * 1000);
LFRuntime.cli.register({
name: "rate",
brief: "Show the latest cached exchange rate",
handler: async () => {
const cached = await LFStore.get("usd-cny", null);
return cached ? `1 USD = ${cached.rate} CNY` : "No exchange rate received yet";
},
});