-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
en dev star guides plugin pages
Plugin Pages let a plugin provide its own pages inside the AstrBot WebUI. Page files live under the plugin's pages/ directory and are loaded by the Dashboard in a restricted iframe. Page scripts communicate with the Dashboard through the window.AstrBotPluginPage bridge, and the Dashboard forwards backend calls to Web APIs registered by the plugin.
If you only need a small set of editable settings, prefer _conf_schema.json. Pages are a better fit for complex forms, runtime dashboards, logs, file upload/download, SSE streams, charts, and other custom workflows.
Each direct child directory under pages/ is one Page. AstrBot only discovers pages/<page_name>/index.html; directories without index.html are ignored.
astrbot_plugin_page_demo/
├─ main.py
└─ pages/
├─ bridge-demo/
│ ├─ index.html
│ ├─ app.js
│ ├─ style.css
│ └─ assets/
│ └─ logo.svg
└─ settings/
└─ index.html
Use simple directory names for page_name, such as settings or bridge-demo. Do not use an empty name, ., .., a name starting with ., or a name containing / or \.
Users open Pages from the plugin detail page in the WebUI.
- Create
pages/<page_name>/index.htmlin the plugin directory. - Use the
window.AstrBotPluginPagebridge from the Page. - Register backend APIs with
context.register_web_api()inmain.py. - Read requests and return responses with
astrbot.api.web. - Reload the plugin after adding or removing Page directories; refreshing the Page is usually enough for static asset edits.
Plugin backend code should use astrbot.api.web. Avoid exposing raw FastAPI, Starlette, or Quart request objects as the public API for your plugin business logic.
from astrbot.api.star import Context, Star
from astrbot.api.web import error_response, json_response, request
PLUGIN_NAME = "astrbot_plugin_page_demo"
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
context.register_web_api(
f"/{PLUGIN_NAME}/ping",
self.page_ping,
["GET"],
"Page ping",
)
context.register_web_api(
f"/{PLUGIN_NAME}/settings/save",
self.save_settings,
["POST"],
"Save Page settings",
)
async def page_ping(self):
limit = request.query.get("limit", 20, type=int)
return json_response(
{
"message": "pong",
"limit": limit,
"username": request.username,
}
)
async def save_settings(self):
payload = await request.json(default={})
if not isinstance(payload.get("enabled"), bool):
return error_response("enabled must be a boolean")
return json_response({"saved": True})pages/bridge-demo/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Plugin Page Demo</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<button id="ping">Ping</button>
<pre id="output"></pre>
<script type="module" src="./app.js"></script>
</body>
</html>pages/bridge-demo/app.js
const bridge = window.AstrBotPluginPage;
const output = document.getElementById("output");
const context = await bridge.ready();
output.textContent = JSON.stringify(context, null, 2);
document.getElementById("ping").addEventListener("click", async () => {
const result = await bridge.apiGet("ping", { limit: 20 });
output.textContent = JSON.stringify(result, null, 2);
});You do not need to import the bridge SDK manually. AstrBot injects /api/plugin/page/bridge-sdk.js into returned HTML. If an inline script must access window.AstrBotPluginPage synchronously, move it to an external module file or explicitly include the SDK before your script:
<script src="/api/plugin/page/bridge-sdk.js"></script>Register plugin APIs with context.register_web_api(route, view_handler, methods, desc).
context.register_web_api(
f"/{PLUGIN_NAME}/items/<item_id>",
self.get_item,
["GET"],
"Get item",
)The registered route must include the plugin name prefix. The bridge endpoint used by the Page does not include the plugin name:
await bridge.apiGet("items/123");The Dashboard forwards it to:
/api/v1/plugins/extensions/<plugin_name>/items/123
The registered route /<plugin_name>/items/<item_id> matches the request, and item_id is passed to the handler as a keyword argument:
async def get_item(self, item_id: str):
return json_response({"item_id": item_id})Supported dynamic segments:
-
<name>: matches one path segment. -
<path:name>: matches the remaining multi-segment path.
Recommended import:
from astrbot.api.web import requestrequest is a context proxy for the current request and is only available while a plugin Web API handler is running. Common fields and methods:
| API | Description |
|---|---|
request.method |
HTTP method, such as GET or POST
|
request.path |
Current Dashboard API path |
request.plugin_name |
Plugin name parsed from the extension path |
request.username |
Current Dashboard username, possibly None
|
request.headers |
Request headers |
request.cookies |
Request cookies |
request.content_type |
Request Content-Type |
request.client_host |
Client address |
request.path_params |
Dynamic route parameters |
request.query |
Query parameters with get() and getlist()
|
await request.body() |
Raw request body bytes |
await request.json(default={}) |
JSON body, returning default on parse failure |
await request.form() |
Form fields without uploaded files |
await request.files() |
Uploaded files |
Query example:
limit = request.query.get("limit", 20, type=int)
tags = request.query.getlist("tag")JSON example:
payload = await request.json(default={})
enabled = bool(payload.get("enabled"))Upload example:
from pathlib import Path
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
from astrbot.api.web import PluginUploadFile, error_response, json_response, request
async def import_file(self):
form = await request.form()
files = await request.files()
upload: PluginUploadFile | None = files.get("file")
if not isinstance(upload, PluginUploadFile):
return error_response("missing file")
target_dir = (
Path(get_astrbot_plugin_data_path())
/ (request.plugin_name or "unknown_plugin")
/ "imports"
)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / Path(upload.filename).name
await upload.save(target)
return json_response(
{
"filename": upload.filename,
"content_type": upload.content_type,
"tag": form.get("tag"),
}
)request.form() and request.files() cache the parsed multipart data, so calling both in the same handler is fine.
Recommended response helpers:
from astrbot.api.web import (
error_response,
file_response,
json_response,
stream_response,
)JSON response:
return json_response({"saved": True})Error response:
return error_response("invalid threshold", status_code=400)File download response:
return file_response(
export_path,
filename="export.json",
content_type="application/json",
)SSE response:
import json
from astrbot.api.web import stream_response
async def stream_events(self):
async def events():
yield f"data: {json.dumps({'state': 'started'})}\n\n"
yield f"data: {json.dumps({'state': 'done'})}\n\n"
return stream_response(events())Returning a dict, list, (body, status_code), or a lower-level Response object still works. New plugins should prefer astrbot.api.web helpers so plugin code remains decoupled from the Dashboard's internal web framework.
For backward compatibility, handlers registered through context.register_web_api() still run inside a Quart-compatible request context. Existing plugins can continue to use:
from quart import jsonify, requestNew plugins and new documentation should use:
from astrbot.api.web import json_response, requestDo not mix the two request proxies in the same handler. Migrate one handler at a time.
The Page iframe cannot directly access Dashboard cookies, LocalStorage, or the parent DOM. Page scripts must use window.AstrBotPluginPage to call backend APIs and read context.
const bridge = window.AstrBotPluginPage;ready() waits for the parent page to send the initial context and returns Promise<context>. Wait for it during page initialization.
const context = await bridge.ready();The context usually contains:
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {},
"isDark": false
}Context APIs:
| API | Returns | Description |
|---|---|---|
ready() |
Promise<context> |
Waits until the bridge is ready and returns the initial context |
getContext() |
context | null |
Synchronously reads the latest context |
getLocale() |
string |
Current WebUI locale, defaulting to zh-CN
|
getI18n() |
object |
Current plugin i18n resources |
t(key, fallback) |
string |
Reads a dot-separated translation key, returning fallback when missing |
onContext(handler) |
() => void |
Listens for context changes and returns an unsubscribe function |
Respond to locale or theme changes:
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("locale").textContent = bridge.getLocale();
}
await bridge.ready();
render();
const off = bridge.onContext(render);
window.addEventListener("beforeunload", off);The endpoint used by apiGet, apiPost, upload, download, and subscribeSSE is a plugin-local relative path, such as stats, settings/save, or files/export. Prefer not to start it with /; the current bridge strips leading / for compatibility.
endpoint must not be empty, contain \, contain a URL scheme, contain query strings or fragments, or contain empty, ., or .. path segments.
Do not append query strings to endpoint:
await bridge.apiGet("stats", { limit: 20 });Bridge JSON calls use this compatibility rule:
- If the backend returns
{ "status": "ok", "data": value }, the Promise resolves tovalue. - If the backend returns plain JSON, such as
{ "message": "pong" }, the Promise resolves to that full JSON body. - If the backend returns
{ "status": "error", "message": "..." }, or the HTTP request fails, the Promise rejects withError.
For Page-only APIs, prefer returning plain business JSON:
return json_response({"message": "pong"})Use this for errors:
return error_response("missing file", status_code=400)Handle errors on the Page:
try {
await bridge.apiPost("settings/save", { enabled: true });
} catch (error) {
console.error(error.message);
}Sends a GET request. params are sent as query parameters.
const stats = await bridge.apiGet("stats", { limit: 20, tag: "today" });Backend:
async def stats(self):
limit = request.query.get("limit", 20, type=int)
tag = request.query.get("tag")
return json_response({"limit": limit, "tag": tag})Sends a POST JSON request.
const result = await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});Backend:
async def save_settings(self):
payload = await request.json(default={})
return json_response({"saved": True, "enabled": payload.get("enabled")})Uploads one file as multipart/form-data. The field name is always file.
const input = document.querySelector("input[type=file]");
const file = input.files[0];
const result = await bridge.upload("files/import", file);Backend:
from astrbot.api.web import PluginUploadFile, error_response, json_response, request
async def import_file(self):
files = await request.files()
upload: PluginUploadFile | None = files.get("file")
if not isinstance(upload, PluginUploadFile):
return error_response("missing file", status_code=400)
return json_response({"filename": upload.filename})If you need extra structured fields, send them through a separate apiPost call or use query parameters to select import behavior. The current upload() bridge method sends one file.
Requests a plugin backend file endpoint and triggers a browser download. params are sent as query parameters. filename is optional; when omitted, the bridge tries to read it from response headers.
await bridge.download("files/export", { format: "json" }, "export.json");Backend:
async def export_file(self):
fmt = request.query.get("format", "json")
return file_response(
export_path,
filename=f"export.{fmt}",
content_type="application/json",
)download() resolves to:
{ "filename": "export.json" }Subscribes to plugin backend SSE and returns Promise<subscriptionId>. handlers may include onOpen, onMessage, and onError.
const subscriptionId = await bridge.subscribeSSE(
"events",
{
onOpen() {
console.log("SSE opened");
},
onMessage(event) {
console.log(event.raw, event.parsed, event.lastEventId);
},
onError() {
console.warn("SSE error");
},
},
{ topic: "logs" },
);event.raw is the raw string. If the message is a JSON string, event.parsed is parsed automatically; otherwise it equals the raw string. event.eventType matches the SSE event: field and defaults to message.
The backend must return text/event-stream:
async def events(self):
async def stream():
yield 'data: {"message": "ready"}\n\n'
return stream_response(stream())Unsubscribe:
await bridge.unsubscribeSSE(subscriptionId);Clean up on unload:
window.addEventListener("beforeunload", () => {
bridge.unsubscribeSSE(subscriptionId);
});Plugin Pages reuse plugin i18n resource files. Add pages.<page_name> to .astrbot-plugin/i18n/<locale>.json:
{
"pages": {
"bridge-demo": {
"title": "Bridge Demo",
"description": "Shows how a plugin page reads the WebUI locale and translations.",
"heading": "Plugin Page",
"refresh": "Render again"
}
}
}title is used by the WebUI shell title and the Page component name on the plugin detail page. description is used by the Page component description. Inside the Page, render text with bridge.t() and react to locale changes with onContext().
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("heading").textContent = bridge.t(
"pages.bridge-demo.heading",
"Plugin Page",
);
}
await bridge.ready();
render();
bridge.onContext(render);AstrBot syncs the current theme to Plugin Pages. The bridge SDK maintains a data-theme attribute on <html>:
- Light mode:
<html data-theme="light"> - Dark mode:
<html data-theme="dark">
When Follow System is selected, the Page still receives either light or dark.
CSS variables are recommended:
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}The server injects data-theme into returned HTML to reduce initial flashing. If JavaScript needs to react to theme changes, read bridge.getContext()?.isDark and listen with onContext().
Use normal relative paths:
<link rel="stylesheet" href="./style.css" />
<script type="module" src="./app.js"></script>
<img src="./assets/logo.svg" alt="" />AstrBot rewrites relative asset URLs and appends a short-lived asset_token. Do not hardcode /api/plugin/page/content/..., append asset_token yourself, or rely on .. to escape the Page root.
AstrBot rewrites:
- HTML
srcandhref - CSS
url(...) - JavaScript
import - JavaScript
export ... from - JavaScript dynamic
import()
If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires a real file at that path.
Plugin Pages run inside a restricted iframe:
allow-scripts allow-forms allow-downloads
The Page cannot directly access Dashboard cookies, LocalStorage, or the parent DOM, and it cannot bypass the bridge to reuse Dashboard auth. All operations that need Dashboard identity should go through the bridge.
Asset responses include security headers such as:
X-Frame-Options: SAMEORIGINContent-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'Cache-Control: no-storeX-Content-Type-Options: nosniff
Backend handlers must still validate input. Do not trust paths, filenames, formats, or numeric ranges sent by the Page. Store files only in safe directories and prefer whitelisted or regenerated filenames.
- Page is missing: check that
pages/<page_name>/index.htmlexists, the plugin is enabled, and the plugin detail page has been refreshed. - Bridge is missing: make sure your script runs after the bridge SDK is injected; external
type="module"scripts are recommended. - API is not matched: make sure the registered route includes the plugin name prefix, such as
/{PLUGIN_NAME}/stats, while the Page endpoint isstats. - Query or JSON is empty: pass GET values through
apiGet(endpoint, params)and POST JSON throughapiPost(endpoint, body). - Upload is empty:
upload()always uses the field namefile; read it with(await request.files()).get("file"). - SSE has no messages: make sure the backend response is
text/event-streamand each message ends with a blank line, such asdata: ...\n\n. - SSE returns 401: do not call
new EventSource("/api/v1/...")directly from the Page. NativeEventSourcecannot send theAuthorizationheader; call throughbridge.subscribeSSE()instead.
- 首页
- 文档入口
- Top Level
- community events
- deploy
- dev
- others
- platform
- 接入 OneBot v11 协议实现
- 接入钉钉 DingTalk
- 接入 Discord
- 接入 Kook
- 接入飞书
- 接入 LINE
- 接入 Matrix
- 接入 Mattermost
- 接入 Misskey 平台
- 接入 QQ 官方机器人平台
- 通过 QQ官方机器人 接入 QQ (Webhook)
- 通过 QQ官方机器人 接入 QQ (Websockets)
- 接入 Satori 协议
- 接入 server-satori (基于 Koishi)
- 接入 Slack
- 接入消息平台
- 接入 Telegram
- 接入 VoceChat
- AstrBot 接入企业微信
- 接入企业微信智能机器人平台
- AstrBot 接入微信公众平台
- 接入个人微信
- providers
- use
- Home
- Docs Entry
- Top Level
- config
- deploy
- Deploy AstrBot on 1Panel
- Deploy AstrBot on BT Panel
- Deploy AstrBot on CasaOS
- Deploy AstrBot from Source Code
- Community-Provided Deployment Methods
- Deploy via Compshare
- Deploy with AstrBot Desktop Client
- Deploy AstrBot with Docker
- Deploy AstrBot with Kubernetes
- Deploy AstrBot with AstrBot Launcher
- Other Deployments
- Package Manager Deployment (uv)
- Installation via System Package Manager
- Preface
- dev
- AstrBot Configuration File
- AstrBot HTTP API
- Developing a Platform Adapter
- plugin
- AI
- Text to Image
- Handling Message Events
- Plugin Configuration
- Plugin Internationalization
- Plugin Pages
- Sending Messages
- Session Control
- Minimal Example
- Plugin Storage
- AstrBot Plugin Development Guide 🌠
- Publishing Plugins to the Plugin Marketplace
- ospp
- others
- platform
- Connect OneBot v11 Protocol Implementations
- Connect to DingTalk
- Connecting to Discord
- Connect to KOOK
- Connecting to Lark
- Connecting to LINE
- Connecting to Matrix
- Connecting to Mattermost
- Connecting to Misskey Platform
- Connect QQ Official Bot
- Connect QQ via QQ Official Bot (Webhook)
- Connect QQ via QQ Official Bot (Websockets)
- Connect to Satori Protocol
- Connect server-satori (Koishi)
- Connecting to Slack
- Messaging Platforms
- Connecting to Telegram
- Connect to VoceChat
- Connect AstrBot to WeCom
- Connect to WeCom AI Bot Platform
- Connect AstrBot to WeChat Official Account Platform
- Connect Personal WeChat
- providers
- Connect 302.AI
- Agent Runners
- Built-in Agent Runner
- Connect to Coze
- Connect to Alibaba Cloud Bailian Application
- Connect to DeerFlow
- Connect to Dify
- Connect AIHubMix
- coze
- dashscope
- dify
- 大语言模型提供商
- NewAPI
- Connect PPIO Cloud
- Connect LM Studio to Use DeepSeek-R1 and Other Models
- Integrating Ollama
- Connecting to SiliconFlow
- Connecting Model Services
- Connecting to TokenPony
- use
- Agent Runner
- Agent Sandbox Environment ⛵️
- astrbot sandbox
- CLI Commands
- Docker-based Code Interpreter
- Built-in Commands
- Computer Use
- Context Compression
- Custom Rules
- Function Calling
- AstrBot Knowledge Base
- MCP
- AstrBot Star
- Proactive Capabilities
- Anthropic Skills
- Agent Handoff and SubAgent
- Unified Webhook Mode
- Web Search
- WebUI