Skip to content

LFAgent ​

Global:LFAgent · Enable:// @require agent · Precondition:workspace: ['agent']

The entry file must declare // @require agent: without it the LFAgent global is never injected, so even a command that declares workspace: ['agent'] only gets ReferenceError: LFAgent is not defined inside its handler (the marketplace static review also only runs when agent is declared).

Agent orchestration: spawn native or custom AI agent instances that run in detached, read-only IDE sessions concurrently with the main Agent. These sessions are managed through the collapsible "Subagents" panel above the spawning session's composer (a tree that mirrors parent-child spawning; opening an entry shows the orange breathing tab) and never appear in the history list. Lease rule: temporary terminal scripts (run <file> / run -c / run --tab / run --target background) may call it directly at top level — the run itself is an IDE/agent authorization, so the IDE issues an agent lease for the process automatically (revoked when the process ends, force-stopping its agent runs), the same rule as direct readFile/writeFile/execTerminalCommand. Plugin source (liveDebug reinjections, enabled/published bundles) is only usable inside a LFRuntime.cli.register handler whose command declared workspace: ['agent'], and only while that plugincmd call is running — outside an invocation LFAgent.create / LFAgent.askUser throw WORKSPACE_LEASE_REQUIRED (in a race right as the call settles, the background may answer WORKSPACE_LEASE_EXPIRED instead); instance methods (send / status / stop / destroy / getTranscript) called after their creating call (or terminal run process) has ended throw LFAGENT_CALL_MISMATCH (or WORKSPACE_LEASE_REQUIRED if no lease is active at all); onProgress and the readonly properties (agentId / kind / sessionId) are not lease-checked. Published apps additionally need the LFRuntime.workspace permission; otherwise plugincmd call is rejected with exit code 126 (WORKSPACE_UNAUTHORIZED) and the handler never runs.

Methods ​

ts
LFAgent.create(options): Promise<LFAgentInstance>

Create an agent instance; kind is 'native' (reuses the full plugin agent with every built-in tool) or 'custom' (own system prompt + built-in whitelist). Both kinds may attach page-side custom tools via tools (add-only, see the table below). You must pass exactly one direct object literal ({} is fine): whenever agent is enabled, Compile / liveDebug / run <file> / run -c run the static review, so a bare LFAgent.create() or a variable argument fails to compile. The returned handle carries readonly agentId / kind / sessionId (the session is lazily created on the first send, so sessionId is an empty string before that; it is reset to "" by destroy and, for destroyOnEnd instances, after every send)

ts
instance.send(text, options?): Promise<{ text, status, usage: { inputTokens, outputTokens, totalTokens, points, costUnit, pointsSource }, estimatedCostCny, sessionId }>

Send an instruction and await the whole run; the first send lazily creates the session (no tab opens automatically — open it from the "Subagents" panel). The owning plugincmd call's ctx.signal cascades automatically, so you don't need to pass it; options.signal is an extra cancellation source (e.g. AbortSignal.timeout(ms)). Any abort tells the IDE to stop the run and rejects with LFAGENT_ABORTED, with the original reason in error.cause; if a signal is already aborted at call time, it rejects immediately with LFAGENT_ABORTED (cause is signal.reason) and no run starts. Sending again before the previous send finishes rejects with LFAGENT_BUSY. Blank text is rejected with LFAGENT_INVALID_OPTIONS. Result fields:

  • text: the model's final answer; "Agent 已结束。" when empty
  • status: how the run ended (defaults to 'completed')
  • usage: inputTokens / outputTokens / totalTokens; points is the turn's points, or null when no billing data is available; costUnit is always 'points'; pointsSource is the billing source ('mixed' when actual and local-fallback turns are mixed; it may also be 'unknown' / 'unavailable' / 'deprecated-estimated-cost-cny')
  • estimatedCostCny: usage.points / 200, deprecated compatibility only
  • sessionId: the session used by this run (with destroyOnEnd it has already been destroyed when the promise resolves)
ts
instance.stop(): Promise<{ stopped }>

Abort the in-flight run (session preserved). It returns stopped: true only when a run was actually stopped; on an idle instance (no send yet, previous run already finished, or destroyed) it returns { stopped: false }

ts
instance.destroy(): Promise<{ destroyed, sessionId }>

