Skip to content

LFRuntime ​

Global:LFRuntime · Enable:auto-injected, no @require · Alias:UserScriptContext

The always-available app runtime context: metadata, hot-reload lifecycle, resource access, style injection, MAIN-world execution, page-request interception, and Agent sidebar control.

Properties ​

ts
LFRuntime.bundleId: string

Unique ID of the current bundle

ts
LFRuntime.meta: BundleMeta

Entry metadata { id, name, description, entryFile, matchPatterns }

ts
LFRuntime.url: string

location.href captured at script start

ts
LFRuntime.version: string

Version of the host extension PluginX (from manifest.json, e.g. "3.0.6"), not the app's own version; empty string when it cannot be resolved

ts
LFRuntime.liveDebug: boolean

Whether this is a debug injection (run / liveDebug: true; saved bundles: false)

ts
LFRuntime.executionEnvironment: 'page' | 'background' | 'editor-sandbox'

Current execution environment: page, background or editor-sandbox, determined by the compiled artifact.

ts
LFRuntime.isBackground: boolean

Whether this instance runs in the background sandbox; equivalent to executionEnvironment === "background".

ts
LFRuntime.signal: AbortSignal | null

Abort signal of the terminal run process (aborts on Ctrl+C / kill / agent stop); null under liveDebug and in enabled/installed apps — hot reload never triggers it

ts
LFRuntime.network: { onResponse, interceptRequest, interceptResponse }

In-page fetch/XHR interception (observe / mock / replace); requires @intercept

ts
LFRuntime.cli: { register, unregister }

Register terminal commands discoverable via plugincmd

ts
LFRuntime.sidebar: { getState, getAgentState, isSupported, open, close, input, setModel }

Query and control the PluginX Agent sidebar: open/close it, push text into its composer, switch its chat model

ts
LFRuntime.input: { click, hover, focus, fill, clear, press, type, text, key, check, uncheck, select, scrollIntoView, readText, move, down, up, drag, scroll }

Trusted input (CDP; events have isTrusted === true) that only drives the tab the script runs in; text is an alias of type. The first use attaches the debugger, so the browser shows a debugging banner; LFDOM is lighter for plain in-page interaction

Methods ​

ts
LFRuntime.sleep(ms): Promise<void>

Abortable delay

ts
LFRuntime.throwIfAborted(): void

Throws the abort reason once the terminal run process has been aborted — call inside long loops to exit promptly; does nothing when signal is null

ts
LFRuntime.registerCleanup(callback): typeof callback

Registers a cleanup callback that runs automatically before a liveDebug hot-reload reinjection; returns the callback itself (not an unregister function — a registration cannot be cancelled). Only liveDebug and terminal run register it; in enabled/installed apps it does nothing

ts
LFRuntime.trackNode(node): Node

Tracks a DOM node for automatic removal on reinjection

ts
LFRuntime.listen(target, type, handler, options?): () => void

Hot-reload-safe addEventListener: auto-removed on reinjection; returns an unlisten function

ts
LFRuntime.interval(callback, ms, ...args): number

Hot-reload-safe setInterval: auto-cleared on reinjection

ts
LFRuntime.timeout(callback, ms, ...args): number

Hot-reload-safe setTimeout: auto-cleared on reinjection

ts
LFRuntime.mountHot(key, setup): T | undefined

Hot-reload-friendly mount: the previous cleanup for the same key runs first

ts
LFRuntime.clearHot(key): void

Clears a hot mount by key

ts
LFRuntime.getResource(name): string

Reads a resource declared with @resource: text resources return their content, binary resources return a data: URL, and an undeclared alias returns an empty string (test with !== "")

ts
LFRuntime.appendStyle(cssText, styleKey?): HTMLStyleElement

Injects CSS; repeated calls with the same styleKey (default "inline") replace the content instead of stacking — pass distinct styleKeys for separate style blocks. CSS @resource files are auto-injected at startup, keyed by their alias

ts
LFRuntime.runInMainWorld(sourceOrFunction, options?): Promise<T>

Runs a function or source string in the page MAIN world and returns the result

ts
LFRuntime.execTerminalCommand(command, options?): Promise<Result>

