NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · chatbot implementation
Developer reference

chatbot implementation

Problem reporting

Page-action policy refusals and unanswered action expiry are informational lifecycle events. Relay silence and failed visitor turns report the distinct codes chatbot.relay_no_reply and chatbot.turn_failed. The host logger receives metadata unchanged through the queue warning callback. These warnings are code-only: local diagnostics retain details, while external reports carry no message sample. No provider or page prose is parsed to choose a code. All structured diagnostics declare 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.

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 a thirty-second heartbeat renews the policy. 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.

An answer may also carry one shared image, shown as an attachment beneath the message in the widget; the composer's image control is limited to one file per turn.

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.

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.

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 51 for 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 55 in both its manifest and registration. 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.