Delete the bound session and close its tab; idempotent — succeeds when the session is already gone. The returned sessionId is the destroyed session's id ("" if no session was ever created); afterwards instance.sessionId is ""

ts
instance.status(): Promise<{ agentId, kind, model, resolvedModel, reasoningEffort, resolvedReasoningEffort, destroyOnEnd, sessionId, sessionExists, running, messageCount, timelineCount }>

Snapshot of the instance and its session: model / reasoningEffort are the requested values (reasoningEffort already normalized), resolvedModel / resolvedReasoningEffort are what they resolve to right now; sessionExists says whether the session record still exists; running says whether a run is in flight; messageCount / timelineCount are the session's API conversation record count and timeline entry count

ts
instance.getTranscript(options?): Promise<{ sessionId, include, totalEntries, entries } | { sessionId, include, meta }>

Export the session context; include is 'messages' (default, the API conversation records used in the prompt) / 'timeline' (session timeline entries) / 'meta'. messages / timeline return { sessionId, include, totalEntries, entries: [{ index, entry }] }; meta returns { sessionId, include, meta: { id, title, createdAt, updatedAt, origin, lfagentMeta } }. Strings longer than 4000 characters are truncated and marked …[truncated N chars]. Any other include value is rejected with LFAGENT_INVALID_OPTIONS; before the first send or after the session is destroyed it throws LFAGENT_SESSION_GONE

ts
instance.onProgress(callback): () => void

Subscribe to coarse progress events; returns an unsubscribe function. The callback receives { event, ...payload }:

  • run-started: { sessionId, model, reasoningEffort } (the model and effort actually sent)
  • progress: { sessionId, entryCount, lastEntryKind, lastEntryLabel }, pushed once per new session timeline entry
  • run-finished: { sessionId }, pushed whether the run completed, failed, or was stopped

Exceptions thrown by the callback are swallowed and do not interrupt dispatch

ts
LFAgent.askUser(options): Promise<{ askId, allowMultiple, allowCustom, selectedIds, selectedLabels, customText }>

Pop a floating question card over the agent panel (NOT embedded in the session timeline) and await the user's answer; instance-independent, so custom tool handlers can call it directly (see "askUser(options) parameters" below)

send() reports canonical billing as usage.points, usage.costUnit === 'points', and usage.pointsSource; mixed actual/fallback turns use 'mixed'. Top-level estimatedCostCny = usage.points / 200 is deprecated compatibility only.

create(options) parameters ​

FieldTypeRequiredDescription
kind / mode'native' / 'custom'nomode is the legacy alias and also works at runtime (kind ?? mode); if both appear and differ, the call is rejected with LFAGENT_INVALID_OPTIONS. Default 'native': reuses the full plugin agent (including the terminal, so it may call plugincmd again recursively). 'custom': fully self-defined. Only 'native' / 'custom' are accepted; any other value (e.g. 'smart') is rejected with LFAGENT_INVALID_OPTIONS instead of silently falling back to native
systemPromptstringrequired for customFull replacement system prompt; the framework injects nothing else. Native must not declare it
tools{ name, description?, parameters?, handler }[]nodescription/parameters are optional at runtime (parameters defaults to { type: 'object', properties: {} }); marketplace review requires name/description/parameters/handler on every declared tool. Both kinds support page-side tools; native adds them on top of the full built-in set. Add-only — a built-in name collision is rejected with LFAGENT_INVALID_OPTIONS (built-in tools cannot be modified or removed); a missing name, a missing handler, or a duplicate tool name is rejected with LFAGENT_INVALID_OPTIONS too. parameters is an OpenAI function-calling JSON schema; handler(params, { signal, toolCallId }) runs in the current web page and returns a string or { output, isError } — when returning an object, put the content in its output field; any other object shape (such as a raw data object) yields empty output, so JSON.stringify data first. If the handler throws, error.message becomes the output and isError is set; the signal fires when the run is aborted.
builtinToolsstring[]nocustom only (ignored by native): built-in tool whitelist (e.g. ['read_file']); unknown tool names are silently ignored. The control tools think / respond / attempt_completion / summarize_task are always kept; ask_followup_question is always excluded (LFAgent sessions are read-only, so nobody could answer a follow-up — call LFAgent.askUser from a custom tool instead). Whitelisting execute_command / execute_run_script auto-includes their read-only help companions get_command_help / get_builtin_library_help
modelstringnoModel id, or 'inherit' (default): the main Agent's model for its current run when it has one in flight, otherwise the IDE's current chat model; re-resolved on every send (status().resolvedModel shows the current result). A nested LFAgent does not inherit its parent LFAgent's model. Model ids are not validated by create
reasoningEffort'inherit' / 'low' / 'medium' / 'high' / 'xhigh'noThinking effort. 'inherit' (default) follows the user's unified setting from the model dropdown (re-read on every send); a pinned level applies the same scale to every model — vendor parameter differences are mapped by the gateway and models without thinking capability ignore it, so no per-model branching is needed. Values are case-insensitive, surrounding whitespace is ignored, and aliases are accepted: none / minimal → low, max / x-high / extra_high → xhigh. Any other value is rejected with LFAGENT_INVALID_OPTIONS. status() returns both the normalized requested reasoningEffort and the resolvedReasoningEffort actually sent. Not part of marketplace static review
destroyOnEndbooleannoAuto-destroy the session (and close its tab) when a send settles (naturally or forcibly), after which instance.sessionId becomes ""; default false

