| name | sap-extension-creator |
| description | Create Super Agent Party (SAP) extensions. This skill should be used when users want to create, build, or scaffold a new extension for Super Agent Party - including static HTML extensions (pure frontend) and Node.js backend extensions. Triggers on requests like "create a new SAP extension", "build an extension for Super Agent Party", "scaffold a plugin", "make a chat UI extension", or when working with sap extension projects. |
SAP Extension Creator
Overview
Create Super Agent Party extensions—self-contained packages that extend the platform with custom chat UI and tools. Two modes are supported:
- Static extension: Pure HTML/CSS/JS frontend, served directly by SAP from the extension folder
- Node.js extension: Full-stack with Express backend, auto-managed by SAP (
npm install + node index.js <port>)
Both modes support MCP tool registration (the register_node_extension_mcp protocol message works for ANY extension via WebSocket, despite the "node" in its name).
Quick Decision Tree
User wants to create an extension?
├─ Only needs UI (chat, display, simple interactions)? → Static Extension
└─ Needs backend logic (API calls, DB, file processing)? → Node.js Extension
Core Files Every Extension Needs
| File | Required | Purpose |
|---|
package.json | ✅ | Metadata, dependencies, window config |
index.html | ✅ | Main UI (full HTML page, single-file app) |
index.js | Node only | Node.js entry point |
node_modules/ | Node only | Auto-installed by SAP via npm install |
Workflow
Step 1: Gather Requirements
Ask the user:
- Extension name? (hyphen-case, e.g.,
my-weather-widget)
- Description? (one sentence)
- Static or Node.js? (Node.js only if backend logic/server-side code is needed)
- For Node.js: what npm dependencies?
- Should it register custom tools for the AI? (works in both static and Node.js modes via WebSocket MCP)
- GitHub repository URL? (optional, for updates)
- Transparent window? (frameless, always-on-top — for mini widgets like music controllers)
- Default window size? (width/height in pixels)
Step 2: Scaffold the Extension
Use the templates in assets/ as starting points:
- Static: Copy
assets/static-template/
- Node.js: Copy
assets/node-template/
Create the extension directory under the workspace (user will later install it into SAP's extensions/ folder).
Step 3: Write package.json
See references/package-json-spec.md for the complete field reference. Minimum:
{
"name": "my-extension",
"version": "1.0.0",
"description": "What it does",
"author": "your-name",
"repository": "https://github.com/user/repo",
"backupRepository": "https://gitee.com/user/repo",
"category": "Tools"
}
For Node.js extensions, also include:
{
"main": "index.js",
"nodePort": 0,
"dependencies": { "express": "^5.1.0" }
}
For transparent/frameless widgets (e.g., mini music controllers, floating panels):
{
"transparent": true,
"width": 280,
"height": 80
}
When transparent: true, SAP creates a frameless, transparent, always-on-top window (see main.js open-extension-window handler). Use this for compact overlay widgets.
Step 4: Write index.html
The HTML page is rendered inside an Electron BrowserWindow (either directly or via an iframe). Key patterns:
Step 5: Write index.js (Node.js only)
See references/node-entry-spec.md for the full protocol. The entry point:
- Receives a port number via
process.argv[2]
- Starts an Express server on that port at
127.0.0.1
- Serves static files from its own directory
- Exposes a
/health endpoint for readiness checks
- SAP reverse-proxies requests to the extension
Step 6: Implement Tool Registration (optional, works in both modes)
Extensions can register tools that the AI agent can call — via WebSocket in the frontend (both static and Node.js). The MCP lifecycle has three mandatory stages:
STARTUP → ws.onopen → registerMcpTools()
RUNTIME → ws.onmessage → handleMcpCall() when AI calls a tool
SHUTDOWN → window.beforeunload → unregisterMcpTools()
① Register on startup — always in ws.onopen, using a dedicated function:
function registerMcpTools() {
getExtId();
ws.send(JSON.stringify({
type: 'register_node_extension_mcp',
data: {
ext_id: MY_EXT_ID,
tools: [{
name: `${MY_EXT_ID}_my_tool`,
description: 'What this tool does (use the user\'s language)',
parameters: {
type: 'object',
properties: {
param1: { type: 'string', description: '...' }
},
required: ['param1']
}
}]
}
}));
}
② Handle tool calls — the AI agent calls your tool:
async function handleMcpCall(data) {
const { ext_id, tool_name, tool_params, call_id } = data;
if (ext_id !== MY_EXT_ID && !tool_name.includes(MY_EXT_ID)) return;
ws.send(JSON.stringify({
type: 'mcp_tool_result',
data: { call_id, result: 'output' }
}));
}
③ Unregister on shutdown — MUST send unregister_node_extension_mcp before the window closes:
function unregisterMcpTools() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'unregister_node_extension_mcp', data: { ext_id: MY_EXT_ID } }));
}
}
window.addEventListener('beforeunload', () => { unregisterMcpTools(); });
Key rule: Registration and unregistration MUST be in separate named functions (registerMcpTools / unregisterMcpTools), NOT inline code. This makes the lifecycle explicit and easy for AI to understand.
If an extension has no MCP tools, all three functions can be deleted.
See sap-lx-music/index.html for a complete real-world MCP implementation example (static extension with 12+ registered tools).
Theme & i18n (Dark/Light Mode + Bilingual)
Every extension should support dark/light mode and Chinese/English bilingual UI. Do NOT hardcode a single theme color scheme — use CSS variables so each extension can have its own identity.
CSS Variable Pattern
Define light theme in :root and override in body.dark:
:root {
--bg: #ffffff;
--bg-secondary: #f5f5f5;
--text: #333333;
--text-sub: #888888;
--accent: #ec4141;
--accent-hover: #d73a3a;
--border: rgba(0,0,0,0.08);
--transition: 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", sans-serif;
}
body.dark {
--bg: #2b2b2b;
--bg-secondary: #222222;
--text: #e0e0e0;
--text-sub: #888888;
--border: rgba(255,255,255,0.06);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; font-family: var(--font);
background: var(--bg); color: var(--text);
transition: background var(--transition);
}
Dark Mode Toggle
function initTheme() {
const saved = localStorage.getItem('myext_dark');
if (saved === 'dark' || (!saved && matchMedia('(prefers-color-scheme:dark)').matches)) {
document.body.classList.add('dark');
}
}
function toggleDarkMode() {
const isDark = document.body.classList.toggle('dark');
localStorage.setItem('myext_dark', isDark ? 'dark' : 'light');
}
i18n Pattern
const i18n = {
zh: {
welcome: '欢迎使用我的扩展',
send: '发送',
},
en: {
welcome: 'Welcome to My Extension',
send: 'Send',
}
};
let lang = localStorage.getItem('myext_lang') || 'zh';
function t(k) { return i18n[lang]?.[k] || i18n.zh[k] || k; }
function toggleLanguage() {
lang = lang === 'zh' ? 'en' : 'zh';
localStorage.setItem('myext_lang', lang);
updateAllTexts();
}
When registering MCP tools, set description and parameters in the current user's language for better AI interaction.
Responsive Design
Every extension should work well across different window sizes. Critical patterns:
Viewport Meta (REQUIRED)
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
CSS Media Queries
Use breakpoints to adapt layout at small sizes:
@media (max-width: 900px) {
}
@media (max-width: 600px) {
}
Key responsive practices:
- Use
vw units for widths as fallback (e.g., width: 65vw; max-width: 360px)
- Use
flex layouts with flex-wrap that naturally adapt
- Hide non-essential elements on small screens (
display: none)
- Reduce font sizes and padding at breakpoints
iframe Compatibility
Extensions may be rendered inside an iframe (depending on SAP's configuration). Ensure:
- Extension ID detection: Use
window.location.pathname (works in both direct and iframe contexts):
function getExtId() {
try {
const match = window.location.pathname.match(/\/extensions\/([^\/]+)/);
return match ? match[1] : 'unknown';
} catch(e) { return 'unknown'; }
}
- WebSocket connection: Use
location.host (not hardcoded):
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
- Window close:
window.close() works in both direct and iframe contexts
- Avoid
window.top / window.parent assumptions — your extension may be the top-level window
- Font Awesome via CDN ensures icons load regardless of serving path
Transparent Window / Compact Mode
When transparent: true is set in package.json, SAP creates a frameless transparent window. The extension must implement compact mode to work correctly.
How SAP Creates Transparent Windows
From main.js, when extension.transparent is true:
{
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: false,
hasShadow: false,
backgroundColor: 'rgba(0, 0, 0, 0)',
}
Compact Mode CSS (REQUIRED for transparent extensions)
body.compact { background: transparent !important; }
html.compact { background: transparent !important; }
body.compact header,
body.compact footer,
body.compact #inputBar {
-webkit-app-region: drag;
}
body.compact button,
body.compact input,
body.compact textarea,
body.compact select,
body.compact a,
body.compact .compact-close-btn {
-webkit-app-region: no-drag;
}
.compact-close-btn { display: none; }
body.compact .compact-close-btn {
display: flex;
position: absolute;
top: 5px; right: 5px;
width: 20px; height: 20px;
background: rgb(255, 57, 57);
border: none; border-radius: 50%;
color: #fff;
align-items: center; justify-content: center;
font-size: 10px; cursor: pointer;
transition: 0.2s;
z-index: 100;
-webkit-app-region: no-drag;
}
body.compact .compact-close-btn:hover { background: #ec4141; }
Compact Mode Detection (REQUIRED)
function checkCompactMode() {
if (window.innerHeight < 200) {
document.documentElement.classList.add('compact');
document.body.classList.add('compact');
} else {
document.documentElement.classList.remove('compact');
document.body.classList.remove('compact');
}
}
function closeWindow() { window.close(); }
checkCompactMode();
window.addEventListener('resize', checkCompactMode);
Placing the Close Button
The close button HTML must be placed at the body level (not nested inside containers), typically right after <body>:
<body>
<button class="compact-close-btn" onclick="closeWindow()" title="关闭窗口">
<i class="fa-solid fa-xmark"></i>
</button>
</body>
For transparent mini-widgets, you can also place the close button inside a content container and make it visible on hover — see sap-lx-music for this pattern.
Using iframes for Custom URL Schemes
If your extension needs to invoke custom protocol URLs (e.g., lxmusic://, myapp://), use a hidden iframe technique:
function invokeScheme(url) {
let iframe = document.getElementById('scheme-invoker');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'scheme-invoker';
iframe.style.display = 'none';
document.body.appendChild(iframe);
}
iframe.src = url;
}
This avoids window.open() popup blockers and works reliably inside Electron.
WebSocket Protocol Reference
| Message Type | Direction | Purpose |
|---|
get_messages | → SAP | Request current message history |
messages_update | ← SAP | Message list updated |
broadcast_messages | ← SAP | Broadcast message update |
set_user_input | → SAP | Update user input text |
trigger_send_message | → SAP | Send current input as user message |
trigger_clear_message | → SAP | Clear all messages |
register_node_extension_mcp | → SAP | Register MCP tools (works for static AND Node.js) |
unregister_node_extension_mcp | → SAP | Unregister on page close |
mcp_registered | ← SAP | Confirmation of registration |
call_mcp_tool | ← SAP | AI agent calls a registered tool |
mcp_tool_result | → SAP | Return tool execution result |
trigger_close_extension | → SAP | Request extension window close |
Simple Chat HTTP API (/simple_chat)
SAP exposes a stateless HTTP endpoint POST /simple_chat that extensions can call for one-off AI tasks — translation, summarization, quick Q&A, code generation — without going through the WebSocket chat flow and without adding messages to the conversation history.
This is ideal when your extension needs a quick, single-turn AI call: translate text, summarize content, extract keywords, classify input, etc.
When to Use /simple_chat vs WebSocket
| Feature | /simple_chat HTTP API | WebSocket (trigger_send_message) |
|---|
| Conversation history | ❌ Stateless — no history | ✅ Full chat history |
| Messages shown in UI | ❌ Not added to chat | ✅ Rendered in message list |
| Use case | One-off: translate, summarize, classify | Multi-turn chat, agent tasks |
| Response format | OpenAI-compatible JSON / NDJSON stream | messages_update / broadcast_messages events |
| Speed | Uses SAP's fast client config | Uses current active model provider |
Endpoint
POST /simple_chat
Content-Type: application/json
The endpoint is on the same origin as the extension, so use a relative URL:
const res = await fetch('/simple_chat', { ... });
Request Format
{
"messages": [
{ "role": "system", "content": "You are a professional translator." },
{ "role": "user", "content": "Translate 'Hello world' to Chinese." }
],
"stream": false,
"temperature": 0.7
}
| Field | Type | Required | Description |
|---|
messages | array | ✅ | Array of {role, content} objects (system/user/assistant) |
stream | boolean | ❌ (default false) | true for streaming, false for one-shot JSON response |
temperature | number | ❌ (default from settings) | 0–2, lower = more deterministic |
Non-Streaming Response (stream: false)
Returns a standard OpenAI-compatible ChatCompletion JSON object:
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "你好世界"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 20,
"completion_tokens": 5,
"total_tokens": 25
}
}
Access the result: data.choices[0].message.content
Streaming Response (stream: true)
Returns NDJSON (one JSON object per line), matching OpenAI's streaming format. Each line contains a delta chunk:
{"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
{"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}
{"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"世界"},"finish_reason":null}]}
{"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
Note: The stream does NOT send a [DONE] marker. Detect completion by checking choices[0].finish_reason.
JavaScript Usage Examples
Non-Streaming (Simple One-Shot Call)
async function simpleChat(messages, temperature = 0.7) {
const res = await fetch('/simple_chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: false, temperature })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error?.message || `HTTP ${res.status}`);
}
return await res.json();
}
async function translate(text, targetLang = 'Chinese') {
const res = await simpleChat([
{ role: 'system', content: `You are a translator. Translate to ${targetLang}. Reply ONLY with the translation, no explanations.` },
{ role: 'user', content: text }
]);
return res.choices[0].message.content;
}
async function summarize(text, maxWords = 50) {
const res = await simpleChat([
{ role: 'system', content: `Summarize in ≤${maxWords} words. Reply ONLY with the summary.` },
{ role: 'user', content: text }
]);
return res.choices[0].message.content;
}
async function classify(text, labels) {
const res = await simpleChat([
{ role: 'system', content: `Classify into one of: ${labels.join(', ')}. Reply ONLY with the label.` },
{ role: 'user', content: text }
]);
return res.choices[0].message.content.trim();
}
Streaming (Real-Time Display)
async function* simpleChatStream(messages, temperature = 0.7) {
const res = await fetch('/simple_chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true, temperature })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error?.message || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
const content = chunk.choices?.[0]?.delta?.content;
if (content) yield content;
if (chunk.choices?.[0]?.finish_reason === 'stop') return;
} catch(e) { }
}
}
}
const el = document.getElementById('output');
el.textContent = '';
for await (const chunk of simpleChatStream([
{ role: 'user', content: 'Write a haiku about coding.' }
])) {
el.textContent += chunk;
}
Error Handling
On error, the endpoint returns a JSON object with an error field:
{
"error": {
"message": "No model providers configured",
"type": "server_error",
"code": 500
}
}
Always check res.ok and parse the error body.
Important Notes for /simple_chat
- Stateless: Each call is independent. No conversation context is preserved between calls.
- No UI impact: Results are NOT displayed in the main chat window. Your extension owns the rendering.
- Uses fast client: The endpoint uses SAP's "fast" model provider configuration. This may be a different model than the main chat.
- Same origin only: Extensions are served from the same origin, so no CORS issues. Use a relative URL (
/simple_chat).
- Not a replacement for MCP tools: If you need the AI agent to call your extension, register MCP tools via WebSocket.
/simple_chat is for your extension to call the AI, not the other way around.
Important Notes
- Extension ID format:
{owner}_{repo} (e.g., heshengtao_sap-example)
- nodePort: 0 means auto-assign a free port (3100-13999 range)
- Always register
beforeunload handler to send unregister_node_extension_mcp
- MCP works in both static and Node.js extensions — the
register_node_extension_mcp message type name is historical; it works over WebSocket from any extension. Always follow the three-stage lifecycle: registerMcpTools() on WS open, handleMcpCall() on tool call, unregisterMcpTools() on beforeunload
- Font Awesome: Always use CDN (
cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css). Relative paths like ../../fontawesome/ do NOT work for Node.js extensions (they're served from Express, not from SAP's static directory)
- Theme colors: Each extension defines its own identity via CSS variables on
:root and body.dark. Do NOT force SAP's theme colors
- Always implement dark/light mode and Chinese/English i18n as basic functionality
- Transparent windows: Always implement compact mode. Without
-webkit-app-region: drag, frameless windows cannot be moved. Without -webkit-app-region: no-drag on interactive elements, buttons become unclickable
- Close button: For transparent/frameless windows, the extension MUST provide its own close button since there's no native title bar
Reference Implementations
Study these real extensions for patterns:
- sap-lx-music — Static extension with MCP, transparent compact mode, dark/light theme, i18n, custom scheme invocation
- sap-example (heshengtao_sap-example) — Basic static chat UI extension
- sap-example-with-node (heshengtao_sap-example-with-node) — Node.js extension with Express backend
Resources
assets/
assets/static-template/ — Complete starter template for static extensions
assets/node-template/ — Complete starter template for Node.js extensions
references/
references/package-json-spec.md — Complete package.json field reference
references/node-entry-spec.md — Node.js entry point and lifecycle specification