Runs a command in the editor terminal (terminal scratch scripts only: run / run -c / run --tab / run --target background)

ts
LFRuntime.readFile(path, options?): Promise<{ ok, content, ... }>

Reads a workspace file — the text is in .content (terminal scratch scripts only, as above)

ts
LFRuntime.writeFile(path, content, options?): Promise<Result>

Writes a workspace file, string or binary (terminal scratch scripts only, as above)

ts
LFRuntime.sidebar.getState(): Promise<{ open, supported }>

Reads whether the sidebar is open and whether this browser supports side panels

ts
LFRuntime.sidebar.getAgentState(): Promise<{ runStatus, isRunning, ready, submitAllowed, ... }>

Reads Agent execution status, readiness and submission authorization without prompting; available even when the panel is closed.

ts
LFRuntime.sidebar.isSupported(): Promise<boolean>

Returns only whether this browser supports side panels

ts
LFRuntime.sidebar.open(): Promise<{ open: true, windowId, alreadyOpenInAnotherWindow? }>

Opens the Agent sidebar (must be the first async call inside a user click handler)

ts
LFRuntime.sidebar.close(): Promise<{ open: false }>

Closes the Agent sidebar

ts
LFRuntime.sidebar.input(text, options?): Promise<{ accepted: true, insertedLength, submitted, mode, ... }>

Writes and focuses the open Agent composer; replaces the draft without submitting by default. Options control appending and submission.

ts
LFRuntime.sidebar.setModel(modelId): Promise<{ modelId, addedToQuickList }>

Switches the open sidebar's Agent model; adds it to the quick list when absent and throws when unknown

ts
LFRuntime.print(...args): void

In terminal run scripts, prints to the terminal's stdout (redirectable); under liveDebug and in enabled/installed apps it writes to the page console, which you can inspect with the terminal diag command under liveDebug; when every argument is an Error instance it goes to stderr. Always write the full LFRuntime.print(...) — a bare print() calls window.print() and opens the browser's print dialog

Availability ​

The same API behaves differently depending on how the code runs. "Enabled/installed" means a saved, compiled app injected into web pages; "background app" means the resident instance of a // @match background app (including the background branch of a both app).

CapabilityEnabled/installed (page)liveDebugrun --tabrun without --tabrun --target backgroundBackground app
Where print goesPage consolePage console (visible via diag)TerminalTerminalTerminalBackground sandbox console
signal / throwIfAbortednull / no-opnull / no-opWorksWorksWorksnull / no-op
Direct readFile / writeFile / execTerminalCommandUnavailable¹Unavailable¹AvailableAvailableAvailableUnavailable¹
networkRequires @interceptAvailableAvailableNever firesNever firesNever fires
runInMainWorldAvailableAvailableAvailable²Runs directly inside the editor sandbox (no target page)rejects MAIN_WORLD_NO_TABrejects MAIN_WORLD_NO_TAB
sidebarAvailableAvailableAvailableAll but open()All but open()All but open()
inputLocal development copies work; marketplace-published copies need the LFRuntime.input capability grantAvailableAvailableRejects RUNTIME_INPUT_NO_TABRejects RUNTIME_INPUT_NO_TABRejects RUNTIME_INPUT_NO_TAB
cliAvailableAvailableAvailableAvailableAvailableAvailable

¹ Calling them directly from app source fails at compile time; if the compile check is bypassed, the call rejects with WORKSPACE_LEASE_REQUIRED at runtime. App code should register a command with LFRuntime.cli.register({ workspace: ['read' / 'write' / 'exec'] }) and use ctx.workspace.readFile / writeFile / exec inside the handler.

² run --tab runs in the MAIN world by default, so the callback executes directly in the current world and options.timeoutMs has no effect; with --world user-script it is bridged into the MAIN world through the background.

