NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · HTTP routes and WebSocket connections
Developer reference

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 be null in open mode.
  • auth.admin.
  • auth.tokenScope, which is "user".
  • Optional auth.agent: true only 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.