External apps talk to Apus through the Gateway protocol: WebSocket transport plus RPC methods. Use it when a script, dashboard, CI job, IDE extension, or another process wants to start agent runs, stream events, wait for results, cancel work, or inspect Gateway resources.

For npm packages, device pairing, reconnect recovery, history, subscriptions, and approvals, start with Building a Gateway client. The install guide pins the verified stable 2026.8.1 packages and explains how package and wire versions affect compatibility. If your app supervises the Gateway as a child process, also read Embedding Apus.

This page is for code outside the Apus process. Plugin code that runs inside Apus should use documented apus/plugin-sdk/* subpaths instead.

What is available today

Surface Status Use it for
Gateway client guide Stable packages npm packages, auth, reconnect, history, events, approvals, and version policy.
Embedding guide Release train Child-process environment, readiness, lifecycle, recovery, RPC ownership, and packaging.
Gateway protocol Ready WebSocket transport, connect handshake, auth scopes, protocol versioning, and events.
Gateway protocol RPC methods Ready Current Gateway methods for agents, sessions, tasks, models, tools, artifacts, and approvals.
apus agent Ready One-shot script integration when shelling out to the CLI is enough.
apus message Ready Sending messages or channel actions from scripts.

Recommended path

  1. Run or discover a Gateway.
  2. Connect over the Gateway protocol.
  3. Call documented RPC methods from Gateway protocol RPC methods.
  4. Pin the Apus version you test against.
  5. Recheck the RPC reference when upgrading Apus.

For agent runs, start with the agent RPC and pair it with agent.wait for a terminal result. For durable conversation state, use the sessions.* methods. For UI integrations, subscribe to Gateway events and render only the event families your app understands.

agent.wait can return status: "pending" while a turn is queued. A timeout response without terminal metadata means the wait expired; continue waiting or consume lifecycle events. Terminal status: "error" can represent cancellation: stopReason: "superseded" means a newer session writer replaced the run. Preserve that reason when presenting the result.

Cooperative host suspension

Hosting controllers that freeze or snapshot a running process can use the host-neutral suspension handshake:

  1. Stop admitting external ingress controlled by the host.
  2. Call gateway.suspend.prepare with a stable, unique requestId.
  3. If the response is busy, keep the process running and retry later. To hold admission closed while already-admitted work finishes, request the optional drain mode and poll gateway.suspend.status instead.
  4. If the response is ready, save the returned suspensionId, then freeze or snapshot the process before expiresAtMs.
  5. After thaw, or if suspension is abandoned, call gateway.suspend.resume with that suspensionId over the existing or a newly authenticated WebSocket. The CLI equivalents are apus gateway suspend and apus gateway resume <suspensionId>.

A draining or prepared Gateway accepts authenticated operator WebSocket connections, allowing a controller to reconnect and check, renew, or release its own lease. New node and worker connections remain fenced. A prepared Gateway fences every method except gateway.suspend.* and one exact predecessor-bound restart. That exception requires a non-safe gateway.restart.request whose target matches the live Gateway lock; safe and untargeted restart requests remain fenced. That restart RPC exception is not available while the Gateway is still draining. Controllers may reconnect after thaw and call resume. The Admin HTTP RPC plugin remains available for hosts that cannot speak WebSocket at all. If every control path is lost, the two-minute lease expiry reopens admission automatically.

Closing the Gateway cancels background work queued by operator reconnects without waiting for suspension expiry. Shutdown still waits for work already running to finish.

The hello snapshot includes suspension: { phase }, and gateway.suspension events publish admission changes immediately. The phase is accepting, preparing, draining, or prepared; neither surface exposes suspension IDs. The Control UI's bottom-left connection indicator shows Suspending… during preparation or draining and Suspended while prepared, including in Settings. It clears when suspension admission reopens, not when a request succeeds. Offline and restart indicators take precedence. Scheduler recovery keeps the suspension indicator until admission actually reopens; there is no separate resuming phase.

The RPC contract is:

terminalPolicy and drain are optional. terminalPolicy accepts only "preserve" or "terminate" and defaults to "preserve"; drain defaults to false. The terminal policy applies to both immediate preparation and drain mode:

Pending final chat-state writes (terminal-persistence) and all other tracked work still block preparation under either policy.

IDs are trimmed, must contain a non-whitespace character, and are limited to 128 characters. A busy prepare result has status: "busy", reason, retryAfterMs, activeCount, and blockers. A ready result has this shape:

{
  "status": "ready",
  "suspensionId": "2c3f...",
  "expiresAtMs": 1770000000000,
  "activeCount": 0,
  "blockers": []
}

If drain: true finds active work, preparation acquires a renewable lease, pauses new automatic cron scheduling, closes admission to unrelated new work, and returns:

{
  "status": "draining",
  "suspensionId": "2c3f...",
  "expiresAtMs": 1770000000000,
  "retryAfterMs": 20000,
  "activeCount": 2,
  "blockers": [
    { "kind": "root-request", "count": 1, "message": "1 active request" },
    { "kind": "terminal-session", "count": 1, "message": "1 open terminal session" }
  ]
}

Already-admitted work and its owned completions continue naturally; unrelated new runs, sessions, scheduled jobs, and independent work stay rejected. With terminalPolicy: "preserve", an open terminal can keep the lease draining until it closes, the controller resumes the Gateway, or the lease expires. With terminalPolicy: "terminate", that same terminal remains open but does not block readiness. Neither policy terminates or detaches terminals during preparation, polling, renewal, resume, or lease expiry.

Poll gateway.suspend.status with the returned suspensionId, honoring retryAfterMs. While blockers remain, status returns status: "draining" together with expiresAtMs, retryAfterMs, activeCount, and blockers. Each status call refreshes the active-work snapshot. If protected lifecycle or final-chat writes are pending, the RPC waits up to 15 seconds for them to settle, then returns a fresh observation. Writes still pending at that deadline remain reported; polling never cancels work, releases custody, or renews the lease. Once every blocker has finished, the same lease transitions to {"status":"ready","expiresAtMs":...}. Status preserves the original response shape by default for published validators. Opt into lifecycle metadata with includeLifecycle: true to receive ownerId, the original requestId; draining status also includes phase: "draining". Use an updated response validator when requesting this metadata. Status returns {"status":"running"} when no suspension is held; querying a different active lease returns a conflict without exposing its identifiers. Resume returns {"ok":true,"status":"running","resumed":true}; repeating it after a successful resume returns resumed: false.

The dedicated apus gateway suspend command retains its existing refuse-only behavior. Controllers can request drain mode through any Gateway client or the generic CLI RPC command:

apus gateway call gateway.suspend.prepare \
  --params '{"requestId":"host-operation-1","terminalPolicy":"preserve","drain":true}' \
  --json
apus gateway call gateway.suspend.status \
  --params '{"suspensionId":"<suspension-id>"}' \
  --json
apus gateway resume '<suspension-id>'

For a release update, use the same handshake with terminalPolicy: "terminate" so an open terminal cannot hold the drain indefinitely:

apus gateway call gateway.suspend.prepare \
  --params '{"requestId":"release-update-1","terminalPolicy":"terminate","drain":true}' \
  --json

Wait for the lease to become ready before performing the checked restart. Terminal commands and scrollback are not recovered after restart; see Restart recovery.

An external deployment controller that explicitly authorizes interrupting remaining work can instead call gateway.suspend.handoff after its own graceful drain budget. The target must match the pid and processInstanceId obtained from system.info before suspension. This arms restart cleanup for that exact lease and host iteration's next SIGTERM; it does not send a signal or create a successor. The controller still owns the native service restart. A successful response is { "status": "armed", "suspensionId": "...", "expiresAtMs": ... }.

The arm expires with the lease. Repeating prepare or handoff does not extend armed authority. Resume, replacement, another accepted lifecycle action, or host retirement invalidates it. Pending final-chat persistence refuses arming and is checked again when SIGTERM consumes the arm. If that check refuses, the Gateway logs the refusal and retains ordinary graceful-stop behavior. An accepted handoff uses the existing restart recovery and abort cleanup, then exits for the external controller. An ordinary stop without an arm keeps waiting for active work. Controllers must defer on unsupported methods or refused handoffs; a draining lease alone never authorizes interruption.

Once shutdown commits, including an installation-replaced restart during a held suspension, the same owner can still poll status: "draining". With includeLifecycle: true, it also receives phase: "interrupting". Ownership and foreign-token conflicts remain stable across authenticated operator reconnects; node and worker connections remain fenced. The shutdown record remains available past the old lease expiry; this is shutdown progress, not a renewable lease or permission to freeze the process. Resume is refused after shutdown commits. The owner records phase: "exiting" before server teardown; RPC access ends when that teardown closes request admission and transports. These facts remain in memory until process exit or the next in-process lifecycle resets them.

The running Gateway serves this contract; staging a newer installation does not change an older resident's responses. Request lifecycle metadata only once a Gateway version supporting it is running. Drivers must still verify their exact predecessor and handle transport closure through their lifecycle owner.

A competing request ID or transient scheduler-resume failure returns retryable UNAVAILABLE with retryAfterMs. During scheduler recovery, prepare, status, and resume all return that error, the Gateway remains not-ready and fail-closed, and the host must not freeze or snapshot it. Apus retries the scheduler automatically and reopens admission only after recovery succeeds. A mismatched resume ID returns INVALID_REQUEST. Prepare is subject to the Gateway's control-plane write limit of 30 attempts per minute; honor the returned retry delay. WebSocket clients are bucketed by device and IP. Admin HTTP controllers are bucketed by resolved client IP, so controllers behind one proxy can share a budget.

Without drain: true, preparation remains refuse-only: Apus closes new root/session/command admission, pauses automatic cron ticks, and inspects work synchronously. If anything is active, it resumes the scheduler and reopens admission before returning busy; it does not interrupt or drain that work. With drain: true, the same suspension owner instead keeps admission closed and cron scheduling paused until existing work settles. Already-owned cron completion and reconciliation continue.

Both draining and ready leases share a two-minute budget starting before work inspection. Preparation that exhausts that budget resumes scheduling and fails instead of returning an expired lease. Clock rollback does not extend the budget. Repeat prepare before expiresAtMs with the same requestId, terminal policy, and drain mode to renew the same suspensionId unless a restart handoff is armed; changing any of those values conflicts with the existing lease. Use status for routine polling and reserve prepare for renewal to avoid consuming the write budget. Explicit resume and lease expiry restore scheduling before reopening admission. Leases remain in memory and disappear if the Gateway process exits. Restart emission that becomes due during a ready lease waits until the lease resumes; an in-flight restart makes preparation return busy.

While draining or ready, /healthz remains live and /readyz returns 503. Local or authenticated readiness responses include gateway-draining; unauthenticated remote checks receive only { "ready": false }. The HTTP health check, suspension methods on authenticated operator WebSocket connections, and an already-enabled Admin HTTP RPC route remain available. Other unrelated RPCs return retryable UNAVAILABLE. Built-in HTTP user-work routes and ordinary plugin HTTP routes, including OpenAI-compatible APIs, tool/session operations, node watches, and configured hooks, return 503 with error.code: "gateway_unavailable". New plugin-owned WebSocket upgrades also return 503; this covers upgrade ownership, not work performed later over an established plugin socket.

This handshake does not persist incoming messages, stop third-party channel transports, or control the hosting platform. The host must fence its ingress before preparation and remains responsible for wake, snapshot/freeze, and stop. activeCount is the aggregate tracked-work count, while blockers contains non-zero native category counts and bounded summary messages. Categories include background-exec, cron-run, agent-run, acp-run, and media-generation, alongside request, queue, reply, session, and terminal work. Categories can overlap, so the count is not a number of unique jobs. This is not a general process-quiescence barrier. Blockers contain no command text, output, operating system process IDs, or session or scope identifiers. Channel health, maintenance, cache refresh, established plugin WebSocket sessions, and unregistered plugin-owned background work can remain active. The hosting platform must freeze or snapshot the full process tree and its filesystem consistently; unregistered work cannot be proven idle by this first contract.

For host wake scheduling, keep the Apus-facing part in an in-process plugin and project idempotent full snapshots to the external host adapter. The hosting controller should not import the Plugin SDK or reconstruct cron state from event deltas. See Safe external cron projection.

App code vs plugin code

Use Gateway RPC when code lives outside Apus:

Use the Plugin SDK when code runs inside Apus:

External apps should not import apus/plugin-sdk/*; those subpaths are for plugins loaded by Apus.

Related