Extensions

Extensions Tutorial

Build your first extension in 30 minutes. This tutorial creates a real, working note-taking extension.

Prerequisites: Chatons installed and running, basic HTML/CSS/JavaScript knowledge.

Reference: Extensions API for the full method reference.


Table of Contents

  1. Project Structure
  2. Step-by-Step Build
  3. How It Works
  4. Common Patterns
  5. Debugging
  6. Performance Tips
  7. Security
  8. Next Steps

Project Structure

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

You can also create topbar-only extensions by declaring ui.topbarItems in the manifest. Those extensions do not need a sidebar item. A topbar item can open a main view with openMainView, dispatch a renderer deeplink with deeplink, or render a compact live widget with widget.viewId. Widget views automatically receive a standard chaton.extension.topbarContext message with the active conversation/project context.


Step-by-Step Build

Step 1: Create the Directory

mkdir -p ~/.chaton/extensions/@yourname/chatons-my-notes
cd ~/.chaton/extensions/@yourname/chatons-my-notes

Step 2: Create chaton.extension.json

{
  "id": "@yourname/chatons-my-notes",
  "name": "My Notes",
  "version": "1.0.0",
  "description": "A simple note-taking extension for Chatons",
  "capabilities": [
    "ui.menu",
    "ui.mainView",
    "storage.kv",
    "host.notifications"
  ],
  "ui": {
    "mainViews": [
      {
        "viewId": "notes.main",
        "title": "My Notes",
        "webviewUrl": "chaton-extension://@yourname/chatons-my-notes/index.html",
        "initialRoute": "/"
      }
    ],
    "menuItems": [
      {
        "id": "notes.menu",
        "label": "Notes",
        "icon": "BookOpen",
        "order": 50,
        "openMainView": "notes.main"
      }
    ]
  }
}

What each field does:

  • id -- Unique identifier. Used as the namespace for storage.
  • capabilities -- Permissions. storage.kv lets you save data, host.notifications lets you show notifications, ui.menu and ui.mainView let you add a sidebar item and page.
  • ui.mainViews -- Declares a full-page view. webviewUrl points to your HTML file.
  • ui.menuItems -- Adds a "Notes" entry to the sidebar. openMainView links it to the view above.

Step 3: Create package.json

{
  "name": "@yourname/chatons-my-notes",
  "version": "1.0.0",
  "description": "A note-taking extension for Chatons",
  "license": "MIT"
}

Step 4: Create index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>My Notes</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">My Notes</h1>
        <p class="ce-page-description">Write and save notes. They persist across restarts.</p>
      </div>
    </div>

    <div class="ce-card">
      <div class="ce-card__body">
        <div class="ce-field">
          <label class="ce-label" for="noteInput">New note</label>
          <textarea id="noteInput" rows="4" placeholder="Write a note..."></textarea>
        </div>
        <div class="ce-toolbar">
          <button id="saveBtn" class="chaton-ui-button chaton-ui-button--primary">
            Save Note
          </button>
        </div>
      </div>
    </div>

    <div class="ce-card">
      <div class="ce-card__body">
        <h2 class="ce-section-title">Saved Notes</h2>
        <div id="notesList" class="ce-list">
          <div class="ce-empty">No notes yet. Write one above!</div>
        </div>
      </div>
    </div>
  </div>

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

Why .ce-page, .ce-card, etc.? Chatons injects a stylesheet with these class names into every extension webview. Using them makes your extension look native without writing custom CSS. See the UI Library for all available classes.

Step 5: Create style.css

/* Chatons injects its own base styles into the webview.
   Only add custom styles here. */

.note-item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 0;
  border-bottom: 1px solid var(--ce-border, #eee);
}

.note-item:last-child {
  border-bottom: none;
}

.note-text {
  flex: 1;
  line-height: 1.5;
  font-size: 14px;
}

