NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Platform adapters and relays
Developer reference

Platform adapters and relays

Platform adapters

Declare adapter names in provides.platforms and register a PlatformAdapter with name, connect, listen, and send. Optional methods include disconnect, proactive notify, and channel control.

The adapter owns transport authentication, normalization, formatting, and platform state. Use elowen-plugin-shared for transport-neutral behavior. Its contract version is currently 8. Declare the exact value with requiresSharedApi: 8 and set requiresCore to the paired core release. Do not preserve retired helper imports or bypass the exact-match gate.

elowen-plugin-shared/access exports normalizeRolePolicies(value, sameId) for Discord, Telegram and Teams. Call it at the config boundary with the adapter's identity equality, for example normalizeRolePolicies(config.rolePolicies, sameId) in Telegram. The equality must compare identities, including * only with itself, rather than granting wildcard access. Matching and editor duplicate validation use the same platform equality: Discord ids compare exactly, Telegram usernames ignore a leading @ and case when either id has that prefix, and Teams ids containing @ ignore case. The helper drops malformed rows and empty ids, trims ids/names, preserves unknown properties and the first equivalent row, and places the wildcard last. Missing/non-array lists yield no policies. It registers nothing, makes no requests and uses linear storage with at most quadratic identity comparisons. This additive export retains shared API 8; consumers require core 0.29.81 or newer.

elowen-plugin-shared/liveMessage supplies createLiveMessage for Discord, Teams and Telegram. Adapters provide editable-message transport verbs and postFinalText, which must reject on any failed send, including a low-level helper's null result. Finalization uses the same delivery-result handling for live answers and collapsed tool activity: total failure posts the complete text with the trigger's reply reference, while partial failure posts only the replacement suffix, sequentially and unthreaded. Before returning that suffix, the engine freezes all its draft bubbles, waits for their send chains and removes them along with obsolete longer tails through the existing best-effort delete transport. The settled prefix remains visible. Cleanup precedes replacement delivery even when that delivery rejects; a platform refusing draft deletion may still leave a draft visible. Text delivery errors reject finalize(), allowing elowen-plugin-shared/turnRunner to mark the turn failed instead of done. Without a live answer the final-text transport posts once through the same path. The footer remains on the final text chunk and image captions stay separate. The helper registers nothing; work scales with the split chunks and the required sequential transport calls. See packages/plugin-shared/README.md for the binding contract.

Put platform-specific prompt fragments in prompt/*.md. The loader only applies them after the matching platform has registered. This directory is reserved for the platform convention. Editable templates registered through registerPrompts belong in a different directory, for example bundled CodeMode's templates/codex-work.md; moving them does not change the template name.

Synthetic relay turns

The host gives a registered adapter PlatformControlApi.relay() through its optional control() callback. Relay is for server-originated platform work: scheduled jobs, cross-person agent messages, webhook workers, and similar turns that have no ordinary human ingress callback.

type PlatformRelayEvent = BrainEvent;
type PlatformRelayEventSink = (event: PlatformRelayEvent) => void;

interface PlatformRelayObserver {
  onEvent: PlatformRelayEventSink;
  signal?: AbortSignal;
}

interface PlatformControlApi {
  relay(
    src: SessionSource,
    text: string,
    observer?: PlatformRelayObserver,
  ): Promise<string | undefined>;
}

Owning code: the types are in src/plugins/api.ts, and the relay itself is in src/brain/platforms.ts (relay and relaySink). A source whose platform differs from the adapter name rejects with relay platform mismatch. An adapter without control() gets no relay at all, and an observer is optional: without one the turn runs with no live subscriber.

The source platform must equal the registered adapter name. Core resolves the acting account, policy, tools, Project, durable channel session, and lock exactly as it does for inbound traffic. actAsUserId is effective only as account scope after host relay provenance and account lookup; it is not proof of a human sender.

Events are ordered and observational. session identifies the durable session before the turn runs. A successful turn emits one terminal idle only after the assistant row is persisted; it may include messageId, model, usage, and timing. The returned promise remains the terminal authority.

Aborting observer.signal stops event delivery only. It does not abort the turn, clear its queue, reject the relay promise, or change durable history. Use PlatformControlApi.abort(ref) when the product explicitly intends to stop the turn. An observer exception is isolated and detaches that observer.

PlatformControlApi.abort(ref) stops the conversation's own turn wherever it is. A streaming turn is interrupted. A turn that holds the conversation but is still being prepared (spawning the session, importing history, cold-start compaction) has no live run yet, so the stop is kept as a pending abort and the turn ends at its next preparation checkpoint before any model call; its relay promise rejects with delegation aborted (user_stop). A stop with no turn in flight changes nothing and never cancels a later turn. The window starts when the turn takes the conversation lock; a stop issued before the host reaches that point finds no turn, so a caller that must not start work after its own stop checks its own state before it calls relay. abort resolves once the run has settled, which can take a while: a request handler should not await it.

Relay events are not a replay log. An authorized host client reconnects through the normal durable conversation read model. A plugin serving its own external client must persist a bounded, redacted projection and reconnect that client to the plugin-owned projection. Do not expose raw reasoning, tool arguments, tool output, or authenticated file references to a public client.

Transcript ownership for host relays

A new channel transcript uses this ownership order:

  1. the existing durable row owner;
  2. the verified linked platform account;
  3. the verified account stamped by the host-relay path;
  4. the instance platform owner fallback.

The ownership order is implemented in src/brain/platforms.ts (the sessionOwner assignment). The third rule applies to every platform equally. The rule itself must not special-case a literal platform name. Two other checks in the same file do name the scheduled platform constant: the scheduled-origin match (src/brain/platforms.ts, around line 143) and the accountless instance automation shape (around line 564). Those validate scheduled sources; they do not change which account owns a new transcript. A source that entered through ordinary listen() ingress cannot become host relay automation merely by carrying access.actAsUserId. An existing transcript never changes owners because a later relay names another account.

Plugins that create account-owned server automation must call PlatformControlApi.relay(). Calling a saved listen() handler directly does not carry host relay provenance and must not receive account-owned transcript semantics.

Account kinds are host data, not a plugin seam

The host user contract distinguishes human and chatbot accounts. A chatbot account is non-interactive: password login, SSO login, full login tokens, and administrator promotion are refused by core (src/store/userStore.ts, src/api/routes/users.ts).

Impersonation is not refused for chatbot accounts. An administrator can start a bounded impersonation transition into one, and the store accepts the impersonation token only while that transition is active (src/store/userStore.ts, principalForToken). Advisor tokens are the other accepted credential for a chatbot account.

Automatic memory recall, live recall and auto-save do not look at the account kind. They follow the same memory-tool grant as manual memory tools, and a stored user setting can only switch a permitted feature off (src/brain/brainService.ts, effectiveUserSettings). A one-time migration removed Memory* grants from chatbot accounts (src/store/migrations/grants.ts); this is not a standing account-kind rule. Plugins may display the kind and require it as a domain invariant, but must not reproduce login or memory policy. Manual memory tools remain governed by the normal tool policy and must be explicitly denied by a plugin whose visitor model is not account-scoped.