Reach the Gateway and paired nodes from plugin code, and the events a long-lived Gateway service receives. Part of the Plugin runtime helpers reference.

Service-owned local browser routes

Gateway-hosted services may use ctx.localPortals?.open({ port, title, ownerSignal, assertCurrent }) to publish a loopback subprocess through the existing Portal owner. The capability is optional on hosts without Portal support. Keep one ownerSignal per exact subprocess generation: repeated opens reuse that route; a new generation gets a separate identity. Abort it synchronously on stop, restart, or crash. assertCurrent must verify that same resource before the host publishes a route after awaited work.

The host independently binds the capability to the active plugin, service, Gateway, and registry lifetimes. Retained capabilities fail once stopping begins, including failed service startup. The Portal owner owns listeners, bearer/cookie authentication, ingress, and cleanup. No browser may choose the target through untrusted parameters; expose an authenticated operator.write operation that selects the plugin's own current service port.

These service routes translate an authenticated request's Origin only when it exactly equals the Portal's published public origin, mapping it to the canonical IPv4 loopback target origin (http://127.0.0.1:<port>), with a matching Host. Service daemons must bind IPv4 loopback; ordinary Portals keep their existing localhost target Host. A service may reserve the alternate loopback alias for isolated project previews, so it cannot be used as the privileged app origin. This preserves a loopback service's same-origin checks across its authenticated transport. Foreign or absent Origin values remain unchanged; ordinary Portals retain their existing behavior for every Origin. Both HTTP and WebSocket traffic use the same header owner.

Returned URLs carry ephemeral browser authority. Return them only to the current authenticated UI; never save them in conversation artifacts or transcripts. Remote browsers require operator-configured reachable ingress, and an HTTPS UI requires HTTPS Portal ingress. Do not substitute the browser's loopback address, relax daemon authentication, or infer a public hostname.

Service scheduling

Register a service with apiVersion: 2 to require the host-owned PluginServiceSchedulerV1 capability in its start and stop context:

api.registerService({
  id: "catalog-refresh",
  apiVersion: 2,
  start({ scheduler }) {
    scheduler.schedule({
      id: "refresh",
      delayMs: 0,
      everyMs: 60_000,
      run: () => refreshCatalog({ signal: scheduler.signal }),
    });
  },
});

Import ApusPluginServiceV2, ApusPluginServiceContextV2, and PluginServiceSchedulerV1 from apus/plugin-sdk/plugin-entry when naming these contracts. Existing ApusPluginService and ApusPluginServiceContext types remain source-compatible; their scheduler field is optional. The Gateway supplies a scheduler to both service versions.

schedule accepts either an absolute atMs or a relative delayMs. Job IDs belong to one scope: scheduling the same ID replaces its pending dispatch, while other services and child scopes can use the same ID independently. mode: "earliest" retains the earlier pending deadline. everyMs schedules the next run after the callback settles and coalesces missed periods; callbacks must return their asynchronous work so retirement can join it. Errors are reported by the host scheduler. The returned handle's cancel() prevents future dispatch, and stop() also waits for that job's running callback.

Use scheduler.scope() for a shorter connection or watcher lifetime. Its beginClose() closes admission, aborts its signal, and cancels pending work; await stop() also joins running work and all descendants. Closing a child does not stop its parent or siblings. Closing the service closes all its child scopes. Retained schedule and scope functions throw after closure. Do not await a scope's stop() from one of its own running callbacks.

The host first closes scheduling admission and signals cancellation, then invokes the service's stop hook. Retirement waits for both that hook and scheduled work. Plugins retain ownership of reconnection policy, sockets, watchers, durable flush ordering, and delivery custody. Stop transports and flush pending delivery in the owner's required order, then join its child scope before closing resources used by that work. Pass the lifetime signal to operations that support cancellation, and finish admitted writes before returning. Scheduling creates no durable jobs and changes no stored config or state; an update requires no state migration.

Channel Gateway adapters opt in with apiVersion: 2. Their startAccount and stopAccount callbacks receive ChannelGatewayContextV2, including the required account scheduler. Import that context and ChannelGatewayAdapterV2 from apus/plugin-sdk/channel-contract. Existing version 1 adapters keep an optional scheduler field and continue to work unchanged. The host supplies the same capability to both versions and joins scheduled work before retiring an account. Bundled plugin timer migrations can adopt it independently.

ChannelPlugin<Account, Probe, Audit> and createChatChannelPlugin default to the version 1 Gateway adapter, preserving existing declarations and manual calls. Use ChannelPlugin<Account, Probe, Audit, 2> or the fourth createChatChannelPlugin type argument for a version 2 adapter. Inline api.registerChannel registrations infer the callback context from the adapter's apiVersion.

Published factories whose older parameter contracts did not accept a scheduler use resolvePluginServiceScheduler from apus/plugin-sdk/runtime in their version 1 adapter, then delegate to their version 2 implementation. The resolver accepts an explicit handle or borrows the currently bound service, account, or executable CLI lifetime. It rejects missing or closed owners and never creates a root scheduler. New factories require the handle explicitly.

CLI callbacks retain the scheduling owner captured when their command is registered. Timed callbacks run in the same plugin instance without inheriting the initiating request or operator authority. A command that serves a runtime must remain pending until that runtime closes; returning from the action lets the executable retire and join its scheduler before releasing the plugin.

One-shot diagnostics exporters borrow the existing CLI SDK host scheduler. When export is enabled without that host, startup reports a clear error; the agent command reports the diagnostic failure and continues. Exporter shutdown retires only its service scope, leaving the CLI scheduler available to other work.

Gateway and node namespaces

Session resource methods

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

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 apus/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.

Upload admission

For a plugin RPC that accepts uploaded file or image bytes, dynamically import assertGatewayUploadsEnabled from apus/plugin-sdk/gateway-runtime. Call it with api.runtime.config.current() during admission and again inside the retained synchronous pre-commit guard. This helper checks only gateway.uploads.enabled; it does not authorize the caller or target. Preserve current request authority and resource-access checks alongside it. Read current config for every guard invocation so disabling uploads stops an in-flight write before commit.

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, optional verified githubAccountIds, and assigned role. Account IDs come from the existing identity owner; display logins and public email do not establish that binding. 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
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.

`await api.runtime.gateway.openPluginPanel({ panelId, sessionKey, agentId })`
opens this plugin's registered native session panel in the requesting
Control UI. Unlike arbitrary `request`, this narrow capability is also
available to user-installed plugins. The host binds the plugin ID, keeps
the caller's existing `operator.write` authority, and targets only the
browser that requested the current action or agent turn. It rejects after
plugin retirement, caller revocation, or requester disconnection; a session
key never selects someone else's browser. `{ ok: true }` confirms command
delivery, not completion of a plugin's asynchronous document load. Native
UI must be enabled and the plugin must register the named panel.

`await api.runtime.gateway.readSessionFacts({ sessionKeys })` reads at most
40 sessions and returns typed `{ sessions, warnings? }` data. Each session
includes its key, identity, agent, bounded redacted title and message preview,
run state (`active`, `idle`, or `failed`), optional observer digest
(health, headline, assessment, revision), pull-request numbers and states,
archive state, and last activity time. The message preview is capped at
400 characters. `pullRequestsUnavailable` distinguishes unknown PR state
from a confirmed empty list. This lifecycle-bound read reuses the Gateway's
session projection and context-bound PR snapshot owner, preserves current
caller authority and session visibility, and omits incognito sessions.
It returns prepared facts without waiting for Git or transcript enrichment;
previews can be absent while enrichment is pending. Missing PR snapshots
refresh in the background. Use `api.runtime.gateway.subscribeSessionChanges`
to reread the affected `sessionKey` when facts change, and call the returned
unsubscribe function when finished. Category-only changes carry
`factsInvalidated: "category"`; projections that do not use session categories
can ignore them. Unchanged facts need no age-based retry.
Retained handles reject after their owner closes; no new SDK barrel export
is needed.

`await api.runtime.gateway.readSessionPresence({ sessionKeys })` answers
which of at most 40 sessions exist, as `{ present }`: the trimmed requested
keys that name an existing session. Unlike `readSessionFacts`, it ignores the
caller's session visibility and counts incognito and draft sessions, and it
returns keys only. Use it when a plugin stored a session key and must tell a
deleted session from one the current caller is not shown; keep showing
session content through reads that apply the caller's visibility. It reads
the Gateway's session projection, is limited to bundled and trusted official
plugins, and fails when the caller's authority or the plugin runtime is no
longer current. A key that names no session (invalid key or unknown agent)
is reported absent; a key whose agent store cannot be read yet fails the
call instead, because absence is the answer a plugin acts on.

`api.runtime.gateway.withSessionFacts(select, async (snapshot) => result)`
selects immutable facts through the same session-list filtering owner,
without pagination. Selection supports agent, archive, automation, activity,
and person filters, including the people facet. Unchanged session facts retain
object identity across reads; `revision` changes with the selected content or
its activity or retry deadline. Eligible human readers receive a stable opaque
`scope` for their query and current viewer, profile, access, configuration,
and redaction facts. Other callers receive `undefined`. The scope is not authority:
the host rechecks the caller after the callback settles, including awaited
plugin work. Consume reused results only inside this callback.

Selected rows include `isMain`. Failed acquisition batches retain authorized
roster fields with an `unavailable` reason; plugins can show their previous
facts for the same session identity and `redactionRevision`. When that revision
changes, discard retained text prepared under the old policy. Missing or no-longer-visible sessions are
omitted; `missingSessionKeys` identifies selected roster entries whose current
facts were omitted, so consumers can retire their fallback facts. Selected PR
facts retry from 60 seconds up to 15 minutes, retain a
last-confirmed list with `pullRequestsStale: true`, and reset on lifecycle
replacement or a successful read. When redaction rules change, the host omits
retained PR titles until fresh source text is available, while preserving
confirmed states and retry deadlines.
`activityExpiresAt` and `retryAt` describe the next relevant deadlines; no polling timer is created. Remove these
selection-only fields when adapting facts to another public response contract.

`await api.runtime.gateway.resolveGitHubAccount({ login, signal? })` resolves a
public GitHub login to `{ accountId, login }` using the Gateway's configured
GitHub API credential, with no anonymous retry. Bundled and trusted official
plugins use the existing `users.list` / `operator.read` permission; the caller,
plugin, and Gateway must stay active. Lookup failures return `{ error }` with
the existing GitHub `statusCode` (404 not found, 429 rate limited, 502 upstream
or network failure), a safe `message`, optional absolute `retryAtMs`, and
`credentialConfigured`. Cancellation and authority failures reject. The
optional capability is absent on older hosts; require an update rather than
implementing a private lookup. The transport owns timeout and identity validation.

`await api.runtime.gateway.withUserProfileIdentity({ profileId, emails, githubAccountIds }, run)`
prepares the canonical profile's original binding lifetimes for up to 500
selected email aliases under the existing `users.list` / `operator.read`
permission. Optional `githubAccountIds` binds up to 500 positive safe numeric
account IDs to that same canonical profile. The callback receives a synchronous `assertCurrent()` function.
Compose it with the action's own authorization in the store's final commit
guard and immediately before dispatching an external mutation. It reads
current facts published by the profile owner without querying SQLite on the
calling thread. Moving an alias away and back, merging the selected profile,
an unsettled profile mutation, or closing the caller invalidates the check.
Selected account IDs must remain verified members of that profile.
Unselected email bindings can change independently. The preparation is
released when the callback settles, and retained assertions then reject.
This capability checks the selected identity; it does not grant permission
to perform the action or roll back a mutation already accepted externally.
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
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
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. Apus 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, Apus 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
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 `apus/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 `apus 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.
The cleanup callback returned by `watchAvailability` may return a promise.
Node shutdown awaits that callback and reports cleanup failures.
The optional `scopes` field requests Gateway operator scopes for the invocation. Apus 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 APUS_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.

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:

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.

Apus 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:

// 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.