Channel Extensions

Build a bridge between Chatons and an external messaging platform.

A channel extension receives messages from an external service (Telegram, Slack, WhatsApp, email, SMS, etc.), routes them into Chatons conversations, and optionally sends AI-generated replies back.

Prerequisites: You should be familiar with the Extensions overview and the Extensions Tutorial before building a channel extension.

Reference: See the Extensions API for the full SDK reference.


Table of Contents

  1. Complete Working Example
  2. How Channel Extensions Work
  3. Host Channel API Reference
  4. Step-by-Step Walkthrough
  5. Routing Model
  6. Storage and State Management
  7. Queue and Retry Strategy
  8. Status Reporting
  9. Optional LLM Tools
  10. UI Expectations
  11. Development Workflow
  12. Debugging
  13. Current Limitations

Complete Working Example

This example builds a channel extension for a hypothetical messaging app called "PingChat". It polls a REST API for new messages, injects them into Chatons, and sends the AI reply back.

Project Structure

~/.chaton/extensions/@yourname/chatons-channel-pingchat/
├── chaton.extension.json
├── package.json
├── index.html
├── style.css
└── app.js

Step 1: Create the Directory

mkdir -p ~/.chaton/extensions/@yourname/chatons-channel-pingchat
cd ~/.chaton/extensions/@yourname/chatons-channel-pingchat

Step 2: Create chaton.extension.json

{
  "id": "@yourname/chatons-channel-pingchat",
  "name": "PingChat Channel",
  "version": "1.0.0",
  "description": "Bridge between PingChat and Chatons",
  "kind": "channel",
  "capabilities": [
    "ui.mainView",
    "storage.kv",
    "host.conversations.read",
    "host.conversations.write",
    "host.notifications"
  ],
  "ui": {
    "mainViews": [
      {
        "viewId": "pingchat.main",
        "title": "PingChat",
        "webviewUrl": "chaton-extension://@yourname/chatons-channel-pingchat/index.html",
        "initialRoute": "/"
      }
    ]
  }
}

Key manifest fields for channel extensions:

  • kind: "channel" classifies this extension as a channel. Chatons groups channel extensions under a dedicated "Channels" sidebar entry instead of creating separate sidebar items for each one.
  • host.conversations.write is required. Without it, the extension cannot create conversations or ingest messages.
  • host.conversations.read is needed to read conversation messages (optional but useful).
  • storage.kv stores your configuration state (API keys, connection status, etc.).

Step 3: Create package.json

{
  "name": "@yourname/chatons-channel-pingchat",
  "version": "1.0.0",
  "description": "PingChat channel extension for Chatons",
  "license": "MIT"
}

Step 4: Create index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>PingChat Channel</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="ce-page">
    <div class="ce-page-header">
      <div class="ce-page-title-group">
        <h1 class="ce-page-title">PingChat Channel</h1>
        <p class="ce-page-description">
          Connect your PingChat account to receive and reply to messages through Chatons.
        </p>
      </div>
    </div>

    <!-- Configuration card -->
    <div class="ce-card">
      <div class="ce-card__body">
        <h2 class="ce-section-title">Configuration</h2>
        <div class="ce-field">
          <label class="ce-label" for="apiUrl">PingChat API URL</label>
          <input id="apiUrl" type="text" placeholder="https://api.pingchat.example.com">
          <div class="ce-help">The base URL for the PingChat API.</div>
        </div>
        <div class="ce-field">
          <label class="ce-label" for="apiToken">API Token</label>
          <input id="apiToken" type="password" placeholder="Enter your API token">
          <div class="ce-help">Get this from PingChat Settings > API Keys.</div>
        </div>
        <div class="ce-toolbar">
          <button id="saveBtn" class="chaton-ui-button chaton-ui-button--primary">
            Save Configuration
          </button>
          <button id="testBtn" class="chaton-ui-button">
            Test Connection
          </button>
        </div>
      </div>
    </div>

    <!-- Connection status card -->
    <div class="ce-card">
      <div class="ce-card__body">
        <h2 class="ce-section-title">Status</h2>
        <div class="ce-list">
          <div class="ce-list-row">
            <div class="ce-list-row__main">
              <div class="ce-list-row__content">
                <span class="ce-list-row__title">Connection</span>
                <span id="statusText" class="ce-list-row__meta">Not configured</span>
              </div>
            </div>
            <span id="statusBadge" class="chaton-ui-badge chaton-ui-badge--secondary">
              Offline
            </span>
          </div>
          <div class="ce-list-row">
            <div class="ce-list-row__main">
              <div class="ce-list-row__content">
                <span class="ce-list-row__title">Polling</span>
                <span id="pollingText" class="ce-list-row__meta">Inactive</span>
              </div>
            </div>
            <button id="togglePolling" class="chaton-ui-button">Start</button>
          </div>
          <div class="ce-list-row">
            <div class="ce-list-row__main">
              <div class="ce-list-row__content">
                <span class="ce-list-row__title">Messages processed</span>
                <span id="msgCount" class="ce-list-row__meta">0</span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- Activity log card -->
    <div class="ce-card">
      <div class="ce-card__body">
        <h2 class="ce-section-title">Recent Activity</h2>
        <div id="activityLog" class="ce-list">
          <div class="ce-empty">No activity yet. Start polling to begin.</div>
        </div>
      </div>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>

