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:
- the existing durable row owner;
- the verified linked platform account;
- the verified account stamped by the host-relay path;
- 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.