Local capability boundary ​

  • Local command execution (localexec), local file access (local fs) and CLI registry requests (plugincmd listing and calls) are accepted only from the PluginX editor page. When an app sends these requests directly — whether it is injected into a web page, runs in the background, or lives in a USER_SCRIPT world — they are rejected with EACCES_PERMISSION. App code that needs the workspace should register a command with LFRuntime.cli.register({ workspace: [...] }) and use ctx.workspace.* inside the handler.
  • Permission-popup decisions are accepted only from the extension's own permission popup page, and request ids are unpredictable, so an app cannot answer on the user's behalf.
  • LFHttp.client.fs (the client file-system channel) is attributed to the browser-attested app identity: allow/deny lists are checked against that identity rather than a caller-supplied bundleId, so an app cannot impersonate another app's bundleId to reach its files.

Parameter details ​

mountHot(key, setup) ​

key is a stable slot identifier (the bundleId is auto-prefixed, so apps never collide). setup must be synchronous and receives a hot context object:

FieldTypeDescription
hot.keystringNormalized key
hot.liveDebugbooleanMirrors LFRuntime.liveDebug
hot.previousValueT / undefinedValue returned by the previous generation's setup (undefined on first run); use it to reuse mounted nodes
hot.onDispose(cleanup)(fn) => fnRegisters the cleanup to run at the next unmount
hot.setValue(value)(v) => vImperatively sets the slot value (useful when you also return a cleanup function)

The setup return value is interpreted by type: a function → treated as cleanup (slot value stays); { value?, cleanup? } → destructured into both; any other non-undefined value → new slot value; undefined → previous value kept.

Each mount slot keeps only one cleanup function: calling onDispose several times keeps only the last one, and a function returned from setup (or an object with cleanup) also overrides whatever onDispose registered. Combine multiple cleanup steps into one function.

Hot-reload idempotency (what happens on a save-triggered soft hot-update) ​

A soft hot-update (triggered automatically on save, or by repeating liveDebug on) re-executes the whole bundle in the current document. Before the new code runs, the framework invokes every cleanup registered by the previous generation via registerCleanup / trackNode / listen / interval / timeout / mountHot. Bare calls are not managed — listeners added with raw addEventListener, raw setInterval timers, and custom properties written onto window survive the update and stack on top of the new code (duplicate listeners, stale closures still running). The terminal command liveDebug reload is different: it reloads the whole page and re-injects clean, destroying every previous-generation artifact whether registered or not.

PatternBehavior on reloadRecommendation
LFRuntime.listen / interval / timeout / trackNode / mountHotCleaned up automaticallyUse these for all top-level side effects
Raw addEventListener / setInterval / setTimeoutOld listeners/timers survive and stack with the new codeOnly inside a mountHot cleanup you manage yourself
Guard flags such as window.__myFlagPersist across reloads → new code thinks it is already initialized and skips mountingAnti-pattern; rely on mountHot key semantics instead
Long loops / polling tasksOld tasks are not interrupted automaticallyUse let alive = true; LFRuntime.registerCleanup(() => { alive = false; }); and check alive in the loop condition; throwIfAborted() / signal only apply to terminal run processes and are never triggered by hot reload

Do not implement idempotency with guard flags

if (window.__inited) return; is guaranteed to break under hot reload: the old flag persists across reloads, so the new code skips mounting entirely — the symptom is "I changed the code but the page did not update". Put the mount inside mountHot(key, setup) instead; the framework runs the previous cleanup before remounting the same key, which is idempotent by construction.

runInMainWorld(sourceOrFunction, options?) ​

ParameterTypeDefaultDescription
sourceOrFunctionstring / Function—A self-contained function or parenthesized expression; it cannot close over local variables — pass values via options.args or options.bindings
options.argsunknown[]—Structured-cloneable arguments passed into MAIN world; Elements connected to the current document are bridged automatically
options.bindingsRecord<string, unknown>—Local values injected explicitly into the callback's lexical scope (keys must be valid identifiers, values structured-cloneable)
options.dependenciesRecord<string, Function / string>—Self-contained helper functions/expressions declared before the callback; the helpers cannot capture outer closures either
options.preludesstring[]—Raw source run inside the callback's private scope before the dependencies (e.g. a UMD/IIFE library build fetched as text) — the way to bring a library into MAIN world
options.timeoutMsnumber10000Timeout in ms; omitted or 0 means 10000, out-of-range values are clamped to [1000, 60000]

