Plugin Runtime Helpers API Reference for OpenClaw

Reference for the api.runtime object injected into plugins, covering version access, config loading, and runtime helpers. Intended for plugin developers building channel or provider integrations.

Read this when

  • You need to call core helpers from a plugin (TTS, STT, image gen, web search, Gateway, subagent, nodes)
  • You want to understand what api.runtime exposes
  • You are accessing config, agent, or media helpers from plugin code
  • You are implementing model-picker persistence in a channel plugin

Reference for the api.runtime object that gets injected into each plugin at registration time. Reach for these helpers rather than pulling in host internals directly.

  • Channel plugins, A walkthrough that puts these helpers to work in channel plugin scenarios.

  • Provider plugins, A walkthrough that puts these helpers to work in provider plugin scenarios.

register(api) {
  const runtime = api.runtime;
}

The current OpenClaw product version is exposed through api.runtime.version, pulled from the shared version resolver so plugins observe the same number the CLI reports.

Config loading and writes

Config that already arrived through the active call path is the preferred source, such as api.config during registration or a cfg argument on channel/provider callbacks. That way a single process snapshot flows through the work instead of config being reparsed on hot paths.

Reach for api.runtime.config.current() only when a long-lived handler needs the current process snapshot and no config reached that function. The value it returns is readonly; clone it or go through a mutation helper before making edits.

Tool factories are handed ctx.runtimeConfig along with ctx.getRuntimeConfig(). Inside a long-lived tool's execute callback, use the getter when config may shift after the tool definition was built.

Persist modifications with api.runtime.config.mutateConfigFile(...) or api.runtime.config.replaceConfigFile(...). Every write has to pick an explicit afterWrite policy:

  • afterWrite: { mode: "auto" } leaves the reload decision to the gateway's planner.
  • afterWrite: { mode: "restart", reason: "..." } forces a clean restart when the writer knows hot reload is not safe.
  • afterWrite: { mode: "none", reason: "..." } turns off automatic reload/restart, but only when the caller handles the follow-up itself.

The mutation helpers hand back afterWrite plus a typed followUp summary so callers can log or verify whether they asked for a restart. The gateway still controls when that restart actually fires.

For runtime config reads and writes, use current(), a passed-in cfg, mutateConfigFile(...), or replaceConfigFile(...).

When importing from the SDK directly, favor the focused config subpaths over the broad openclaw/plugin-sdk/config-runtime compatibility barrel: config-contracts covers types, runtime-config-snapshot covers current process snapshots, and config-mutation covers writes. Read entry-scoped values from api.pluginConfig; use a supplied tool context only for its runtime-wide config snapshot, and keep plugin-specific merging at that boundary. Bundled plugin tests should mock these focused subpaths directly rather than mocking the broad compatibility barrel.

Internal OpenClaw runtime code follows the same direction: load config once at the CLI, gateway, or process boundary, then pass that value through. Successful mutation writes refresh the process runtime snapshot and advance its internal revision; long-lived caches should key off the runtime-owned cache key instead of serializing config locally. Long-lived runtime modules have a zero-tolerance scanner for ambient loadConfig() calls; use a passed cfg, a request context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary.

Provider and channel execution paths must use the active runtime config snapshot, not a file snapshot returned for config readback or editing. File snapshots preserve source values such as SecretRef markers for UI and writes; provider callbacks need the resolved runtime view. When a helper may be called with either the active source snapshot or the active runtime snapshot, route through selectApplicableRuntimeConfig() before reading credentials.

Reusable runtime utilities

Model-picker integrations rely on two focused runtime subpaths. Import the typed ModelPickerAction and ModelPickerCapabilityProfile contracts from openclaw/plugin-sdk/interactive-runtime. Import applySessionModelSelection(...) and its result types from openclaw/plugin-sdk/model-session-runtime; this is the live-session mutation seam, including its authoritative conflict check and post-commit effects. The lower-level applyModelOverrideToSessionEntry(...) helper is not a picker persistence API.

Use applyModelOverrideWithAuthProfileCompatibility(...) only as the direct persistence fallback when a channel callback cannot enter the full live-session transaction and already owns an atomic canonical session-entry patch. Pass the active config, resolved agent directory, entry, effective provider before the change, and validated selection. The helper mutates that entry only: it keeps a pinned auth profile when its recorded credential provider or configured alias is compatible, clears an incompatible pin, and enforces the model-selection lock. The caller still owns model allowlist validation, atomic persistence, markLiveSwitchPending, and any post-commit effects. Prefer applySessionModelSelection(...) whenever the full transaction is available.

Model-picker actions carry only bounded snapshot and catalog tokens. Channel actor identity, source-message binding, and serialized callback data stay in the channel's private authenticated envelope. Channel codecs opt into resolving these actions with { modelPicker: true }; channels without a picker capability continue to fail closed instead of treating the action as an opaque callback.

Use inbound botLoopProtection facts for bot-authored inbound messages. Core applies the shared in-memory sliding-window guard before session record and dispatch, without tying the policy to one channel. The guard tracks (scopeId, conversationId, participant pair) keys, counts both directions of a pair together, applies a cooldown once the window budget is exceeded, and prunes inactive entries opportunistically. Retryable transports should also supply a stable eventId; replaying an accepted event while it remains in the active window does not consume another budget slot. Suppressed events add no retained event-identity state.

Channel plugins that expose this behavior to operators should prefer the shared channels.defaults.botLoopProtection shape for baseline budgets, then layer channel/provider-specific overrides on top. The shared config uses seconds because it is user-facing:

type ChannelBotLoopProtectionConfig = {
  enabled?: boolean;
  maxEventsPerWindow?: number;
  windowSeconds?: number;
  cooldownSeconds?: number;
};

Pass normalized bot-pair facts with the resolved turn. Core resolves defaults, unit conversion, and enabled semantics:

return {
  channel: "example",
  routeSessionKey,
  storePath,
  ctxPayload,
  recordInboundSession,
  runDispatch,
  botLoopProtection: {
    scopeId: "account-1",
    conversationId: "channel-1",
    senderId: "bot-a",
    receiverId: "bot-b",
    eventId: providerEvent.id,
    config: channelConfig.botLoopProtection,
    defaultsConfig: runtimeConfig.channels?.defaults?.botLoopProtection,
    defaultEnabled: allowBotsMode !== "off",
  },
};

Use openclaw/plugin-sdk/pair-loop-guard-runtime directly only for custom two-party event loops that do not go through the shared inbound reply runner.

Plugin command runtime helpers

Plugin command handlers receive request-bound capabilities through ctx.runtimeContext. When the command is bound to a current session, ctx.runtimeContext.compactCurrent() runs the same manual compaction pipeline as /compact, including native agent-harness completion and session token accounting:

const compactCurrent = ctx.runtimeContext?.compactCurrent;
if (!compactCurrent) {
  return { text: "This command needs a bound session." };
}

const result = await compactCurrent();
return {
  text: result.compacted
    ? `Compacted to ${result.tokensAfter ?? "an unknown number of"} tokens.`
    : `Compaction did not complete: ${result.reason ?? "unknown reason"}.`,
};

This capability applies broadly to every plugin command, not just Codex. The host restricts it to the current invocation and the exact bound session generation. When no session is currently bound, the capability is unavailable, and a retained callback fails closed after the handler finishes. Avoid retaining it or rebuilding compaction through session-store patches and harness calls. The result includes compacted, optionally reason, and optionally tokensBefore and tokensAfter snapshots; OpenClaw manages all persistence and lifecycle coordination.

Runtime namespaces

api.runtime.agent

Agent identity, directories, and session management.

// Resolve the agent's working directory (agentId is required)
const agentDir = api.runtime.agent.resolveAgentDir(cfg, agentId);

// Resolve agent workspace
const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(cfg, agentId);

// Get agent identity
const identity = api.runtime.agent.resolveAgentIdentity(cfg);

// Get default thinking level
const thinking = api.runtime.agent.resolveThinkingDefault({
  cfg,
  provider,
  model,
});

// Validate a user-provided thinking level against the active provider profile
const policy = api.runtime.agent.resolveThinkingPolicy({ provider, model });
const level = api.runtime.agent.normalizeThinkingLevel("extra high");
if (level && policy.levels.some((entry) => entry.id === level)) {
  // pass level to an embedded run
}

// Resolve a synchronous create target for a session catalog
const target = api.runtime.agent.resolveSessionCatalogCreateTarget({
  config: api.runtime.config.current(),
  requestedAgentId: agentId,
  provider: "example",
  modelIds: ["example-model"],
  agentRuntime: "example-cli",
});

// Get agent timeout
const timeoutMs = api.runtime.agent.resolveAgentTimeoutMs(cfg);

// Ensure workspace exists
await api.runtime.agent.ensureAgentWorkspace(cfg);

// Run an embedded agent turn
const result = await api.runtime.agent.runEmbeddedAgent({
  sessionId: "my-plugin:task-1",
  runId: crypto.randomUUID(),
  workspaceDir: api.runtime.agent.resolveAgentWorkspaceDir(cfg, agentId),
  prompt: "Summarize the latest changes",
  timeoutMs: api.runtime.agent.resolveAgentTimeoutMs(cfg),
});

runEmbeddedAgent(...) serves as the neutral utility for launching a standard OpenClaw agent turn from plugin code. It relies on the same provider/model resolution and agent-harness selection that channel-triggered replies use.

runEmbeddedPiAgent(...) persists as a deprecated compatibility alias for existing plugins. New code should adopt runEmbeddedAgent(...).

resolveCliBackendDispatchEligibility({ provider, model, agentId, authProfileId, config, agentDir, workspaceDir }) exposes the embedded runner's CLI-backend dispatch decision (route, the backend's declared subscriptionAuthDispatch capability, stored credential mode, honoring an explicitly pinned authProfileId) to callers that opt embedded runs into cliBackendDispatch: "subscription-auth". It yields { provider } when the run would go through the CLI backend and undefined when it remains on the direct passthrough, letting callers estimate timeouts for the run that will actually occur.

resolveThinkingPolicy(...) provides the provider/model's supported thinking levels and optional default. Provider plugins own the model-specific profile via their thinking hooks, so tool plugins should invoke this runtime helper rather than importing or duplicating provider lists.

normalizeThinkingLevel(...) normalizes user text such as on, x-high, or extra high into the canonical stored level before validating it against the resolved policy.

resolveSessionCatalogCreateTarget(...) is the supported synchronous policy seam for trusted native plugins that implement SessionCatalogProvider.resolveCreateSession. It picks the first candidate model routed to the requested runtime and allowed for the requested or default agent. It returns undefined when no candidate meets both policies. Use this helper instead of importing or duplicating core model-selection policy in a plugin.

Session store helpers live under api.runtime.agent.session:

const entry = api.runtime.agent.session.getSessionEntry({ agentId, sessionKey });
for (const { sessionKey, entry } of api.runtime.agent.session.listSessionEntries({ agentId })) {
  // Iterate session rows without depending on the legacy sessions.json shape.
}
await api.runtime.agent.session.patchSessionEntry({
  agentId,
  sessionKey,
  update: (entry) => ({ thinkingLevel: "high" }),
});

const created = await api.runtime.agent.session.createSessionEntry({
  cfg,
  key: "agent:main:my-plugin:task-1",
  initialEntry: {
    agentHarnessId: "my-harness",
    modelSelectionLocked: true,
    pluginExtensions: { "my-plugin": { phase: "initializing" } },
  },
  afterCreate: async () => ({
    pluginExtensions: { "my-plugin": { phase: "ready" } },
  }),
});

const storePath = api.runtime.agent.session.resolveStorePath(cfg.session?.store, { agentId });
await api.runtime.agent.session.runWithWorkAdmission(
  { storePath, sessionKey },
  async (signal) => {
    // Create or update the session, then pass signal to the admitted agent run.
  },
);