Strict marketplace static-review rules ​

  • Marketplace code must call LFAgent.create({ ... }) directly with exactly one object-literal config ({} is fine); zero-argument calls, aliases, indirect calls, and dynamic configs cannot be reviewed
  • kind or legacy mode must statically resolve to native / custom and agree when both appear; custom requires a non-empty static systemPrompt, while native forbids it
  • Every declared tool must contain name / description / parameters / handler; the first three must be literals or recursively statically evaluable, never-reassigned const bindings (stable compiler-emitted var is compatibility-only), and names must match [A-Za-z0-9_.-]{1,64} uniquely
  • Static string concatenation, templates with static substitutions, and JSON-literal objects / arrays / primitives are allowed and are extracted and server-sealed
  • Generate stable const source; stable var exists only for esbuild-lowered compatibility and should not be handwritten. Calls/await, network / storage / environment reads, computed properties, spread, and cycles are forbidden
  • Indirect, optional, computed, or aliased LFAgent.create access is forbidden. Reviewed tool arrays/objects must not be mutated or aliased, nor passed to unknown functions; sensitive constants must not be reused outside the LFAgent declaration's reviewed fields
  • Do not forge __lfReviewed, authorPack, or lf-author-pack-v1. Limits: 32 agents, 64 tools per agent, 512 author entries, 65536 systemPrompt chars, 4096 description chars, and 32768 UTF-8 bytes of canonical parameters JSON
  • handler is execution code rather than model schema and may keep normal dynamic page logic, closures, and async work
  • Author prompts or schemas that cannot be extracted strictly are rejected at review time and never downgraded to plaintext. Compile, liveDebug, run <project-file>, and run -c apply the same static rule whenever agent is enabled and report source diagnostics. Local/liveDebug still use the v2 development transport, but cannot bypass static extractability

askUser(options) parameters ​

A single/multi choice question popup. By default a built-in "Other" option (reserved id __custom__) is appended after the fixed options: selecting it reveals a text input for a free-form answer. Concurrent questions — even from different plugincmd invocations — do not stack extra popups; they queue into the same popup whose counter goes from 1/1 to 1/2, advancing to the next question after each answer. The popup cannot be dismissed: the user must answer. The only exception is when the owning plugincmd invocation settles or the signal aborts, which withdraws the question automatically.

FieldTypeRequiredDescription
questionstringyesQuestion body (rejected with LFAGENT_INVALID_OPTIONS when empty)
titlestringnoPopup title
optionsArray<string / { id?, label }>yesChoice list (1–24 items, labels must be non-empty); string items are equivalent to { id: '<index>', label } with ids defaulting to '1', '2', …; ids must be unique; the id '__custom__' is reserved for the built-in Other option. Any violation is rejected with LFAGENT_INVALID_OPTIONS
allowMultiplebooleannotrue = multi-select (at least one required); default false = single-select
allowCustombooleannoDefault true: append the built-in "Other" option whose selection reveals a text input; the typed text is returned as customText. Pass false when only the fixed options are acceptable
signalAbortSignalnoAn extra cancellation source; the owning plugincmd call's ctx.signal already cascades automatically, so you don't need to pass it. Any abort withdraws the question from the popup queue and rejects with LFAGENT_ABORTED (original reason in error.cause); a signal that is already aborted at call time rejects immediately without showing the question

