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.
67
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Create Super Agent Party extensions—self-contained packages that extend the platform with custom chat UI and tools. Two modes are supported:
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).
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| 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 |
Ask the user:
my-weather-widget)Use the templates in assets/ as starting points:
assets/static-template/assets/node-template/Create the extension directory under the workspace (user will later install it into SAP's extensions/ folder).
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.
The HTML page is rendered inside an Electron BrowserWindow (either directly or via an iframe). Key patterns:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">../../fontawesome/ — these may work for static extensions but break for Node.js extensions (different serving paths).ws://host/ws for messaging and MCP.window.location.pathname for /extensions/{ext_id}/.messages_update and broadcast_messages events.set_user_input then trigger_send_message.See references/node-entry-spec.md for the full protocol. The entry point:
process.argv[2]127.0.0.1/health endpoint for readiness checksExtensions 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;
// ... execute logic, then:
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).
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.
Define light theme in :root and override in body.dark:
:root {
--bg: #ffffff;
--bg-secondary: #f5f5f5;
--text: #333333;
--text-sub: #888888;
--accent: #ec4141; /* extension's own brand color */
--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);
}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');
}const i18n = {
zh: {
welcome: '欢迎使用我的扩展',
send: '发送',
// ... all UI strings
},
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(); // re-render all i18n-dependent UI
}When registering MCP tools, set description and parameters in the current user's language for better AI interaction.
Every extension should work well across different window sizes. Critical patterns:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />Use breakpoints to adapt layout at small sizes:
@media (max-width: 900px) {
/* stack layouts vertically, reduce padding */
}
@media (max-width: 600px) {
/* hide secondary elements, compact controls */
}Key responsive practices:
vw units for widths as fallback (e.g., width: 65vw; max-width: 360px)flex layouts with flex-wrap that naturally adaptdisplay: none)Extensions may be rendered inside an iframe (depending on SAP's configuration). Ensure:
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'; }
}location.host (not hardcoded):
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);window.close() works in both direct and iframe contextswindow.top / window.parent assumptions — your extension may be the top-level windowWhen transparent: true is set in package.json, SAP creates a frameless transparent window. The extension must implement compact mode to work correctly.
From main.js, when extension.transparent is true:
{
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: false,
hasShadow: false,
backgroundColor: 'rgba(0, 0, 0, 0)',
}/* Transparent backgrounds */
body.compact { background: transparent !important; }
html.compact { background: transparent !important; }
/* Drag regions — make structural elements draggable for frameless windows */
body.compact header,
body.compact footer,
body.compact #inputBar {
-webkit-app-region: drag;
}
/* Interactive elements MUST opt-out of 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 button (red circle, top-right) */
.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; }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);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>
<!-- rest of content -->
</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.
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.
| 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)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.
/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 |
POST /simple_chat
Content-Type: application/jsonThe endpoint is on the same origin as the extension, so use a relative URL:
const res = await fetch('/simple_chat', { ... });{
"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 |
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
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.
/**
* Call SAP's /simple_chat for a one-off AI task.
* @param {Array} messages - [{role, content}, ...]
* @param {number} [temperature=0.7]
* @returns {Promise<object>} OpenAI-compatible ChatCompletion
*/
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();
}
// ---------- Practical Examples ----------
// Translation
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;
}
// Summarization
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;
}
// Quick classification
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();
}/**
* Call /simple_chat with streaming. Yields delta content strings.
* @param {Array} messages
* @param {number} [temperature=0.7]
* @returns {AsyncGenerator<string>} Yields delta content chunks
*/
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(); // keep incomplete line in buffer
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) { /* ignore parse errors for partial lines */ }
}
}
}
// Usage: render streaming response into an element
const el = document.getElementById('output');
el.textContent = '';
for await (const chunk of simpleChatStream([
{ role: 'user', content: 'Write a haiku about coding.' }
])) {
el.textContent += chunk;
}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.
/simple_chat/simple_chat)./simple_chat is for your extension to call the AI, not the other way around.{owner}_{repo} (e.g., heshengtao_sap-example)beforeunload handler to send unregister_node_extension_mcpregister_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 beforeunloadcdnjs.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):root and body.dark. Do NOT force SAP's theme colors-webkit-app-region: drag, frameless windows cannot be moved. Without -webkit-app-region: no-drag on interactive elements, buttons become unclickableStudy these real extensions for patterns:
assets/static-template/ — Complete starter template for static extensionsassets/node-template/ — Complete starter template for Node.js extensionsreferences/package-json-spec.md — Complete package.json field referencereferences/node-entry-spec.md — Node.js entry point and lifecycle specificationb3af7a0
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.