.note-date {
  font-size: 12px;
  color: var(--ce-muted, #999);
  margin-top: 4px;
}

Step 6: Create app.js

This is the heart of the extension. Pay close attention to how the API is called.

(function () {
  'use strict';

  // Your extension ID -- must match the "id" in chaton.extension.json
  var EXTENSION_ID = '@yourname/chatons-my-notes';

  // Enable the injected Chatons UI styles
  if (window.chatonExtensionComponents) {
    window.chatonExtensionComponents.ensureStyles();
  }

  // ---- State ----
  var notes = [];

  // ---- DOM refs ----
  var noteInput = document.getElementById('noteInput');
  var saveBtn = document.getElementById('saveBtn');
  var notesList = document.getElementById('notesList');

  // =========================================================================
  // Storage API
  //
  // Extensions use window.chaton.extensionStorageKvGet/Set/Delete/List
  // to persist data. The first argument is always your extension ID.
  //
  // KV storage is backed by SQLite. Values are stored as JSON -- you can
  // pass objects directly without JSON.stringify().
  //
  // Returns: Promise<{ ok: true, data: ... }> or Promise<{ ok: false, ... }>
  // =========================================================================

  function loadNotes() {
    return window.chaton.extensionStorageKvGet(EXTENSION_ID, 'notes')
      .then(function (result) {
        if (result && result.ok && result.data) {
          // KV stores JSON objects directly -- no need to JSON.parse
          notes = Array.isArray(result.data) ? result.data : [];
        }
      })
      .catch(function (err) {
        console.error('[Notes] Failed to load:', err);
      });
  }

  function saveNotes() {
    // Pass the array directly -- the host JSON-serializes it automatically
    return window.chaton.extensionStorageKvSet(EXTENSION_ID, 'notes', notes)
      .catch(function (err) {
        console.error('[Notes] Failed to save:', err);
      });
  }

  // =========================================================================
  // Host calls
  //
  // window.chaton.extensionHostCall(extensionId, method, params)
  // is used for notifications, conversation access, project access, etc.
  // =========================================================================

  function showNotification(title, body) {
    return window.chaton.extensionHostCall(EXTENSION_ID, 'notifications.notify', {
      title: title,
      body: body,
    });
  }

  // ---- Actions ----

  function addNote() {
    var text = noteInput.value.trim();
    if (!text) {
      showNotification('Notes', 'Please write a note first.');
      return;
    }

    var note = {
      id: Date.now(),
      text: text,
      date: new Date().toISOString(),
    };

    notes.unshift(note);
    saveNotes();
    noteInput.value = '';
    renderNotes();
    showNotification('Notes', 'Note saved.');
  }

  function deleteNote(id) {
    notes = notes.filter(function (n) { return n.id !== id; });
    saveNotes();
    renderNotes();
  }

  // ---- Rendering ----

  function renderNotes() {
    if (notes.length === 0) {
      notesList.innerHTML = '<div class="ce-empty">No notes yet. Write one above!</div>';
      return;
    }

    notesList.innerHTML = notes.map(function (note) {
      var dateStr = new Date(note.date).toLocaleString();
      return (
        '<div class="note-item">' +
          '<div>' +
            '<div class="note-text">' + escapeHtml(note.text) + '</div>' +
            '<div class="note-date">' + dateStr + '</div>' +
          '</div>' +
          '<button class="chaton-ui-button" data-delete="' + note.id + '">Delete</button>' +
        '</div>'
      );
    }).join('');
  }

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

  // ---- Event listeners ----

  saveBtn.addEventListener('click', addNote);

  noteInput.addEventListener('keydown', function (e) {
    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
      addNote();
    }
  });

  // Delegated click handler for delete buttons
  notesList.addEventListener('click', function (e) {
    var btn = e.target.closest('[data-delete]');
    if (btn) {
      deleteNote(Number(btn.getAttribute('data-delete')));
    }
  });

  // ---- Initialize ----

  loadNotes().then(function () {
    renderNotes();
    console.log('[Notes] Loaded', notes.length, 'notes');
  });
})();

Step 7: Test It

  1. Save all 5 files
  2. Restart Chatons
  3. Click Notes in the sidebar
  4. Write a note and click Save Note
  5. Restart Chatons -- notes persist

How It Works