Resolves with { askId, allowMultiple, allowCustom, selectedIds, selectedLabels, customText }; single-select always yields exactly one entry in selectedIds. When the Other option was chosen, selectedIds contains '__custom__', the matching selectedLabels entry is the user's typed text, and customText carries the same text (empty string otherwise).

Lifecycle & concurrency ​

  • An instance lives and dies with the plugincmd invocation that created it: when the call settles (result / timeout / Ctrl+C / disconnect), every run it created is force-stopped, and destroyOnEnd instances have their sessions destroyed too. Omitting --timeout on plugincmd call means unlimited (the default), so long tasks just keep the default; handlers can read ctx.timeoutMs (0 = unlimited) to detect a time-boxed call at entry and refuse it
  • Instance methods (send / status / stop / destroy / getTranscript) are bound to the lease of the plugincmd call (or terminal run process) that created the instance and only work while it is alive; other calls running concurrently in the same page instance do not affect it. Once the creating call has ended, instance methods are rejected with LFAGENT_CALL_MISMATCH (or WORKSPACE_LEASE_REQUIRED if no lease is active at all). LFAgent.create / LFAgent.askUser bind to the "current" call instead — when several plugincmd calls overlap in the same page instance, they bind to the most recently started one (and are cleaned up when that call settles). Avoid letting commands that create agents or call askUser overlap concurrently
  • Concurrency is async on the same event loop (the subagent mechanism), not real threads; CPU-bound work still blocks
  • Every LFAgent session owns an isolated background terminal: execute_command / execute_run_script run in the session's own terminal, with cwd, environment variables, and the foreground queue fully isolated from the main agent terminal and other LFAgent sessions (workspace files still sync in real time). It never waits on the main agent terminal that hosts the originating plugincmd, and it is reclaimed automatically when the session is deleted or the invocation settles
  • LFAgent sessions are read-only in the IDE: no composer, agent send and rollback are rejected; context viewing/exporting works normally. Sessions never appear in the history list and no tab opens automatically — their only entry point is the collapsible "Subagents" panel above the spawning session's composer (a tree of parent-child spawning; nested spawns nest under their parent entry), where you can stop a run, open the orange tab to watch, or — once the whole subtree has stopped — delete with confirmation (cascades to every descendant). The shell's agent session commands can delete them too
  • Rollback semantics: workspace file changes made by an LFAgent are journaled against the session that issued the plugincmd call — rolling that session back to the plugincmd node (or earlier) also reverts every file change the LFAgent produced
  • One send per instance at a time: sending again before the previous send finishes is always rejected with LFAGENT_BUSY (guarded on both the page side and the IDE side)

Error codes ​

