Extensions

Composer Button Extensions

The Composer Button Framework allows you to create custom buttons in the Composer input area (next to the Send button) without modifying core Chatons code. This guide covers architecture, APIs, and implementation patterns.

Overview

Composer buttons are extension-based additions to the Composer that provide:

  • Context usage ring showing model context window consumption
  • Microphone button for Speech-to-Text transcription
  • Custom AI tools for specialized tasks
  • Model shortcuts for quick model switching
  • Extensible framework for any composer-based action

All buttons integrate seamlessly with Chatons' design system and support both icon and widget rendering modes, plus requirement sheets for setup workflows.

Architecture

Core Components

SDK (composer-button-sdk.ts)
  └─ Registry + Interfaces
     ├─ ComposerButtonExtension
     ├─ ComposerButtonAction
     ├─ ComposerButtonContext
     └─ ComposerButtonRequirement

Hook (use-composer-extension-buttons.ts)
  └─ State management + requirement checking
     ├─ getAllButtons()
     ├─ executeButtonAction()
     └─ requirement sheet display

Component (ComposerExtensionButtons.tsx)
  └─ UI rendering
     └─ Button icons + interactions

Composer (Composer.tsx)
  └─ Integration point
     ├─ Hook usage
     └─ ComposerRequirementSheet display

Data Flow

  1. Initialization: startExtensionLoader() registers built-in extensions
  2. Discovery: Hook subscribes to registry changes
  3. Rendering: ComposerExtensionButtons renders available buttons
  4. Execution: Click triggers executeButtonAction()
  5. Requirements: Check if requirements satisfied
  6. Display: Show requirement sheet if needed
  7. Action: Execute button's onAction() callback

Core Interfaces

ComposerButtonExtension

interface ComposerButtonExtension {
  id: string;                          // Unique extension ID (e.g., '@vendor/name')
  name: string;                        // Display name
  version: string;                     // Semantic version
  getButtons(): Promise<ComposerButtonAction[]>;  // Get available buttons
  onEnable?(): void;                   // Called when enabled
  onDisable?(): void;                  // Called when disabled
}

ComposerButtonAction

interface ComposerButtonAction {
  id: string;                          // Unique button ID
  label: string;                       // Display label
  icon: string;                        // Lucide icon name (used in icon mode)
  tooltip?: string;                    // Hover tooltip
  renderMode?: 'icon' | 'widget';     // 'icon' (default) or 'widget' for custom HTML
  widgetHtml?: string;                 // HTML string rendered in an iframe (widget mode)
  widgetSize?: { width: number; height: number };  // Widget dimensions (default 32x32)
  requirements?: ComposerButtonRequirement[];  // Setup requirements
  onAction(context: ComposerButtonContext): Promise<void>;
}

ComposerButtonContext

interface ComposerButtonContext {
  conversationId: string | null;
  projectId: string | null;
  setText(text: string, append?: boolean): void;
  getText(): string;
  addAttachment(file: File): Promise<void>;
  sendMessage(): Promise<void>;
  notify(title: string, body?: string, type?: 'info' | 'success' | 'error' | 'warning'): void;
  getCurrentModel(): Promise<{ provider: string; id: string } | null>;
  getAvailableModels?(): Promise<Array<{ provider: string; id: string; name: string; capabilities?: string[] }>>;
  showRequirementSheet?(requirement: ComposerButtonRequirement): Promise<'confirm' | 'dismiss' | 'open-settings'>;
  accessMode: 'secure' | 'open';
  contextUsage: ComposerContextUsageData | null;  // Live token usage stats
}

ComposerContextUsageData

interface ComposerContextUsageData {
  usedTokens: number;       // Estimated tokens currently occupying the thread context
  contextWindow: number;    // Model context window capacity (0 if unknown)
  percentage: number;       // Percentage of context window used (0-100)
}

ComposerButtonRequirement

interface ComposerButtonRequirement {
  id: string;                          // Unique requirement ID
  title: string;                       // Display title
  html: string;                        // HTML content (displayed in iframe)
  satisfied(): Promise<boolean>;       // Check if satisfied
}

Requirement Sheets

Requirement sheets are optional setup workflows displayed before button execution. They:

  • Display in a modal sliding from the top
  • Use Chatons' official CSS classes and styling
  • Support light and dark modes automatically
  • Allow HTML content via iframe sandboxing
  • Communicate via postMessage for user actions

HTML Content Requirements

Requirement sheet HTML is rendered in an iframe sandbox with:

sandbox="allow-scripts allow-same-origin allow-forms"

Send messages to parent for user actions:

// User confirmed
window.parent.postMessage({ type: 'chaton:requirement-sheet:confirm' }, '*');

// User dismissed
window.parent.postMessage({ type: 'chaton:requirement-sheet:dismiss' }, '*');

// User wants to open settings
window.parent.postMessage({ type: 'chaton:requirement-sheet:open-settings' }, '*');