Inside the callback only LFRuntime.print and LFRuntime.sleep work; touching any other LF* member throws MAIN_WORLD_RUNTIME_UNAVAILABLE. Return plain data from the callback and keep other LF* calls outside it. The cross-world return value is capped at 16 MiB; larger results reject with MAIN_WORLD_RESULT_TOO_LARGE. See "Availability" for which execution modes support it.

execTerminalCommand(command, options?) ​

The three workspace methods in this section and the next can only be called directly from terminal scratch scripts (run / run -c / run --tab / run --target background). Calling them directly from app source (liveDebug and enabled/installed/background apps) fails at compile time and rejects with WORKSPACE_LEASE_REQUIRED at runtime; app code should register a command with LFRuntime.cli.register({ workspace: ['read' / 'write' / 'exec'] }) and use ctx.workspace.readFile / writeFile / exec inside the handler (same semantics as here).

Runs a command in the editor's built-in /workspace terminal (not the host OS shell). Options: timeoutMs (default 30000, clamped to [1000, 60000]), echoCommand (echo the command in the terminal UI), recordHistory (add to the history ring), writePrompt (write a fresh prompt after the result), focusTerminal (focus the terminal panel). Result shape:

FieldTypeDescription
okbooleanConvenience flag, defaults to exitCode === 0
exitCodenumberCommand exit code (127 for command-not-found)
stdout / stderrstringCaptured output (empty string when none)
messagestringOptional annotation from the bridge (usually empty)
clearRequestedbooleanWhether the command asked the terminal to clear (e.g. clear)

readFile(path, options?) / writeFile(path, content, options?) ​

Both are async and must be awaited; they resolve to an envelope object, not the file content itself — the text is in .content (common mistake: JSON.parse(await LFRuntime.readFile(p)) is wrong; destructure first: const { content } = await LFRuntime.readFile(p)).

Parameter/fieldTypeDefaultDescription
pathstring—Workspace-relative (e.g. src/foo.ts) or /workspace/... prefixed; writes are restricted to /workspace
options.timeoutMsnumber30000Clamped to [1000, 60000]
options.encoding (read)'utf8' / 'base64' / 'binary' / null'utf8'utf8 returns text; binary (or null, or the aliases 'buffer' / 'uint8array') returns a Uint8Array; base64 returns a base64 string. Unrecognized values (e.g. 'utf-8') are treated as utf8. Always use binary for binary files (PNG, fonts, …)
options.encoding (write)'utf8' / 'base64''utf8'Only applies when content is a string; binary buffers are always written as raw bytes
options.mkdirP (write)booleantrueAuto-creates missing parent directories (like mkdir -p)
content (write)string / Uint8Array / ArrayBuffer / TypedArray / DataView—Pass binary buffers directly (e.g. await response.arrayBuffer()); never write a binary response through response.text() — the decode is lossy and unrecoverable. null/undefined writes an empty file; any other non-string, non-binary value goes through String() (an object is written as [object Object]), so JSON.stringify JSON first

In utf8 mode readFile resolves to { ok, path, content, message }; binary/base64 modes add encoding and bytesRead. writeFile resolves to { ok, path, bytesWritten, message }. When the file does not exist, the path is a directory, or a parent path is not a directory, readFile does not throw — it resolves { ok: false } with an empty-string content (an empty Uint8Array in binary mode), so check ok === false; transport, timeout and other errors still reject.

network.onResponse / interceptRequest / interceptResponse(urlPattern, callback, options?) ​

ParameterTypeDescription
urlPatternstring / RegExpStrings match by substring against the request URL; RegExp by test
callbackFunctiononResponse receives { request, response, status, source } (request/response carry url/method/headers/body); interceptRequest receives request — return { body, init? } to short-circuit with a mock (the real request is never sent), or undefined to pass through; interceptResponse receives { request, response } — return { body, init? } to replace what the page sees
options.oncebooleanAuto-unsubscribe after taking effect once: onResponse is consumed by its first match; interceptRequest / interceptResponse are consumed only when the callback actually returns data (returning undefined to pass through does not count)
options.timeout / timeoutHandlernumber / () => voidAuto-unsubscribe and call timeoutHandler when nothing matched in time

