Plugin maintainer reference
Gateway routes
How a plugin registers HTTP endpoints on the Gateway, and the auth, scope, replacement, and admission rules those routes follow. Part of the Plugin architecture internals guide.
Gateway HTTP routes
Plugins can expose HTTP endpoints with api.registerHttpRoute(...).
api.registerHttpRoute({ path: "/acme/webhook", auth: "plugin", match: "exact", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); return true; },});Route fields:
path: route path under the gateway HTTP server.auth: required,"gateway"or"plugin". Use"gateway"to require normal gateway auth, or"plugin"for plugin-managed auth/webhook verification.match: optional."exact"(default) or"prefix".handleUpgrade: optional handler for WebSocket upgrade requests on the same route.replaceExisting: optional. Required only for dynamic lifecycle registration to replace its own existing route.handler: returntruewhen the route handled the request.
Notes:
api.registerHttpHandler(...)was removed and will cause a plugin-load error. Useapi.registerHttpRoute(...)instead.- Plugin routes must declare
authexplicitly. - Canonically equivalent paths with the same
matchmode occupy one route. Staticapi.registerHttpRoute(...)calls from the same plugin replace that route; another plugin cannot replace it. - Overlapping routes with different
authlevels are rejected. Keepexact/prefixfallthrough chains on the same auth level only. - Dynamic lifecycle code using
registerPluginHttpRoute(...)fromopenclaw/plugin-sdk/webhook-ingressmust setreplaceExisting: trueto refresh its own canonical route. Named registrations can replace only the same nonemptypluginId; when either side sets a routesource, both must set the same nonempty source. Same-plugin source-less-to-source-less refresh and anonymous-to-anonymous refresh remain supported for shipped SDK callers, but named and anonymous routes cannot replace each other. - Set
reuseExistingSameOwner: trueto share a canonical route with the same nonemptypluginIdandsource. A dynamically created route remains until its last holder releases it; reusing a staticapi.registerHttpRoute(...)route leaves its lifetime with the plugin registry. - Channel lifecycle callbacks use their Gateway's route registry. Startup routes expire when their task settles or recovery abandons it;
stopAccountroutes expire when that stop attempt settles or times out. Recovery revokes abandoned task routes before replacement startup, even if startup fails. Expired callbacks cannot dynamically register or replace routes. - Treat route
sourceas a stable same-plugin sub-owner, not a diagnostic label. Existing source-less callers may keep omitting it; source-aware callers must keep it unchanged across refreshes. - Dynamic lifecycle registration logs and returns a no-op unregister callback on rejection by default. Set
throwOnFailure: truewhen readiness depends on that route; required bundled webhook transports use strict registration so they cannot report ready without live ingress. auth: "plugin"routes do not receive operator runtime scopes automatically. They are for plugin-managed webhooks/signature verification, not privileged Gateway helper calls.auth: "gateway"routes run inside a Gateway request runtime scope. The default surface (gatewayRuntimeScopeSurface: "write-default") is intentionally conservative:- shared-secret bearer auth (
gateway.auth.mode = "token"/"password") and any non-trusted-proxy auth method get a singleoperator.writescope, even if the caller sendsx-openclaw-scopes trusted-proxycallers without an explicitx-openclaw-scopesheader also keep the legacyoperator.write-only surfacetrusted-proxycallers that do sendx-openclaw-scopesget the declared scopes instead- a route can opt into
gatewayRuntimeScopeSurface: "trusted-operator"to always honorx-openclaw-scopesfor identity-bearing auth modes (falling back to the full CLI default scope set when the header is absent)
- shared-secret bearer auth (
- Sandboxed external Control UI tabs backed by
auth: "gateway"routes use a short-lived signed cookie grant minted only by authenticated bootstrap; plugin-auth tabs keep their direct iframe path. Before mounting, the parent runs a route-owned probe inside the same opaque sandbox and fails closed when browser privacy policy blocks the cookie. The grant is bound to the owning plugin, matched route root, and current auth generation; its process-random cookie name prevents trusted same-host Gateways from overwriting one another, but cookies never isolate TCP ports. The Gateway hostname is therefore one credential boundary: do not cohost mutually untrusted services on that hostname, including other ports. Route dispatch rejects reuse against a nested route owned by another plugin. Because sandbox descendants are cross-site for cookie purposes, the grant accepts onlyGETandHEADwithoperator.read; mutations and WebSocket upgrades stay on explicit Gateway-authenticated surfaces. The cookie intentionally cannot use CHIPS: current browsers include a cross-site-ancestor bit in the partition key, so nested opaque sandbox frames would lose access to same-route assets. The cookie requires a secure context and browser permission for cross-site cookies, so gateway-auth external tabs are unavailable on plain-HTTP LAN origins or under full third-party-cookie blocking; use HTTPS/Tailscale Serve or browser-trusted loopback with a compatible cookie policy. - The grant prevents Gateway bearer-token disclosure and accidental route/scope reuse; it does not create a security boundary between native plugins. Native plugin code and the UI content it serves remain part of the same trusted in-process plugin boundary.
- Practical rule: do not assume a gateway-auth plugin route is an implicit admin surface. If your route needs admin-only behavior, opt into
trusted-operatorscope surface, require an identity-bearing auth mode, and document the explicitx-openclaw-scopesheader contract. - Startup plugins register HTTP routes with their full runtime after the Gateway starts listening. Until startup sidecars are ready, an otherwise-unclaimed HTTP request returns
503withRetry-After: 1; core routes continue to dispatch normally. This generic fallback covers plugin routes before the runtime registry can identify their owners. - After route matching and authentication, ordinary handlers participate in Gateway root-work admission. A prepared or restarting Gateway returns
503before invoking the handler. The narrow exception is a manifest-entitledauth: "gateway"route that also opts into the route-specifictrusted-operatorsurface; it remains reachable so suspension control dispatch cannot be stranded, while ordinary sibling routes from the same plugin remain behind the admission boundary. WebSockethandleUpgradeownership uses the same atomic admission boundary; once the handler accepts a socket, the socket's later lifetime is plugin-owned and is not tracked by this boundary.
Was this useful?