For session workflows, prefer getSessionEntry(...), listSessionEntries(...), patchSessionEntry(...), or upsertSessionEntry(...). These helpers target sessions by agent/session identity so plugins avoid depending on the legacy sessions.json storage shape. Use preserveActivity: true for metadata-only patches that should not refresh session activity, and replaceEntry: true only when the callback returns a complete entry and deleted fields must remain deleted. Doctor and migration paths can use fallbackEntry, skipMaintenance, and requireWriteSuccess together for one atomic canonical-store repair.

createSessionEntry(...) creates a new canonical session row and transcript. Its trusted initialEntry surface is intentionally narrow. A plugin may choose an owned agentHarnessId; seed an owned CLI backend with cliBackendId, model, and cliSessionBinding; or seed a persistent ACP session with acpBackendId and acpSessionBinding: { acpAgentId, agentSessionId }. The ACP variant persists the supplied native agent session id through the canonical SQLite ACP metadata owner so the first turn resumes that external session. The injected runtime confines plugin-owned CLI and ACP sessions to the calling plugin's plugin:<id>: namespace; harness ids must be owned through registerAgentHarness(...). These are ownership invariants, not a sandbox between in-process plugins. Creation rejects an existing row; label and spawnedCwd are separate creation fields rather than trusted-entry patches.

Before advertising an ACP-backed action, call resolveAcpSessionAvailability(...) from openclaw/plugin-sdk/acp-runtime. It applies the canonical enablement, dispatch, allowed-agent, registered-backend, and backend-health checks; recheck it immediately before creating the session.

Creation holds the session lifecycle mutation fence through afterCreate, so new work waits for plugin-owned initialization to finish and pre-existing admitted work makes creation fail. The callback receives a clone of the created state. If it returns a patch, that patch may contain only pluginExtensions, and its value is the complete final pluginExtensions field. A callback or final-persistence failure rolls back the unchanged new row and transcript; guarded rollback preserves a row changed or claimed concurrently. recoverMatchingInitialEntry: true is only for retrying interrupted initialization when the persisted trusted fields match exactly, and recovery requires afterCreate to return a final patch.

Use runWithWorkAdmission(...) when a plugin starts work on a persisted session. The callback rejects archived or concurrently replaced sessions, keeps archive/reset/delete mutations coordinated through completion, and receives an AbortSignal that must be forwarded to the agent run. A harness may explicitly name trusted execution delegates through its experimental delegatedExecutionPluginIds registration field. Delegates can admit and run only an exact existing model-locked session; all session mutations remain restricted to the harness owner. See Agent harness plugins.

Maintenance and repair plugins can rely on deleteSessionEntry(...) for a single scoped session entry, cleanupSessionLifecycleArtifacts(...) for scratch sessions tied to lifecycle, and resolveSessionStoreBackupPaths(...) before any store mutation. When deletion must not clash with a concurrent session update, supply expectedSessionId and expectedUpdatedAt; if the earlier snapshot lacked a session id, use expectedSessionId: null. These helpers form a narrow repair and lifecycle surface, not a general-purpose store deletion interface.

The session helper set is completed by resolveStorePath(...) and updateSessionStoreEntry(...): resolveStorePath resolves the session store path for a given scope, while updateSessionStoreEntry({ storePath, sessionKey, update }) patches a single entry directly via store path when the caller already has that path.

For synchronous doctor and repair paths that cannot rely on the async transcript runtime, loadTranscriptEventsSync(...) is provided. It returns raw SessionStoreTranscriptEvent records. Standard plugin runtime code should opt for openclaw/plugin-sdk/session-transcript-runtime instead.

Transitional support is offered by formatSqliteSessionFileMarker(...), parseSqliteSessionFileMarker(...), and sqliteSessionFileMarkerMatchesSession(...) for code that still receives a legacy field named sessionFile. A parsed SQLite marker points to a live SQLite transcript target, not a filesystem path. New APIs should carry typed session identity rather than marker strings.

When reading or writing transcripts, import openclaw/plugin-sdk/session-transcript-runtime and pair it with resolveSessionTranscriptIdentity(...), resolveSessionTranscriptTarget(...), readSessionTranscriptEvents(...), readSessionTranscriptRawDelta(...), readSessionTranscriptVisibleMessageDelta(...), readVisibleSessionTranscriptMessageEntries(...), appendSessionTranscriptMessageByIdentity(...), publishSessionTranscriptUpdateByIdentity(...), or withSessionTranscriptWriteLock(...) alongside { agentId, sessionKey, sessionId }. These APIs let plugins identify a transcript, read raw events or visible branch-safe message entries, append messages, publish updates, and run related operations under the same transcript write lock without relying on active transcript file paths. readVisibleSessionTranscriptMessageEntries(...) yields ordered read metadata; its seq field is not a resumable cursor.

appendSessionTranscriptMessageByIdentity(...) performs a low-level append of an already canonical message. Plugins must not fabricate media-bearing user rows with top-level MediaPath, MediaPaths, MediaUrl, MediaUrls, MediaType, or MediaTypes. Channel ingress should feed ordered facts through MsgContext.media and let the host manage user-turn persistence. A host-prepared persisted user message carries canonical ordered facts under message.__openclaw.media; the generic append API does not infer or fix legacy parallel arrays.

readSessionTranscriptRawDelta(...) returns a bounded page, reset, or missing result. Hand the opaque page.cursor to the next invocation. Pure appends preserve the cursor, whereas transcript replacement yields reset with a new bootstrap cursor. Pages default to 1,000 events and 1,000,000 serialized bytes; callers may ask for up to 10,000 events and 64 MiB. When a single next event exceeds maxBytes, the page comes back empty and reports requiredBytes; retry with at least that byte limit if it stays at or below 64 MiB. Larger individual events call for the complete-read API. A cursor indicates position only and never grants access to another session.