Startup sequence

  1. Chatons reads chaton.extension.json from your extension folder
  2. It registers your menu item and main view
  3. When you click "Notes" in the sidebar, it loads your index.html in an iframe
  4. Chatons injects the UI bridge script, which sets up window.chaton, window.chatonUi, and window.chatonExtensionComponents
  5. Your app.js runs and calls loadNotes() via the KV storage API

What the bridge script injects

The bridge script runs automatically before your code. It provides:

GlobalPurpose
window.chatonHost API: storage, events, queue, host calls, model listing
window.chatonUiUI helpers: ensureStyles(), createButton(), createModelPicker(), createComponents()
window.chatonExtensionComponentsPre-initialized component library (same as window.chatonUi.createComponents())

Storage behavior

  • extensionStorageKvGet() returns { ok: true, data: <value> } where data is the parsed JSON value (object, array, string, number, null)
  • extensionStorageKvSet() accepts any JSON-serializable value -- you do not need to JSON.stringify() it
  • extensionStorageKvList() returns { ok: true, data: [{ key, value, updatedAt }] }
  • Storage is namespaced by extension ID -- your data cannot collide with another extension's

Common Patterns

Pattern 1: Listen to Events

// Capability required: "events.subscribe"

window.chaton.extensionEventSubscribe(EXTENSION_ID, 'conversation.created')
  .then(function () {
    console.log('Subscribed to conversation.created');
  });

// Events arrive as IPC messages -- the subscription is registered
// in the main process and forwarded to your extension's webview.

Available event topics:

TopicPayload
conversation.created{ conversationId, projectId }
conversation.updated{ conversationId }
conversation.message.received{ conversationId, message }
conversation.agent.started{ conversationId }
conversation.agent.ended{ conversationId }
conversation.turn.ended{ conversationId }
conversation.tool.executed{ conversationId }
project.created{ project }
project.deleted{ project }
extension.installed{ extension }
extension.enabled{ extension }

Pattern 2: Read Conversations

// Capability required: "host.conversations.read"

// List all conversations
var result = await window.chaton.extensionHostCall(EXTENSION_ID, 'conversations.list');
if (result.ok) {
  result.data.forEach(function (conv) {
    console.log(conv.id, conv.title, conv.modelProvider + '/' + conv.modelId);
  });
}

// Read messages from a specific conversation
var msgResult = await window.chaton.extensionHostCall(
  EXTENSION_ID,
  'conversations.getMessages',
  { conversationId: 'uuid-...' }
);
if (msgResult.ok) {
  msgResult.data.forEach(function (msg) {
    var payload = JSON.parse(msg.payloadJson);
    console.log(msg.role, payload.content);
  });
}

Pattern 3: List Projects

// Capability required: "host.projects.read"

var result = await window.chaton.extensionHostCall(EXTENSION_ID, 'projects.list');
if (result.ok) {
  result.data.forEach(function (project) {
    console.log(project.id, project.name, project.repoPath);
  });
}

Pattern 4: File Storage

// Capability required: "storage.files"
// Files are stored under ~/.chaton/extensions/data/<extensionId>/

// Write a file
await window.chaton.extensionStorageFilesWrite(EXTENSION_ID, 'exports/report.md', '# Report\n...');

// Read a file
var result = await window.chaton.extensionStorageFilesRead(EXTENSION_ID, 'exports/report.md');
// result is { ok: true, data: '# Report\n...' }

// Subdirectories are created automatically
await window.chaton.extensionStorageFilesWrite(EXTENSION_ID, 'deep/nested/file.txt', 'content');

Pattern 5: Queue Processing

// Capabilities required: "queue.publish", "queue.consume"

// Enqueue a job
await window.chaton.extensionQueueEnqueue(
  EXTENSION_ID,
  'my-jobs',
  { type: 'export', conversationId: 'uuid-...' },
  { idempotencyKey: 'export-uuid' }
);

// Consume jobs (typically in a polling loop)
var messages = await window.chaton.extensionQueueConsume(
  EXTENSION_ID,
  'my-jobs',
  'worker-1',
  { limit: 5 }
);

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

// Check dead letters (repeatedly failed jobs)
var dead = await window.chaton.extensionQueueDeadLetterList(EXTENSION_ID, 'my-jobs');