CodeMeaning
WORKSPACE_LEASE_REQUIREDPlugin source outside a plugincmd invocation, or the command did not declare workspace: ['agent'] (temporary terminal run scripts hold a run lease automatically and never hit this)
WORKSPACE_LEASE_EXPIREDThe lease was already revoked or expired when the request reached the background (a race with the call settling, timing out, or being aborted)
WORKSPACE_SCOPE_DENIEDThe background lease check found no agent scope, or the request's terminal session differs from the one the lease is bound to
WORKSPACE_UNAUTHORIZEDA published app lacks the LFRuntime.workspace permission: plugincmd call exits with code 126 and an error output starting with WORKSPACE_UNAUTHORIZED:; the invoke is never delivered and the handler never runs (it never appears as an error.code in the page)
LFAGENT_BUSYThe previous send on this instance has not finished
LFAGENT_INVALID_OPTIONSInvalid arguments: create with a kind other than native / custom or conflicting kind/mode, custom without systemPrompt, a tool with no name / no handler / a duplicate name / a built-in name, or an invalid reasoningEffort; empty send text; an invalid getTranscript include; askUser with an empty question, or options that are empty / more than 24 / have an empty label / a duplicate id / the reserved id __custom__
LFAGENT_CALL_MISMATCHAn instance method was called after the plugincmd invocation (or terminal run process) that created the instance had ended
LFAGENT_NOT_FOUNDThe IDE no longer knows the instance (e.g. its instance table was lost when the IDE page reloaded)
LFAGENT_SESSION_GONEThe instance has not sent yet or its session was destroyed (getTranscript)
LFAGENT_SESSION_UNAVAILABLEOn send, the session body could not be loaded back from the workspace, so the run is refused to avoid losing history
LFAGENT_ABORTEDsend / askUser was aborted (ctx.signal, options.signal, AbortSignal.timeout, Ctrl+C, …), or its signal was already aborted at call time (no run starts / no question is shown); the original reason is in error.cause
LFAGENT_INVOKE_SETTLEDThe owning plugincmd invocation settled (pending askUser questions are withdrawn)
LFAGENT_TRANSPORT_UNAVAILABLENo channel to the IDE could be opened: this world has no extension messaging (e.g. the MAIN world of run --tab without a terminal relay), or this script instance's USER_SCRIPT world lost its messaging configuration (after the app was disabled/re-enabled, liveDebug was started for the same project, etc., while plugincmd can still reach the stale instance) — reload the host page or restart liveDebug / re-run the script
LFAGENT_TRANSPORT_LOSTThe channel to the extension background dropped before the request finished: the extension reloaded or its background service worker restarted, the terminal run's host relay disconnected or never answered, a message could not be delivered, etc. A plugincmd call settling does not trigger it by itself (settling shows up as LFAGENT_ABORTED or LFAGENT_INVOKE_SETTLED)

Check error.code rather than parsing error.message. Requests other than send / askUser (create / stop / destroy / status / getTranscript) that get no IDE answer within 60 seconds are rejected with a timeout error that carries no code.

Example

js
// ==UserScript==
// @name Data Analyst Command
// @match https://example.com/*
// @require agent
// ==/UserScript==

LFRuntime.cli.register({
  name: "analyze",
  brief: "Spawn a sub-agent to analyze the current page data",
  workspace: ["agent"], // Declare the agent scope to use LFAgent inside the handler
  async handler(argv) {
    // native: reuse the full plugin agent and inherit the caller’s model
    const agent = await LFAgent.create({ kind: "native", destroyOnEnd: false });
    // In app source (liveDebug / installed) LFRuntime.print writes to the host page's console (view it with diag
    // under liveDebug), not the terminal; plugincmd shows the handler's return value in the terminal.
    // Always write LFRuntime.print in full — a bare print() is window.print()
    const stop = agent.onProgress((event) => LFRuntime.print("progress", JSON.stringify(event)));
    try {
      // Fall back to a default instruction when no arguments are given, otherwise the
      // empty text is rejected with LFAGENT_INVALID_OPTIONS; ctx.signal cascades
      // automatically, so aborting the command also stops the run
      const task = argv.join(" ") || "Summarize the main data on this page";
      const result = await agent.send(task);
      return `${result.text}\n(tokens: ${result.usage.totalTokens}, points: ${result.usage.points})`;
    } finally {
      stop();
    }
  },
});

// Example stub: replace with your own page-reading logic
const readSkuFromPage = (sku) => ({ sku, title: document.title, url: location.href });

// Create custom agents inside a plugincmd handler with the agent workspace scope too
LFRuntime.cli.register({
  name: "audit-sku",
  workspace: ["agent"],
  async handler(argv) {
    const custom = await LFAgent.create({
      kind: "custom",
      systemPrompt: "You are a terse SKU auditor. Use the lookup_sku tool.",
      model: "inherit",
      destroyOnEnd: true,
      tools: [{
        name: "lookup_sku",
        description: "Look up a SKU record on this page",
        parameters: { type: "object", properties: { sku: { type: "string" } }, required: ["sku"] },
        // Return a string; an object must take the form { output, isError }
        handler: async ({ sku }) => JSON.stringify(readSkuFromPage(sku)),
      }],
    });
    // options.signal is an extra cancellation source; here it caps this send at 2 minutes
    const answer = await custom.send(`Audit SKU ${argv[0] || "ABC-123"}`, { signal: AbortSignal.timeout(120000) });
    return answer.text;
  },
});

When calling LFAgent directly from a temporary terminal run script, write LFRuntime.print(...) in full to print to the terminal — a bare print() is window.print() (the browser print dialog).