readSessionTranscriptVisibleMessageDelta(...) offers the same bounded bootstrap-and-resume pattern over the host-owned active message projection. It returns messages oldest to newest, so context engines can drain initial history and store the opaque cursor as their watermark. Keep and return the cursor unchanged; it is a continuation hint, not an authorization credential. Linear appends resume after the last returned message. Transcript replacement, a cursor whose anchor left or shifted within the active branch, malformed cursors, and cross-session cursors all return reset with a fresh bootstrap cursor. Count and byte defaults and caps match the raw delta API. While the active projection rebuilds after a branch change, the result is unavailable with reason projection_rebuilding; retry later instead of falling back to an active transcript file.

The legacy whole-store and active transcript file helpers are no longer exported from the plugin SDK. Use the scoped entry helpers for session metadata and the transcript identity helpers for active transcript operations. Archive and support workflows needing file artifacts should use their dedicated archive surfaces, not active session runtime APIs.

api.runtime.agent.defaults

Default model and provider constants:

const model = api.runtime.agent.defaults.model; // e.g. "gpt-5.6-sol"
const provider = api.runtime.agent.defaults.provider; // e.g. "openai"

api.runtime.llm

Run a host-owned text completion without importing provider internals or duplicating OpenClaw model, auth, or base URL preparation.

const result = await api.runtime.llm.complete({
  messages: [{ role: "user", content: "Summarize this transcript." }],
  purpose: "my-plugin.summary",
  maxTokens: 512,
  temperature: 0.2,
  reasoning: "high",
});

maxTokens and temperature are advisory sampling hints. The selected provider, CLI, or harness applies them when its transport exposes an equivalent control and otherwise may ignore them. They do not weaken the execution mode's isolation guarantees.

To require the configured agent runtime and a literal zero-tool model surface, select isolated execution explicitly:

const result = await api.runtime.llm.complete({
  messages: [{ role: "user", content: "Return one JSON value." }],
  systemPrompt: "You are a JSON-only function.",
  model: "openai/gpt-5.6-sol",
  execution: {
    mode: "isolated-agent-runtime",
    authProfileId: "openai:work",
    timeoutMs: 30_000,
  },
});

This mode accepts exactly one user message. Core derives the configured CLI or harness owner, starts a fresh context, exposes no model-callable tools, and never falls back to direct provider transport. Unsupported runtimes fail before inference. result.execution.owner reports the selected owner; token usage remains absent when a CLI cannot report it.

Completion failures expose a stable code on the thrown error. Isolated callers can distinguish authorization, invalid isolated input, unsupported or unavailable runtimes, aborts, timeouts, rejected output, and other completion failures without matching message text.

Provider orchestration can also obtain the configured local-service lifecycle prior to sending an HTTP request:

const lease = await api.runtime.llm.acquireLocalService(
  {
    providerId,
    baseUrl,
    headers,
  },
  signal,
);
try {
  // Send and fully consume the provider request.
} finally {
  await lease?.release();
}

acquireLocalService(...) represents a stable, general-purpose provider-service SDK contract. The host resolves process configuration from models.providers.<providerId>.localService; callers have no ability to specify a command, arguments, environment, or lifecycle policy. Internal to the host remain process spawning, readiness, diagnostics, and idle-stop policy.

Provide the exact configured provider id and the resolved request base URL. Avoid substituting aliases with an adapter id: distinct aliases may target distinct local GPU hosts. Endpoints that fail to match the configured provider base URL are rejected by the host, with the exception of the /v1 normalization applied by Ollama and LM Studio adapters. Startup serialization, readiness probes, request leases, abort handling, and idle shutdown are all managed by the host.

The helper follows the identical simple-completion preparation route as OpenClaw's built-in runtime and the host-owned runtime config snapshot. Context engines receive a session-bound llm.complete capability, ensuring model calls employ the active session's agent rather than quietly reverting to the default agent. The output carries provider/model/agent attribution, along with normalized token, cache, and estimated cost usage when those are available.

To request a reasoning effort for the chosen model, set reasoning. Before dispatching the completion, the host normalizes the canonical thinking levels (off, minimal, low, medium, high, xhigh, adaptive, max, and ultra) for the selected provider and model. adaptive maps to medium; max and ultra become max when supported, otherwise they fall back to xhigh.

Warning

Operator opt-in through plugins.entries.<id>.llm.allowModelOverride: true in config is mandatory for model overrides. plugins.entries.<id>.llm.allowedModels limits those overrides; plugins.entries.<id>.llm.allowedCompletionModels separately limits every completion, including host-resolved defaults. For direct completions, a model@profile override stays within the authorized model override. Isolated model@profile overrides and execution.authProfileId demand plugins.entries.<id>.llm.allowAuthProfileOverride: true. Cross-agent completions demand plugins.entries.<id>.llm.allowAgentIdOverride: true.

api.runtime.gateway

Invoke another Gateway method in process while keeping the current plugin's trusted runtime identity intact. This path suits bundled or trusted official plugins that compose plugin-owned Gateway capabilities without establishing a loopback WebSocket connection.

if (await api.runtime.gateway.isAvailable()) {
  const result = await api.runtime.gateway.request<{ callId: string }>(
    "voicecall.start",
    { to: "+15550001234", mode: "conversation" },
    { timeoutMs: 60_000 },
  );
}

Requests carry operator.write scope and never grant admin scope. Calls originating from arbitrary external plugins are refused. Failed methods raise a GatewayClientRequestError, retaining structured details, retry metadata, and the Gateway error code for recovery flows. Before selecting this path from tools that may also run in standalone agent processes, use isAvailable().

api.runtime.subagent

Start and oversee background subagent runs.

// Start a subagent run
const { runId } = await api.runtime.subagent.run({
  sessionKey: "agent:main:subagent:search-helper",
  message: "Expand this query into focused follow-up searches.",
  toolsAlsoAllow: ["my_plugin_progress"],
  provider: "openai", // optional override
  model: "gpt-5.6-sol", // optional override
  deliver: false,
  completionDelivery: "current-requester", // optional, before_dispatch hooks only
});