The returned body may be a string, an ArrayBuffer, a TypedArray or a DataView (binary bodies reach the page byte-for-byte — do not convert them to a base64 string first); plain objects/arrays are JSON.stringify-ed into the response body; Blob / FormData / URLSearchParams are not supported — that result is rejected with an error in the page console and the request passes through unchanged.

All three return an unsubscribe function, and subscriptions are released automatically on liveDebug hot reload. Background apps, run --target background and run without --tab have no page, so their subscriptions never match. init defaults to { status: 200, statusText: 'OK', headers: { 'Content-Type': 'application/json' } }. Note: you can only mock or pass through — you cannot modify a request and let it continue. Installed apps must declare // @intercept <url-pattern> in the header. Only fetch/XHR issued by the page's own JS is intercepted: app code runs in an isolated world, so the app's own fetch and LFHttp requests bypass interception and never fire onResponse.

cli.register(def) ​

FieldTypeRequiredDescription
def.namestringyesCommand name (used by plugincmd call <name>); non-empty, no whitespace, ≤ 64 chars; register() throws synchronously otherwise
def.briefstringnoOne-line summary (≤ 200 chars; anything longer is silently truncated) shown in plugincmd list. The IDE injects name: brief of every callable command into the agent's context on each user message (web chat mode: current tab + background providers; IDE mode: background providers), so write it like a skill description — what it does and when to use it
def.usagestring / string[]noDetailed usage shown by plugincmd help <name> (up to 50,000 chars, anything longer is silently truncated; arrays are joined with newlines). Loaded on demand, so it can hold a full playbook — argument contract, preconditions, steps, pitfalls — the equivalent of a skill body
def.workspace('read' / 'write' / 'exec' / 'agent')[]noWorkspace scopes this command needs: read/write/exec unlock ctx.workspace.readFile/writeFile/exec; agent unlocks the LFAgent built-in library (see "LFAgent"). An unknown scope throws at registration. The lease lives only for the duration of the plugincmd call and is revoked when it settles
def.handler(argv, ctx) => string / { exitCode?, output?, errorOutput? } / voidyesSee the table below

Handler arguments and return value:

ItemDescription
argvThe token array after the command name. -- is optional: argv starts at the first token that is not a plugincmd call option; write -- first when an argument itself starts with --reg / --tab / --bundle / --source / --timeout
ctx.signalAborts when the caller interrupts (Ctrl+C / agent abort / IDE close / timeout) — long handlers should check ctx.signal.aborted
ctx.callIdUnique ID of this call
ctx.timeoutMsThis call's effective timeout in milliseconds, 0 = unlimited (the caller omitted --timeout, which is the default) — tasks that must run without a time box can check it at entry and refuse time-boxed calls
ctx.workspaceWorkspace facade { scopes, readFile, writeFile, exec }, present only when the command declared def.workspace and the caller has a terminal session; calls made after the handler settles reject with WORKSPACE_LEASE_EXPIRED, and using an undeclared scope rejects with WORKSPACE_SCOPE_DENIED
Return valueA string is stdout; undefined means empty output; the object form lets you set exitCode. output / errorOutput are each capped at 1,000,000 characters and truncated beyond that

Exit codes:

Exit codeMeaning
0Normal return (a string, undefined, or an object without exitCode)
1The handler threw; the error message goes to stderr
126A marketplace-published app declared def.workspace but lacks the LFRuntime.workspace grant; the call is refused with WORKSPACE_UNAUTHORIZED and the handler never runs
127The command no longer exists in the provider context (e.g. it was unregistered)
130The caller interrupted

register() returns an unregister function; cli.unregister(name) unregisters a command registered in the current context by name. A registration lives as long as its provider context: a run script's commands unregister the moment the process exits, so keep the provider alive to serve calls. A compiled app's page instance unregisters when its page/tab closes; a background instance persists independent of any tab (re-registering automatically when the instance is restored after a browser restart) and unregisters when the app is disabled or uninstalled. A both app registers the same command name once per branch, and plugincmd prefers the background branch by default.