Pattern 6: Cross-Extension API Calls

// In your manifest, expose an API:
// "apis": { "exposes": [{ "name": "notes.search", "version": "1.0.0" }] }

// Handle calls from other extensions via postMessage
window.addEventListener('message', function (event) {
  var data = event && event.data;
  if (!data || data.type !== 'chaton.extension.apiCall') return;
  if (data.apiName !== 'notes.search') return;

  var query = data.payload && data.payload.query;
  var results = notes.filter(function (n) {
    return n.text.toLowerCase().includes((query || '').toLowerCase());
  });

  window.parent.postMessage({
    type: 'chaton.extension.apiCallResponse',
    callId: data.callId,
    result: results,
  }, '*');
});

// Other extensions call your API via:
// window.chaton.extensionCall(
//   theirExtId, '@yourname/chatons-my-notes',
//   'notes.search', '^1.0.0', { query: 'hello' }
// );

Pattern 7: Model Picker

// Use the built-in model picker widget
var picker = window.chatonUi.createModelPicker({
  host: document.getElementById('modelPickerContainer'),
  onChange: function (modelKey) {
    console.log('Selected model:', modelKey);
    localStorage.setItem('myext:model', modelKey);
  },
  labels: {
    filterPlaceholder: 'Filter models...',
    more: 'more',
    scopedOnly: 'scoped only',
    noScoped: 'No scoped models',
    noModels: 'No models',
  },
});

// Load models from the host
window.chaton.listPiModels().then(function (res) {
  if (res.ok) {
    picker.setModels(res.models);
    picker.setSelected(localStorage.getItem('myext:model'));
  }
});

Debugging

Check the console

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

Verify the SDK is available

Add this to the top of your app.js:

console.log('[MyExt] window.chaton available:', !!window.chaton);
console.log('[MyExt] extensionStorageKvGet available:', typeof window.chaton.extensionStorageKvGet);
console.log('[MyExt] extensionHostCall available:', typeof window.chaton.extensionHostCall);
console.log('[MyExt] chatonUi available:', !!window.chatonUi);
console.log('[MyExt] chatonExtensionComponents available:', !!window.chatonExtensionComponents);

Common issues

ProblemSolution
Extension not in sidebarCheck folder is in ~/.chaton/extensions/, validate JSON with jq . chaton.extension.json, restart Chatons
Main view is blankCheck webviewUrl format matches chaton-extension://<id>/<path>, check F12 console for errors
Storage calls failVerify "storage.kv" is in capabilities, check the extensionId matches your manifest id
Host call returns unauthorizedAdd the required capability (e.g. "host.notifications") to the manifest
Notes disappear on restartYou might be calling extensionStorageKvSet with a stringified value that gets double-encoded. Pass objects directly.

Validate manifest JSON

jq . ~/.chaton/extensions/@yourname/chatons-my-notes/chaton.extension.json > /dev/null

Check extension logs

~/.chaton/extensions/logs/

Performance Tips

1. Debounce frequent writes

var saveTimeout;
function debouncedSave() {
  clearTimeout(saveTimeout);
  saveTimeout = setTimeout(function () {
    saveNotes();
  }, 1000);
}

2. Cache data in memory

Load from KV once at startup, keep an in-memory copy, write back on changes. Don't re-read from KV on every render.

3. Use delegated event listeners

// One listener for all delete buttons
notesList.addEventListener('click', function (e) {
  var btn = e.target.closest('[data-delete]');
  if (btn) deleteNote(Number(btn.getAttribute('data-delete')));
});

4. Keep extensions small

Avoid large dependencies. Use native browser APIs. The extension loads in an iframe -- heavy bundles slow down startup.


Security

1. Escape user input

// Safe
div.textContent = userText;

// Dangerous -- XSS risk
div.innerHTML = userText; // Never do this with untrusted input

2. Declare only needed capabilities

Don't request host.conversations.write if you only need to read.

3. Don't store secrets in KV

KV storage is not encrypted. For API keys, consider prompting the user each session or using the requirement sheet pattern (see Requirement Sheets).


Next Steps

On this page