// Wait for completion
const result = await api.runtime.subagent.waitForRun({ runId, timeoutMs: 30000 });

// Read session messages
const { messages } = await api.runtime.subagent.getSessionMessages({
  sessionKey: "agent:main:subagent:search-helper",
  limit: 10,
});

// Delete a session
await api.runtime.subagent.deleteSession({
  sessionKey: "agent:main:subagent:search-helper",
});

Warning

Model overrides (provider/model) require operator opt-in via plugins.entries.<id>.subagent.allowModelOverride: true in config. Untrusted plugins can still launch subagents, but override requests are denied.

toolsAlsoAllow adds exact, uniquely owned tools registered by the calling plugin to the worker's standard tool surface. Core tools and names shared with another plugin are rejected by the runtime. Profiles and operator tool policies remain in force, including explicit allowlists and denies.

completionDelivery: "current-requester" is off by default and becomes available only while a before_dispatch hook is processing an authenticated inbound request. OpenClaw records the canonical requester session and delivery route before invoking the plugin, then routes the subagent completion through the standard announce path. Plugins cannot supply or override requester lineage or destination fields. Calls made outside that requester-bound hook context are refused.

deleteSession(...) can remove sessions created by the same plugin through api.runtime.subagent.run(...). Deleting arbitrary user or operator sessions still demands an admin-scoped Gateway request.

api.runtime.sandbox

Examine the effective sandbox workspace authority for an agent session.

const authority = api.runtime.sandbox.resolveWorkspaceAuthority({
  config: cfg,
  agentId,
  sessionKey,
});

const liveAuthority = await api.runtime.sandbox.prepareWorkspaceAuthority({
  config: cfg,
  agentId,
  sessionKey,
  workspaceDir,
  confinedToolNames: ["my_plugin_safe_tool"],
});

The output indicates whether this session is sandboxed, whether its workspace is unavailable, read-only, or writable, and an optional confinementError when the effective Docker, tool, session, browser, or elevated policy can leave that workspace. Use this for host-owned delegation decisions that must not hand a worker more authority than its caller holds. It serves as an attestation helper, not a substitute for verifying the caller's own authorization.

prepareWorkspaceAuthority(...) runs the same policy check and additionally prepares the Docker sandbox for workspaceDir. A hot container whose live config hash mismatches the requested mounts or policy is rejected. Pass only exact tool names whose registered implementations the calling plugin confines; wildcard prefixes do not demonstrate tool ownership.

api.runtime.nodes

Enumerate connected nodes and run a node-host command from Gateway-loaded plugin code or from plugin CLI commands. Use this when a plugin owns local work on a paired device, such as a browser or audio bridge on another Mac.

const controller = new AbortController();
const { nodes } = await api.runtime.nodes.list({ connected: true });

const result = await api.runtime.nodes.invoke({
  nodeId: "mac-studio",
  command: "my-plugin.command",
  params: { action: "start" },
  timeoutMs: 30000,
  signal: controller.signal,
});

Pass the agent tool or request AbortSignal as signal when the caller can be canceled. Gateway-loaded calls forward cancellation to the paired node; node-host command handlers receive it as context.signal so they can halt in-flight requests and free local resources. Existing calls that omit the signal retain their prior behavior.

nodes.list(...) includes each connected node's advertised nodePluginTools descriptors when that node exposes plugin or MCP-backed tools to the agent. Those descriptors reflect live connection state: the Gateway removes them when the node disconnects, and a node can swap them for node.pluginTools.update after local plugin/MCP inventory changes.

Within the Gateway this runtime operates in-process. In plugin CLI commands it reaches the configured Gateway over RPC, so commands such as openclaw googlemeet recover-tab can inspect paired nodes from the terminal. Node commands still traverse normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling.

Plugins that expose node-hosted agent tools can set agentTool.defaultPlatforms for non-dangerous commands that should be allowlisted by default. Omit it when operators must opt in with gateway.nodes.commands.allow. Dangerous node-host commands should register a node-invoke policy with api.registerNodeInvokePolicy(...); the policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct node.invoke calls, node-hosted plugin tools, and higher-level plugin tools share the same enforcement path.

Warning

The optional scopes field requests Gateway operator scopes for the invocation. OpenClaw honors it only for bundled plugins and trusted official plugin installations; requests from other plugins do not elevate the call. Use it only when a trusted plugin must invoke a node command with a stricter Gateway scope, such as operator.admin.

api.runtime.tasks

Bind Task Flow and Task Run state to an existing OpenClaw session key or trusted tool context.

  • api.runtime.tasks.managedFlows is mutation-capable: create, advance, and cancel Task Flows.
  • api.runtime.tasks.flows and api.runtime.tasks.runs are read-only DTO views for listing and status lookups; both expose bindSession(...) / fromToolContext(...) plus get, list, findLatest, and resolve.

Task Flow tracks durable multi-step workflow state. It is not a scheduler: use Cron or api.session.workflow.scheduleSessionTurn(...) for future wakeups, then use managedFlows from the scheduled turn when that work needs flow state, child tasks, waits, or cancellation.

const taskFlow = api.runtime.tasks.managedFlows.fromToolContext(ctx);

const created = taskFlow.createManaged({
  controllerId: "my-plugin/review-batch",
  goal: "Review new pull requests",
});

const child = taskFlow.runTask({
  flowId: created.flowId,
  runtime: "acp",
  childSessionKey: "agent:main:subagent:reviewer",
  task: "Review PR #123",
  status: "running",
  startedAt: Date.now(),
});

const waiting = taskFlow.setWaiting({
  flowId: created.flowId,
  expectedRevision: created.revision,
  currentStep: "await-human-reply",
  waitJson: { kind: "reply", channel: "telegram" },
});