The sidebar is open to every installed user — these methods draw no role distinction (real authorization is enforced by the server APIs). The whole group needs an execution context that can talk to the background: enabled/installed apps (including background apps), liveDebug, run --tab (the editor injects an ISOLATED relay bridge), run without --tab, and run --target background all qualify; when no channel is available, the returned Promise rejects (nothing is thrown synchronously). Background apps, run --target background and run without --tab have no caller window, so open() is rejected there while the other methods work normally.

MethodPreconditionResult and behavior
getState()—Returns { open, supported }: open reflects whether any panel has an active connection (the panel is a singleton, so it is not judged per caller window), and supported reports whether this browser has the Side Panel API
getAgentState()— (does not throw when the panel is closed)Returns the agent's execution state { open, supported, runStatus, isRunning, stopRequested, ready, submitAllowed, sessionId, modelId }. runStatus: closed no panel connected / idle ready to accept a run / running mid-turn / aborting stop requested but not yet unwound. ready predicts whether a submit would pass the preconditions (a project is open and a model is configured); submitAllowed is a permission probe that never opens a popup
isSupported()—Returns a boolean equivalent to getState().supported — a browser capability probe
open()The caller's window must be resolvable (i.e. the script runs in a page); must be the first async call inside a direct user gestureOpens the Agent panel in the caller's window and waits for its connection, returning { open: true, windowId }. If a panel is already open in another window it is not reopened: you get { open: true, windowId: <that window>, alreadyOpenInAnotherWindow: true }
close()—Closes the panel; returns { open: false }
input(text, options?)Panel must be open; trimmed text must be non-empty. submit: true additionally requires the run permission plus a sidebar with a project open and a model configuredWrites text into the Agent composer and focuses it. It replaces the existing draft by default (mode: "replace"); pass { mode: "append" } to add to it instead. It does not submit by default — the user presses send. Pass { submit: true } to submit it so the agent starts running. Returns { accepted: true, insertedLength, submitted, draftAppended, sessionId, mode }; check submitted even when you passed submit
setModel(modelId)Panel must be open; modelId non-emptySwitches by model id, canonical slug, or alias; automatically adds a missing model to the quick list first; throws when unknown or unavailable for Agent chat; returns { modelId, addedToQuickList }

Call timing for open()

open() wraps chrome.sidePanel.open(), which must be issued before the transient user activation expires: make it the first asynchronous call in a click handler with no preceding await (do probes like isSupported() during page initialization instead). It also has to resolve the caller's window from the message, so it only works from a page — the editor sandbox (run without --tab) has no window id and is rejected. When a panel is already open in another window it is not reopened (two panels means two agent runtimes, and keeping one would mean killing a live conversation), so read alreadyOpenInAnotherWindow and use the returned windowId to offer a jump.

How input writes into the composer ​

input(text) replaces whatever is already in the composer by default (equivalent to { mode: "replace" }). An app usually sends one complete instruction, and gluing it onto the half-sentence the user was typing is rarely what anyone wants — with submit: true that stray fragment would be sent along with it. Replacing also clears any code selections and file references the user had staged, because selection tokens live inside the draft text and clearing only the text would leave orphaned references behind. When you genuinely want incremental composition (feeding a long body in several pieces, say), pass { mode: "append" }: it appends to the end of the draft, inserting a newline when needed. The returned mode echoes which one actually applied.

ModeBehaviour
"replace" (default)Clears the draft (including code selections and attachments), then writes the text
"append"Appends to the end of the existing draft, adding a newline when needed; keeps existing selections and attachments

The submit option and its permission ​

input(text) only drops text into the composer — the user still presses send, so a human stays in the loop and no permission is needed. input(text, { submit: true }) starts an agent turn immediately, and during that turn the agent can read and write the workspace, run terminal commands, and spend model credits. That upgrades "the app suggests something" into "the app orders something done on the user's behalf", so it sits behind the same kind of gate as local command execution.

OptionTypeDefaultDescription
mode"replace" / "append""replace"How the text lands in the draft: replace clears it first (including code selections and attachments), append adds to the end
submitbooleanfalseWhen true, submit the prompt so the agent runs right away; omitted, the text just sits in the composer
onBusy"reject" / "append_draft""reject"Only meaningful with submit. While a run is in progress: reject throws and writes nothing to the draft (so text cannot pile up unnoticed); append_draft silently degrades to appending the draft. There is no queueing

