chatbot implementation
Exact text identity
Visitor credentials, action nonces and navigation handoffs use hashText directly from elowen-plugin-shared/contentHash; the independent service uses the same helper for cached credentials and the JSON tuple identifying durable intake. It returns lowercase SHA-256 hex of exact UTF-8 text, without trimming or normalization. Existing stored hashes remain valid without migration. Token signing, randomness, constant-time comparison and admission ordering stay plugin-owned and unchanged.
Local platform lifetime
ChatbotAdapter.connect({ signal, onConnectionState }) requires both the host's
listen handler and relay control. After that handshake it reports one canonical
state for chatbot:local: local-owner authority chatbot, unknown transport,
ready application, fresh observation and no retry. This makes core's
PlatformConnectionRuntime available and releases interrupted-channel recovery;
it does not assert independent serving-process or remote transport health.
Registration inherits the core's PluginContext.registerPlatform contract rather
than restating a parameterless connect. Missing wiring rejects initialization
and leaves public admission closed.
Host abort and disconnect() remove the abort listener and close admission without
cancelling already-owned relay turns. The adapter owns no timers. Initialization
or report failure cleans up and rejects; an already-aborted lifetime never reports.
Project removal
This participant requires core 0.30.11 or later for durable removal acknowledgement and transaction-scoped active-Project admission. Update core before installing this plugin version.
Enabling a bot requires the authorized Project ID in setBotStatus. The store calls tx.host.projects.requireActive(id) in the same transaction as the enabled-status update. Core's Project row lock serializes activation with deletion, so a preflight completed before deletion cannot re-enable a bot after cleanup. Disabling, draft configuration and retained history do not require active Project admission.
The plugin declares provides.projectRemoval: "services". While core keeps the Project in lifecycle deleting, the idempotent registerProjectRemoved callback runs disableUnusableBots, marks serving state changed and awaits serving.publish before acknowledging cleanup. A publication failure rejects the participant for retry instead of completing deletion with a stale enabled snapshot. The chatbot account, visitors and conversation history are retained; no replacement Project is assigned. Core's human-account last-active-Project invariant does not prevent this chatbot deactivation.
Migration entry
The manifest entry exports migrate(db: PluginDb<never>): Promise<void> beside register.
The daemon loader always awaits it before registration for a plugin that declares the db capability;
the DB CLI calls the same export without registering tools, routes, timers or services.
The export runs only the existing transactional plugin SQL through db.migrate.
A failed step rolls back its SQL and ledger entry and prevents registration. Repeated
calls skip applied versions. Runtime store construction never invokes migrations.
Tests that bypass the loader explicitly await this export before using their fixture.
Asynchronous PostgreSQL persistence
The plugin requires core 0.30.11 and shared API 14. The host awaits the entry export migrate(db) before registration;
registration awaits ctx.db() and ChatbotStore.create(db) before exposing consumers. Every store
read and write returns a promise. The committed dist/ files must be rebuilt from these
sources before shipping: Node reporting tests execute those release artifacts directly.
Page-action requests own and await their outcome timers; a simulated restart in a test
must drain the original request before closing its database pool.
Public hooks, admin routes, authorization, dictation,
retention, platform callbacks and serving projection await those results. Visitor signing keys
use the asynchronous core secret bag's atomic getOrCreate; retention awaits account
credentials from the core CLI bridge before sending its deletion request.
Migration 21 is the single PostgreSQL baseline in the host-provided plugin_chatbot
schema. SQL quotes the raw schema identifier, uses native numbered parameters and
stores instants as timestamptz. Tables retain their public identifiers; owned child
tables additionally use a row_id bigint identity for ordered bounded deletion.
Migration 21 is the baseline. Installed ledgers already record it, so it is never
renumbered and later steps start at version 22.
Migration DDL and its ledger entry commit or roll back together.
The store opens transactions only on a root PluginDb handle and creates a store bound
to the callback's PluginDbTx. Nested operations reuse that connection. Admission,
capacity claims, upload reservation and terminal writes lock the payer's elowen.users
row, then the bot row, then a turn row when applicable. The payer lock is shared with
core usage settlement. Budget decisions read only that account's platform:chatbot
rows in elowen.usage_by_origin, using UTC day/month boundaries. They constrain
settled spend; they do not reserve the unknown cost of a future model call.
Event allocation, action-budget rechecks and terminal status changes serialize on the turn lock. The relay's synchronous observer captures events immediately and chains asynchronous writes in arrival order. The worker drains those writes before a terminal outcome and publishes broker hints only after commit. The stream serializes asynchronous replay and wakeups, reading turn status before its event page so terminal closure cannot hide the final frame. Action waiters subscribe before asynchronous state reads and release their timers on terminal state or failure. Dispatch repeats when a submit arrives during an awaited pass.
Retry reporting
The daemon publication retry and scheduled retention cleaner use ConsecutiveFailures from
elowen-plugin-shared/consecutiveFailures, using the plugin's core/shared version floor. Publication
has one counter per daemon; retention has one per conversation. Only the third consecutive failure
exports the existing problem code; other attempts remain local. Successful publication or confirmed
core deletion resets that counter. Invalid persisted appearance is reported immediately. Manual
erasure still reports its failed final result immediately; missing credentials remain local.
Bounded deletion
Database deletion uses core-owned drainDbRows from elowen-plugin-shared/dbBatches.
It receives raw schema/table/identity names, native parameterized predicates and an
asynchronous transaction callback. Each locked batch commits at most 64 rows / 256 KiB,
processes one oversized row alone and yields between commits. Other bounded loops use
the same module's yieldDbBatch for their event-loop yield.
Deletion markers live on bot and conversation rows. A selected turn remains until its handoffs, actions and public events are gone. The conversation or bot stays marked until every owned row is removed. Boot reconciliation awaits marked drains. A rejected drain fails reconciliation explicitly and preserves the marker for the next attempt. Markers continue blocking admission.
Admission, upload reservation, feedback and event settlement refuse a marked scope. Conversation erasure rechecks that no live turn appeared while core deletion was awaited, and retains visitor isolation in every predicate. Core transcript confirmation still precedes any plugin deletion. Retention and account-removal callers await the complete drain before reporting success. Creating a handoff removes at most 64 expired hashes plus the one prior hash for that action; expiry checks continue refusing all expired hashes immediately. The native baseline owns deletion scope indexes and handoff expiry indexes.
Form submission policy
The native baseline defines plugin_chatbot.bots.submit_forms_without_confirmation, a checked bigint with default 0. Existing and new bots require confirmation unless an administrator explicitly opts in through the bots API. validateBotCreate and validateBotPatch accept only a boolean; omission means false. ChatbotBotView.submitFormsWithoutConfirmation is edited by the existing BotDetail autosave and shared SettingsRow/Toggle components, with English, Czech and Slovak risk text.
PageActionService.request is the single confirmation decision point after origin, form permission, snapshot, target and budget approval. The shared decideAction policy approves only kind/target/value and makes no confirmation decision. Dispatch atomically writes the existing requires_confirmation column and action frame. Direct submissions use pending then done/error, with requires_confirmation=0; confirmed submissions retain their existing nonce and confirmation_required/confirmed flow. settleActionResult refuses a successful result from confirmation_required, including a crafted result that claims an unconfirmed submit succeeded. Changing a bot later does not rewrite its issued action audit.
readActionFrame validates and retains the server's boolean rather than deriving it from kind. SessionActions routes request_submit to the confirmation flow or directly to the same page bridge submit operation. Direct submission uses the existing cursor cue and rechecks turn liveness, snapshot ownership, target connectivity, sensitivity, submit capability, form ownership and validity after the cue. Default confirmation flow remains unchanged. No widget-side bot configuration or duplicate confirmation policy exists.
The tool is one platform-scoped declaration shared by bots, so its description describes both policies without promising confirmation. Each successful snapshot tool result states the current bot's concrete policy before the untrusted snapshot text. Submit-button click refusals refer to that policy instead of promising a visitor confirmation. No new core tool-composition seam is introduced.
PostgreSQL query indexes
The baseline creates visitor/time/turn indexes for widget history and the administrator's latest-turn lookup, a chatbot/UTC-date expression index for daily statistics, a visitor key for the retention orphan check, and the global feedback time/turn index. Instants use an explicit UTC conversion for calendar grouping. Counts and conversation aggregates read qualifying rows. Plugin migration tests verify native identity and index installation; concurrency tests exercise admission, event sequencing and payer protection on real independent PostgreSQL transactions.
Problem reporting
Page-action policy refusals and unanswered action expiry are informational lifecycle events. The relay contract's deliberate silence/refusal remains a local chatbot.relay_no_reply warning. Failed visitor turns report chatbot.turn_failed only when the cause is a software failure; typed core instance conditions remain local.
The host logger receives metadata unchanged through the queue warning callback. These warnings report their
redacted message sample. No provider or page prose is parsed to choose a code.
All structured diagnostics decide reporting at their call sites, including admission, upload, context,
retention, adapter and independent service failures. Expected operational conditions remain local.
Policy publication alerts are already-reported after their diagnostic. InvalidVisitorImage distinguishes
visitor validation refusals from host upload failures without parsing error prose. Admission, image-read, visitor-context and dictation producers preserve the core typed instance cause; dictation input validation remains local. Software and persistence defects retain their codes. Diagnostic text is unchanged. This uses the paired core candidate's exported elowen-plugin-shared/errors seam.
Independent-service readiness and event recovery
The daemon's ChatbotServingService publishes the required non-secret canonical
retryPolicy in strict serving snapshot schema 3, using native durable private
0600 publication. prepare() republishes the current schema before service checks;
the existing --check defers older integer schemas until daemon publication.
The serving process accepts only the current validated schema, without a schema-2
compatibility reader or default retry policy. Visitor credentials and live daemon
authorization retain their existing owners.
A service start captures serviceBuildId() and the service lifetime signal, and
supplies only GET /_elowen/service/health to the core finite start owner.
Readiness proves the captured executable build, not merely an active OS unit.
HTTP status is classified before JSON: 401/403 block, while 429/5xx may retry with
the canonical Retry-After minimum. Malformed successful payloads fail visibly.
Stopping the daemon-side service aborts its readiness lifetime.
src/service/relay.ts owns one finite canonical request episode per browser answer
stream using that validated snapshot policy. It resumes only the existing durable
event GET with the last delivered sequence, discarding duplicate sequence numbers;
it never replays submission, page actions or unknown writes. Authentication
refusal terminates recovery. The five-minute stream lifetime remains independent.
The deadline closes the browser response immediately; retry completion after that
abort does not end it a second time. Terminal frames, exhaustion, browser close and
the deadline release the reader,
cancel bodies and remove listeners, ping timers and lifetime timers. Ordinary
non-NDJSON transport deadlines end on consumption/cancellation; NDJSON keeps its
header-only deadline and the service-owned stream lifetime.
Availability during a daemon restart
The independent plugin service owns the public /hooks/chatbot/v2 transport after the operator's probe-gated nginx cutover. It uses the existing core manifest.service, ctx.service and live-configuration seams, not a separate supervisor. The widget and appearance keep loading while the daemon restarts. Existing visitors whose credential is present in the bounded recent-token cache can submit text; the widget displays a busy/retrying notice without asking them to resend. New visitors wait for the daemon to issue their credential. Accounts, token issuance, model turns, costs, uploads and page actions remain daemon-owned.
The service persists a bounded intake file beneath the plugin's private service/ directory. Admission is keyed by chatbot, visitor and the existing client-turn UUID. The daemon revalidates the live token, account, Project and budget before starting work. No model or brain logic runs in the service, and it never opens the instance database. The exact recent credential hashes authorize intake; the visitor signing key never leaves the daemon. The SQL projection retains at most 4096 valid credentials, ordered by their latest visitor activity or issuance. A cache miss is not a revoked token: the online daemon handles it authoritatively; during an outage it receives an explicit 503 with no durable waiting receipt and no unproven message stored. Newly issued or rotated tokens may take one five-second background tick to enter the cache. Token validity itself remains daemon-owned. Saved messages and credentials are erased after admission or timeout. Receipt tombstones expire ten minutes after the intake deadline.
Public streams have a separate limit of 32, and short requests a limit of 16; private health and static widget delivery do not consume either pool. Intake is limited to 256 retained entries globally, the chatbot's queue depth, and the same configured IP/chatbot/visitor minute ceilings. Rate windows survive service restart; retries of one message do not consume another intake window. Daemon admission still applies its own authoritative rate/budget checks. Waiting expires at the smaller of the chatbot's queue timeout and two minutes. An expired message receives queue_timeout without a new model call. A daemon-written policy expires after five minutes without a heartbeat; stale policy refuses stateful access. Snapshot publication runs outside HTTP handlers: changes coalesce on the five-second background tick, and every thirty seconds the daemon recomputes the policy and compares a fingerprint of it (everything but generatedAt) with the last written one; an unchanged policy is rewritten only as a heartbeat every two and a half minutes, half the five-minute expiry. The same five-second tick asks the service's health endpoint first and queries the host's privileged service status only when health does not answer, to decide between restarting a stopped service and reporting a running one that answered badly. Publication failures are logged and raise an administrator alert; committed token/turn receipts are not replaced by a storage error. Snapshot/read/parse/disk errors fail explicitly. The widget only retries the explicit service-busy response, never uploads or page actions.
The service keeps the browser's answer stream for at most five minutes, sends pings and resumes the daemon's existing public event log from its last sequence after a transport drop. The daemon request timeout applies only to NDJSON headers, not the long-lived body; failed reconnect bodies are cancelled. A failed health probe does not restart an active service. Only an inactive unit or a successful health response proving a different build triggers replacement. It does not duplicate the transcript. A model turn that was already running when the daemon died closes as server_restarted; it is never automatically repeated because it may have already acted on the page. A daemon-admitted turn still marked queued has never invoked the model: it survives boot reconciliation and is recovered only after the platform connects, under its original queue deadline. Expired and disabled-bot work is closed before any model call.
Boot reconciliation uses an identity start fence, not a timestamp cutoff. During plugin registration, before public routes or the platform are exposed, ChatbotStore captures only the turn IDs already running. The daemon's existing registerBootReconcile callback may then close only those still-running predecessor turns; a fresh admission or an older queued row started by this boot is never included. Registration captures IDs without closing anything, so loading the plugin in a delegated runner does not reconcile the daemon's turns. The fence costs one read of predecessor-running IDs per registration, with no per-request scan or admission delay. Terminal public frame, turn status and conversation retention commit together through finishTurn. The first terminal outcome wins: subsequent events and late model completion are refused before persistence or broker notification. This changes no public URL or schema and needs no database migration.
Operators must follow the core Deployment guide's "Chatbot service cutover and rollback" section. Installing the manifest does not switch nginx. Probe the service and customer flow before elowen proxy apply --plugin-service-hook chatbot/v2, retain the original daemon hook, and drain intake before rolling routing back. No systemd or production restart proof is implied by unit tests.
The visitor's page
An allowed origin permits the page actions the recorded page itself exposes: describing the page, navigating between allowed pages, and reading, focusing, clicking, filling, selecting and scrolling. Form submission is the only extra decision and can be switched off per chatbot with May submit forms; only form submission is blocked by that switch, and the visitor still confirms every submission.
The model reaches the page through the ChatbotPageAction tool, which works only inside a running chatbot visitor turn. First request snapshot with no other fields. Its result carries the exact snapshotId both inside the page JSON and beside the action receipt in tool details; the receipt's actionId is not a snapshot handle. The tool's flat parameter schema marks snapshotId optional, because core refuses root unions; the description still asks for it on every action except snapshot, including the target-free navigate and scroll actions, and the server refuses an omitted one as missing_snapshot. Copy the snapshot ID exactly, and use only target IDs and capabilities from that same result. A fresh snapshot invalidates older handles. Navigation invalidates the source page and requires a new snapshot on arrival. The widget does not refresh snapshots in the background; a replaced DOM element is reported as target_gone, never rebound to a lookalike.
Native links with an href deliberately expose focus, not click. Follow their accessibility-tree /url using navigate with the current snapshotId, the absolute destination URL in value and no target ID. scroll likewise needs the current snapshotId, with up or down in value and no target ID. The destination still has to match an allowed origin, and the single-use navigation handoff preserves the visitor and the active turn across the page load. Snapshot binding also protects the source document: the widget checks the exact current page URL and its local snapshot handle before requesting a handoff, so an old streamed navigation cannot run from a different page even on an allowed origin. Local section fragments are described only when they name an existing element in the current document; query strings and other fragments stay private. An element's capability_not_granted refusal does not mean the account lost its plugin grant.
A missing or empty snapshot ID is refused as missing_snapshot, not stale_snapshot, without writing an action row or sending anything to the browser. When this turn has a usable recorded snapshot, the refusal includes its exact snapshotId in both the recovery sentence and tool details; repeat the action with that explicit field. If there is no usable snapshot, call snapshot first and use its returned ID. After a navigation, an older source snapshot is not offered as a recovery hint. A supplied but mismatched ID remains stale_snapshot and requires a fresh capture. The same server validation handles code-mode exec calls, which invoke the tool outside the agent's argument-validation loop; no ID is filled in automatically.
A snapshot stays within 32 KiB and 500 interactive elements, and reports truncation explicitly. Complete accessibility lines and their retained handles are fitted together, so omitted text never leaves an actionable handle. Accessible control names are bounded before handle annotation, preventing exceptionally long labels from hiding the reference. Open shadow roots are flattened into inert markup; iframe contents and closed shadow roots are not traversed. Below-fold elements can be focused, filled or clicked through their existing handles; an unavailable target requires a new snapshot, not a selector or a retry. Visitor turns get no memory tools, because one account serves every visitor and nothing the agent remembers may reach a prompt it was not written for. The plugin does not deny them per turn. A chatbot account starts with an empty tool grant, migration v36 strips any Memory* tool an earlier chatbot account held, and recall and curation require the MemorySearch or MemoryAdd grant that these accounts do not hold.
The ChatbotOffer tool attaches a short option set or one next step to a text answer. Choices send a reply; links and cards point only to this chatbot's allowed pages.
ChatbotPageAction and ChatbotOffer wrap their final text and domain details with textResult(text, details) from elowen-plugin-shared/toolResult. The helper only allocates the single-text-block envelope; it performs no I/O, status inference or error classification. Page-action sentences, receipts and offer statuses remain plugin-owned. Invalid visitor-turn guards still throw; page and offer refusals retain their existing returned text and details.
The adapter declares sharedFileMaxBytes = SHARED_FILE_MAX_BYTES, the same 25 MiB bound used by publicAttachment. Core resolves that registered declaration and refuses larger ShareFile requests before copying or recording success. The widget still collects accepted downloads into a blob, so lifting this bound would require a separate public-client change.
An answer may also carry shared images and files, shown as attachments beneath the message in the widget. Relay attachment events are capped per turn at four images and four files in queue.ts; the composer's image control is limited to one file per turn.
GET v2/turns/:turnId/files/:kind/:storedName validates the visitor token, turn ownership and durable attachment event before calling the host's conversationFiles().readShared. ChatbotConversationFiles.readShared returns a promise: publicRoutes.ts awaits the core's session-scoped authorization and bytes before constructing a response. A resolved null returns the same opaque 404 as an absent share; a rejected read propagates to the hook error boundary instead of becoming a successful download or a fabricated missing-file answer. Images return bounded Buffer bytes and retain their validated MIME and inline disposition. General files return bytes: ReadableStream<Uint8Array>, passed unchanged to the HTTP response so core's backpressure and cancellation remain effective; the route never collects those bytes. General files remain opaque downloads with nosniff and private, non-storable responses. The plugin keeps no attachment-ownership cache or journal reader. Host fixtures and the browser E2E fixture implement the same asynchronous seam.
The widget opts into the waiting receipt with Accept: application/vnd.elowen.chatbot-intake+json. Only negotiated requests are queued; other v2 clients retain daemon admission semantics and never receive a 202 without the original turnId. A negotiated durable receipt is HTTP 202 with X-Elowen-Busy: 1, Retry-After: 1 and { schemaVersion: 2, status: "waiting_for_daemon", clientTurnId, retryAfterSeconds: 1 }. The widget repeats the exact same submission until normal admission returns a turnId, or the bounded wait ends. Network or unavailable responses preserve that submission id for a manual retry too. Before a daemon attempt, intake persists that attempt marker and carries the original deadline in X-Elowen-Intake-Deadline. The daemon checks its existing client-id receipt first and refuses NEW admission beyond that deadline. An uncertain lost admission reply can therefore be reconciled after the deadline without replaying model work; its private receipt material is bounded to ten minutes after the deadline. Unattempted messages expire immediately at the deadline.
Page-action cursor and thinking indicator
embed-src/pageActions.ts uses showClickTarget from clickFeedback.ts before click, fill, select and focus. The decorative black cursor has a white outline and soft blue glow, approaches the live target, then scales with a ripple and fades. Consecutive actions within five seconds continue from the last position and replace the previous fading overlay. The overlay is non-interactive, aria-hidden and excluded from page snapshots; it dispatches no events. Reduced motion keeps it stationary without a press animation.
After arrival, the existing 200 ms pause remains. The action rechecks its active turn, connected target and sensitive-field policy; clicks also recheck submitter and navigation refusals. Failed or cancelled actions remove the overlay. Hidden pages skip decoration, and the existing animation deadline bounds throttled frames. Form submission still uses wouldSubmit and the live form owner, without reviving a separate submitter helper.
chatConfig.ts sizes the deep-chat loading bubble to content and applies the configured color and corner radius. panelStyles.ts reserves space for all three dots, including the two pseudo-elements, and centers them in a one-line bubble with desktop or mobile padding. ChatPanel and the administrator appearance preview consume these existing owners; neither duplicates loading configuration or CSS. Focused coverage lives in tests/chatbotClickFeedback.test.ts, and the existing panel browser harness exercises the real deep-chat element.
Widget session ownership
embed-src/session.ts owns the active turn, stop state, reconnect cursors and the single public request transport. It creates one VisitorToken and one SessionActions per widget. The panel, page bridge and action owner share the type-only view and page contracts in embed-src/sessionContract.ts, without depending on the session implementation. Both use that transport through the typed WidgetRequest contract in embed-src/protocol.ts, which also reads JSON response bodies before each owner validates its fields. Requests retain the existing credentials, cookie omission, busy retry rules and public bodies; neither owner opens a second transport.
embed-src/visitorToken.ts is the sole credential owner. It reads the optional browser storage once, shares in-flight acquisition between avatar and conversation reads, rotates a live credential and remembers only the latest successfully redeemed handoff receipt. A restored fragment is skipped only when its receipt matches and a token remains available. A refused handoff with a stored token preserves that identity; without one it fails without creating an unrelated visitor. Transient refresh failures preserve the token. Only an explicit invalid-credential response permits replacing the identity, and the session does not follow the old visitor's turn with the replacement. Storage refusal keeps the token in memory for the page lifetime; widget destruction does not erase the saved conversation. An untouched visitor still causes no conversation or credential request.
embed-src/sessionActions.ts owns snapshot handles, per-turn action counts and action-id replay deduplication. The stream forwards an eligible action to SessionActions.handle without waiting for its confirmation. The owner uses the existing policy, captures the page only for an explicit snapshot, records the visitor's confirmation before submission and reads the session's stop state after awaited decisions. Navigation clears the snapshots. A reload creates a new owner with no handles, so pending old-page actions are refused as stale while settled actions stay skipped by the session's restore cursor. Form submission uses the same wouldSubmit predicate as snapshot capabilities and ordinary-click refusal, retaining the live form-owner check. Restored attachment validation uses ATTACHMENT_NAME_MAX_CHARS, ATTACHMENT_CAPTION_MAX_CHARS and SHARED_FILE_MAX_BYTES from the server's publicContract.ts; visitor image uploads retain their separate smaller byte budget.
VisitorToken owns rotation-only retries through withRotationRetry(submit). A submit callback reads the current credential and creates a fresh body or upload stream each time. Upload, feedback, stop and shared-file reads retry once only after successful rotation of the same visitor. Replacement of an invalid visitor identity does not retry an operation owned by the previous visitor; conversation restoration retains its separate replacement behavior. Unavailable or unproven 401 responses do not authorize refresh.
The widget parser returns TurnFrame type/seq/data and ActionFrame action handles, kind, target/value, snapshot and nonce. Incoming requiresConfirmation is still mandatory and must match the action kind; actual confirmation is decided by the approved action. The parser no longer returns turnId or requiresConfirmation to its callers.
GET v2/conversation returns schemaVersion, activeTurnId and the retained turns, without clientTurnId, errorCode or truncated. Each turn retains status, cursor, pending action handles, message, upload, attachments, reply, offer and feedback. Restoration is bounded to the newest 50 turns and a 256 KiB response budget, then ordered for display. It is not a history export and provides no truncation notice. Admission still uses clientTurnId for durable idempotency, and diagnostic error codes remain stored.
Stopping an answer
The widget's stop control stops the turn for real, not only the visitor's view of it. The turn's state in the plugin's database is the one truth, and the widget only shows it.
States. A turn is queued, running, stopping, done, error or stopped. stopping is a visitor's request that is waiting for the run to really end; stopped is the terminal state it ends in. The turn keeps its concurrency slot and its place as the visitor's one open turn until it is terminal, so a new message sent while it is stopping is refused with turn_in_progress.
Route. POST v2/turns/:turnId/stop takes the visitor token and the allowed-origin check of every other turn route and no body. A visitor can stop only their own turn; anyone else's looks like a turn that does not exist (404 not_found). The call answers at once: 202 { schemaVersion, turnId, status: "stopping" } for a turn that was running, and 200 with the state it ended in for one that was already terminal or was still queued (a queued turn never starts and ends stopped immediately). Repeating the call is harmless. It is not rate-limited, because a refused stop is not a stop.
What happens. The plugin moves the turn to stopping in one step against the stored row and asks Elowen to stop the conversation's turn, without waiting for it: the host's stop returns only after the run has settled. A turn still being prepared never starts (core 0.29.56 or later). When the run has ended, the turn's own worker writes the terminal event: stopped carrying the text the visitor had already read, never relay_no_reply. Text produced after the stop request is not added to the log. If the run finishes on its own first, the turn ends done or error as usual. Nothing is billed here beyond what Elowen accounted for the run before the stop landed; the plugin keeps no spend counter of its own.
Events. The turn's event log (GET v2/turns/:turnId/events) gains two frames. stopping (data: {}) is written when the request is accepted and is not terminal. stopped (data: { text }) is terminal, like done and error: the stream ends after it. A client that does not know them ignores them.
Conversation read. GET v2/conversation reports status per turn, including stopping and stopped. activeTurnId names a turn that is queued, running or stopping, and a stopped turn's reply is the partial text it ended with.
Page actions. ChatbotPageAction honours the tool call's abort signal: when Elowen stops the turn, an action still waiting for the page is closed at once as cancelled and the model is told the visitor stopped the turn. The widget performs no action of a turn it knows is stopping, and withdraws a confirmation it was asking for. An action expires unanswered only when the visitor's page really stopped answering.
Widget. Stop sends the request and says so where the visitor is looking: the empty input reads "Stopping, one moment…" (there is no status bar for it; a screen reader still hears Stopping…), the stop control is spent and the input is locked; the panel returns to idle only when the log reports stopped, done or error, and keeps reading the stream until then. After a reload the widget derives the same state from GET v2/conversation. If the request cannot reach the server the control comes back with a short error, because the answer is still arriving. Closing the tab or the panel is not a stop: the turn runs on.
The widget treats a stop as decided from the moment the visitor presses it, not only from the server's stopping frame: a form confirmation or navigation handoff the server answers after that is not carried out, and the widget re-checks this after every server answer it awaited before it submits or navigates.
Lost connection. When the widget cannot read a running turn's log after its bounded reconnect attempts, it does not unlock the input, because not hearing the server is not the turn ending and the next message would be refused as turn_in_progress. It shows that the connection was interrupted and a Reconnect button that reads the log again from the last rendered event. The input unlocks only on the server's word: a terminal event, a state read after a reload, or a new visitor identity that the server confirmed (which owns no running turn). A turn that was stopped while the image it carried was being read ends stopped, not error.
Administrators. Conversations shows the last turn's state, including Stopping and Stopped.
Visitor admission and media boundaries
createVisitorAuth owns both admission chains. presentedToken(request, origin) verifies the live signed token ledger, enabled bot, visitor identity agreement and the bot's website allowlist. Locally validated bot selections use selectedBot(bot, origin) for enabled/ready, website and account checks; bootstrap, issuance and navigation redemption share it. A missing or disabled selection is unavailable, and a failed identity check never grants access. Routes retain their specific body, account, ownership and rate ordering; conversation, stop and action reporting do not acquire a new account-preflight requirement.
ChatbotStore.issueToken has no rotation option. Its transaction revokes all usable predecessor tokens for that visitor before inserting the new token; an insert failure rolls back revocation. Refresh and navigation redemption use this path. Historical ledger fixtures must use test-only SQL rather than weakening production issuance.
registerVisitorPageContext uses xmlEscape from elowen-plugin-shared/xml for the browser-reported URL and title in after-user visitor_page context. XML entities and both quotes are escaped, forbidden XML 1.0 C0 controls become the replacement character, and tab/LF/CR are preserved. Non-chatbot or non-running turns receive no visitor context; existing account eligibility and context failure reporting remain authoritative.
verifiedImageStream uses sniffImageMime from elowen-plugin-shared/imageSniff on a buffered twelve-byte probe before completing an upload. PNG, JPEG, GIF and WebP remain the admitted MIME vocabulary, with the existing extension list and 12-byte to 10 MiB upload bounds. Signature classification is not decoder validation. Unknown signatures or a declared/received byte mismatch fail the upload; the same validated stream is passed to the host project-image upload seam.
Voice dictation
POST v2/dictations takes Authorization: ChatbotVisitor <token>, browser Origin and strict JSON {schemaVersion:2, language:'cs'|'sk'|'en'}. validateDictationTicketRequest in src/validation.ts owns payload validation. Success is 200 {schemaVersion:2, ticket, socketPath:'/hooks/chatbot/v2/dictations/<ticket>'}. This creates only a memory reservation; no provider session starts until redemption. src/publicContract.ts exports PUBLIC_PATHS.dictations, DictationLanguage, DictationTicketRequest, DictationTicketAnswer, DictationError, DictationFrame and PublicBootstrapAnswer. HTTP remains schema 2; voice socket frames have no schemaVersion. The manifest declares HTTP v2/dictations, public WebSocket v2/dictations/:ticket and reads voice.
Admission in src/visitorDictation.ts uses the existing network-origin gate and presentedToken chain: signature, expiry, revocation, visitor ledger, enabled bot and allowed website. blockedReply requires a live non-administrator chatbot account with exactly one active managed Project. The live dictation_enabled flag is authoritative; a serving snapshot may refuse early but cannot admit. Admission runs before JSON parsing and again afterward, followed by the existing IP, bot and conversation rate windows and spendingBudget. A synchronous slot keyed by the verified bot's chatbot_user_id refuses a second reservation with 409 dictation_busy. Normal admission codes, retry headers and budget handling remain unchanged. Invalid JSON, unknown/missing keys, schema, language or query answer 400 invalid_dictation_request; non-JSON answers 415.
A random 32-byte hex ticket binds the visitor, payer, language, original bearer authorization and browser origin. It expires after 30 seconds. authorize consumes it on the first attempt, checks the service-overwritten x-chatbot-origin, expiry, live token, bot, flag, account, Project and budget. Handler startup repeats live checks before invoking ctx.host.voice().startDictation({accountUserId, language, connection}) with the original PluginPublicWebSocketConnection. An unused or failed reservation releases its slot; an active session holds the slot through the awaited core promise and usage settlement, including after visitor disconnection. Tickets are not reusable after failed redemption or daemon restart.
Core owns readiness, live transcription, validation, session bounds, plugin-attributed pricing and usage. Client PCM is mono 24 kHz PCM16LE in nonempty even-length frames up to 96000 bytes (VOICE_AUDIO_FRAME_MAX_BYTES); controls are JSON commit, stop, cancel. Core sends ready with dictationId and live maxCallSeconds, delta and final with itemId and text, then ended with reason. The shared SDK exports PluginDictationFrame; DictationFrame adds only the plugin error frame. Core uses voice.dictationModel and live transcription pricing. Provider configuration is bounded to 10 seconds, idle input to 15 seconds, and graceful stop drains finals/usage for at most 5 seconds. The instance duration advertised by ready is authoritative.
Core refusals are {type:'error', error:'not_granted'|'disabled'|'spend_limit'|'dictation_active'|'provider_unavailable'|'daemon_restart'} followed by close 1008. VoiceDictationInputError.code maps to invalid_dictation_input; unexpected setup failures map to provider_error. Exception prose is never forwarded or logged. Core sends successful session frames and closes after ended. Chatbot never creates a conversation turn for dictation.
src/service/server.ts forwards the JSON POST once with the ordinary 1 MiB body ceiling and a 5-second response deadline. Daemon 5xx responses are preserved; transport failure answers 503 daemon_unavailable. src/service/dictationRelay.ts validates the real browser Origin against an enabled bot in a fresh projection and the trusted network origin before acquiring the existing 32-stream limit shared with NDJSON. It forwards to the pinned daemon /ws/plugins/chatbot/v2/dictations/<ticket>, omits native Origin for core's same-host gate, overwrites x-chatbot-origin from the browser Origin, and forwards neither bearer headers nor cookies. The upgrade deadline is 5 seconds; browser frames are capped at 96000 bytes, upstream frames and outbound queued bytes at 1 MiB. Binary/text framing and final/ended ordering are preserved. Either-side close tears down its peer; overflow terminates both. Nothing enters DurableIntake, no request is replayed, and service shutdown terminates its sockets. The obsolete batch POST and its PCM-to-WAV path are removed.
Persistence: the native baseline in src/db.ts defines plugin_chatbot.bots.dictation_enabled BIGINT NOT NULL DEFAULT 0 CHECK(dictation_enabled IN (0,1)). Create defaults to off; omitted PATCH keeps the stored value under the existing expectedUpdatedAt CAS; non-boolean input fails validation. ChatbotBotView.dictationEnabled and bootstrap read the same column. Tickets, audio and unsent transcripts remain memory-only: no plugin row, turn, intake entry, file or log. The payer always comes from the verified bot row, never visitor input. Core requires enabled voice, a live non-admin chatbot account and its explicit userGrantable Chatbot grant on every start. The bot flag does not grant core permission. The registry type resolver in scripts/core-contract.mjs uses the paired core's actual public SDK and ws declarations; it does not copy the socket contract.
Serving snapshot: ServingBot.dictationEnabled is part of the strict schema 3 in src/service/state.ts, and src/service/daemon.ts publishes it. prepare() republishes an existing projection in the current schema before the health check. SNAPSHOT_VERSION names the current schema for validation and preflight. Before the new daemon boots, service --check defers an absent serving folder or a parsed integer schema below that version with exit 75, so daemon registration can republish it. Current-schema snapshots are fully validated; missing files, malformed JSON, future or non-integer schemas, and invalid current content still fail. The running service continues to require the strict current schema. Focused source-entry tests exercise replacement of schema-1 and schema-2 snapshots; packaged build/update acceptance remains a separate check. A change reaches the serving process on the next publication, within the five-second background tick. Until then a snapshot that says disabled refuses early, and a snapshot that says enabled is still checked by the daemon.
Widget: embed-src/dictation.ts owns one held Send gesture. A primary pointer or Space/Enter on the focused Send control held for 300 ms starts capture, and release finishes it. Short keyboard activation sends exactly once on release; key repeat cannot submit or create another capture. Escape, keyboard focus loss and document hiding cancel the attempt. There are no separate Dictate/Finish/Cancel controls or dictation bar; the existing status line announces progress through aria-live. The controller keeps capture and transport separate from the panel's readDraft, writeDraft and status callbacks, which own all draft/status rendering. prepareVoiceCapture from elowen-plugin-shared/voiceAudio runs synchronously inside the pointer or key gesture; only the start requests the microphone. Capture waits at most 15 seconds for core readiness, retaining only the latest three seconds of PCM before ready. It then streams PCM, including quiet frames, with a 384000-byte outbound buffer ceiling matching the core call path. The shared package currently exposes the sample rate, so the widget derives this bound as eight seconds of mono PCM16 from VOICE_SAMPLE_RATE, independently of preroll. A speech RMS threshold of 0.012 followed by 700 ms of quiet PCM sends one commit; release stops capture and sends stop. The instance duration advertised by ready is authoritative. Delta text appends per item, final text replaces that item, and insertion order is retained. Preview text updates the current draft without focusing the composer or submitting. Successful completion keeps confirmed items only. Failure also keeps confirmed items and removes unfinished text; explicit cancellation restores the exact original draft. Attached images are preserved in either case. Stop waits at most six seconds for ended. Closing the panel, Escape, focus loss and document hiding cancel and release capture, socket and pending work. chatPanel.ts is the only adapter for the deep-chat composer. onComponentRender fires once, so the adapter rebinds when a property update replaces the composer, and it finds Send by .input-button.inside-end:not(.custom-button). It inserts text with DOM APIs and dispatches input; it never calls submitUserMessage, and the controller never submits. WidgetSession requests a transient ticket and opens the advertised WebSocket without replay. chatConfig.ts enables Send for an empty draft only when dictation is on, and ordinary validation still refuses an empty message. The admin preview never receives the live dictation capability. ChatPanel.setDictationEnabled also keeps dictation off when the host page's Permissions-Policy forbids the microphone (document.featurePolicy.allowsFeature('microphone') is false in Chromium), because that policy blocks the widget's getUserMedia as well; browsers without the API keep dictation and show the denied status on failure.
The widget build resolves elowen-plugin-shared/voiceAudio from ELOWEN_CORE_ROOT (scripts/build-chatbot-assets.mjs), so the paired core must export it. Browser requirements: connect-src must allow the serving hook's WebSocket origin as well as its HTTP origin. A Blob-backed AudioWorklet needs blob: in script-src; worker-src blob: alone was not enough in Chromium. Microphone permission belongs to the embedding page's origin, and a cross-origin iframe needs allow="microphone". Safari and iOS are not verified.
Admin UI: web-src/BotDetail.tsx has a collapsed Dictation group with per-account storage key chatbot:detail:<id>:dictation. Its Toggle uses the existing autosave and CAS path; one help hint explains visitor dictation and account billing. The host's ['users'] query reads the existing administrator-only GET /users directory. Eligibility mirrors core's plugin identity checks and shared voice admission: a non-administrator chatbot account with the chatbot plugin grant and can_use_voice. Visitor dictation has no chatbot exception: enable the account's voice access in Users. A missing voice grant displays a localized unavailable reason, using the same directory response without another request. Only an ineligible account shows one ghost account-settings button, which flushes autosave before navigating to /users; the current Users screen has no account-specific deep link. Query failures render an error rather than an eligibility claim. English copy is in the manifest under web.strings (dictationTitle, dictationEnabled, dictationHint, dictationVoiceRequired, dictationAccountSettings), with Czech and Slovak overrides. Widget copy lives in embed-src/strings.ts.
Appearance settings contract
Shared settings on the Chatbots page and the plugin detail both edit the same instance-wide visitor-token lifetime through the host's configuration editor. Domains, limits, appearance and form-submission permission remain per-chatbot settings. The browser bundle requires UI API 62, and it relies on the localized configuration editor, unit-aware numeric input and shared limit slider rows.
Appearance and public route ownership
The visitor widget and the administrator's AppearancePreview use the same ChatPanel. Its shadow-root DOM, messages and main lifecycle stay in embed-src/chatPanel.ts. embed-src/panelStyles.ts owns both generated stylesheets, the quick-button styles and the viewport inset used by the panel and preview; each reads the existing validated appearance without a replacement default.
embed-src/launcherAttention.ts owns each panel's launcher preferences, teaser and nudge timers, unread count, sound mute control and temporary document title. The panel passes its DOM elements and live appearance/open-state readers, calls attention scheduling after applying a changed look, and notifies the owner when opened or destroyed. Opening persists teaser dismissal, cancels timers and clears unread state. Changing the appearance replaces timers while retaining the existing nudge count and preferences. Destroy cancels timers, removes the visibility listener and restores the title only if it still equals the owner's last write. The credential-free preview alone can show launcher effects immediately. The initial schedule nudges twice; reduced motion suppresses nudges. No attention owner sends network requests.
Stored appearance resolution lives in src/appearanceRead.ts. Public bootstrap and avatar reads and the daemon's service publisher use the same validated result. If a stored look is rejected, publication logs chatbot.appearance_invalid and omits only that chatbot and its cached credentials from the fresh snapshot. Healthy chatbots still receive a renewed policy. No replacement appearance is invented; the daemon's public bootstrap still answers 503 appearance_invalid. Repairing the stored look includes the chatbot again on the next publication.
The daemon's src/publicRoutes.ts owns route dispatch, handoff and endpoint ordering. src/visitorAuth.ts owns visitor credential issuance, live token/visitor ledger checks, enabled-bot admission, account preflight and the allowlist refusal. Handlers call those checks at their existing positions: bootstrap and visitor issuance parse JSON before bot admission; token-protected routes authenticate before the allowlist; handoff parses its request before selecting issuance or redemption; feedback consumes its rate window before turn/body validation; stop and reconnect reads have no account preflight or rate charge. The network-origin gate still precedes all stateful dispatch, while the public widget asset bypasses it.
src/publicReplies.ts owns response construction and request-body/cursor parsing without reading visitor state. src/conversationView.ts reads only the authenticated visitor's recent public rows, batches projection lookups and retains the existing 50-turn, 256 KiB reconnect window. src/turnEventStream.ts owns the NDJSON cursor, broker subscription and idle ping timer: terminal replay closes and releases them, and cancellation detaches only the watcher, leaving the queued turn intact. Each handler authenticates before invoking either reader. src/publicContract.ts is the dependency-free home of attachment name/caption limits and the shared-file ceiling, separately from the smaller visitor-image upload ceiling. These module boundaries add no HTTP routes, storage, retry or background work.
The standalone service owns browser stream heartbeat and lifetime. PUBLIC_SCHEMA_VERSION and STREAM_PING_INTERVAL_MS are defined in publicContract; the heartbeat interval is 15000 ms. keepTurnStream drops daemon ping frames, forwards durable frames unchanged, advances only the durable sequence cursor, and reconnects from that cursor during daemon outages. The service flushes HTTP headers immediately when it accepts an NDJSON response. A single five-minute timer aborts upstream reads and closes the browser response, with timers and readers released on completion or disconnect. Daemon turnEventStream retains its own direct-connection heartbeat for its upstream transport. Public routes require the current host streaming contract and no longer negotiate an unsupported older daemon.
The shared chatbot register's useChatbots hook formats its load error once, using the server message or the localized empty-detail message. Bots, Conversations, Feedback and Statistics render that result directly with the host's retry control; loading and an empty register remain distinct states.
The chatbot admin bundle requires host UI API 62 in both its manifest and registration. The conversations register narrows by visitor with the host ChoiceField, typed as ElowenUiRuntime['components']['ChoiceField'] (ChoiceFieldProps: title, options, value, onChange, optional manageAriaLabel); it is always the anchored single-choice dropdown, so the consumer passes no presentation prop, and a visitor id stays selectable through the host's stored-unknown-value row. Its runtime api/apiJson signatures consume PluginApiRequestInit, and its utils.interpolate member uses the exact ElowenUiRuntime signature. Writes pass a structured json member through the host's authenticated request path; raw request bodies retain the published raw-body semantics. Text substitution uses host literal interpolation, including dollar signs and unknown placeholders, without a local serializer or interpolation copy. Migrated chatbot appearance, bot, conversation, feedback and statistics views are the real consumers. A missing runtime fails mounting rather than installing a fallback; host API version admission occurs before mounting.
Current admin and widget boundaries
Chatbot admin reads use the host runtime's useQuery and useQueryClient, with plugin-owned keys beginning with ['plugin', 'chatbot']. Conversations include bot, visitor, page, sort and direction; visitors include bot; feedback includes bot, rating and page; statistics include bot and UTC date range. A missing bot disables its scoped query. Previous rows are retained only within the same bot and visitor scope. Failed queries expose the shared retry state; the visitor picker has its own error state. Erasing conversations invalidates conversations, visitors, feedback and statistics. ConversationsSection is a real consumer.
ChatbotBotView exposes account.username, full stored limits and readiness blockers. It does not carry account.type/isAdmin or missingLimits. Conversation rows retain lastAt but omit firstAt; visitor rows omit lastAt while SQL keeps the aggregate needed for newest-first ordering. Statistics expose daily counters and origin-attributed spend, without queue-wait percentiles or redundant totals. Feedback totals remain available. Both conversations and feedback accept a nonnegative safe-integer offset, defaulting to zero when absent, and return 400 for malformed offsets. Eligibility and limits remain server-enforced.
widgetAssetAnswer(ifNoneMatch) owns the widget's HTTP status, headers and body for both the daemon hook and independent service. It reads the plugin's cached asset and returns a bodyless 304 for a matching validator, otherwise the real JavaScript body. ETag, JavaScript MIME and revalidation cache policy are identical on both adapters. Missing or empty artifacts still fail. Public hook headers are already lowercased by core. Core handles daemon preflight; the independent service keeps its own OPTIONS behavior. isJsonContentType is the shared media-type boundary, accepting case-insensitive application/json with parameters and refusing absent or wrong types.
allowedPageUrl(value, allowedOrigins) is the dependency-free URL policy used by offer frames, page navigation and handoff. It permits HTTP/HTTPS only, rejects credentials and requires an exact allowed origin. Length validation belongs to the surrounding payload boundary. PageActionService parses navigation first so parse failures remain invalid_value and a disallowed parsed URL remains navigation_not_allowed. A refused URL produces no navigation or offer link.
SharedAttachment is the widget view's guarded attachment reference, with kind, storedName, optional name and caption. The incoming file size is validated before this reference is created; the view does not retain another size copy. ChatPanel draws a provided caption with textContent, under images and files, for streamed and restored answers including loading and failed-download rows. Without a caption it adds no caption row. The existing caption bound and guarded blob download remain authoritative. ChatSession and ChatPanel are real consumers; captions never become HTML or action labels.
chatConfig.ts owns pure deep-chat configuration, greeting markup and lastUserSubmission. ChatPanel calls it both when creating and reconfiguring the message element; the same look and strings feed visitor rendering and the admin preview. It configures no model transport. Empty panels can reconfigure in place; populated panels rebuild through the existing lifecycle, preserving attachments and avatar ownership in ChatPanel. lastUserSubmission reads only the latest submitted user text or single image, never a historical message's attachment.
BotPicker accepts a numeric bot value, or null with an explicit allLabel. Feedback uses null for all bots; conversations and statistics use their local selected-bot resolution. The control contributes no fetching or selection store of its own.
service/intake.ts owns the retained entry capacity, rate-window capacity and maximum admission-result body. The persisted schema, accept path and relay all use these same limits. Hitting a capacity returns the existing refusal; retry deduplication, persistence-before-admission and separate widget/service/daemon outage semantics remain unchanged. DurableIntake and resultOf are real consumers. Retention sweeps also remove expired upload receipts; managed Project files remain ordinary files outside transcript cleanup.
Chatbot diagnostics use the existing elowen-plugin-shared/errors errorText helper. It preserves Error.message, stringifies other thrown values and propagates conversion failures. Call sites retain their original public codes and local-only detail; the helper does not decide visibility or retry policy.
capturePageSnapshot reads document.body, uses PAGE_SNAPSHOT_MAX_ELEMENTS directly and accepts only an optional maxBytes override. It returns the snapshot id, serialized JSON and retained element handles. Truncation remains in the serialized page metadata, rather than in another view field. Sensitive fields expose focus only and their values are absent from the inert aria copy. The widget page bridge is the real consumer; it captures only approved snapshot actions.
ChatbotAdapter requires the host warning logger at construction. An absent listen/control handshake still makes the adapter unavailable and reports its existing structured diagnostic. There is no silent default logger. The plugin registration and its host fixture are real consumers. Implementation-only input/result type names stay local; callers derive input shapes from the owning function signature when needed.
Retired fields and internal ownership
readPageUrl validates the recorded page URL and returns only its origin. RecordedPageState retains snapshotId, origin and targets; it carries no path projection. HTTP/HTTPS, credential, query, fragment and allowed-origin refusal rules remain unchanged. PageActionService remains the real consumer, with snapshot-bound targets and the existing snapshot limits.
The PostgreSQL baseline omits retired fields: bots.sensitive_mode; created_at on bots, visitors, conversations, turn_events and upload_receipts; visitors.revoked_at; conversations.last_activity_at; turns.finished_at; and actions.completed_at. Token revocation, token hashes, issuance, expiry and rotation remain authoritative, as do turns/actions.created_at, turns.started_at ordering, bots/feedback.updated_at, receipt expiry and conversations.delete_after. Store SQL and direct callers no longer pass timestamps used only by retired columns. Event persistence still precedes broker notification, and event sequence, ownership predicates and settlement conditions remain unchanged. PostgreSQL fixtures verify rollback, preserved rows/indexes and repeated baseline checks. ChatbotBotView no longer exposes sensitiveMode; create/update requests asking for sensitive mode still fail with privacy_policy_unresolved before any write.
appearanceColor.ts is the sole implementation owner of appearanceInk, appearanceShade, appearanceRamp and RGB channels. It operates on appearance already validated by appearanceContract.ts, which retains the schema, types, templates and parser. Widget effects, panelStyles, chatConfig and AppearanceModal import the math directly. channels returns a three-number tuple; gradient mixing, rounding, contrast and rgba semantics remain unchanged. There is no backward re-export or runtime fallback. This move adds no independent-service dependency requiring a service build fingerprint.
storePayloads.ts owns eventPayload, actionRequestPayload, actionResultPayload and their stored-payload interfaces. Their decoder bodies and corruption failures are unchanged, including JSON shape, schema version, action kind and outcome validation. ChatbotStore, PageActionService, publicRoutes and turnEventStream import this owner directly; store.ts does not re-export the decoders. actionsTool and conversationView have no direct decoder dependency.