Use bindSession({ sessionKey, requesterOrigin }) when you already have a trusted OpenClaw session key from your own binding layer. Do not bind from raw user input.

api.runtime.tts

Text-to-speech synthesis.

// Standard TTS
const clip = await api.runtime.tts.textToSpeech({
  text: "Hello from OpenClaw",
  cfg: api.config,
});

// Telephony-optimized TTS
const telephonyClip = await api.runtime.tts.textToSpeechTelephony({
  text: "Hello from OpenClaw",
  cfg: api.config,
});

// List available voices
const voices = await api.runtime.tts.listVoices({
  provider: "elevenlabs",
  cfg: api.config,
});

Uses core tts configuration and provider selection. Returns PCM audio buffer + sample rate. textToSpeechStream is also available for streaming synthesis.

api.runtime.mediaUnderstanding

Image, audio, and video analysis.

// Describe an image
const image = await api.runtime.mediaUnderstanding.describeImageFile({
  filePath: "/tmp/inbound-photo.jpg",
  cfg: api.config,
  agentDir: "/tmp/agent",
});

// Transcribe audio
const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({
  filePath: "/tmp/inbound-audio.ogg",
  cfg: api.config,
  mime: "audio/ogg", // optional, for when MIME cannot be inferred
});

// Describe a video
const video = await api.runtime.mediaUnderstanding.describeVideoFile({
  filePath: "/tmp/inbound-video.mp4",
  cfg: api.config,
});

// Generic file analysis
const result = await api.runtime.mediaUnderstanding.runFile({
  filePath: "/tmp/inbound-file.pdf",
  cfg: api.config,
});

// Structured image extraction through a specific provider/model.
// Include at least one image; text inputs are supplemental context.
const evidence = await api.runtime.mediaUnderstanding.extractStructuredWithModel({
  provider: "codex",
  model: "gpt-5.6-sol",
  input: [
    {
      type: "image",
      buffer: receiptImageBuffer,
      fileName: "receipt.png",
      mime: "image/png",
    },
    { type: "text", text: "Prefer the printed total over handwritten notes." },
  ],
  instructions: "Extract vendor, total, and searchable tags.",
  schemaName: "receipt.evidence",
  jsonSchema: {
    type: "object",
    properties: {
      vendor: { type: "string" },
      total: { type: "number" },
      tags: { type: "array", items: { type: "string" } },
    },
    required: ["vendor", "total"],
  },
  cfg: api.config,
});

Returns { text: undefined } when no output is produced (e.g. skipped input).

describeImageFileWithModel(...) describes an already-known image through a specific provider/model, bypassing the default active-model resolution that describeImageFile(...) uses.

api.runtime.imageGeneration

Image generation.

const result = await api.runtime.imageGeneration.generate({
  prompt: "A robot painting a sunset",
  cfg: api.config,
});

const providers = api.runtime.imageGeneration.listProviders({ cfg: api.config });

api.runtime.videoGeneration

Video generation, mirroring the image generation shape.

const result = await api.runtime.videoGeneration.generate({
  prompt: "A drone shot flying over a coastline at sunrise",
  cfg: api.config,
});

const providers = api.runtime.videoGeneration.listProviders({ cfg: api.config });

api.runtime.musicGeneration

Music generation, mirroring the image generation shape.

const result = await api.runtime.musicGeneration.generate({
  prompt: "An upbeat lo-fi track for a coding session",
  cfg: api.config,
});

const providers = api.runtime.musicGeneration.listProviders({ cfg: api.config });

api.runtime.webSearch

Web search.

const providers = api.runtime.webSearch.listProviders({ config: api.config });

const result = await api.runtime.webSearch.search({
  config: api.config,
  args: { query: "OpenClaw plugin SDK", count: 5 },
});

api.runtime.media

Low-level media utilities.

const webMedia = await api.runtime.media.loadWebMedia(url);
const mime = await api.runtime.media.detectMime(buffer);
const kind = api.runtime.media.mediaKindFromMime("image/jpeg"); // "image"
const isVoice = api.runtime.media.isVoiceCompatibleAudio(filePath);
const metadata = await api.runtime.media.getImageMetadata(filePath);
const resized = await api.runtime.media.resizeToJpeg(buffer, { maxWidth: 800 });
const terminalQr = await api.runtime.media.renderQrTerminal("https://openclaw.ai");
const pngQr = await api.runtime.media.renderQrPngBase64("https://openclaw.ai", {
  scale: 6, // 1-12
  marginModules: 4, // 0-16
});
const pngQrDataUrl = await api.runtime.media.renderQrPngDataUrl("https://openclaw.ai");
const tmpRoot = resolvePreferredOpenClawTmpDir();
const pngQrFile = await api.runtime.media.writeQrPngTempFile("https://openclaw.ai", {
  tmpRoot,
  dirPrefix: "my-plugin-qr-",
  fileName: "qr.png",
});

api.runtime.config

Current runtime config snapshot and transactional config writes. Prefer config that was already passed into the active call path; use current() only when the handler needs the process snapshot directly.

const cfg = api.runtime.config.current();
await api.runtime.config.mutateConfigFile({
  afterWrite: { mode: "auto" },
  mutate(draft) {
    draft.plugins ??= {};
  },
});

mutateConfigFile(...) and replaceConfigFile(...) return a followUp value, for example { mode: "restart", requiresRestart: true, reason }, which records the writer intent without taking restart control away from the gateway.

api.runtime.system

System-level utilities.

const accepted = api.runtime.system.enqueueSystemEvent(text, options);
api.runtime.system.requestHeartbeat({
  source: "other",
  intent: "event",
  reason: "plugin-event",
});
api.runtime.system.requestHeartbeatNow({ reason: "plugin-event" }); // Deprecated compatibility alias.
const heartbeatResult = await api.runtime.system.runHeartbeatOnce({
  reason: "plugin-triggered-check",
});
const output = await api.runtime.system.runCommandWithTimeout(cmd, args, opts);
const hint = api.runtime.system.formatNativeDependencyHint(pkg);