Step 5: Create style.css

/* The chaton UI styles are injected automatically via chatonExtensionComponents.
   Add only custom styles here. */

#activityLog {
  max-height: 300px;
  overflow-y: auto;
}

.activity-entry {
  padding: 8px 0;
  border-bottom: 1px solid var(--ce-border, #eee);
  font-size: 13px;
}

.activity-entry:last-child {
  border-bottom: none;
}

.activity-time {
  color: var(--ce-muted, #999);
  font-size: 12px;
}

.activity-direction {
  display: inline-block;
  padding: 1px 6px;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 600;
  margin-right: 6px;
}

.activity-direction.inbound {
  background: #dbeafe;
  color: #1d4ed8;
}

.activity-direction.outbound {
  background: #dcfce7;
  color: #166534;
}

Step 6: Create app.js

This is the core of the channel extension. It demonstrates all three host channel APIs.

(function () {
  'use strict';

  var EXTENSION_ID = '@yourname/chatons-channel-pingchat';

  // Ensure the injected UI styles are available
  if (window.chatonExtensionComponents) {
    window.chatonExtensionComponents.ensureStyles();
  }

  // ---- State ----
  var state = {
    apiUrl: '',
    apiToken: '',
    connected: false,
    polling: false,
    pollTimer: null,
    lastCursor: null,
    messageCount: 0,
    activityLog: [],
  };

  // ---- DOM refs ----
  var refs = {
    apiUrl: document.getElementById('apiUrl'),
    apiToken: document.getElementById('apiToken'),
    saveBtn: document.getElementById('saveBtn'),
    testBtn: document.getElementById('testBtn'),
    statusText: document.getElementById('statusText'),
    statusBadge: document.getElementById('statusBadge'),
    pollingText: document.getElementById('pollingText'),
    togglePolling: document.getElementById('togglePolling'),
    msgCount: document.getElementById('msgCount'),
    activityLog: document.getElementById('activityLog'),
  };

  // =========================================================================
  // Host Channel API helpers
  //
  // Channel extensions talk to the Chatons host via:
  //   window.chaton.extensionHostCall(extensionId, method, params)
  //
  // The host provides these channel-specific methods:
  //   - channels.upsertGlobalThread  (create or reuse a conversation)
  //   - channels.ingestMessage       (send a message into a conversation)
  //   - channels.reportStatus        (report connection health to the UI)
  //   - channels.getStatus           (read back the last reported status)
  //   - conversations.list           (list all conversations)
  //   - conversations.getMessages    (read messages from a conversation)
  // =========================================================================

  /**
   * Create or reuse a Chatons global conversation for a remote thread.
   *
   * @param {string} mappingKey - Stable identifier for this remote thread
   *   (e.g. "pingchat:room:42" or "pingchat:user:alice"). The host stores
   *   the mapping in KV under 'channel.threadMappings' automatically.
   * @param {string} title - Display title for the conversation.
   * @param {string} [modelKey] - Optional model key (e.g. "openai/gpt-4o").
   *   If omitted, the user's default model is used.
   * @returns {Promise<{ok, data: {created, conversation}}>}
   */
  function upsertThread(mappingKey, title, modelKey) {
    return window.chaton.extensionHostCall(EXTENSION_ID, 'channels.upsertGlobalThread', {
      mappingKey: mappingKey,
      title: title || 'PingChat',
      modelKey: modelKey || null,
    });
  }

  /**
   * Inject an external message into a Chatons conversation and get an AI reply.
   *
   * The host runs an ephemeral Pi subagent against the conversation's history,
   * generates a reply, and stores both the user message and assistant reply
   * in the conversation's message cache.
   *
   * @param {string} conversationId - The Chatons conversation ID.
   * @param {string} message - The text message from the external user.
   * @param {string} [idempotencyKey] - Optional deduplication key. If the same
   *   key is sent twice, the second call is a no-op (returns {reply: null}).
   * @param {object} [metadata] - Optional metadata stored alongside the
   *   deduplication record.
   * @returns {Promise<{ok, data: {reply}}>} - reply is the AI-generated response text.
   */
  function ingestMessage(conversationId, message, idempotencyKey, metadata) {
    return window.chaton.extensionHostCall(EXTENSION_ID, 'channels.ingestMessage', {
      conversationId: conversationId,
      message: message,
      idempotencyKey: idempotencyKey || null,
      metadata: metadata || null,
    });
  }

  /**
   * Report the extension's connection status to the Chatons UI.
   *
   * This status is shown in the Channels view. Call it whenever your
   * connection state changes (connected, disconnected, error, etc.).
   *
   * @param {object} status
   * @param {boolean} status.configured - Whether the extension has valid config.
   * @param {boolean} status.connected - Whether the extension is actively connected.
   * @param {string} [status.lastActivity] - ISO timestamp of last message.
   * @param {string[]} [status.issues] - List of current problems.
   * @param {string} [status.info] - Free-form info string.
   */
  function reportStatus(status) {
    return window.chaton.extensionHostCall(EXTENSION_ID, 'channels.reportStatus', status);
  }

  /**
   * Show a notification in the Chatons UI.
   */
  function notify(title, body) {
    return window.chaton.extensionHostCall(EXTENSION_ID, 'notifications.notify', {
      title: title,
      body: body,
    });
  }

  // ---- Storage helpers ----

  function loadConfig() {
    return window.chaton.extensionStorageKvGet(EXTENSION_ID, 'config').then(function (result) {
      if (result && result.ok && result.data) {
        var config = typeof result.data === 'string' ? JSON.parse(result.data) : result.data;
        state.apiUrl = config.apiUrl || '';
        state.apiToken = config.apiToken || '';
        state.lastCursor = config.lastCursor || null;
        state.messageCount = config.messageCount || 0;
        refs.apiUrl.value = state.apiUrl;
        refs.apiToken.value = state.apiToken;
      }
    });
  }

  function saveConfig() {
    return window.chaton.extensionStorageKvSet(EXTENSION_ID, 'config', {
      apiUrl: state.apiUrl,
      apiToken: state.apiToken,
      lastCursor: state.lastCursor,
      messageCount: state.messageCount,
    });
  }

  // ---- UI helpers ----

  function updateStatusDisplay() {
    var isConfigured = state.apiUrl && state.apiToken;
    if (!isConfigured) {
      refs.statusText.textContent = 'Not configured - enter API URL and token above';
      refs.statusBadge.textContent = 'Offline';
      refs.statusBadge.className = 'chaton-ui-badge chaton-ui-badge--secondary';
    } else if (state.connected) {
      refs.statusText.textContent = 'Connected to ' + state.apiUrl;
      refs.statusBadge.textContent = 'Online';
      refs.statusBadge.className = 'chaton-ui-badge chaton-ui-badge--default';
    } else {
      refs.statusText.textContent = 'Configured but not connected';
      refs.statusBadge.textContent = 'Offline';
      refs.statusBadge.className = 'chaton-ui-badge chaton-ui-badge--secondary';
    }

    refs.pollingText.textContent = state.polling ? 'Active (every 5s)' : 'Inactive';
    refs.togglePolling.textContent = state.polling ? 'Stop' : 'Start';
    refs.msgCount.textContent = String(state.messageCount);

    // Report status to the Chatons host UI
    reportStatus({
      configured: !!(state.apiUrl && state.apiToken),
      connected: state.connected,
      lastActivity: state.activityLog.length > 0
        ? state.activityLog[0].time
        : null,
      issues: state.connected ? [] : ['Not connected to PingChat'],
    });
  }

  function addActivity(direction, text) {
    var entry = {
      time: new Date().toISOString(),
      direction: direction,
      text: text,
    };
    state.activityLog.unshift(entry);
    if (state.activityLog.length > 50) state.activityLog.pop();
    renderActivity();
  }

  function renderActivity() {
    if (state.activityLog.length === 0) {
      refs.activityLog.innerHTML =
        '<div class="ce-empty">No activity yet. Start polling to begin.</div>';
      return;
    }
    refs.activityLog.innerHTML = state.activityLog
      .map(function (entry) {
        var dirClass = entry.direction === 'inbound' ? 'inbound' : 'outbound';
        var label = entry.direction === 'inbound' ? 'IN' : 'OUT';
        var timeStr = new Date(entry.time).toLocaleTimeString();
        return (
          '<div class="activity-entry">' +
          '<span class="activity-direction ' + dirClass + '">' + label + '</span>' +
          '<span>' + escapeHtml(entry.text) + '</span>' +
          ' <span class="activity-time">' + timeStr + '</span>' +
          '</div>'
        );
      })
      .join('');
  }

  function escapeHtml(text) {
    var div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }

  // ---- PingChat API simulation ----
  // In a real extension, these would call an actual external API.

  function pingchatFetch(endpoint) {
    // Simulated API - replace with real fetch() calls in production
    return new Promise(function (resolve) {
      setTimeout(function () {
        if (endpoint === '/api/status') {
          resolve({ ok: true, status: 'connected' });
        } else if (endpoint === '/api/messages') {
          // Simulate occasional incoming messages
          if (Math.random() < 0.3) {
            resolve({
              ok: true,
              messages: [
                {
                  id: 'msg-' + Date.now(),
                  threadId: 'thread-general',
                  senderName: 'Alice',
                  text: 'Hello from PingChat! (' + new Date().toLocaleTimeString() + ')',
                },
              ],
              cursor: 'cursor-' + Date.now(),
            });
          } else {
            resolve({ ok: true, messages: [], cursor: state.lastCursor });
          }
        } else if (endpoint.startsWith('/api/reply')) {
          resolve({ ok: true, delivered: true });
        } else {
          resolve({ ok: false, error: 'Unknown endpoint' });
        }
      }, 200);
    });
  }

  function sendReplyToPingChat(threadId, replyText) {
    return pingchatFetch('/api/reply?thread=' + threadId).then(function (result) {
      if (result.ok) {
        addActivity('outbound', 'Reply to ' + threadId + ': ' + replyText.substring(0, 80));
      }
      return result;
    });
  }

  // =========================================================================
  // Core bridge logic: poll -> upsert thread -> ingest message -> send reply
  // =========================================================================

  function pollForMessages() {
    if (!state.polling || !state.apiUrl || !state.apiToken) return;

    pingchatFetch('/api/messages').then(function (result) {
      if (!result.ok || !result.messages) return;

      state.lastCursor = result.cursor || state.lastCursor;

      // Process each inbound message
      var chain = Promise.resolve();
      result.messages.forEach(function (msg) {
        chain = chain.then(function () {
          return processInboundMessage(msg);
        });
      });

      return chain;
    }).catch(function (err) {
      console.error('[PingChat] Poll error:', err);
      addActivity('inbound', 'Poll error: ' + err.message);
    });
  }

  /**
   * The core message processing pipeline:
   * 1. Upsert a Chatons conversation for this remote thread
   * 2. Ingest the message (Chatons runs a subagent and returns a reply)
   * 3. Send the reply back to PingChat
   */
  function processInboundMessage(msg) {
    var mappingKey = 'pingchat:thread:' + msg.threadId;
    var title = 'PingChat - ' + (msg.senderName || msg.threadId);

    addActivity('inbound', msg.senderName + ': ' + msg.text);

    // Step 1: Upsert a global thread for this remote conversation
    return upsertThread(mappingKey, title)
      .then(function (result) {
        if (!result.ok) {
          console.error('[PingChat] upsertThread failed:', result);
          addActivity('inbound', 'Error: could not create conversation');
          return null;
        }

        var conversationId = result.data.conversation.id;
        var wasCreated = result.data.created;

        if (wasCreated) {
          addActivity('inbound', 'Created new conversation: ' + title);
        }

        // Step 2: Ingest the message and get an AI reply
        return ingestMessage(conversationId, msg.text, msg.id);
      })
      .then(function (result) {
        if (!result) return; // upsertThread failed
        if (!result.ok) {
          console.error('[PingChat] ingestMessage failed:', result);
          addActivity('inbound', 'Error: could not ingest message');
          return;
        }

        state.messageCount++;
        saveConfig();
        updateStatusDisplay();

        // Step 3: Send the AI reply back to PingChat
        var reply = result.data && result.data.reply;
        if (reply) {
          return sendReplyToPingChat(msg.threadId, reply);
        }
      })
      .catch(function (err) {
        console.error('[PingChat] Processing error:', err);
        addActivity('inbound', 'Processing error: ' + err.message);
      });
  }

  // ---- Polling control ----

  function startPolling() {
    if (state.polling) return;
    if (!state.apiUrl || !state.apiToken) {
      notify('PingChat', 'Please configure API URL and token first.');
      return;
    }

    state.polling = true;
    state.connected = true;
    updateStatusDisplay();
    addActivity('inbound', 'Polling started');

    // Poll immediately, then every 5 seconds
    pollForMessages();
    state.pollTimer = setInterval(pollForMessages, 5000);
  }

  function stopPolling() {
    state.polling = false;
    state.connected = false;
    if (state.pollTimer) {
      clearInterval(state.pollTimer);
      state.pollTimer = null;
    }
    updateStatusDisplay();
    addActivity('inbound', 'Polling stopped');
  }

  // ---- Event handlers ----

  refs.saveBtn.addEventListener('click', function () {
    state.apiUrl = refs.apiUrl.value.trim();
    state.apiToken = refs.apiToken.value.trim();
    saveConfig().then(function () {
      notify('PingChat', 'Configuration saved.');
      updateStatusDisplay();
    });
  });

  refs.testBtn.addEventListener('click', function () {
    if (!state.apiUrl || !state.apiToken) {
      notify('PingChat', 'Please enter API URL and token first.');
      return;
    }
    pingchatFetch('/api/status').then(function (result) {
      if (result.ok) {
        notify('PingChat', 'Connection test successful!');
        state.connected = true;
      } else {
        notify('PingChat', 'Connection test failed: ' + (result.error || 'Unknown error'));
        state.connected = false;
      }
      updateStatusDisplay();
    });
  });

  refs.togglePolling.addEventListener('click', function () {
    if (state.polling) {
      stopPolling();
    } else {
      startPolling();
    }
  });

  // ---- Initialization ----

  loadConfig().then(function () {
    updateStatusDisplay();
    renderActivity();

    // Auto-start polling if previously configured
    if (state.apiUrl && state.apiToken) {
      startPolling();
    }
  });
})();

Test It

  1. Save all files
  2. Restart Chatons
  3. Go to Channels in the sidebar (it appears when channel extensions are installed)
  4. Click Configure next to "PingChat Channel"
  5. Enter any URL and token (the example simulates the API)
  6. Click Save Configuration, then Start polling
  7. Watch simulated messages arrive and AI replies get generated

How Channel Extensions Work

A channel extension bridges an external messaging platform and Chatons. Here is the lifecycle:

External Service                     Your Extension                      Chatons Host
     |                                    |                                   |
     |--- new message ------------------>|                                   |
     |                                    |-- upsertGlobalThread ----------->|
     |                                    |<-- { conversation } ------------|
     |                                    |                                   |
     |                                    |-- ingestMessage ---------------->|
     |                                    |   (host runs Pi subagent)        |
     |                                    |<-- { reply } -------------------|
     |                                    |                                   |
     |<-- send reply --------------------|                                   |
     |                                    |                                   |
     |                                    |-- reportStatus ----------------->|
     |                                    |   (UI shows connection health)   |

What the Host Does Automatically

When you call channels.ingestMessage, the host:

  1. Validates the conversation exists and is a global thread (not project-linked)
  2. Checks the idempotency key (if provided) to skip duplicate messages
  3. Runs an ephemeral Pi subagent against the conversation's message history
  4. Generates an AI reply using the conversation's selected model
  5. Stores both the user message and the assistant reply in the conversation's message cache
  6. Returns the reply text to your extension

When you call channels.upsertGlobalThread, the host:

  1. Checks if a conversation already exists for that mapping key
  2. If it exists and is still a global thread, returns it
  3. If not, creates a new global conversation with the specified title and model
  4. Stores the mapping in KV under channel.threadMappings automatically

Background Execution

Channel extensions with kind: "channel" get a special behavior: Chatons automatically loads their main view in a hidden background iframe when the app starts. This means your polling or WebSocket logic keeps running even when the user has not opened the extension's UI.

You do not need to implement a server process just to keep polling alive. Your index.html + app.js is enough.


Host Channel API Reference

Channel extensions call host methods through window.chaton.extensionHostCall(). This function is available in all extension webviews.

window.chaton.extensionHostCall(extensionId, method, params)
  • extensionId: Your extension's ID (must match manifest id)
  • method: The host method name (e.g. 'channels.upsertGlobalThread')
  • params: An object with the method's parameters
  • Returns: A Promise resolving to { ok: true, data: ... } or { ok: false, error: { code, message } }

channels.upsertGlobalThread

Create or reuse a global (non-project) conversation for a remote thread.

Required capability: host.conversations.write

Parameters:

ParameterTypeRequiredDescription
mappingKeystringYesStable identifier for the remote thread (e.g. "telegram:chat:12345"). The host uses this to find or create the conversation.
titlestringNoDisplay title for the conversation. Defaults to "Nouveau fil".
modelKeystringNoModel to use, formatted as "provider/modelId" (e.g. "openai/gpt-4o"). Defaults to the user's default model.
hiddenFromSidebarbooleanNoIf true, the conversation is not displayed in the app sidebar but remains accessible via the extension and through API calls. Useful for internal or background conversations. Defaults to false.

Response:

{
  ok: true,
  data: {
    created: true,       // false if conversation already existed
    conversation: {
      id: "uuid-...",
      projectId: null,   // always null for channel conversations
      title: "PingChat - Alice",
      updatedAt: "2026-03-10T12:00:00.000Z",
      lastMessageAt: "2026-03-10T12:00:00.000Z",
      modelProvider: "openai",
      modelId: "gpt-4o",
      thinkingLevel: null,
    }
  }
}

Example:

var result = await window.chaton.extensionHostCall(
  EXTENSION_ID,
  'channels.upsertGlobalThread',
  {
    mappingKey: 'telegram:chat:12345',
    title: 'Telegram - Alice',
    modelKey: 'openai/gpt-4o',
    hiddenFromSidebar: false,  // Optional: show in sidebar
  }
);

if (result.ok) {
  var conversationId = result.data.conversation.id;
  console.log('Using conversation:', conversationId);
  console.log('Was newly created:', result.data.created);
}

How mapping works: The host stores { mappingKey -> conversationId } in your extension's KV storage under the key channel.threadMappings. You do not need to manage this mapping yourself.

Hidden conversations: When hiddenFromSidebar is true, the conversation:

  • Does not appear in the Chatons sidebar
  • Is still fully functional for messaging and AI interactions
  • Remains accessible through the extension that created it
  • Can be accessed via conversations.list API calls
  • Is useful for background processing or internal automation conversations

Best Practices: Hidden Conversations

Use hidden conversations when:

  • Processing bulk messages — Keep the sidebar clean when handling many inbound messages
  • Background automation — Internal workflows don't need visible prominence
  • Transient threads — Short-lived conversations that users won't interact with directly
  • High-volume channels — Channels with constant activity (e.g., Slack #general)

Example: Slack Integration with Hidden Conversations

async function processInboundMessage(slackMsg) {
  // Create a conversation for this Slack channel (hidden for cleaner UX)
  const upsertResult = await window.chaton.extensionHostCall(
    EXTENSION_ID,
    'channels.upsertGlobalThread',
    {
      mappingKey: `slack:channel:${slackMsg.channel_id}`,
      title: `Slack - ${slackMsg.channel_name}`,
      modelKey: 'openai/gpt-4o',
      hiddenFromSidebar: true,  // Keep sidebar clean
    }
  );

  if (!upsertResult.ok) {
    console.error('Failed to create conversation');
    return;
  }

  const conversationId = upsertResult.data.conversation.id;

  // Ingest the message and get a reply
  const ingestResult = await window.chaton.extensionHostCall(
    EXTENSION_ID,
    'channels.ingestMessage',
    {
      conversationId,
      message: slackMsg.text,
      idempotencyKey: slackMsg.ts,  // Use Slack timestamp for deduplication
    }
  );

  if (ingestResult.ok && ingestResult.data.reply) {
    // Send the AI-generated reply back to Slack
    await sendToSlack(slackMsg.channel_id, ingestResult.data.reply);
  }
}

User Experience: Even though conversations are hidden from the sidebar:

  • Users can view all channel conversations in Channels tabView Conversations
  • Conversations are organized by extension for easy browsing
  • Hidden conversations are still fully accessible and functional
  • Users can always open a hidden conversation from the Channels tab

channels.ingestMessage

Send an external message into a Chatons conversation and receive an AI-generated reply.

Required capability: host.conversations.write

Parameters:

ParameterTypeRequiredDescription
conversationIdstringYesChatons conversation ID (from upsertGlobalThread).
messagestringYesThe text message from the external user.
idempotencyKeystringNoDeduplication key. If the same key is sent again, the call is a no-op and returns { reply: null }. Use the external message ID for this.
metadataobjectNoArbitrary metadata stored alongside the deduplication record.

Response:

{
  ok: true,
  data: {
    reply: "Hello! How can I help you today?"  // AI-generated reply, or null
  }
}

Example:

var result = await window.chaton.extensionHostCall(
  EXTENSION_ID,
  'channels.ingestMessage',
  {
    conversationId: 'uuid-...',
    message: 'Hello from Telegram!',
    idempotencyKey: 'telegram-msg-42',
  }
);

if (result.ok && result.data.reply) {
  // Send the AI reply back to the external platform
  await sendToTelegram(chatId, result.data.reply);
}

What happens internally:

  1. The host validates the conversation is a global thread
  2. If idempotencyKey was seen before, returns { reply: null } immediately
  3. If a subagent is already processing a previous message for this conversation, the new message is steered into the same subagent run
  4. Otherwise, a fresh ephemeral Pi subagent runs against the conversation's history
  5. The user message and the assistant reply are appended to the conversation's message cache
  6. The reply text is returned

channels.reportStatus

Report the extension's connection health to the Chatons UI.

Required capability: host.conversations.write

Parameters:

ParameterTypeRequiredDescription
configuredbooleanYesWhether the extension has valid configuration.
connectedbooleanYesWhether the extension is actively connected.
lastActivitystringNoISO timestamp of the last processed message.
issuesstring[]NoArray of current problem descriptions.
infostringNoFree-form informational string.

Example:

await window.chaton.extensionHostCall(EXTENSION_ID, 'channels.reportStatus', {
  configured: true,
  connected: true,
  lastActivity: new Date().toISOString(),
  issues: [],
  info: 'Polling every 5 seconds',
});

The reported status is displayed in the Channels view. The green/gray connection dot next to your extension reflects the connected field.

There is also a convenience method available on the SDK:

await window.chatonExtension.api.host.channels.reportStatus({
  configured: true,
  connected: true,
  lastActivity: new Date().toISOString(),
});

channels.getStatus

Read back the last status you reported.

No special capability required.

var result = await window.chaton.extensionHostCall(
  EXTENSION_ID,
  'channels.getStatus'
);
// result.data is the status object, or null if never reported

conversations.list

List all conversations (useful for diagnostics).

Required capability: host.conversations.read

var result = await window.chaton.extensionHostCall(
  EXTENSION_ID,
  'conversations.list'
);
// result.data is an array of conversation objects

conversations.getMessages

Read messages from a specific conversation.

Required capability: host.conversations.read

var result = await window.chaton.extensionHostCall(
  EXTENSION_ID,
  'conversations.getMessages',
  { conversationId: 'uuid-...' }
);
// result.data is an array of { id, role, payloadJson, createdAt, updatedAt }

notifications.notify

Show a notification in the Chatons UI.

Required capability: host.notifications

await window.chaton.extensionHostCall(EXTENSION_ID, 'notifications.notify', {
  title: 'PingChat',
  body: 'New message received',
});

Step-by-Step Walkthrough

The Message Processing Pipeline

When an external message arrives, a channel extension follows this sequence:

1. Receive the external message

Your extension is responsible for getting messages from the external platform. Options:

  • Polling: Use setInterval to periodically call the external API. Since channel extension main views run in background iframes, this works reliably.
  • WebSocket: Open a WebSocket connection in your app.js. The background iframe keeps the connection alive.
  • Server process: For webhook-based integrations, declare a server.start in your manifest to run a local HTTP server that receives webhooks.

2. Map the remote thread to a Chatons conversation

Call channels.upsertGlobalThread with a stable mapping key. The key should uniquely identify the remote thread:

// For group chats, use the thread/channel ID
var mappingKey = 'slack:channel:C0123456';

// For DMs, use the remote user ID
var mappingKey = 'telegram:user:789012';

3. Ingest the message

Call channels.ingestMessage with the conversation ID and the message text. The host runs an AI model against the conversation's history and returns a reply.

4. Send the reply back

If the ingestion returns a reply (it can be null if deduplicated), send it back to the external platform using your own API client.

5. Report your status

Call channels.reportStatus periodically or when your connection state changes. This updates the Channels view in the Chatons UI.

Choosing a Mapping Key Strategy

The mapping key determines how remote conversations map to Chatons threads.

StrategyKey FormatUse Case
Per-threadprovider:thread:THREAD_IDGroup chats, channels
Per-userprovider:user:USER_IDDirect messages
Per-room+userprovider:room:ROOM_ID:user:USER_IDPer-user threads within a room

Choose the strategy that matches your external platform's conversation model.


Routing Model

Global Threads Only

Channel messages must go into global conversations (projectId = null). The host enforces this:

  • channels.upsertGlobalThread always creates conversations with projectId = null
  • channels.ingestMessage rejects messages to project-linked conversations

This is a deliberate design constraint. Channel traffic should not interfere with project-specific conversations.

Thread Mapping Persistence

The host automatically stores thread mappings in your extension's KV storage under the key channel.threadMappings. You do not need to manage this mapping yourself.

The stored shape is:

{
  "pingchat:thread:general": {
    "chatonsConversationId": "uuid-...",
    "modelKey": "openai/gpt-4o",
    "updatedAt": "2026-03-10T12:00:00.000Z"
  },
  "pingchat:thread:support": {
    "chatonsConversationId": "uuid-...",
    "modelKey": null,
    "updatedAt": "2026-03-10T12:01:00.000Z"
  }
}

If you need to inspect or repair mappings, read them directly from KV:

var result = await window.chaton.extensionStorageKvGet(
  EXTENSION_ID,
  'channel.threadMappings'
);
var mappings = result.ok && result.data ? result.data : {};

Storage and State Management

Channel extensions should store state in KV storage. Common keys to manage:

KeyPurposeExample
configAPI credentials, connection settings{ apiUrl, apiToken }
channel.threadMappingsThread-to-conversation mapping (host-managed)Automatic
lastCursorSync cursor for polling"cursor-abc123"
statsMessage counters, metrics{ totalMessages: 42 }

Important: KV values are stored as JSON objects. You do not need to stringify them before calling extensionStorageKvSet.

// Store an object directly
await window.chaton.extensionStorageKvSet(EXTENSION_ID, 'config', {
  apiUrl: 'https://api.example.com',
  token: 'abc123',
});

// Read it back
var result = await window.chaton.extensionStorageKvGet(EXTENSION_ID, 'config');
if (result.ok && result.data) {
  var config = result.data; // Already an object
}

Queue and Retry Strategy

For production channel integrations, use the extension queue for reliable message processing:

// Publish inbound messages to a queue for processing
await window.chaton.extensionQueueEnqueue(
  EXTENSION_ID,
  'inbound-messages',
  { threadId: msg.threadId, text: msg.text, messageId: msg.id }
);

// Consume and process messages with retry
var messages = await window.chaton.extensionQueueConsume(
  EXTENSION_ID,
  'inbound-messages',
  'worker-1',
  { limit: 5 }
);

for (var i = 0; i < messages.length; i++) {
  var job = messages[i];
  try {
    await processInboundMessage(job.payload);
    await window.chaton.extensionQueueAck(EXTENSION_ID, job.id);
  } catch (err) {
    // Retry later
    await window.chaton.extensionQueueNack(EXTENSION_ID, job.id, null, err.message);
  }
}

The queue provides:

  • At-least-once delivery - messages are retried if not acknowledged
  • Dead-letter handling - repeatedly failing messages go to a dead-letter queue
  • Persistence - queue state survives app restarts

This is strongly recommended when your external platform has strict delivery requirements.


Status Reporting

Call channels.reportStatus whenever your connection state changes. The Chatons Channels view reads this to show a connection indicator (green dot = connected, gray = offline).

// On successful connection
reportStatus({ configured: true, connected: true });

// On connection failure
reportStatus({
  configured: true,
  connected: false,
  issues: ['Connection refused: api.example.com:443'],
});

// On configuration change
reportStatus({
  configured: false,
  connected: false,
  issues: ['API token not configured'],
});

Call reportStatus at these points:

  1. After loading configuration (to set initial state)
  2. After a successful connection test
  3. When polling starts or stops
  4. When an error occurs
  5. Periodically during normal operation (optional)

Optional LLM Tools

A channel extension can optionally expose tools to the AI model. This is useful for letting the user check channel status from within a conversation.

Status Tool Example

Add to your manifest:

{
  "capabilities": ["llm.tools", "...other capabilities..."],
  "llm": {
    "tools": [
      {
        "name": "pingchat_get_status",
        "description": "Get the connection status of the PingChat channel.",
        "parameters": {
          "type": "object",
          "properties": {},
          "required": []
        }
      }
    ]
  },
  "apis": {
    "exposes": [
      { "name": "pingchat_get_status", "version": "1.0.0" }
    ]
  }
}

Then handle the tool call in your app.js:

// Register the tool handler via the extension call system
// The host routes LLM tool calls through the extension's exposed APIs
window.addEventListener('message', function (event) {
  var data = event && event.data;
  if (!data || data.type !== 'chaton.extension.apiCall') return;
  if (data.apiName !== 'pingchat_get_status') return;

  var response = {
    configured: !!(state.apiUrl && state.apiToken),
    connected: state.connected,
    polling: state.polling,
    messagesProcessed: state.messageCount,
    lastActivity: state.activityLog.length > 0
      ? state.activityLog[0].time
      : null,
  };

  // Reply to the host
  window.parent.postMessage({
    type: 'chaton.extension.apiCallResponse',
    callId: data.callId,
    result: response,
  }, '*');
});

With this, users can ask the AI: "Is my PingChat channel connected?" and get an instant answer without navigating away from the conversation.


UI Expectations

A channel extension should ship a main view for:

  • Configuration - API credentials, connection settings
  • Connection status - online/offline indicator, last activity
  • Diagnostics - recent activity log, error messages
  • Mapping inspection - which remote threads map to which conversations

Use the built-in CSS classes (.ce-page, .ce-card, .ce-field, etc.) for a look that is consistent with the Chatons UI. These styles are automatically injected into your webview.

Your main view runs inside an iframe. In normal use, it runs as a hidden background iframe for polling. When the user clicks "Configure" in the Channels view, the same iframe is displayed in a slide-over sheet.


Development Workflow

  1. Create the extension folder:

    mkdir -p ~/.chaton/extensions/@yourname/chatons-channel-myapp
  2. Create the manifest (chaton.extension.json) with kind: "channel"

  3. Create the UI files (index.html, app.js, style.css)

  4. Restart Chatons

  5. Open the Channels view in the sidebar - your extension should appear

  6. Click Configure to open your extension's main view

  7. Test the message flow end-to-end

  8. Check the browser DevTools console (F12) for errors

  9. After any file changes, restart Chatons to reload

Common Development Mistakes

MistakeFix
Extension does not appear in ChannelsVerify kind: "channel" is in the manifest
extensionHostCall returns unauthorizedAdd host.conversations.write to capabilities
Messages fail to ingestCheck conversation is global (not project-linked)
Polling stops when navigating awayThis should not happen - channel iframes run in background. Check for JS errors.
Duplicate messages processedUse idempotencyKey in ingestMessage

Debugging

Check the Console

Press F12 in Chatons to open DevTools. Your extension's console.log() output from the background iframe appears here.

Check Extension Logs

Extension runtime logs are written to:

~/.chaton/extensions/logs/

Look for files matching your extension ID.

Inspect Thread Mappings

// In your extension's app.js or via DevTools console
var result = await window.chaton.extensionStorageKvGet(
  '@yourname/chatons-channel-pingchat',
  'channel.threadMappings'
);
console.log('Thread mappings:', result.data);

Validate Your Manifest

# Check JSON syntax
cat ~/.chaton/extensions/@yourname/chatons-channel-pingchat/chaton.extension.json | python3 -m json.tool > /dev/null

Current Limitations

Be aware of these constraints when building channel extensions:

  • Global threads only. Channel messages cannot target project conversations. This is enforced by the host.
  • Restart required after changes. Modifying extension files requires restarting Chatons for changes to take effect. Hot reload is not fully reliable.
  • No host-managed webhooks. The host does not provide a webhook receiver. If your external platform pushes via webhooks, you need to run a local server process (via server.start in the manifest) or use an external relay.
  • No attachment sync. The current contract does not include a built-in attachment or media synchronization spec. Handle media in your own bridge logic.
  • No project routing. Multi-project dispatch is not supported. All channel messages go to global threads.
  • Sequential subagent execution. If a second message arrives while the subagent is processing a previous message for the same conversation, the new message is steered into the running subagent rather than queued separately. This means replies may be combined.

On this page