> ## Documentation Index
> Fetch the complete documentation index at: https://docs2.openclaw.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Plugin runtime Gateway and nodes

Reach the Gateway and paired nodes from plugin code, and the events a long-lived Gateway service receives. Part of the [Plugin runtime helpers](https://docs2.openclaw.ai/plugins/sdk-runtime.md) reference.

## Gateway and node namespaces

### Session resource methods

A plugin can declare `sessionAccess` when registering an additive Gateway method:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
api.registerGatewayMethod("my-plugin.session.open", handleOpen, {
  scope: "operator.write",
  sessionAccess: { mode: "write", allowOwnSessionScope: true, requiredTool: "my_tool" },
});
```

This requires a current authenticated profile, an existing canonical top-level
`sessionKey`, and, when supplied, its matching `agentId`. Broad writers retain
the session's sharing rules. `allowOwnSessionScope` additionally admits
`operator.sessions.write` only for the caller's own session. `requiredTool`
checks the canonical effective session tool policy and an admitted agent run's
tool limits. Agent callers are bound to their own conversation. Currently,
resource tool-policy admission does not support locked model selection.

The handler receives `sessionAccessAuthority`. Call `assertCurrent()` before
and after awaited preparation and immediately before effects. The router closes
this invocation handle when the handler finishes; it cannot be reused to adopt
new resources afterward.

Use `retain()` during the handler to obtain an original-person access borrow
with `signal`, `assertCurrent()` and `release()`, for example for an interactive
viewer. Use `retainSession()` for a resource shared by independently authorized
collaborators. That second borrow checks only the exact session incarnation and
store generation; it does not authorize operations. Each operation still needs
its own person/run admission. Normal row progress preserves both lifetimes;
reset, deletion or physical-store replacement invalidates the session borrow.
Release every borrow on failed startup, replacement and service cleanup.

`sandboxRequired` describes the admitted session/role constraint. A consumer
must provide a compliant backend or report that its backend is unsupported.
This metadata does not turn a Gateway-hosted process into a sandbox.

For tool presentation, `readGatewayToolOperatorScopes()` from
`openclaw/plugin-sdk/agent-harness-runtime` returns a copy of the current admitted
operator's scopes after checking that authority. It returns `undefined` for
system/local calls without an operator capture. It does not grant permissions;
the selected Gateway method still authorizes the request.

### Person access lifetimes

`api.registerGatewayAccessPolicy({ authorize })` adds a plugin-owned access
requirement to authenticated person admission. The callback receives the current
configuration and the canonical profile's ID, email aliases, and assigned role.
The Gateway resolves `requiredByRole` from the person's effective role and this
plugin's ID; role-bound policies use that fact instead of inferring a binding
from the default role's name.
Return `undefined` when the policy does not govern that person. Otherwise return
`{ assertCurrent, signal }`; reject admission when the required access is absent.

A named Gateway role can set `accessPolicyPlugin` to your plugin ID. That role
requires a current authority from your registered policy, including when the
plugin cannot load or its manifest is unavailable. Returning `undefined` does
not satisfy an explicit role binding. Roles without the binding retain their
existing policy behavior, and the Gateway owner remains independent.

The assertion must check the original access source immediately before an action.
Abort its signal when that source expires or is revoked, including plugin service
shutdown. An ended source must stay ended if a later grant is created. A renewal
may extend an uninterrupted source. Keep the source in its existing lifecycle
owner and initialize it before accepting person access.

Return a native `AbortSignal`. Registration preserves native cancellation and
cleanup while keeping the assertion and callable abort-reason values bound to
the plugin instance. Plugin retirement also ends captured access.

The Gateway binds the returned authority to the original person and carries it
through WebSocket and HTTP requests, and through commands admitted for a linked
channel sender. Linked administrators must satisfy the same role-bound person
policy, including after awaited command preparation. Revoking an admitted grant
ends that command's authority; a replacement grant applies only to new admission.
Explicitly configured command owners retain their independent authority.
Ordinary transport disconnect is distinct
from revocation. A policy must preserve independent staff access; it must not
infer the requesting person's authority from a session's creator, display name,
or sandbox state. Shared-secret system authority remains outside person policies.

### Durable person access grants

A policy that supports deferred shared publication returns a stable UUID as
`grantId` on its access authority and implements
`resume({ config, profile, requiredByRole, grantId })`. The Gateway records the plugin ID and this
original grant reference with the accepted requester and scope ceiling; it does
not persist the authority callback, signal, credentials, or email aliases.

The Gateway also retains opaque lifetime IDs for the person's original email
bindings. Moving an original alias to another profile ends that publication
authority, even if the alias is later restored. Display edits and changes to
aliases added after admission preserve the original binding. The plugin's grant
UUID remains independently checked; these identity facts cannot replace it.

`resume` must check that exact original grant, even when the person's current role
would otherwise be exempt. Return its current authority while it remains active,
and `undefined` only when the grant is definitively ended, absent, or replaced.
Throw while the service is starting or its state is unavailable, so recovery
retains the pending request instead of treating an unreadable grant as revoked.
A renewal can retain the UUID only if it commits before the old grant expires;
reinvitation after expiry or revocation must use a new UUID.

Policies without durable grant support still govern live admission. Their
unclassified authority cannot be converted into a restartable shared publication
request. Shared publication currently supports one original governing grant;
multiple dependencies cannot be inferred from that single reference. A newly
applicable policy also requires fresh publication admission.

> **api.runtime.gateway**
>
> Call another Gateway method in process while preserving the current plugin's trusted runtime
> identity. This is intended for bundled or trusted official plugins that compose plugin-owned
> Gateway capabilities without opening a loopback WebSocket connection.
>
> ```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
> 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 use `operator.write` scope and do not grant admin scope. Calls from arbitrary external
> plugins are rejected. Failed methods throw a `GatewayClientRequestError`, preserving structured
> `details`, retry metadata, and the Gateway error code for recovery flows. Use `isAvailable()`
> before choosing this path from tools that can also run in standalone agent processes.

> **api.runtime.nodes**
>
> List connected nodes and invoke 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, for example a browser or audio bridge on another Mac.
>
> ```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
> 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 stop
> in-flight requests and release local resources. Existing calls that omit the
> signal retain their previous behavior.
>
> Gateway-loaded plugins can open a connection-scoped binary channel to a
> registered node-host command with `nodes.openDuplex(...)`:
>
> ```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
> const controller = new AbortController();
> const channel = await api.runtime.nodes.openDuplex({
>   nodeId: "paired-node",
>   command: "my-plugin.image-bridge",
>   params: { format: "png" },
>   timeoutMs: 30000,
>   maxMessageBytes: 4 * 1024 * 1024,
>   signal: controller.signal,
> });
>
> const unsubscribe = channel.onMessage((message: Uint8Array) => {
>   console.log("Received one complete binary message:", message.byteLength);
> });
>
> try {
>   await channel.send(Uint8Array.of(1, 2, 3));
>   const result = await channel.closed;
> } finally {
>   unsubscribe();
>   channel.close();
> }
> ```
>
> `openDuplex` accepts the same node, command, parameters, timeout,
> idempotency key, session key, caller signal, and requested scopes as
> `nodes.invoke`, plus optional `maxMessageBytes` and
> `maxOutstandingDeliveryBytes` limits. The per-message limit defaults to
> 100 MiB and can be reduced, but never increased beyond 100 MiB.
> `maxOutstandingDeliveryBytes` bounds the combined size of complete messages
> whose asynchronous listener callbacks have not settled; it defaults to
> `maxMessageBytes`, cannot be smaller than that limit, and cannot exceed
> 100 MiB. A protocol that can follow a maximum-sized response with a bounded
> asynchronous notification may request a larger outstanding-delivery budget
> without raising its per-message ceiling. OpenClaw splits each binary message
> into ordered 8 KiB payload fragments that fit the existing 16 KiB
> transport-frame limit; callers always send and receive complete
> `Uint8Array` messages. Concurrent sends preserve message boundaries.
>
> Register the channel's single message listener immediately after
> `openDuplex` resolves. Before a listener is registered, OpenClaw buffers at
> most eight complete messages and 1 MiB total; exceeding either limit closes
> the invocation. The unsubscribe callback removes that listener. Listeners
> may return `Promise<void>`; a thrown error or rejected promise, caller
> abort, `close()`, node disconnect, pairing change, plugin reload or
> retirement, or Gateway shutdown closes the channel and cancels outstanding
> node work. Successful node command completion and `channel.closed` wait
> for asynchronous message listeners already in progress. `close()` is
> idempotent, and retained channel methods reject after closure.
> `channel.closed` resolves with the successful command result or rejects
> with the node, authorization, transport, or cancellation error. Channels
> cannot reconnect or survive a node disconnection.
>
> The node plugin declares `duplex: true` and registers a message listener
> through the optional framed command I/O capability. Use `duplex: "optional"`
> when the same command also supports unary calls; it remains advertised on
> nodes without duplex support. Select binary behavior from an explicit request
> parameter, not from I/O presence alone:
>
> ```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
> api.registerNodeHostCommand({
>   command: "my-plugin.image-bridge",
>   duplex: true,
>   async handle(_paramsJSON, io) {
>     if (!io?.frames) {
>       throw new Error("Framed node command I/O is unavailable.");
>     }
>
>     const frames = io.frames;
>     return await new Promise<string>((resolve, reject) => {
>       frames.onMessage((message) => {
>         void frames.send(message).then(() => resolve('{"ok":true}'), reject);
>       });
>       io.signal.addEventListener(
>         "abort",
>         () => reject(new Error("Node command was canceled.")),
>         { once: true },
>       );
>     });
>   },
> });
> ```
>
> Register `frames.onMessage(...)` before sending: the node announces framed
> readiness only after the listener exists, and `openDuplex` resolves only
> after both command dispatch and framed readiness. This prevents input from
> arriving before the plugin can consume it. The existing raw `emitChunk`
> and `onInput` helpers remain available to terminal-style commands.
>
> Interactive commands using `runNodePtyCommand` from `openclaw/plugin-sdk/node-host`
> can pass an `assertCurrent` callback for prepared source authority. The PTY
> owner rechecks it and the invocation's abort signal after asynchronous native
> loading, immediately before spawning.
>
> `openDuplex` is available only to a current, trusted in-process Gateway
> plugin runtime. Plugin CLI runtimes reject it with an actionable error;
> there is no remote polling or local fallback. Every invocation uses the
> same pairing, declared-command allowlist, plugin policy, approval,
> authorization, and connection-ownership checks as `nodes.invoke`.
>
> `nodes.list(...)` includes each connected node's advertised
> `nodePluginTools` descriptors when that node exposes plugin or MCP-backed
> tools to the agent. Those descriptors are live connection state: the Gateway
> drops them when the node disconnects, and a node can replace them with
> `node.pluginTools.update` after local plugin/MCP inventory changes.
>
> Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling.
>
> When execution identity auditing is enabled for an admitted run, those
> Gateway gates appear as enforced decision receipts. A successful node
> result is attribution-only. A policy that returns without calling its
> supplied `invokeNode` callback leaves the action unknown; returning a
> successful plugin result does not prove that the node action occurred.
>
> 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.
>
> A node-invoke policy receives the selected node's advertised `commands` and
> optional `caps`. Use those facts to require supported command behavior, then
> dispatch through the supplied `invokeNode` callback, which revalidates the
> current connection and pairing before forwarding the command.
>
> `allow-always` remains one policy decision unless the node-invoke policy explicitly declares `standingApproval: { kind: "placement", scope: "<capability>" }`. That opt-in permits later launches only for a high-risk command on the same current managed placement, node pairing, environment owner, workspace, and semantic capability scope, for at most 30 days and never across Gateway restart. Use a stable, content-free scope for a capability whose approval deliberately covers later argument changes. Do not opt in when the approved target or other request arguments must remain exact.
>
> A node command may declare `prepare(context)` for asynchronous native startup.
> Node-host initialization awaits it before publishing the initial manifest or
> connecting to the Gateway; plugin registration itself stays synchronous.
> Shared preparation callbacks run once per node registry initialization, not
> per invocation or reconnect. Optional providers should retain a known
> unavailable state on expected preparation failure and let `isAvailable`
> withhold their commands; throwing aborts node startup. Use `watchAvailability`
> for later availability changes and `onDisconnect` for execution cleanup.
>
> > [!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. When `openDuplex` runs inside an authenticated Gateway request, its effective scopes never exceed that authenticated caller's actual scopes, even if a trusted plugin requests stronger scopes. Without an authenticated incoming client, existing trusted-plugin scope behavior applies. Use requested scopes only when a trusted plugin must invoke a node command with a stricter Gateway scope, such as `operator.admin`.

## Gateway service events

Gateway-hosted services can use `ctx.invokeNode?.()` for their own registered
node commands. This uses the service's identity, so a read-only document request
can fetch a remote file without granting its caller `operator.write`.
Authorize the public operation before using this capability. Node pairing,
command grants, and plugin path policies still apply. The capability accepts no
caller-selected scopes and stops accepting work when the service stops or its
Gateway closes. Ordinary `api.runtime.nodes.invoke` keeps its caller's authority.

`ctx.openNodeDuplex?.()` opens the same framed binary transport for the service's
own commands registered with `duplex: true` or `duplex: "optional"`. It uses the same node policy and
service lifetime as `invokeNode`; callers cannot select an identity or scopes.
An optional `assertCurrent` callback adds the current operation's liveness check
before dispatch and each frame. Closing the service cancels open channels.

Gateway-hosted services also receive `ctx.getCron?.()` for the scheduler operations
already available to Gateway hooks: `list`, `add`, `update`, `remove`, and
`removeStaleJobFamily`. Non-Gateway service hosts omit this getter.

Current service handles also expose `enqueueRun(id, mode)` for service-owned
work. It uses the normal cron admission queue with the service's live authority,
independently of a completed agent tool caller. Use `"if-enabled"` to request an
immediate run without overriding a disabled job. Retained handles still reject
when the service stops or the scheduler is replaced, including while waiting for
admission. This optional method is absent on older hosts; it has no caller-scoped
fallback.

Current Gateway service handles also provide `await cron.isEnabled()` to observe
whether automatic scheduling is enabled, including the `OPENCLAW_SKIP_CRON`
override. It returns only a boolean, not storage metadata or permission to mutate
jobs. The method is optional in the public type for older host implementations;
its absence means unknown, not enabled or disabled. Consumers that support older
hosts can keep their previous reconciliation behavior when it is absent.
Disabled scheduling does not disable job CRUD or required plugin cleanup.

Service cleanup retains the owning plugin's cleanup context so `stop()` can
release resources after ordinary call admission closes. Keep the resources and
unsubscribe functions acquired by that startup attempt, and release those exact
handles. Cleanup failures do not imply that native resources were terminated;
see [Plugin lifecycle and cleanup](https://docs2.openclaw.ai/plugins/sdk-runtime.md#plugin-lifecycle-and-cleanup).

Use the service's `start()` and `stop()` methods to own recurring reconciliation.
They run for service or plugin replacement as well as Gateway startup and shutdown;
full plugin replacement also runs `gateway_stop` and `gateway_start` for affected
plugins. A service-only config reload does not replay those hooks.
Each returned scheduler handle belongs to one service lifetime and one scheduler
instance. Calls, including queued writes, reject once service shutdown begins or
that scheduler is replaced. Call `ctx.getCron()` again to obtain the replacement
scheduler while the service remains active.

A service can declare `reload: { configPrefixes: ["myConfig.service"] }` alongside
its `id`, `start`, and `stop`. After a matching config change commits, the Gateway
stops that service and calls `start(ctx)` again with the new `ctx.config`. Only
loaded services declaring the matching prefix are replaced; overlapping owners
all refresh. Existing equal or narrower restart or no-op policies still take precedence.
Each start receives a new capability lease and health reporter. Stop must release
resources before resolving; failed replacement cleanup or startup triggers
Gateway recovery. A full plugin replacement subsumes these service restarts.
The stop hook runs after that attempt's original start settles. A replacement
deadline can end the caller's wait and revoke service capabilities while final
cleanup remains owned.

Trusted official diagnostics exporter services can also receive
`ctx.internalDiagnostics.getRuntimeIdentity?.()`. It returns the hosting
process's canonical `processInstanceId` and optional loaded `buildId`, with no
filesystem lookup or RPC. Capture it during service startup; a retained getter
throws after the service lease is revoked. Hosts that do not provide this
optional capability leave runtime identity unavailable. This diagnostic fact
does not grant authority or identify a service-reload epoch.

Their `ctx.internalDiagnostics.onEvent(listener, filter?, options?)` subscription
delivers events, trust metadata, and a frozen private-data object. Exporters that
only need events and metadata can pass `{ includePrivateData: false }` as the
third registration argument to skip private payload copies and receive a frozen
empty object instead. This preserves event filters, trusted-only delivery, and
service lease cleanup. Private data remains enabled by default; older hosts
ignore the optional argument and retain their existing copying behavior.

Long-lived services registered with `api.registerService(...)` receive a process-local
`ctx.gatewayEvents` facade when the process runs a Gateway broadcaster; in runtimes without one the
field is absent, so feature-detect it and keep a fallback (for example a coarse poll). Use
`onSessionsChanged(...)` to react after the Gateway broadcasts a `sessions.changed` notice:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
let unsubscribeSessionsChanged: (() => void) | undefined;

api.registerService({
  id: "session-index",
  start(ctx) {
    unsubscribeSessionsChanged = ctx.gatewayEvents?.onSessionsChanged((event) => {
      // event: { sessionKey, agentId?, label?, displayName?, reason?, phase? }
      // refreshSession is your plugin's own handler, not an SDK export.
      refreshSession(event.sessionKey);
    });
  },
  stop() {
    unsubscribeSessionsChanged?.();
    unsubscribeSessionsChanged = undefined;
  },
});
```

The handler runs in the Gateway process and does not add a Gateway protocol subscription. Keep the
returned unsubscribe function and call it during service cleanup. The payload is a lightweight
change notice; use `api.runtime.agent.session.getSessionEntry(...)` when the plugin needs the full
current session entry.

OpenClaw calls a service's `stop()` at most once per startup attempt, including when a replacement
times out before startup fails. Failed-start rollback and shutdown share the same cleanup result;
a cleanup failure is recorded rather than retried within that attempt.

If a replacement fails, the Gateway may call `start()` again on the previous service to restore
it. Recreate resources released by `stop()` and reset per-start flags so tools and background
work remain usable after rollback.

Service startup failures from a returned or awaited promise are recorded automatically. A service
that intentionally starts required work in the background must report later failure and recovery
through its generation-bound health reporter:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
// startIndexWorker and stopIndexWorker are your plugin's own background-work
// helpers, not SDK exports.
api.registerService({
  id: "index-worker",
  start(ctx) {
    void startIndexWorker().then(
      () => ctx.serviceHealth?.clearFailure(),
      (error) => ctx.serviceHealth?.reportFailure(error),
    );
  },
  stop() {
    stopIndexWorker();
  },
});
```

The reporter is revoked when the service stops or its plugin registry generation is replaced, so a
late callback from an old generation cannot overwrite current health. Prefer returning the startup
promise when the service is not usable until that promise settles; use the reporter only for
deliberately nonblocking work that owns its own stop path.
