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
- Complete Working Example
- How Channel Extensions Work
- Host Channel API Reference
- Step-by-Step Walkthrough
- Routing Model
- Storage and State Management
- Queue and Retry Strategy
- Status Reporting
- Optional LLM Tools
- UI Expectations
- Development Workflow
- Debugging
- 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.writeis required. Without it, the extension cannot create conversations or ingest messages.host.conversations.readis needed to read conversation messages (optional but useful).storage.kvstores 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
- Save all files
- Restart Chatons
- Go to Channels in the sidebar (it appears when channel extensions are installed)
- Click Configure next to "PingChat Channel"
- Enter any URL and token (the example simulates the API)
- Click Save Configuration, then Start polling
- 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:
- Validates the conversation exists and is a global thread (not project-linked)
- Checks the idempotency key (if provided) to skip duplicate messages
- Runs an ephemeral Pi subagent against the conversation's message history
- Generates an AI reply using the conversation's selected model
- Stores both the user message and the assistant reply in the conversation's message cache
- Returns the reply text to your extension
When you call channels.upsertGlobalThread, the host:
- Checks if a conversation already exists for that mapping key
- If it exists and is still a global thread, returns it
- If not, creates a new global conversation with the specified title and model
- Stores the mapping in KV under
channel.threadMappingsautomatically
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:
| Parameter | Type | Required | Description |
|---|---|---|---|
mappingKey | string | Yes | Stable identifier for the remote thread (e.g. "telegram:chat:12345"). The host uses this to find or create the conversation. |
title | string | No | Display title for the conversation. Defaults to "Nouveau fil". |
modelKey | string | No | Model to use, formatted as "provider/modelId" (e.g. "openai/gpt-4o"). Defaults to the user's default model. |
hiddenFromSidebar | boolean | No | If 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.listAPI 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 tab → View 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | string | Yes | Chatons conversation ID (from upsertGlobalThread). |
message | string | Yes | The text message from the external user. |
idempotencyKey | string | No | Deduplication 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. |
metadata | object | No | Arbitrary 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:
- The host validates the conversation is a global thread
- If
idempotencyKeywas seen before, returns{ reply: null }immediately - If a subagent is already processing a previous message for this conversation, the new message is steered into the same subagent run
- Otherwise, a fresh ephemeral Pi subagent runs against the conversation's history
- The user message and the assistant reply are appended to the conversation's message cache
- The reply text is returned
channels.reportStatus
Report the extension's connection health to the Chatons UI.
Required capability: host.conversations.write
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
configured | boolean | Yes | Whether the extension has valid configuration. |
connected | boolean | Yes | Whether the extension is actively connected. |
lastActivity | string | No | ISO timestamp of the last processed message. |
issues | string[] | No | Array of current problem descriptions. |
info | string | No | Free-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
setIntervalto 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.startin 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.
| Strategy | Key Format | Use Case |
|---|---|---|
| Per-thread | provider:thread:THREAD_ID | Group chats, channels |
| Per-user | provider:user:USER_ID | Direct messages |
| Per-room+user | provider:room:ROOM_ID:user:USER_ID | Per-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.upsertGlobalThreadalways creates conversations withprojectId = nullchannels.ingestMessagerejects 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:
| Key | Purpose | Example |
|---|---|---|
config | API credentials, connection settings | { apiUrl, apiToken } |
channel.threadMappings | Thread-to-conversation mapping (host-managed) | Automatic |
lastCursor | Sync cursor for polling | "cursor-abc123" |
stats | Message 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:
- After loading configuration (to set initial state)
- After a successful connection test
- When polling starts or stops
- When an error occurs
- 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
-
Create the extension folder:
mkdir -p ~/.chaton/extensions/@yourname/chatons-channel-myapp -
Create the manifest (
chaton.extension.json) withkind: "channel" -
Create the UI files (
index.html,app.js,style.css) -
Restart Chatons
-
Open the Channels view in the sidebar - your extension should appear
-
Click Configure to open your extension's main view
-
Test the message flow end-to-end
-
Check the browser DevTools console (F12) for errors
-
After any file changes, restart Chatons to reload
Common Development Mistakes
| Mistake | Fix |
|---|---|
| Extension does not appear in Channels | Verify kind: "channel" is in the manifest |
extensionHostCall returns unauthorized | Add host.conversations.write to capabilities |
| Messages fail to ingest | Check conversation is global (not project-linked) |
| Polling stops when navigating away | This should not happen - channel iframes run in background. Check for JS errors. |
| Duplicate messages processed | Use 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.startin 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.