Styling Requirements Sheets

Use Chatons' official color palette:

Light Mode:

  • Text: #222632
  • Secondary: #4a4f5f
  • Primary button: #2563eb
  • Status box: #f0fdf4 bg + #22c55e border

Dark Mode:

  • Add .dark prefix to selectors:
    .dark body { color: #e6ecfa; }
    .dark .btn-primary { background: #3b82f6; }

Max Height

Requirement sheets use 80% of viewport height (80vh) for maximum content visibility while leaving space for the Composer below.

Example: Speech-to-Text Extension

The built-in Speech-to-Text extension demonstrates best practices:

const extension: ComposerButtonExtension = {
  id: '@thibautrey/chatons-extension-speech-to-text',
  name: 'Speech to Text',
  version: '1.0.0',
  
  async getButtons(): Promise<ComposerButtonAction[]> {
    return [createSpeechToTextButton()];
  },
};

function createSpeechToTextButton(): ComposerButtonAction {
  let hasShownRequirementSheet = false;

  return {
    id: 'speech-to-text-button',
    label: 'Speech to Text',
    icon: 'Mic',
    tooltip: 'Click to dictate your message (🎙️)',
    
    requirements: [
      {
        id: 'speech-to-text-setup',
        title: 'Speech to Text',
        html: generateSpeechToTextRequirementHTML(),
        satisfied: async () => hasShownRequirementSheet,
      },
    ],

    async onAction(context: ComposerButtonContext) {
      hasShownRequirementSheet = true;
      
      const SpeechRecognition = (window as any).SpeechRecognition || 
                                (window as any).webkitSpeechRecognition;
      
      if (!SpeechRecognition) {
        context.notify('Speech Recognition Not Available', 'Your browser does not support the Web Speech API.', 'error');
        return;
      }

      const recognition = new SpeechRecognition();
      recognition.lang = navigator.language || 'en-US';
      recognition.continuous = false;
      recognition.interimResults = true;

      return new Promise<void>((resolve) => {
        let finalText = '';

        recognition.onstart = () => {
          context.notify('Listening...', 'Speak now. Your speech will be transcribed.', 'info');
        };

        recognition.onresult = (event: any) => {
          for (let i = event.resultIndex; i < event.results.length; i++) {
            const transcript = event.results[i][0].transcript;
            if (event.results[i].isFinal) {
              finalText += transcript + ' ';
            }
          }
        };

        recognition.onend = () => {
          if (finalText.trim()) {
            context.setText(finalText.trim() + ' ', true);
            context.notify('Speech Recognized', `"${finalText.trim()}"`, 'success');
          } else {
            context.notify('No speech detected', 'Please try again', 'warning');
          }
          resolve();
        };

        recognition.onerror = (event: any) => {
          context.notify('Speech Recognition Error', event.error, 'error');
          resolve();
        };

        recognition.start();
      });
    },
  };
}

Creating Your Own Extension

1. Define Your Extension

Icon Mode (default)

import { ComposerButtonExtension } from '@/extensions/composer-button-sdk';

const myExtension: ComposerButtonExtension = {
  id: '@myorg/my-composer-button',
  name: 'My Custom Button',
  version: '1.0.0',
  
  async getButtons() {
    return [
      {
        id: 'my-button',
        label: 'My Action',
        icon: 'Zap',
        tooltip: 'Do something awesome',
        
        async onAction(context) {
          // Your implementation here
          const text = context.getText();
          context.setText(text + ' [processed]', false);
          context.notify('Done!', 'Text processed', 'success');
        },
      },
    ];
  },
};

Widget Mode

Widget mode renders custom HTML in an iframe instead of a Lucide icon. This is useful for rich visual elements like progress indicators, status badges, or mini-charts.

const myWidgetExtension: ComposerButtonExtension = {
  id: '@myorg/my-widget',
  name: 'My Widget',
  version: '1.0.0',
  
  async getButtons() {
    return [
      {
        id: 'my-widget-button',
        label: 'Status Widget',
        icon: 'Circle',           // Fallback, not shown in widget mode
        renderMode: 'widget',
        widgetHtml: `
          <!DOCTYPE html>
          <html><head>
          <style>
            body { margin: 0; background: transparent; }
            .dot { width: 12px; height: 12px; border-radius: 50%;
                   background: #22c55e; margin: 10px auto; }
          </style>
          </head><body>
            <div class="dot" id="dot"></div>
            <script>
              window.addEventListener('message', function(e) {
                if (e.data?.type !== 'chaton.composerButton.context') return;
                // React to context updates
                var usage = e.data.payload?.contextUsage;
                if (usage && usage.percentage > 75) {
                  document.getElementById('dot').style.background = '#d97706';
                }
              });
            </script>
          </body></html>
        `,
        widgetSize: { width: 32, height: 32 },
        async onAction() {
          // Widget is display-only
        },
      },
    ];
  },
};

Widget Communication Protocol

Widgets receive context updates from the host via postMessage:

Host to Widget (automatic on every context change):

{
  type: 'chaton.composerButton.context',
  payload: {
    buttonId: 'my-widget-button',
    conversationId: 'uuid' | null,
    projectId: 'uuid' | null,
    contextUsage: {
      usedTokens: 1245,
      contextWindow: 4096,
      percentage: 30
    } | null
  }
}

Widget to Host (optional, for dynamic tooltips):

window.parent.postMessage({
  type: 'chaton.composerButton.tooltip',
  buttonId: 'my-widget-button',
  text: 'Current status: OK'
}, '*');

The host renders the tooltip text as a native title attribute on the widget container, so it appears on hover without the iframe needing to overflow.

2. Register Your Extension

For built-in extensions, register in registerBuiltInExtensions():

function registerBuiltInExtensions(): void {
  composerButtonRegistry.register(myExtension);
}

3. Add Requirements (Optional)

requirements: [
  {
    id: 'my-setup',
    title: 'Setup Required',
    html: `<html>...<button onclick="window.parent.postMessage({type:'chaton:requirement-sheet:confirm'},'*')">OK</button></html>`,
    satisfied: async () => {
      // Check if setup is complete
      return localStorage.getItem('my-setup-done') === 'true';
    },
  },
],

4. Use Context Methods

async onAction(context) {
  // Get current state
  const text = context.getText();
  const model = await context.getCurrentModel();
  const models = await context.getAvailableModels?.();
  
  // Read context usage
  const usage = context.contextUsage;
  if (usage && usage.percentage > 80) {
    context.notify('Warning', 'Context window almost full', 'warning');
  }
  
  // Modify composer
  context.setText('new text', false);           // Replace
  context.setText(' appended', true);           // Append
  
  // Attach files
  await context.addAttachment(file);
  
  // Notify user
  context.notify('Title', 'Body', 'success');
  
  // Send message
  await context.sendMessage();
  
  // Show requirement sheet
  const result = await context.showRequirementSheet?.(requirement);
}

Built-in: Context Usage Widget

The context usage circular progress ring is implemented as a built-in widget-mode composer button (@chaton/context-usage). It shows an estimate of how much of the active model's context window is currently occupied by the thread.

The widget:

  1. Receives contextUsage data via chaton.composerButton.context messages
  2. Renders a circular SVG progress ring (24x24 in a 32x32 container)
  3. Changes stroke color based on usage level: default (slate), warning at 75% (amber), danger at 90% (red)
  4. Sends dynamic tooltip text back to the host via chaton.composerButton.tooltip

This extension demonstrates the full widget pattern and can be used as a reference for building similar visual widgets.

Best Practices

UI/UX

  • Use Lucide icons for icon-mode buttons (Mic, Zap, Filter, etc.)
  • Use widget mode for rich visual elements (progress rings, status indicators)
  • Provide clear tooltips explaining what the button does
  • Show notifications for user feedback
  • Keep actions fast and responsive

Widget Mode

  • Keep widgets small and focused (32x32 default)
  • Use pointerEvents: none for display-only widgets (the host handles this)
  • Send tooltip updates via chaton.composerButton.tooltip for hover text
  • React to chaton.composerButton.context messages for live data
  • Use transparent backgrounds to blend with the composer

Requirements

  • Show requirements only once (use a flag or localStorage)
  • Provide clear setup instructions in the requirement sheet
  • Use Chatons' official colors for visual consistency
  • Support both light and dark modes with .dark CSS

Performance

  • Lazy-load expensive operations in onAction()
  • Clean up resources in error handlers
  • Use Promise properly to avoid hanging actions
  • Cache results when possible (requirements.satisfied state)

Accessibility

  • Use semantic HTML in requirement sheets
  • Provide ARIA labels for screen readers
  • Support keyboard navigation (Escape to close)
  • Maintain high contrast in both themes

File Locations

SDK:                src/extensions/composer-button-sdk.ts
Hook:               src/hooks/use-composer-extension-buttons.ts
Component:          src/components/shell/composer/ComposerExtensionButtons.tsx
Requirement Sheet:  src/components/shell/composer/ComposerRequirementSheet.tsx
Composer:           src/components/shell/Composer.tsx
CSS:                src/styles/components/chat.css

Troubleshooting

Button Not Appearing

  1. Check browser console for [Composer Extensions] messages
  2. Verify extension is registered in registerBuiltInExtensions()
  3. Ensure getButtons() returns non-empty array
  4. Restart Chatons

Requirement Sheet Not Showing

  1. Check satisfied() returns false on first click
  2. Verify HTML is valid and renders in iframe
  3. Check browser console for errors
  4. Verify postMessage communication works

Button Click Not Working

  1. Check onAction() implementation
  2. Verify context methods are available
  3. Check for TypeScript errors
  4. Review browser console logs

On this page