runHeartbeatOnce(...) runs a single heartbeat cycle immediately, bypassing the normal coalesce timer. Delivery defaults to the configured operator DM (commands.ownerAllowFrom, then channel allowFrom); pass { heartbeat: { target: "none" } } for an internal-only run.

runCommandWithTimeout(...) yields the captured stdout and stderr, along with optional truncation counts, code, signal, killed, termination, and noOutputTimedOut. When a timeout or no-output-timeout occurs, the result reports code: 124 if the child process fails to supply a non-zero exit code. Signal exits that are not timeouts may still produce code: null, so rely on termination and noOutputTimedOut to tell timeout causes apart.

api.runtime.events

Subscribing to events.

api.runtime.events.onAgentEvent((event) => {
  /* ... */
});
api.runtime.events.onSessionTranscriptUpdate((update) => {
  /* ... */
});

api.runtime.logging

Writing logs.

const verbose = api.runtime.logging.shouldLogVerbose();
const childLogger = api.runtime.logging.getChildLogger({ plugin: "my-plugin" }, { level: "debug" });

api.runtime.modelAuth

Resolving model and provider authentication.

const auth = await api.runtime.modelAuth.getApiKeyForModel({ model, cfg });

// Request-ready auth, including provider runtime exchanges (e.g. OAuth refresh)
const runtimeAuth = await api.runtime.modelAuth.getRuntimeAuthForModel({ model, cfg });

const providerAuth = await api.runtime.modelAuth.resolveApiKeyForProvider({
  provider: "openai",
  cfg,
});

api.runtime.state

Resolving the state directory and SQLite-backed keyed storage.

const stateDir = api.runtime.state.resolveStateDir(process.env);
const store = api.runtime.state.openKeyedStore<MyRecord>({
  namespace: "my-feature",
  maxEntries: 200,
  defaultTtlMs: 15 * 60_000,
});

await store.register("key-1", { value: "hello" });
const claimed = await store.registerIfAbsent("dedupe-key", { value: "first" });
const value = await store.lookup("key-1");
await store.deleteIf?.("key-1", (current) => current.value === "hello");
await store.consume("key-1");
await store.clear();

const blobs = api.runtime.state.openBlobStore<MyBlobMetadata>({
  namespace: "rendered-artifacts",
  maxEntries: 100,
  maxBytesPerEntry: 4 * 1024 * 1024,
  maxBytesPerNamespace: 64 * 1024 * 1024,
  defaultTtlMs: 15 * 60_000,
});
await blobs.register(
  "artifact-1",
  new TextEncoder().encode("binary or text payload"),
  { contentType: "text/plain" },
);
const blob = await blobs.lookup("artifact-1");

Keyed stores persist across restarts and are scoped by the runtime-bound plugin id. For atomic dedupe claims, use registerIfAbsent(...): it returns true when the key was absent or expired and then registered, or false when a live value already exists, leaving its value, creation time, and TTL untouched. When cleanup should delete only the value previously seen, use deleteIf(...); its synchronous predicate and deletion execute within a single SQLite transaction. The limits are maxEntries per namespace, 50,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry. By default, a write that hits either row limit evicts the oldest live rows from the namespace being written; sibling namespaces are not evicted for that write, and the write still fails if the namespace cannot free enough rows. Set overflowPolicy: "reject-new" for durable ownership records that must never be evicted: new keys fail at either limit, while existing keys remain updateable.

openSyncKeyedStore<T>(...) returns the same store shape with synchronous methods (register, registerIfAbsent, deleteIf, lookup, consume, clear all return values directly instead of promises) for callers that cannot await.

openBlobStore<TMetadata>(...) stores bounded binary payloads in shared SQLite without base64 or file sidecars. It requires per-entry, per-namespace byte, and row limits; copies byte arrays at the API boundary; and lists metadata without loading every BLOB. register(...) is an explicit upsert, including for expired keys. registerIfAbsent(...) provides collision-safe creation: an expired key remains occupied until its owner claims it with deleteExpiredKey(key) or deleteExpired(), preserving metadata needed to remove related named artifacts after the SQLite commit. Any row with a TTL is transient and excluded from backup/restore even before it expires; omit TTL for durable, restorable state. Host fuses cap each BLOB at 100 MiB, each plugin at 512 MiB of physically stored BLOBs, and each plugin at 50,000 physically stored rows, including expired rows awaiting owner cleanup. Use registerIfAbsent(...) with overflowPolicy: "reject-new" when external materializations must not be silently orphaned by replacement or eviction.

openChannelIngressQueue<TPayload>(...) opens a persisted ingress queue scoped to the calling plugin, for buffering inbound events that need at-least-once processing across restarts. When stale-claim recovery uses shouldRecover, also provide shouldRecoverCorrupt if corrupt claimed payloads should be quarantined: its payload-independent claim identity lets the plugin preserve live owner and lane policy before the queue tombstones the row.

Plugin-state leases were removed. Use short SQLite transactions for atomic database work and plugin-scoped keyed stores (openKeyedStore or openSyncKeyedStore) for bounded durable state.

openChannelIngressDrain(...) opens the core channel-agnostic worker over that queue (or creates a queue when none is supplied). The drain owns stale-claim recovery, per-lane claim serialization, complete-at-adoption or complete-on-dispatch-return, retry/dead-letter disposition, optional pre-adoption supersede, and claim→adoption stall timeout. Wire claim ownership into reply generation with turnAdoptionLifecycle (via bindIngressLifecycleToReplyOptions from plugin-sdk/channel-outbound). Channel plugins keep accept-side enqueue, lane derivation, non-retryable classification, and any supersede authorization policy.

Warning

openBlobStore, openKeyedStore, openSyncKeyedStore, openChannelIngressQueue, and openChannelIngressDrain are available only to bundled plugins and trusted official plugin installations in this release. The rejection names the plugin id and the origin it loaded from; a channel plugin loaded from plugins.load.paths or an unofficial install is untrusted, so its ingress monitor fails channel start instead of running without a durable queue.

