Packages / @de_canter/apogee-chat-kit-app

@de_canter/apogee-chat-kit-app

v0.1.0Live demo →

The client half of the chat kit: one React card renders an envelope and acts through tools, in an MCP Apps host or in the product's own app.

$ pnpm add @de_canter/apogee-chat-kit-app

@de_canter/apogee-chat-kit-app

The client half of the chat kit: render an envelope from @de_canter/apogee-chat-kit inside an MCP Apps host or inside the product's own app, act through tools, and keep the model informed. One React card, two transports. Spec: docs/design/chat-kit.md §6.

Concept Import One-liner
Runtime createFragment({ name, version, render, view?, theme?, app? }) → { connect(), destroy(), app } wraps the ext-apps App; tool results become envelopes; act() calls the tool, re-renders, updates model context
Context FragmentContext { envelope, host, status, error, act, ask, openLink } everything render gets
Parse parseToolResult(result, view?), toolResultText(result) never throws; accepts an Envelope or a ListEnvelope; isError → FragmentError, anything else → INVALID_INPUT
Theme applyTheme(host, map?), defaultThemeMap, ThemeMap host theme + variables onto the document, then your CSS custom properties
React EnvelopeProvider, useEnvelope(), useAction(action) from ./react one card component for both hosts
Transports mcpAppTransport(app), httpTransport(call) from ./react the only difference between a fragment and a product screen

Guarantees

Building a fragment

Bundle one HTML file per fragment (vite-plugin-singlefile), register it with registerKit(server, kit, { fragments }), and point the resource's ui map at it. In the product app, wrap the same card in EnvelopeProvider with httpTransport(api.callKit).

The transport passed to EnvelopeProvider must be a stable reference — create it once with useMemo (or at module scope) rather than inline on every render — because the provider re-subscribes whenever the transport identity changes.

// card.tsx — identical in the fragment bundle and the product app
export function ScanCard() {
  const { envelope, status } = useEnvelope<ScanView>();
  const picks = useAction(envelope?.allowed_next_actions.find((a) => a.capability === 'scan_review') ?? NONE);
  if (envelope && isListEnvelope(envelope)) return <ScanList list={envelope} />;
  …
}

// fragment entry: build the transport BEFORE connect() so it cannot miss the initial tool result
const fragment = createFragment({ name: 'scan-card', version: '1.0.0', render: () => undefined });
const transport = mcpAppTransport(fragment.app);
await fragment.connect();
createRoot(el).render(<EnvelopeProvider transport={transport}><ScanCard /></EnvelopeProvider>);