SAP Extension Creator
Build custom extensions for Super Agent Party with frontend or Node.js backends.
Installation
- Make sure Claude is on your device and in your terminal.
Skills load from
~/.claude/skills/when Claude Code starts up — so you need it on your machine first. If you don't have it yet, install it once with the command below, then runclaudein any terminal to verify.One-time setupnpm i -g @anthropic-ai/claude-codeAlready have it? Skip ahead.
- Paste into Claude Code or into your terminal.
This copies the whole skill folder into
~/.claude/skills/sap-extension-creator-heshengtao/— the SKILL.md plus any scripts, reference docs, or templates the skill ships with. Safe default: works for every skill.Faster alternative (instruction-only skills)
Skips the clone and grabs only the SKILL.md file. Don't use this if the skill ships Python scripts, reference markdowns, or asset templates — they won't be downloaded and the skill will fail when it tries to load them.
Quick install (SKILL.md only)Sign up to copy - Restart Claude Code.
Quit and reopen Claude Code (or any other agent that loads from
~/.claude/skills/). New skills are picked up on startup. - Just ask Claude.
Skills auto-activate when your request matches the skill's description — no slash command needed. Trigger phrases live in the skill's own frontmatter; you can read them in the “What this skill does” section above.
Prefer to read the source first? Open on GitHub.
When Claude uses it
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.
What this skill does
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:
- Self-contained: The extension is a single HTML file with all CSS/JS inlined or loaded from CDN. For Node.js extensions, static assets are served from the extension directory.
- Font Awesome: Use CDN to ensure reliable loading in both static and Node.js modes:
Avoid relative paths like<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). - Dark/Light mode: Always support both (see "Theme & i18n" section below).
- i18n (Chinese/English): Always support bilingual UI (see "Theme & i18n" section below).
- WebSocket connection: Connect to
ws://host/wsfor messaging and MCP. - Extension ID: Parse
window.location.pathnamefor/extensions/{ext_id}/. - Message rendering: Listen for
messages_updateandbroadcast_messagesevents. - Send user input: Send
set_user_inputthentrigger_send_message.
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
/healthendpoint 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;
// ... 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).
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; /* 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);
}
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: '发送',
// ... 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.
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) {
/* stack layouts vertically, reduce padding */
}
@media (max-width: 600px) {
/* hide secondary elements, compact controls */
}
Key responsive practices:
- Use
vwunits for widths as fallback (e.g.,width: 65vw; max-width: 360px) - Use
flexlayouts withflex-wrapthat 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.parentassumptions — 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)
/* 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; }
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>
<!-- 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.
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)
/**
* 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();
}
Streaming (Real-Time Display)
/**
* 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;
}
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_chatis 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
beforeunloadhandler to sendunregister_node_extension_mcp - MCP works in both static and Node.js extensions — the
register_node_extension_mcpmessage 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
:rootandbody.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-dragon 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 extensionsassets/node-template/— Complete starter template for Node.js extensions
references/
references/package-json-spec.md— Complete package.json field referencereferences/node-entry-spec.md— Node.js entry point and lifecycle specification
Related skills
Generative Code Art
anthropics
Create algorithmic art with p5.js using randomness and interactive parameters.
UI/UX Pro Max
anthropics
Build production-grade web components and interfaces with distinctive, polished design.
Artifact Theme Toolkit
anthropics
Apply professional color and font themes to slides, docs, and web pages.
Multi-Component Web Artifacts
anthropics
Build complex React artifacts with Tailwind CSS and shadcn/ui components.