api.runtime.channel

Channel-specific runtime helpers (available when a channel plugin is loaded). Grouped by concern:

GroupPurpose
textSplitting into chunks (chunkText, chunkMarkdownText, resolveChunkMode), spotting control commands, converting Markdown tables.
replySending buffered-block replies, formatting envelopes, resolving effective messages and human-delay settings.
routingbuildAgentSessionKey, resolveAgentRoute.
pairingbuildPairingReply, reading and removing allowlist entries, upserting pairing requests, and approval entries derived from requests.
mediaFetching and storing remote media (details below).
activityLogging and retrieving the latest channel activity.
sessionExtracting session metadata from inbound events, updating last-route info.
mentionsMention-policy utilities (details below).
reactionsAck-reaction handles for showing in-flight processing.
groupsResolving group policy and require-mention settings.
debounceDebouncing inbound messages.
commandsCommand authorization and text-command access control.
outboundLoading a channel's outbound adapter.
inboundConstructing inbound event context and executing the shared inbound-event/reply core.
threadBindingsModifying idle-timeout and max-age for bound session threads.
runtimeContextsRegistering, reading, and monitoring process-local context for channels, accounts, and capabilities.

For downloading and storing channel media, api.runtime.channel.media is the recommended interface:

const saved = await api.runtime.channel.media.saveRemoteMedia({
  url,
  subdir: "inbound",
  maxBytes,
  filePathHint: fileName,
});

When a remote URL needs to become OpenClaw media, call saveRemoteMedia(...). If the plugin has already fetched a Response with its own auth, redirects, or allowlist logic, use saveResponseMedia(...). Reach for readRemoteMediaBuffer(...) only when raw bytes are needed for inspection, transformation, decryption, or re-upload. fetchRemoteMedia(...) is still available but deprecated as a compatibility alias for readRemoteMediaBuffer(...).

For bundled channel plugins using runtime injection, api.runtime.channel.mentions is the shared inbound mention-policy surface:

const mentionMatch = api.runtime.channel.mentions.matchesMentionWithExplicit(text, {
  mentionRegexes,
  mentionPatterns,
});

const decision = api.runtime.channel.mentions.resolveInboundMentionDecision({
  facts: {
    canDetectMention: true,
    wasMentioned: mentionMatch.matched,
    implicitMentionKinds: api.runtime.channel.mentions.implicitMentionKindWhen(
      "reply_to_bot",
      isReplyToBot,
    ),
  },
  policy: {
    isGroup,
    requireMention,
    allowTextCommands,
    hasControlCommand,
    commandAuthorized,
  },
});

Mention helpers available:

  • buildMentionRegexes
  • matchesMentionPatterns
  • matchesMentionWithExplicit
  • implicitMentionKindWhen
  • resolveInboundMentionDecision

Base mention decisions on the normalized { facts, policy } path.

Within reply, session, and inbound, several fields carry per-field @deprecated notes pointing to the current channel-turn kernel or channel-outbound adapters. Before building new code on any helper, review the inline JSDoc for that specific function.

Gateway service events

Services registered for the long term with api.registerService(...) get a process-local ctx.gatewayEvents facade when the process is running a Gateway broadcaster. In runtimes lacking one, that field is absent, so detect the feature and provide a fallback, such as a coarse poll. To respond after the Gateway emits a sessions.changed notice, use onSessionsChanged(...):

let unsubscribeSessionsChanged: (() => void) | undefined;

api.registerService({
  id: "session-index",
  start(ctx) {
    unsubscribeSessionsChanged = ctx.gatewayEvents?.onSessionsChanged((event) => {
      // event: { sessionKey, agentId?, label?, displayName?, reason?, phase? }
      refreshSession(event.sessionKey);
    });
  },
  stop() {
    unsubscribeSessionsChanged?.();
    unsubscribeSessionsChanged = undefined;
  },
});

This handler executes in the Gateway process and does not subscribe to a Gateway protocol. Keep the returned unsubscribe function and invoke it during service cleanup. The payload is a lightweight change notice; when the full current session entry is needed, call api.runtime.agent.session.getSessionEntry(...).

Storing runtime references

Store the runtime reference with createPluginRuntimeStore so it can be used outside the register callback:

Create the store

import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";

const store = createPluginRuntimeStore<PluginRuntime>({
  pluginId: "my-plugin",
  errorMessage: "my-plugin runtime not initialized",
});

Wire into the entry point

export default defineChannelPluginEntry({
  id: "my-plugin",
  name: "My Plugin",
  description: "Example",
  plugin: myPlugin,
  setRuntime: store.setRuntime,
});

Access from other files

export function getRuntime() {
  return store.getRuntime(); // throws if not initialized
}

export function tryGetRuntime() {
  return store.tryGetRuntime(); // returns null if not initialized
}

Note

For the runtime-store identity, pluginId is the preferred choice. The lower-level key variant is meant for rare situations where a single plugin intentionally needs multiple runtime slots.

Other top-level api fields

Beyond api.runtime, the API object exposes several additional fields:

  • api.id (string), The identifier of the plugin.

  • api.name (string), The name shown for the plugin.

  • api.config (OpenClawConfig), A snapshot of the current configuration (the live in-memory runtime snapshot, when one exists).

  • api.pluginConfig (true), "> Plugin-specific settings sourced from plugins.entries.<id>.config.

  • api.logger (PluginLogger), A logger scoped to the plugin (debug, info, warn, error).

  • api.registrationMode (PluginRegistrationMode), The active loading mode: "full" (activate live), "discovery" / "tool-discovery" (discover capabilities read-only), "setup-only" (minimal setup entry), "setup-runtime" (setup flow requiring the runtime channel entry), or "cli-metadata" (collect CLI command metadata).

  • api.resolvePath(input) (true), string"> Resolve a path relative to the plugin's root directory.

6,433 words · updated Aug 17, 2026