Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 20. októbra 2026

Prechádzať dokumentáciu · Platform adapters and relays

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

Developer reference

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:

  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 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.