Platform adapters and relays
Account-paid live dictation
Chatbot calls await ctx.host.voice().startDictation({ accountUserId: bot.chatbot_user_id, language: 'cs', connection }) inside an authorized public plugin WebSocket handler. Declare
reads:['voice'], userGrantable:true and the route in provides.wsRoutes. The daemon requires
an existing non-admin chatbot account, its explicit caller grant and can_use_voice, then stamps platformOrigin(plugin).
Visitor dictation has no chatbot exception: administrators enable that account's voice access in Users.
The shared voice route rechecks voice access and the current configured provider/model pair at admission;
chat-model grants do not substitute for voice access.
Only the admitted bot row supplies the payer; the visitor never supplies an Elowen account or origin.
Human live calls, third-party calls, cost reports and plugin conversations keep their human-only rule.
The existing live dictation engine configures dictationModel with the validated language and sends
{type:'ready',dictationId,maxCallSeconds}. Stream PCM16LE mono 24 kHz in binary frames of at most
96,000 bytes; keep controls at most 16,384 bytes. Send commit at a segment boundary, stop on release,
or cancel to discard. Delta text appends to an item's preview; final replaces it. Ended closes the
connection after usage settlement. The visitor sees partials while holding Send and final text enters
the draft after release, without automatic sending. Capture comes from elowen-plugin-shared/voiceAudio.
The promise lasts through cleanup and returns {reason} or {refused} with not_granted, disabled,
spend_limit, dictation_active, provider_unavailable or daemon_restart. A refused start emits no ready.
Invalid host input rejects with VoiceDictationInputError.code = invalid_dictation_input; unexpected
provider/setup errors reject. One slot per paying account, instance maxCallSeconds, a 15-second inbound
silence timeout, a three-second final wait and two-second usage drain bound the session.
The caller's control watchdog is six seconds. AbortSignal cancels startup and active media.
Live-transcription provider receipts settle through the existing sink under the captured route and
platform origin; the plugin stores no audio or transcript and owns no provider credentials or prices.
The independent serving process relays to the existing public plugin WebSocket mount. Its native
daemon upgrade omits Origin and overwrites x-chatbot-origin from the actual browser handshake.
The plugin redeems a transient one-shot ticket bound to the admitted bot/visitor/origin and checks
live policy; the proxy header alone grants nothing. Do not queue or replay offline dictation.
Encoded platform voice notes retain transcribe({source,audio,mimeType}) and their existing health guard.
The seam adds no plugin-shared helper, so a consumer pins the exact requiresSharedApi value this daemon
ships and a requiresCore whose release first carried startDictation.
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.
Initialization, readiness and terminal shutdown
PlatformAdapter.connect(context: PlatformConnectionContext): Promise<void>
receives { signal, onConnectionState } for the whole host-owned adapter lifetime,
not only initial setup. Optional disconnect(): void | Promise<void> releases its
resources. Keep the supplied signal and report canonical ConnectionState through
the callback; returning from connect alone never makes an adapter usable.
PlatformConnectionRuntime is the existing host readiness/lifetime owner, not a
transport retry controller. Its 30000 ms initialization bound is neither pairing
timeout nor retry budget. Cancellation fences late callbacks, attempts disposal
once and joins unfinished initialization before allowing a successor lifetime;
a noncooperative initializer is not abandoned behind a hidden replacement timeout.
Availability requires successful initialization plus accepted fresh/ready state,
transport connected or unknown, and retry absent or succeeded. Unknown transport
supports truthful local/webhook availability, not assumed socket readiness.
Disconnected transport, pending/unknown readiness, stale observation and scheduled
or blocked retry cannot deliver. Reports are copied and canonical guards reject
malformed/non-platform state or mismatched nested retry targets. Older generation/
sequence and same-generation account changes cannot restore readiness. A new
external binding needs a newer generation; adapters must retire their old account/
socket callbacks themselves. accountId still means Elowen User.id only.
PlatformOrchestrator.pendingConnection(platform) observes application readiness,
not connect completion. It stays pending across adapter retry waits and settles
on usable readiness, typed terminal retry, failed initialization or disposal.
A later disconnect creates a new readiness wait. Durable recovery must preserve
that waiting state rather than spending/resetting model budget or turning mere
unavailability into a terminal turn failure. The adapter remains sole owner of
its explicit unlimited long-lived-transport retry policy/timing. Core adds no
SDK wrapper, retry maximum, timer or periodic reset.
notify and canDeliver use accepted availability, not a list of initialized
adapters. An unavailable targeted destination rejects without sending. Broadcast
succeeds if any real sink delivered, rejects if every configured sink is
unavailable/failed, and preserves the existing successful no-op when no notification
sink exists. Identity, routing, authorization, provenance and delivery receipts
retain their existing owners.
stopAll(): Promise<void> is permanent, coalesced terminal shutdown. It invalidates
all outstanding starts and report generations synchronously before any abort
callback, then aborts all contexts before awaiting cleanup. Readiness waiters
settle unavailable even while another disconnect hangs. It awaits each resource
owner's disconnect and in-flight initializer, aggregates cleanup failures after
all attempts settle, and cannot start a successor generation even after success.
One user's conversation Stop does not stop all platforms.
The shutdown caller is BrainService.shutdownPluginServices, which runs after the
durable checkpoint but before plugin WebSocket/service cleanup and the
startup-flag early returns, without loading plugins during shutdown. The daemon
still bounds the entire plugin-service shutdown by
3000 ms: slow/noncooperative cleanup can outlive dependency or database closure.
This seam guarantees synchronous report invalidation/abort, NOT arbitrary cleanup
completion before dependencies close, and changes no daemon shutdown budget.
Bundled Subagent is a real local consumer: it reports target subagent:local,
local-owner authority, unknown transport, ready application and no retry, then
removes its run handle on context abort. Already-started work keeps its existing
delegated/checkpoint ownership. Registry Cron follows the same explicit local
availability contract; its caller migration is separate. Without registered
adapters no connection/readiness work runs. Host state is linear in adapters;
existing adapter sockets/timers remain adapter-owned. Component tests do not
establish full shutdown composition or all external adapter migrations.
Declare sharedFileMaxBytes when the transport or public client has a smaller outgoing
file envelope than core downloads. It must be a positive safe integer in bytes; invalid
values fail registration. For example, Chatbot declares its existing 25 MiB public
attachment limit and Teams its existing 20 MiB upload limit. ShareFile resolves the
current registered adapter on every call and checks the smaller of this declaration
and harness.sharedFileMaxBytes before copying or recording success. Omit it to use
the harness limit. Buffered adapters pass the same value as the required fourth argument
of resolveSharedFiles(dir, refs, cap, maxBytes) so old references cannot bypass the
pre-buffer disk-size check. Oversized entries contain {name, data: null, size};
Teams uses them for its existing refusal notice, while other provider adapters omit them.
This declaration adds no requests or storage.
The adapter owns transport authentication, normalization, formatting, and
platform state. Use elowen-plugin-shared for transport-neutral behavior.
Its contract version is currently 14. Declare the exact value with
requiresSharedApi: 14 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. Consumers pin the exact requiresSharedApi value together with the paired core artifact.
elowen-plugin-shared/liveMessage supplies createLiveMessage for platform
editable-message bindings. transport.create must resolve a confirmed message
id; transport.edit must resolve true. Reject failed requests with their
original typed cause rather than swallowing them or returning a success-shaped
value. postFinalText must reject failed sends as well.
The first delivery failure freezes pending edits and timers. The engine retains
its cause under FinalTextDeliveryError and reports it through idempotent
finalize(), so fire-and-forget updates do not produce unhandled rejections.
It never performs a second settle flush, replacement answer or suffix repost
after an uncertain create/edit. A new semantic first final answer is allowed
once only when the failed operation is provably progress/status and no answer
mutation has ever been dispatched, including failures during final settlement.
Otherwise finalization rejects and the existing turn runner reports failure.
That status-only exception still awaits the existing supported, resolvable image
and shared-file uploads before its first text fallback and the turn's done marker.
Captions and the trigger reference stay unchanged. Concurrent/repeated finalization
shares one promise: it never restarts attachments or text. postImages/postFiles
must reject with the original cause; a resolver/upload failure rejects the turn
without replaying a confirmed earlier image or falling back on the status error.
A resolved adapter callback cannot prove a provider receipt or expose an error
the adapter swallowed. Platform migrations and actual delivery acceptance remain
separate from this shared-engine contract.
If stream.fail throws while sending the error reply itself, runTurn logs the
original typed cause and completes failed reaction/typing cleanup without a
replacement sendError or postFinalText: that error reply may have landed. A
confirmed false from stream.fail instead means no error reply was dispatched
and permits the existing error sender. This distinction never turns failure into
a handled-success result.
The adapter's request transport owns its finite retry episode; the live engine
adds no private retry budget or mutation replay. Draft deletion remains
best-effort and cannot prove that a failed send did not reach the platform.
Canonical model retry renders before the first token, tool or card using
formatRetryProgress and adapter.cfg.language (the existing English default).
The line carries no surface-specific dictionary and no countdown, and it does not
describe platform transport or computer waiting. Same-target stale generations/sequences are ignored;
success/cancellation removes only the model line. Empty retry-only bubbles retire
after in-flight creates finish, while tools, cards and compaction remain intact.
The footer stays on the final text chunk and image captions remain separate.
The helper registers nothing; work scales with split chunks and sequential
transport calls, plus the existing throttled progress machinery. Discord,
Telegram, WhatsApp and Teams bindings consume this seam; their adapter migrations
and end-to-end delivery acceptance are separate from these shared-engine tests.
See packages/plugin-shared/README.md for the binding contract.
The shared runTurn helper calls log(detail, metadata) with the original typed failure for turn, delivery and afterReply errors. Forward metadata to ctx.logger.warn beside the adapter's declared turn_failed code. elowen-plugin-shared/errors owns the audience: typed user or instance conditions receive reporting: 'local-only',
while a software defect has no override. A HostConditionError — measured host load or database
availability — is such an instance condition but stays unmarked, so the daemon's problem collector reports
it once it repeats. Existing local diagnostics, error replies and status markers are unchanged; the
additional callback argument keeps the shared API contract, so the consumer's requiresCore names the
first verified release carrying it. See Typed logging and reports.
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>;
}
The host's status, fastStatus and setAccountFast return Promises, and
adapters await /chatCommands::runControlCommand, which awaits
these results before replying. /stop checks the resolved streaming flag and
awaits abort before confirming; /stats uses resolved usage and work mode.
/fast status uses resolved account state, while a /fast change renders its
resolved recorded command event. A resolved null retains the existing missing
session or linked-account response. Discord, Teams, Telegram and WhatsApp share
this command core; it performs no host work without an invocation.
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 scheduledAutomation
origin match and the accountless instance-automation guard. 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 the PostgreSQL users repository
(packages/db/src/users/accounts.ts, packages/db/src/users/auth.ts) and core
HTTP policy (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
(packages/db/src/users/auth.ts, users.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). 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.