HTTP routes and WebSocket connections
HTTP routes
Public webhooks
ctx.registerHttpRoute({ path, handler }) mounts a public webhook at
/hooks/<plugin>/<path>. The path is lowercase slash-separated segments and
must appear in provides.httpRoutes. The public types are in src/plugins/api.ts;
the dispatcher is src/api/routes/hooks.ts.
The route receives:
type PluginHttpRequest = {
origin: ClientOrigin;
method: string;
path: string;
query: Record<string, string>;
headers: Record<string, string>;
body: () => Promise<Buffer>;
json: <T = unknown>() => Promise<T>;
stream?: () => ReadableStream<Uint8Array>;
acceptsStreamBody?: boolean;
};
origin is resolved by the host through the same clientOrigin() rule used by
authenticated plugin API routes. x-real-ip is trusted only when
security.trustProxy is enabled. x-forwarded-for is always an untrusted
hint. A request without either forwarding header is reported as the local
origin, marked trusted. The rule lives only in src/api/clientIp.ts.
origin.trusted does not authenticate the request and does not validate the
browser Origin header. A public handler must still verify its signature,
JWT, shared secret, or other protocol authority. If an IP address is part of
an authorization decision, reject an untrusted origin. If it is only a
rate-limit key, keep a tenant or global limit as well and preserve the
untrusted flag in audit data. Never re-parse proxy headers inside a plugin.
An exact registered mount can opt into a bounded request stream with
maxStreamBodyBytes (up to 10 MiB). The route still must be declared in
provides.httpRoutes. Only the exact mount receives req.stream(); its
handler authenticates before consuming it. A declared Content-Length above
the limit is refused with HTTP 413 before the handler runs. Calling body() or json() on
that mount fails. Descendant paths and all other hooks retain the 1 MiB
buffering cap. Oversize streams abort rather than commit a partial upload.
The reads:['conversation-files'] grant exposes ctx.host.conversationFiles():
uploadProjectImage writes through the same managed Project guest upload
transport as /brain/uploads; readProjectImage re-reads the bounded stored
image; readShared requires an exact session owned by the supplied account
and a parsed ShareImage/ShareFile reference in that session. A public plugin
must additionally authorize its own visitor token, Origin and turn event.
The daemon does not authenticate this mount with an Elowen bearer token. Authenticate the external sender in the plugin, for example with a signature or JWT, and reject unproven requests.
A response may set status, headers, and body. An object body is
JSON-serialized. Strings and byte arrays pass through. A byte-mode Node
Readable or web ReadableStream<Uint8Array> streams a large response when
req.acceptsStreamBody is true. Return Node sources directly rather than
using Readable.toWeb: the shared hook/API mapper owns a byte-counted,
64 KiB, backpressured adapter. Cancellation marks the adapter closed before
destroying the source, so pending data/read callbacks cannot enqueue or
close an already-cancelled controller. HEAD and bodyless statuses destroy
the source without reading it. Early request aborts also cancel the source;
real read failures remain errors and are reported through the existing
stream-error callback. Sites returns its Project response and file sources
this way; its independent worker pipes the same Node sources directly to
HTTP and needs no web conversion. Authenticated API routes additionally
support sse(send, signal).
Authenticated plugin API
ctx.registerApiRoute({ path, method, access, handler }) mounts a route at
/plugins/<plugin>/api/<path>. The path must be declared in
provides.apiRoutes, uses lowercase slash-separated segments, and
access is "user" or "admin". The daemon authenticates the bearer
token and applies the declared access level before invoking the handler.
The request adds:
auth.userId, which can benullin open mode.auth.admin.auth.tokenScope, which is"user".- Optional
auth.agent: trueonly for a verified turn-bound ElowenApi credential. A human-only action rejects it; login sessions and personal API tokens omit it. This marks the verified principal, never a caller-supplied header or body field. auth.credentialScope, the verified HTTP credential origin (src/plugins/api.ts).auth.accessibleProjects, the caller's project scope.
Buffered request bodies on /plugins/<plugin>/api/ are capped at 8 MiB
(MAX_API_BODY_BYTES in src/api/routes/pluginApi.ts).
params, containing decoded values for pattern parameters when a root mount is used.
A GET route must not change state, start inference or mint anything: a turn in
Plan mode and a read-only delegated child call the API with a credential
that may use only GET and HEAD, and the bearer gate refuses every other
method on that credential before the handler runs (src/api/auth.ts).
A route registered without method answers every method, not only GET.
The registry prefers an exact-method route and falls back to the method-less
one (src/plugins/registry.ts), so such a handler must check the method itself
before it changes state. Put any action behind POST, PUT, PATCH or DELETE.
A handler must still enforce its domain rules. In particular,
accessibleProjects === null is not universally an authorization grant:
setup mode can have no identity. Re-check durable Project membership for
Project-owned operations.
A route may set rootMount to preserve a pre-existing root API path.
The full root path must be declared in provides.apiRoutes, including its
leading slash. Root mounts use the same authentication and access checks. Unknown roots
return 404; a discovered but disabled or unavailable owner returns 503.
Core does not implement fallback behavior for plugin-owned URLs.
WebSockets
ctx.registerWebSocketRoute mounts at
/ws/plugins/<plugin>/<path>. The path must be in provides.wsRoutes, use
lowercase segments, and may contain :param segments. Access is "user",
"admin", or "public". User/admin routes use tickets; public routes require
plugin-owned authorization before the upgrade.
The browser does not send a bearer token on the direct upgrade. Mint a ticket from an authenticated API route:
const issued = ctx.issueWebSocketTicket({
userId: req.auth.userId,
payload: { sessionId: "example" }
});
Use the ticket once as ?ticket=.... It expires after 30 seconds by
default and is clamped to five minutes. The daemon resolves the ticket owner's
current identity, admin state, and project scope at redemption time. It also
checks the same-host Origin for browser requests. A missing, spent or
expired ticket, a deleted owner, or an admin route with a non-admin owner
receives HTTP 401. A grantable plugin whose owner lacks the grant receives 403
(src/api/pluginWebSocket.ts). Inbound frames are capped at 16 MiB
(MAX_FRAME_BYTES).
The handler receives conn.auth, conn.payload, decoded params, query
values without the spent ticket, send, onMessage, onClose, close,
signal, and bufferedAmount(). Stop producing data when signal aborts
and apply backpressure using bufferedAmount(). A handler failure closes
the connection with code 1011 and does not crash the daemon.
For external media bridges, declare a public route with a mandatory verifier:
ctx.registerWebSocketRoute({
path: 'media/:token',
access: 'public',
authorize: ({ params, headers }) => verifyCallToken(params.token, headers),
handler: conn => bridgeMedia(conn)
});
authorize may be async. It receives decoded path parameters and lowercase header
names with string values; repeated values are comma-joined. Only true permits an
upgrade. False receives HTTP 401 before any 101, and verifier failures receive 500.
A public connection has no auth or payload; no ticket or account is resolved.
Same-host Origin checking remains before authorization, with no-Origin native
clients permitted to reach the verifier. The registry Twilio plugin consumes this path
for call media as media/:callId/:token; token/signature validation stays in the plugin. Logs for every route
name its declared pattern rather than concrete parameter values. No route means
no listener work beyond matching; a public handshake adds one verifier invocation.