@molecule/app-ai-chat
Core interface · ai-chat · App (browser) · v1.6.0 · Apache-2.0
AI chat panel interface for streaming conversations with tool use
npm install @molecule/app-ai-chatHow it works
@molecule/app-ai-chat is the ai-chat core interface on the app (browser) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 1 provider: @molecule/app-ai-chat-http.
import { requireProvider, setProvider } from '@molecule/app-ai-chat'
import { createProvider } from '@molecule/app-ai-chat-http'
setProvider(createProvider()) // at startup; same-origin base URL by default
const chat = requireProvider()
const config = { endpoint: '/api/chat' }
await chat.sendMessage('Summarize my open orders', config, (event) => {
if (event.type === 'text') appendTokens(event.content)
if (event.type === 'error') showError(event.message)
})
const history = await chat.loadHistory(config)Providers (1): @molecule/app-ai-chat-http
Works with: @molecule/app-bond, @molecule/app-i18n
Reference
AI chat core interface for molecule.dev.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { requireProvider, setProvider } from '@molecule/app-ai-chat'
import { createProvider } from '@molecule/app-ai-chat-http'
setProvider(createProvider()) // at startup; same-origin base URL by default
const chat = requireProvider()
const config = { endpoint: '/api/chat' }
await chat.sendMessage('Summarize my open orders', config, (event) => {
if (event.type === 'text') appendTokens(event.content)
if (event.type === 'error') showError(event.message)
})
const history = await chat.loadHistory(config)
Type
core
Installation
npm install @molecule/app-ai-chat @molecule/app-bond @molecule/app-i18n
API
Interfaces
BackgroundTaskState
A command running in the sandbox that outlived the tool call that started it.
exec_command with run_in_background returns a handle immediately, so the
command's real outcome arrives later. The UI tracks these separately from
tool calls for that reason: one tool call, two states, minutes apart.
interface BackgroundTaskState {
/** The handle the tool returned, e.g. `mol-bg-1a2b3c`. */
id: string
/** What kind of work it is: a detached shell command, or a subagent. */
kind?: 'command' | 'agent'
/** The command as the executor wrote it, or the subagent's task. */
command: string
/** Why the executor started it, in its own words; absent when it gave none. */
description?: string
status: 'running' | 'succeeded' | 'failed'
/** Present once it has finished. */
exitCode?: number
/** Milliseconds, server clock. */
startedAt: number
finishedAt?: number
}
ChatAttachment
A file attachment sent with a chat message.
interface ChatAttachment {
/** MIME type (e.g., 'image/jpeg', 'application/pdf'). */
mediaType: string
/** Base64-encoded file data (no data-URL prefix). */
data: string
/** Original filename for display. */
filename: string
/** File size in bytes (for validation and display). */
size: number
}
AttachmentMeta
Attachment metadata stored in message history (no base64 data).
interface AttachmentMeta {
/** Original filename. */
filename: string
/** MIME type. */
mediaType: string
/** File size in bytes. */
size: number
}
ChatMessage
A single message in a chat conversation, including role, content, and optional tool-call metadata.
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
/** Ordered sequence of text and tool-call blocks, preserving interleaved order. */
blocks?: MessageBlock[]
/**
* Present when this message IS an inline transcript card (not a dialogue turn) —
* a model-switch / phase / skills / custom notice. Carries the raw `CardEvent`
* the UI renders the card from. Such a message has `role: 'system'`, is shown +
* persisted in the same transcript as every other message (so live === stored), and
* is excluded from the model request server-side. Render by `cardEvent`, not `content`.
*/
cardEvent?: CardEvent
toolCalls?: ToolCall[]
isStreaming?: boolean
/** Set when the user aborted the response mid-stream. */
aborted?: boolean
/** Set on user messages that are queued waiting for the current stream to finish. */
queued?: boolean
/** Set when the agentic loop hit its iteration limit before finishing. */
loopLimitReached?: number
/** Persisted commit record for display in conversation history. */
commitRecord?: { message: string; files: string[]; hash?: string }
commitSuggestion?: CommitSuggestion
/** File attachments sent with this message (metadata only — no base64 data in history). */
attachments?: AttachmentMeta[]
/**
* Internal driver message (e.g. the post-boot build kickoff) that the user
* must NEVER see: it is filtered from history on read and never rendered,
* though the model still reads it during the turn it drives. This persisted
* flag is the deterministic replacement for the legacy `[auto-continue]`
* content-prefix hack — a hidden message can never reappear after a refresh.
*/
hidden?: boolean
/**
* A message sent automatically on the user's behalf (e.g. an auto-fix prompt)
* that SHOULD stay visible — but rendered so it is obvious it was sent
* automatically on behalf of the agent, not typed by the user (distinct
* avatar + accent border), never styled like a real user message.
*/
automatic?: boolean
/**
* Author of this message. Optional + solo-safe: when absent, the UI falls back to
* the signed-in user (for `role: 'user'`) or the agent (for `role: 'assistant'`).
* Populated per-message once real-time multi-user collaboration lands, so each
* message shows WHO sent it (name + avatar) — not just "you" vs the agent.
*/
author?: MessageAuthor
/**
* A human-only team note (side channel): visible to every project member, NEVER
* sent to the model. Renders like a regular user message — author header,
* relative time, plain content — but with the team-only (gold) accent border and
* a gold badge next to the time so it is obvious the agent will ignore it.
* Persisted `role: 'system'` server-side (the agent loop skips `system`
* messages) and carries `author` for attribution.
*/
teamOnly?: boolean
/**
* The user composed this message through voice dictation — text the mic
* button dictated was still present in the composer at submit. Display-only:
* the chat renders a mic badge on the message (live and on reload) so the
* transcript shows HOW it was written; the model request ignores it.
*/
viaDictation?: boolean
}
MessageAuthor
Identity of a message's sender, for (eventually) multi-participant conversations.
interface MessageAuthor {
/** Stable sender id — a user id; absent for the AI agent. */
id?: string
/** Display name for the message header (a teammate's name, or the agent's name). */
name?: string
/** Avatar value — inline `data:image/*` URI or `http(s)` URL; null/absent → icon fallback. */
avatar?: string | null
}
CommitSuggestion
A suggested git commit after file changes, shown to the user for one-click committing.
interface CommitSuggestion {
files: string[]
message?: string
status: 'pending' | 'committing' | 'committed' | 'error'
}
ToolCall
A tool invocation within an assistant message, tracking its lifecycle from pending through completion or error.
interface ToolCall {
id: string
name: string
input: unknown
output?: unknown
status: 'pending' | 'running' | 'done' | 'error'
/**
* Characters of this tool's input streamed so far via `tool_input_delta`,
* before the complete `input` arrives. Drives the live token estimate while a
* large input is still generating. Transient — not persisted; `input` is the
* source of truth once the call completes.
*/
streamInputChars?: number
/** Snapshot of original/modified file content captured at tool-call time (not sent to AI). */
fileDiff?: { original: string; modified: string }
/** Whether this tool call's file change has been undone. */
isUndone?: boolean
}
ChatConfig
Configuration for a chat session, including the API endpoint, project context, and optional model/prompt overrides.
interface ChatConfig {
/** API endpoint for sending messages. */
endpoint: string
/** Project ID for context. */
projectId?: string
/** System prompt override. */
systemPrompt?: string
/** AI model to use. */
model?: string
/** When true, resume the last interrupted assistant response without adding a user message. */
resume?: boolean
/**
* The last SSE event id the client received for this turn, sent as
* `Last-Event-ID` on a resume. A server that supports resumption continues
* from the event AFTER it instead of replaying the whole turn. Omit it and
* the provider uses the id it captured on this instance's own stream.
*/
lastEventId?: string
/**
* When true, this send is an internal driver (e.g. the post-boot build
* kickoff): the server persists the user message as `hidden` so it never
* appears in history, and the optimistic local bubble is suppressed. The text
* is still sent to the model. (Sent to the server in the request body — this
* is NOT a client-only toggle.)
*/
suppressUserMessage?: boolean
/**
* When true, this send was issued automatically on the user's behalf (e.g. an
* auto-fix prompt): the server persists the user message with `automatic` set
* so it stays visible but renders in the distinct auto-sent style.
*/
automatic?: boolean
/**
* When true alongside `automatic`, the send was directly requested by
* the user (e.g. a "Fix with AI" button) rather than dispatched autonomously.
* The server's user-stop gate refuses autonomous automatic turns after an
* explicit Stop; a user-initiated one is the user re-engaging, so it runs
* (and clears the stop). Sent to the server in the request body.
*/
userInitiated?: boolean
/**
* When true, the user composed this message through voice dictation (the mic
* button's transcript was still present in the composer at submit). Sent to
* the server in the request body so it tags the persisted user message —
* the mic badge then survives a reload like the live one.
*/
viaDictation?: boolean
}
ChatState
Reactive state for a chat session, including messages, loading state, error state, and WebSocket connection status.
interface ChatState {
messages: ChatMessage[]
isLoading: boolean
error: string | null
connectionStatus: 'connected' | 'disconnected' | 'connecting'
}
ChatProvider
AI chat provider interface that all chat bond packages must implement. Provides streaming message sending, abort, and conversation history management.
interface ChatProvider {
readonly name: string
/** Sends a message and streams the response via the event handler. */
sendMessage(
message: string,
config: ChatConfig,
onEvent: ChatEventHandler,
attachments?: ChatAttachment[],
): Promise<void>
/** Aborts the current streaming response. */
abort(): void
/**
* Optional: sends a SIDE-CHANNEL message (e.g. a human-only team note the
* server intercepts before running any agent turn) without touching the main
* turn stream. Must be safe to call while `sendMessage` is streaming: it uses
* an independent request/abort lifecycle, never aborts (and is never aborted
* by) the main stream or `abort()`. Events from the response — typically a
* single complete `message` (+ `done`) — are delivered to `onEvent`.
* Providers that omit it: callers fall back to `sendMessage`, which may queue
* the message behind an active turn.
*/
sendSideMessage?(message: string, config: ChatConfig, onEvent: ChatEventHandler): Promise<void>
/** Clears the conversation history on the server. */
clearHistory(config: ChatConfig): Promise<void>
/** Loads the conversation history from the server. */
loadHistory(config: ChatConfig): Promise<ChatMessage[]>
}
Types
MessageBlock
One ordered item of an assistant message: text, a tool-call reference, thinking, or a notice.
type MessageBlock =
| { type: 'text'; content: string }
| { type: 'tool_call'; id: string }
| { type: 'thinking'; content: string }
| {
type: 'verification'
status: 'ok' | 'error'
output?: string
workspaces: string[]
categories?: string[]
}
| { type: 'resource_limit'; resource: string; message: string }
CardEvent
The RAW payload of an inline transcript card (model-switch notice, "Building your
app" phase marker, "Loaded N skills", and app-specific custom cards). A card is
persisted as a role: 'system' ChatMessage carrying this in cardEvent,
so it lives in the ONE message transcript (live === stored) instead of a separate
store. The card's user-facing copy/actions/tone are built from this payload at
RENDER time — identically live and on reload — keeping app-specific text out of the
shared packages (the server records the data; the app renders it).
type CardEvent =
| { kind: 'model'; model: string; label?: string; mode?: 'plan' | 'execute'; by?: string }
| { kind: 'mode'; mode: 'plan' | 'execute'; by?: string }
| { kind: 'skills'; count: number }
| { kind: 'custom'; name: string; data?: Record<string, unknown> }
// An agent-altering SETTING changed — effort level, fast/priority mode, max tool
// loops, processing region, or the auto-fix toggle. Carded so every member sees
// the change live and on reload. `by` names the member who changed it (absent
// when the agent/system drove it); `mode` scopes a per-mode value (e.g. the
// execute-mode effort); `label` carries a display name (e.g. the model a region
// change applies to). Values are data, not copy — the app renders the text.
| {
kind: 'setting'
setting: 'effort' | 'fastMode' | 'maxToolLoops' | 'region' | 'autoFix' | 'autoApprove'
value?: string | number | boolean | null
label?: string
mode?: 'plan' | 'execute'
by?: string
}
ChatEventHandler
Callback invoked for each event in a streaming chat response.
type ChatEventHandler = (event: ChatStreamEvent) => void
ChatStreamEvent
Discriminated union of events emitted during a streaming chat response. Events include text chunks, tool invocations, tool results, completion, and errors.
type ChatStreamEvent =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
| { type: 'tool_use'; id: string; name: string; input: unknown }
// The model has BEGUN a tool call (id + name known) but its input is still
// streaming — lets the UI show activity ("Writing the plan") immediately.
| { type: 'tool_use_start'; id: string; name: string }
// Progress for the in-flight tool call's input — `chars` is the number of
// input characters since the last delta (coalesced server-side). Drives the
// live token counter while a large input (file / plan) is being generated.
// `partialInput` carries short display fields (e.g. file `path`, plan `name`)
// extracted server-side from the partial args as soon as they're known, so the
// tool card can label itself ("Write `app.ts`") before the full input arrives.
| {
type: 'tool_input_delta'
id: string
chars: number
partialInput?: Record<string, string>
}
| { type: 'tool_result'; id: string; output: unknown }
| { type: 'file_diff'; path: string; oldContent: string | null; newContent: string }
| { type: 'commit_suggestion'; files: string[] }
| { type: 'conversation'; id: string }
// The server is opening a new persisted assistant message (one per agentic-loop
// iteration). Carries the STABLE id + the server timestamp (ms) the message will be
// persisted with, so the live transcript builds the SAME per-message structure the
// server stores — every stream item after this belongs to this message until the next
// `message_start`. There is NO per-message terminal event: finalization is driven
// solely by the next `message_start` (finalize the previous) and the final
// `done`/`error` (finalize the last). `timestamp` is ms and equals
// `new Date(persistedISO).getTime()` so the live message is byte-identical to history.
| { type: 'message_start'; id: string; timestamp: number }
// The server attached this request to a turn ALREADY running for the conversation —
// a reconnect or a resume that arrived while the turn was live (including a turn the
// server itself resumed after a restart). Every frame from here on is that live turn's.
// `messageId`/`timestamp` name the assistant message it is currently streaming (absent
// between messages), so the client can adopt that message as its accumulator instead of
// dropping every delta until the next `message_start`; whatever streamed before this
// moment is in the persisted transcript, which the client converges on.
| { type: 'attached'; messageId?: string; timestamp?: number }
// An inline transcript CARD (model-switch / phase / skills / custom notice), recorded
// by the server as a `role:'system'` message in the ONE transcript and emitted live with
// the SAME `id` + `timestamp` it is persisted with — so the card the client renders live
// is byte-identical to the one it loads on refresh (no separate card store, no client
// decision). The app builds the card's copy/actions from `card` at render time.
| { type: 'card'; id: string; timestamp: number; card: CardEvent }
// A COMPLETE, non-streaming chat message appended to the transcript in one piece —
// e.g. a teammate's human-only team note (`ChatMessage.teamOnly`) fanned out
// live. `message` is the full client-shape `ChatMessage` (ms `timestamp`),
// emitted with the SAME id + timestamp it was persisted with (the card-event
// invariant), so the live message is byte-identical to what history reloads.
// Receivers append it de-duped by id and never persist it — the emitting server
// already did.
| { type: 'message'; message: ChatMessage }
// `timestamp` (ms, server clock) is when the transition occurred — set so any card
// the app derives from this event sorts on the SAME clock as the messages (which are
// server-stamped via `message_start`), instead of a client-receipt time that can skew
// the card above/below the response. Optional + additive; consumers fall back to now.
| { type: 'mode'; mode: 'plan' | 'execute'; timestamp?: number }
| { type: 'loop_limit_reached'; maxLoops: number }
| { type: 'compaction'; compactedCount: number; remainingCount: number; summary: string }
| {
type: 'verification_result'
status: 'ok' | 'error'
output?: string
workspaces: string[]
categories?: ('type' | 'lint' | 'runtime')[]
changedPaths?: string[]
}
| {
type: 'preview_error'
errors: Array<{ message: string; source?: string; line?: number; column?: number }>
}
| { type: 'resource_limit'; resource: 'memory'; message: string }
// A command the executor detached with `run_in_background`. Emitted once when
// it starts and again when it finishes, so the UI can show work that outlives
// the tool call that started it — the tool returned a handle, not a result.
| { type: 'background_task'; task: BackgroundTaskState }
// Generic extension point for app-specific stream events the SHARED package knows
// nothing about. A consuming app emits `{ type: 'custom', name, data }` and (for
// the react IDE) registers a renderer via `registerCustomEventCard(name, …)` from
// `@molecule/app-ide-react` to surface it as a chat card. This is how an app-specific
// notice (e.g. molecule.dev's `upgrade_prompt` / `guest_reminder` / `build_degraded`
// upgrade-and-billing cards) stays OUT of this core union — the app owns its own
// event names + copy + routes, not this package.
// `timestamp` (ms, server clock): when the event was emitted, so a card derived from it
// sorts on the SAME monotonic clock as the messages (see the `mode`/`model` events).
// Optional + additive; a consumer falls back to the client clock when it's absent.
| { type: 'custom'; name: string; data?: Record<string, unknown>; timestamp?: number }
| {
type: 'activity'
activity: {
id: string
type: 'email' | 'sms' | 'push' | 'webhook' | 'channel'
status: 'captured' | 'sent' | 'delivered' | 'failed'
recipient?: string
summary?: string
timestamp: string
}
}
// A transient, human-readable status for a background phase that is neither a
// token stream nor a tool call — e.g. the post-response verification pass
// ("Type-checking the API", "Linting the app", "Checking the preview loads").
// The UI shows `label` in place of the rotating spinner messages so it's clear
// what's happening right now; `label: null` clears it. Generic on purpose: the
// app supplies the label text, so this core union stays free of any app-specific
// phase vocabulary (same decoupling rule as the `custom` event above).
| { type: 'status'; label: string | null }
| { type: 'done'; usage?: { inputTokens: number; outputTokens: number; contextWindow?: number } }
| {
type: 'error'
message: string
/**
* HTTP status code of the failed backend response, when the error
* originated from a non-ok HTTP response (e.g. `503`). Absent for
* transport/stream errors that never produced a response. Consumers use it
* to distinguish a retryable server error (5XX) — which should back off and
* auto-resume — from a client error (4XX) or a limit/quota gate, which
* should not. Additive + optional, so emitting it is backward-compatible.
*/
status?: number
/**
* True when the error came from the TRANSPORT layer — the fetch failed or
* the connection dropped mid-stream (server crash/restart, network blip,
* proxy reset) — rather than from an error event the server itself sent.
* Consumers use it to auto-resume an interrupted turn: a transport drop is
* always retryable (the server never said "stop"), while a server-emitted
* error without a `status` is terminal. Additive + optional, so emitting
* it is backward-compatible.
*/
// … truncated — the full declaration is in node_modules/@molecule/app-ai-chat/dist/types.d.ts
Functions
setProvider(provider)
Registers an AI chat provider as the active singleton. Called by bond packages during application startup.
function setProvider(provider: ChatProvider): void
provider— The chat provider implementation to bond.
getProvider()
Retrieves the bonded AI chat provider, or null if none is bonded.
function getProvider(): ChatProvider | null
Returns: The bonded chat provider, or null.
hasProvider()
Checks whether an AI chat provider is currently bonded.
function hasProvider(): boolean
Returns: true if an AI chat provider is bonded.
requireProvider()
Retrieves the bonded AI chat provider, throwing if none is configured.
function requireProvider(): ChatProvider
Returns: The bonded chat provider.
Available Providers
| Provider | Package |
|---|---|
| HTTP | @molecule/app-ai-chat-http |
Injection Notes
Requirements
Peer dependencies:
@molecule/app-bond^1.0.1@molecule/app-i18n^1.0.1
Runtime Dependencies
@molecule/app-bond@molecule/app-i18n
Chat runs through YOUR backend, not the AI provider directly. Bond a chat provider (e.g.
@molecule/app-ai-chat-http) pointed at your API's chat endpoint; the frontend sends
messages there and streams the reply over SSE. The AI provider API key lives ONLY in your API
(see @molecule/api-ai) — the browser NEVER calls Anthropic/OpenAI directly or holds a
provider key, which would ship the key to every user.
- Render model output safely. Never
dangerouslySetInnerHTML/v-htmla raw model response — a model (or an injected prompt) can emit<script>/HTML. Render markdown through a sanitizing renderer. - Model output is UNTRUSTED (see api-ai): a tool call or action it suggests must be authorized
- validated server-side, never auto-executed from the client.
- An
errorevent can carry limit metadata:limitType(the rule that fired),requiresSignup, plusbillingAction(the remedy the backend resolved — e.g.add_funds,add_payment_method,raise_spend_cap,upgrade,none) andupgradeTier(nullwhen there is no higher plan). Build the call-to-action frombillingAction, not fromlimitTypealone: the same limit needs a different button depending on the account (an empty balance is "add funds" with a card on file and "add a payment method" without one). The vocabulary belongs to your API — these are typed as plain strings so the core stays free of any app's billing model.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Sending a message renders it in the thread and a streamed assistant reply appears incrementally (visible tokens while generating — not a frozen UI that dumps one blob).
- The reply flows through the app's OWN backend: the browser's network log shows no direct calls to an AI provider and no provider key anywhere client-side.
- Model output renders as sanitized markdown — a reply containing HTML
or
<script>displays as text and never executes. - If the app claims conversation persistence, reloading restores the thread history.
- A backend failure (endpoint down, missing API key) surfaces a readable, actionable error — not an infinite spinner.
- Sending again while a reply streams is handled sanely (queued, blocked, or parallel — never corrupted/interleaved text).
Translations
Translation strings are provided by @molecule/app-locales-ai-chat.