Version: v1.0 | For Axons ≥ 0.8.0
This guide helps developers build an Axons plugin from scratch, covering directory structure, manifest protocol, frontend build, backend development, packaging, and publishing.
English | 简体中文
Official extension repository: axons-extension-packages — includes language packs, model manager, and other complete examples you can reference or contribute to.
- 1. Quick Start
- 2. Directory Structure
- 3. manifest.json Protocol
- 4. Frontend Development
- 5. Backend Development
- 6. Install Scripts
- 7. Packaging & Publishing
- 8. Debugging Tips
- 9. FAQ
A minimal "frontend-only" plugin only needs 3 files:
com.example.hello/
├── manifest.json
├── ui/
│ └── index.js
└── ui/
└── icon.svg
manifest.json:
{
"id": "com.example.hello",
"name": "Hello World",
"version": "1.0.0",
"description": "My first Axons plugin",
"author": "example",
"icon": "ui/icon.svg",
"category": "productivity",
"minAxonsVersion": "0.8.0",
"permissions": ["panel:create"],
"frontend": {
"entry": "ui/index.js",
"panels": [{
"id": "hello",
"title": "Hello",
"icon": "ui/icon.svg",
"location": "right",
"activator": "activityBar"
}]
},
"activationEvents": ["onStartup"]
}ui/index.js:
export default function HelloPanel({ pluginApi, onClose, panelId }) {
const el = document.createElement('div');
el.innerHTML = '<h2>Hello Axons!</h2>';
return el; // Note: for React component pattern, see Section 4
}However, we recommend using React + Vite for development. See below for details.
Recommended complete plugin directory:
com.example.my-plugin/
├── manifest.json # Plugin manifest (required)
├── install.sh # Install script (required for backend dependencies)
├── uninstall.sh # Uninstall script (optional)
├── requirements.txt # Python dependencies (for Python backends)
├── server.py # Backend service (optional)
├── .venv/ # Python virtual environment (created by install.sh)
├── src/ # Frontend source code
│ ├── index.tsx # Entry component
│ └── types.ts # Type definitions
├── ui/ # Frontend build output + static assets
│ ├── index.js # Build artifact (generated by Vite)
│ └── icon.svg # Panel icon
├── package.json # Frontend dependencies
├── tsconfig.json # TypeScript configuration
├── vite.config.js # Vite build configuration
└── .axons-ignore # Packaging exclusion rules
Exclude unnecessary files during packaging:
node_modules/
.venv/
src/
.git/
*.tar.gz
package-lock.json
tsconfig.json
vite.config.js
| Archetype | Backend | Frontend | Use Case |
|---|---|---|---|
| Full-stack | ✓ | ✓ | Panel + own API service |
| Frontend-only | ✗ | ✓ | Panel that calls Axons APIs only |
| Backend-only | ✓ | ✗ | MCP toolsets |
| Frontend+CLI | ✗ | ✓ | Frontend directly calling Axons API |
mkdir com.example.my-plugin && cd com.example.my-plugin
npm init -y
npm install react react-dom
npm install -D vite @vitejs/plugin-react typescript @types/react @types/react-domThis is the most common source of issues. Plugins are loaded as ES Modules by Axons, so you must configure externalization and environment variables correctly:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// ⚠️ Must be defined, otherwise the browser throws "process is not defined"
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
lib: {
entry: 'src/index.tsx',
formats: ['es'], // Must be ES Module
fileName: () => 'index.js',
},
rollupOptions: {
// ⚠️ Externalization: React and axons-plugin-ui are provided by the Axons runtime
// Do NOT bundle them into the plugin artifact, otherwise React multi-instance issues occur (hooks break)
external: ['react', 'react-dom', 'axons-plugin-ui'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'axons-plugin-ui': 'AxonsPluginUI',
},
},
},
outDir: 'ui',
emptyOutDir: false, // Preserve ui/icon.svg and other static files
},
});Two key points:
-
define: { 'process.env.NODE_ENV': ... }— The browser doesn't have aprocessglobal variable. Without this line, you'll getprocess is not defined. Vite replacesprocess.env.NODE_ENVwith a string constant at build time, and development-mode code gets tree-shaken away. -
external: ['react', ...]— React etc. are provided by the Axons host via import map. Plugins should not bundle these dependencies. Bundling them causes React multi-instance issues, breaking hooks.
The plugin entry component must follow the PluginPanelProps interface:
// src/index.tsx
import React from 'react';
interface PluginPanelProps {
pluginApi: import('../lib/pluginApi').PluginApi;
onClose: () => void;
panelId: string;
}
export default function MyPanel({ pluginApi, onClose, panelId }: PluginPanelProps) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Your panel content */}
</div>
);
}pluginApi— The single entry point for communicating with the Axons platform, see Section 4.6onClose— Close the current panelpanelId— Current panel ID
Note: The panel is already wrapped in a resizable container by Axons (default 384px wide, draggable). Your component only needs to focus on the content area.
Axons provides a UI component library that plugins can use directly, with styles consistent with the main interface:
import React, { useState } from 'react';
import { Button, Card, CardHeader, CardBody, Badge, Spinner, Tabs } from 'axons-plugin-ui';
export default function MyPanel({ pluginApi }) {
const [activeTab, setActiveTab] = useState('tab1');
return (
<div style={{ height: '100%', padding: '12px' }}>
<Tabs
tabs={[{ id: 'tab1', label: 'Tab One' }, { id: 'tab2', label: 'Tab Two' }]}
activeTab={activeTab}
onChange={setActiveTab}
/>
<Card>
<CardHeader>Title</CardHeader>
<CardBody>
<Badge variant="success">Ready</Badge>
<Button variant="primary" onClick={() => {}}>Action</Button>
</CardBody>
</Card>
</div>
);
}Available components:
| Component | Description |
|---|---|
Button |
Button, variant: primary/secondary/ghost, size: default/sm |
Card / CardHeader / CardBody |
Card container |
Input |
Text input |
Select |
Dropdown select |
Textarea |
Multi-line text input |
Badge |
Badge, variant: default/success/warning/error/info |
Divider |
Horizontal separator, spacing: default/lg |
EmptyState |
Empty state placeholder (icon + title + description) |
Spinner |
Loading spinner |
ProgressBar |
Progress bar, value 0-1 |
List / ListItem |
List with icon, active state, click handler |
Tabs |
Tab switcher (with keyboard navigation) |
Modal |
Modal dialog (with focus trap) |
ConfirmDialog |
Confirmation dialog |
TypeScript support: Axons provides type declarations at /plugin-sdk/axons-plugin-ui.d.ts. This file is compile-time only — it never ships in your plugin bundle. The host maintains it as the single source of truth; plugin developers never need to edit it.
To use it during development:
- Inside the axons repo: add a
pathsentry intsconfig.json:{ "compilerOptions": { "paths": { "axons-plugin-ui": ["../axons/internal/api/static/dist/plugin-sdk/axons-plugin-ui"] } } } - Outside the axons repo: copy the type declaration file from the axons repository into your plugin project (re-copy when the host updates axons-plugin-ui):
cp <axons-repo>/internal/api/static/dist/plugin-sdk/axons-plugin-ui.d.ts src/axons-plugin-ui.d.ts
At runtime, axons-plugin-ui is provided by the host iframe (UMD global + ESM shim) — your plugin's index.js never bundles it.
Axons provides theme support through CSS variables. Plugins should use variables instead of hardcoded colors:
/* Use CSS variables in component styles */
background: var(--axons-color-surface);
color: var(--axons-text-primary);
border: 1px solid var(--axons-border-subtle);
font-family: var(--axons-font-sans);Common variables:
| Variable | Purpose |
|---|---|
--axons-color-void |
Deepest background |
--axons-color-deep |
Secondary deep background |
--axons-color-surface |
Panel background |
--axons-color-elevated |
Overlay background |
--axons-color-hover |
Hover background |
--axons-border-subtle |
Subtle border |
--axons-border-default |
Default border |
--axons-text-primary |
Primary text |
--axons-text-secondary |
Secondary text |
--axons-text-muted |
Muted text |
--axons-accent |
Theme accent color |
--axons-success / warning / error / info |
Status colors |
--axons-font-sans |
Sans-serif font |
--axons-font-mono |
Monospace font |
pluginApi is the single entry point for plugins to communicate with the Axons platform, automatically handling desktop/web differences:
// Auto-selects: desktop connects directly / web goes through Axons proxy
const resp = await pluginApi.fetch('/api/models');
const data = await resp.json();const es = pluginApi.createEventSource('/api/events');
es.onmessage = (e) => {
const data = JSON.parse(e.data);
// Handle pushed data
};
// Cleanup
es.close();// Subscribe to events (returns unsubscribe function)
const unsubscribe = pluginApi.onEvent('node:selected', (payload) => {
console.log('Selected node:', payload);
});
// Broadcast events
pluginApi.emitEvent('model:downloaded', { name: 'llama3' });
// Unsubscribe when component unmounts
unsubscribe();// Write state (namespaced by pluginId)
await pluginApi.setState('lastModel', { name: 'llama3', size: '4.7G' });
// Read state
const lastModel = await pluginApi.getState('lastModel');Axons injects the following environment variables when starting the plugin backend process:
| Variable | Description |
|---|---|
AXONS_API_URL |
Axons API address, e.g. http://127.0.0.1:8080 |
AXONS_PLUGIN_PORT |
Port the plugin should bind to (when port: 0 in manifest, OS assigns dynamically) |
AXONS_PLUGIN_TOKEN |
Plugin authentication token |
Python backend example:
import os
API_URL = os.environ.get('AXONS_API_URL', 'http://127.0.0.1:8080')
PORT = int(os.environ.get('AXONS_PLUGIN_PORT', '0'))
TOKEN = os.environ.get('AXONS_PLUGIN_TOKEN', '')The healthCheck endpoint declared in manifest.json must return 200:
# server.py (FastAPI example)
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}On desktop, the frontend connects directly to the plugin backend, so CORS headers must be returned:
# FastAPI — Global CORS middleware
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)On web, requests go through the Axons proxy, so CORS is not needed (but adding it won't hurt).
The backend uses AXONS_API_URL to call Axons API directly (same-machine HTTP, no cross-origin issues):
import requests
API_URL = os.environ.get('AXONS_API_URL', 'http://127.0.0.1:8080')
# Get code graph
resp = requests.get(f"{API_URL}/v1/graph/{project_id}")
graph = resp.json()
# Semantic search
resp = requests.post(f"{API_URL}/v1/search", json={
"query": "authentication logic",
"project_id": project_id
})install.sh runs once after a user imports the plugin, used to install backend dependencies:
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# 1. Check runtime environment
if ! command -v python3 &>/dev/null; then
echo "Error: python3 not found"
exit 1
fi
# 2. Create virtual environment and install dependencies
python3 -m venv "$SCRIPT_DIR/.venv"
source "$SCRIPT_DIR/.venv/bin/activate"
pip install --quiet -r "$SCRIPT_DIR/requirements.txt"
echo "Installation complete"Important notes:
- Python plugins must use
.venv. Themanifest.jsonbackend.commandshould point to.venv/bin/python - The script must exit with code 0 for success, non-zero for failure
- Avoid using
sudoand do not modify system-level configurations
# 1. Build frontend
npx vite build
# 2. Package as .tar.gz (filename format: {id}-{version}.axons-plugin.tar.gz)
cd ..
tar -czf com.example.my-plugin-1.0.0.axons-plugin.tar.gz \
-C com.example.my-plugin \
--exclude-from=com.example.my-plugin/.axons-ignore \
.Users upload .axons-plugin.tar.gz files in the Axons Extensions panel, or via API:
curl -X POST http://localhost:8080/v1/plugins/import \
-F "file=@com.example.my-plugin-1.0.0.axons-plugin.tar.gz"- Browser DevTools — Open DevTools in the Axons web interface. The Sources panel can find plugin code
- console.log — Plugin
console.logoutput goes to the browser console - Vite dev server — You can run
npx vite devseparately to develop components, but note thatpluginApiis not available in standalone mode
- Manual start — Set environment variables and run the backend directly:
export AXONS_API_URL=http://127.0.0.1:8080 export AXONS_PLUGIN_PORT=18080 export AXONS_PLUGIN_TOKEN=test .venv/bin/python server.py
- curl testing — Directly request the plugin backend API
- Axons logs — View plugin stdout/stderr output
# View installed plugins
curl http://localhost:8080/v1/plugins
# View plugin panel registry
curl http://localhost:8080/v1/plugins/registry/panels
# Read shared state
curl http://localhost:8080/v1/plugins/state/com.example.my-plugin:lastModel
# Write shared state
curl -X PUT http://localhost:8080/v1/plugins/state/com.example.my-plugin:key \
-H "Content-Type: application/json" \
-d '{"value": "test"}'Cause: The browser cannot resolve bare module specifiers (like import from "react"). Axons resolves this via import map. Ensure your vite.config.js has external configured for react, react-dom, and axons-plugin-ui.
Cause: Build artifacts contain references to process.env.NODE_ENV, but the browser doesn't have a process global variable.
Solution: Add to vite.config.js:
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
}Cause: The plugin bundled React, causing two instances alongside the Axons host's React.
Solution: Ensure vite.config.js has external: ['react', 'react-dom']. Do not bundle React into the plugin artifact.
Solution: The plugin backend must add CORS headers to allow cross-origin requests. See the FastAPI example in Section 5.3.
Troubleshooting steps:
- Set environment variables manually and run
backend.command - Check if the port is occupied
- Check if the
healthCheckendpoint is ready withinreadyTimeout - Check plugin stdout/stderr in Axons logs
- Rebuild frontend:
npx vite build - Re-package the plugin:
tar -czf ... - Uninstall the old plugin in the Axons Extensions panel, then import the new package
- Or via API: first
DELETE /v1/plugins/{id}thenPOST /v1/plugins/import
Set location in manifest.json panels:
| location | Description | Panel width |
|---|---|---|
right |
Right panel (default) | 384px, draggable |
left |
Left panel | Follows left panel container |
center-bottom |
Bottom panel | Full width |
modal |
Modal dialog | Centered popup |
For complete example code and ready-to-use plugin templates, check the official extension repository: axons-extension-packages
| Plugin | Type | Description |
|---|---|---|
com.axons.locale-zh-cn |
Static | Simplified Chinese language pack |
com.axons.huggingface |
Full-stack (Frontend + Backend) | HuggingFace GGUF model browser and local LLM management |
- Plugin Authoring Guide — Build a plugin from scratch in three steps
- Development Manual — Monorepo development, validation, and maintenance
- Releasing Guide — Version release workflow
{ // ─── Basic Info ─── "id": "com.example.my-plugin", // Reverse-DNS, globally unique "name": "My Plugin", // Display name "version": "1.0.0", // Semantic version "description": "Plugin description", "author": "author-name", "icon": "ui/icon.svg", // Relative path "category": "productivity", // analysis | visualization | search | productivity "minAxonsVersion": "0.8.0", // Minimum compatible version // ─── Permission Declarations ─── "permissions": [ "project:read", // Read project info "graph:read", // Read code graph data "model:register", // Register/unregister LLM models "panel:create", // Create UI panels "state:read", // Read shared state "state:write" // Write shared state ], // ─── Backend (optional) ─── "backend": { "command": [".venv/bin/python", "server.py"], // Startup command "port": 0, // 0 = OS dynamic allocation, or specify a fixed port "healthCheck": "/health", // Health check path "readyTimeout": "15s", // Ready timeout "install": { "command": ["bash", "install.sh"], "timeout": "300s" }, "uninstall": { "command": ["bash", "uninstall.sh"] } }, // ─── Frontend (optional) ─── "frontend": { "entry": "ui/index.js", // ES Module entry "panels": [{ "id": "my-panel", // Panel ID (unique within plugin) "title": "My Panel", // Panel title "icon": "ui/icon.svg", // Panel icon "location": "right", // right | left | center-bottom | modal "activator": "activityBar" // activityBar | footer | node-select | gearMenu | command }], "commands": [{ "id": "my-plugin.open", "title": "Open My Plugin", "shortcut": "Ctrl+Shift+P" }] }, // ─── Activation Events ─── "activationEvents": ["onStartup"] }