← All @molecule/* packages · App templates

@molecule/app-ai-chat

Core interface · ai-chat · App (browser) · v1.3.0 · Apache-2.0

AI chat panel interface for streaming conversations with tool use

npm install @molecule/app-ai-chat

npm · Source on GitHub

How 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

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.ts JSDoc, not this file.

AI chat core interface for molecule.dev.

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

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
}

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
}

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
  /**
   * 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 {@link 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
}

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 {@link 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 {@link author} for attribution.
   */
  teamOnly?: boolean
}

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[]>
}

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'
}

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'
}

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
}

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
}

Types

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' {@link 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 }
  // 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 ({@link ChatMessage.teamOnly}) fanned out
  // live. `message` is the full client-shape {@link 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 }
  // 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.
       */
      transport?: boolean
      limitType?: string
      requiresSignup?: boolean
    }
  // The active model changed (e.g. planner → executor); surfaced in the chat.
  // `timestamp` (ms, server clock): see the `mode` event above — same card-clock fix.
  | { type: 'model'; model: string; label?: string; mode?: 'plan' | 'execute'; timestamp?: number }
  // Post-discovery: the server is selecting a starting point / about to boot.
  | { type: 'designing' }
  // Discovery done + starting point chosen — the client boots the sandbox.
  | { type: 'ready_to_build' }
  // The agent asks the IDE to perform a non-mutating UI action (reload/navigate
  // the preview, open a file, or drive the preview's interaction bridge).
  // Handled by the host app, not rendered in the chat.
  | {
      type: 'client_action'
      action: 'reload_preview' | 'navigate_preview' | 'open_file' | 'preview_ui'
      path?: string
      /** preview_ui: correlates the command with its ui-result round-trip. */
      requestId?: string
      /** preview_ui: the interaction the preview bridge should perform. */
      command?: 'snapshot' | 'click' | 'fill' | 'select' | 'waitFor'
      /** preview_ui: the `data-mol-id` of the target element (preferred). */
      molId?: string
      /** preview_ui: CSS-selector fallback when no molId is available. */
      selector?: string
      /** preview_ui: visible-label match for apps whose elements carry no molId. */
      text?: string
      /** preview_ui: value to set for fill/select. */
      value?: string
    }

MessageBlock

An ordered block within an assistant message, preserving the interleaved sequence of text chunks and tool calls as they were received from the stream.

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 }

Functions

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.

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.

Available Providers

ProviderPackage
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-html a 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.

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.