The gate has two layers, in this order: (1) Agent settings → Agent → "allow apps to make the sidebar agent run commands", on by default — while it is on, submits go straight through; (2) once the user turns it off, each app is asked once through a popup, and approving is remembered long-term (same pattern as local command execution), while denying fails that call and records nothing, so the next attempt asks again. In other words, turning the setting off does not disable the feature outright — it switches to per-app consent.

Probe first, then decide whether to submit

submitAllowed from getAgentState() is a read-only probe that never opens a popup, so an app that would rather not interrupt the user can degrade gracefully: when it is false, just fill the draft and ask the user to press send. Note also that the agent emits no "run finished" event — after submitting, poll getAgentState().isRunning. Terminal run scripts carry a ::terminal-run::<pid> suffix in their bundleId, and the permission is attributed to the host app so each run does not ask again.

Sidebar example

js
// @require dom
// @require model

// Probe browser support during initialization, before the click handler
const sidebarSupported = await LFRuntime.sidebar.isSupported();

function addButton(label, onClick) {
  const button = LFRuntime.trackNode(document.createElement("button"));
  button.textContent = label;
  document.body.appendChild(button);
  LFRuntime.listen(button, "click", onClick);
}

addButton("Ask Agent about this page", async () => {
  if (!sidebarSupported) return;
  // open must be the first asynchronous call in the click handler
  const result = await LFRuntime.sidebar.open();
  if (result.alreadyOpenInAnotherWindow) {
    // Already open in another window: ask the user to switch to that window
    LFDOM.toast("Sidebar is already open in window " + result.windowId);
    return;
  }

  // input / setModel need an open sidebar, so call them after open()
  await LFRuntime.sidebar.input("Analyze the products on this page");
  // Replace the draft by default; the user sends it manually

  // Append only when building a message across several calls
  await LFRuntime.sidebar.input("Additional context: only orders from the last 30 days", { mode: "append" });
});

addButton("Let Agent export a CSV", async () => {
  // This handler never calls open(), so other awaits may come first
  const { open } = await LFRuntime.sidebar.getState();
  if (!open) {
    LFDOM.toast("Open the Agent sidebar first");
    return;
  }

  // Select a chat model from the current catalog
  const { models } = await LFModel.listModels();
  const chatModel = models.find((model) => model.kind === "chat");
  if (!chatModel) {
    LFDOM.toast("No chat model in the current list");
    return;
  }
  // Adds the model to the quick list if needed; throws if the model does not exist
  await LFRuntime.sidebar.setModel(chatModel.id);

  // To submit automatically, check the run state first to avoid an active run
  const agentState = await LFRuntime.sidebar.getAgentState();
  if (agentState.runStatus === "idle" && agentState.ready) {
    // If submitAllowed is false, authorization would prompt; fall back to filling the draft
    const sent = await LFRuntime.sidebar.input("Count the products on this page and export a CSV", {
      submit: agentState.submitAllowed,
    });
    if (sent.submitted) {
      // No run-finished event is available; poll the status
      while ((await LFRuntime.sidebar.getAgentState()).isRunning) {
        await LFRuntime.sleep(1000);
      }
      LFDOM.toast("Agent finished");
    }
  }
});

Example

js
// LFRuntime is automatically injected; no @require needed
LFRuntime.print(LFRuntime.bundleId, LFRuntime.meta.name);

// Hot-reload-friendly mount: clean up old nodes before reinjection
LFRuntime.mountHot("main-panel", (hot) => {
  const el = document.createElement("div");
  // Reads the file when the header declares `// @resource panel ./panel.html`; an undeclared alias returns ""
  const html = LFRuntime.getResource("panel");
  if (html !== "") {
    el.innerHTML = html;
  } else {
    el.textContent = "panel";
  }
  document.body.appendChild(el);
  hot.onDispose(() => el.remove());
});

// Inject styles: repeated calls with the same styleKey replace instead of stacking
LFRuntime.appendStyle(".lf-btn { color: red; }", "theme");