Skip to content

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.

DeclarationexecutionTargetBehavior
URL-style @match only (or none)pageStatus quo: injected into matching pages
// @match background onlybackgroundOne persistent instance in the background sandbox; never injected into pages
BothbothTwo 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: document exists but is invisible and querySelector usually returns null; do not treat DOM mounting as deliverable UI — LFDOM page capabilities are unavailable
  • Opaque origin: direct localStorage / IndexedDB access throws SecurityError; persist with LFStore (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-at only 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.

CapabilityBackground branchNotes
LFStore (incl. watch)AvailableShares one namespace with the page branch — the recommended channel for keeping a both app's two branches in sync
LFModel (incl. chatStream)AvailableStreaming goes over a Port, same as the page branch
LFUserAvailableLogin state and user info read normally
LFHttpAvailablerequest / stream / websocket / client.* / server.* all work; page.* needs a tab and returns TAB_UNAVAILABLE
LFRuntime.cliAvailableRegistrations show up under plugincmd list --source background and do not depend on any tab
LFRuntime.sidebarPartly availablegetState / 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 LFRuntimeAvailablesleep / registerCleanup / print, etc.
Workspace bridge (readFile / writeFile / execTerminalCommand)Lease onlySame 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
LFDOMUnavailableThe 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
LFNetworkUnavailableIt attaches CDP to a tab, so start() throws LF_NETWORK_NO_TAB outright
LFRuntime.inputUnavailableIt drives only the tab the script runs in; with no tab it throws RUNTIME_INPUT_NO_TAB outright
LFRuntime.runInMainWorldUnavailableThere 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.

ValueMeaning
pageInjected into a web page: the page branch of an enabled/published app, liveDebug, run --tab. Page DOM and location available
backgroundShared offscreen sandbox: a background app branch, run --target background. LFRuntime.isBackground is the shortcut
editor-sandboxrun 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

js
// ==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 on works 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)
  • plugincmd prefers commands registered by the background instance; use --source background|page to pick explicitly
  • The TARGET column of app list and 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

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";
  },
});