diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 0000000..4093804
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,408 @@
+# 插件系统(Plugin System)
+
+> **English**: this document is the reference for writing ModelRouter plugins.
+> The Chinese version is the primary one; section titles map 1:1.
+
+ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
+插件能做两件事:
+
+1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
+ 并可以把结果累加进自己的状态。
+2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
+ 新页面,也可以是往现有页面里追加一个组件。
+
+两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
+
+---
+
+## 1. 快速上手
+
+一个最小的插件:
+
+```lua
+-- plugins/hello.lua
+local plugin = {
+ name = "hello",
+ version = "1.0.0",
+ description = "示例插件",
+ author = "you",
+}
+
+-- 声明钩子
+plugin.hooks = {
+ request_end = "on_request_end",
+}
+
+-- 钩子实现
+function plugin.on_request_end(payload)
+ -- payload 是解码后的 table,不是 JSON 字符串
+ log("info", string.format("%s via %s: %d prompt tokens",
+ payload.model, payload.source, payload.prompt_tokens or 0))
+ return nil -- 最后一个 stage 没有下游,return 无意义
+end
+
+-- 贡献界面
+plugin.ui = {
+ page = {
+ page_id = "hello", -- kebab-case
+ title = "Hello",
+ icon = "👋",
+ order = 90, -- 侧栏排序
+ mount = [[
hello
]],
+ },
+}
+
+return plugin -- 必须返回一个 table
+```
+
+放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
+
+---
+
+## 2. 加载与生命周期
+
+```
+core.New
+ └─ lua.NewVM(adapter_dir).Start() 适配器状态
+ └─ lua.NewPlugins(vm, plugin_dir)
+ ├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
+ └─ LoadDir() 按文件名字典序逐个加载
+```
+
+**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
+的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
+第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
+
+**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
+的文件说了算,删除或编辑内置插件都是真实生效的操作。
+
+### 2.1 热更新
+
+| 方式 | 效果 |
+|---|---|
+| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
+| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
+| 改文件后 `POST` 同名 | 同上 |
+
+改文件但**不** POST,需要重启才生效。
+
+---
+
+## 3. 流水线 stage
+
+一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
+被忽略,所以插件不会因为网关将来新增 stage 而报错。
+
+```
+ 客户端请求
+ │
+ ┌─────────▼──────────┐
+ │ request_start │ 已解析、已鉴权,尚未选源
+ │ · type │ "chat" | "stream" | "image"
+ │ · model │ 客户端请求的原始 model("AUTO" 也在这里)
+ │ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
+ │ · source │ 空(还没选源)
+ │ · stream │
+ │ · messages_count │
+ │ · tools_count │
+ │ · ts │ unix 秒
+ └─────────┬──────────┘
+ │ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
+ ┌─────────▼──────────┐
+ │ routed │ 已选定 (source, model),尚未发往上游
+ │ · source / model │ 实际选中的
+ │ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
+ │ · stream / key │
+ └─────────┬──────────┘
+ │ (HTTP 往返 / SSE 流)
+ ┌─────────▼──────────┐
+ │ request_end │ 每个请求恰好一次,成功失败都触发
+ │ · ok / status │
+ │ · latency_ms │
+ │ · first_byte_ms │ 流式的首字节时间
+ │ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
+ │ · completion_tokens
+ │ · cache_hit_tokens / cache_miss_tokens
+ │ · image_count │ 生图数量(图片不计 token)
+ │ · error │ 失败原因,成功时为 ""
+ │ · time │ unix **毫秒**
+ └─────────┬──────────┘
+ │
+ 写审计 + 聚合统计
+```
+
+### 3.1 触发点在哪
+
+| stage | 代码位置 | 说明 |
+|---|---|---|
+| `request_start` | `gateway/chat.go` `handleChat` | 每个 chat 请求一次 |
+| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
+| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
+
+`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
+经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
+
+**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
+加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
+这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
+反模式。
+
+### 3.2 钩子的返回值
+
+- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
+- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
+
+前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
+所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
+
+---
+
+## 4. 界面扩展
+
+### 4.1 整页
+
+```lua
+plugin.ui = {
+ page = {
+ page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
+ title = "Billing", -- 必填,侧栏文字
+ icon = "💰", -- 可选
+ order = 40, -- 侧栏排序,默认 100
+ mount = [[...HTML...]],
+ },
+}
+```
+
+### 4.2 往现有页面追加元素
+
+```lua
+plugin.ui = {
+ elements = {
+ {
+ target = "status", -- status | chat | keys | sort | sources | adapters
+ anchor = "top", -- "top" | "bottom" | "before:" | "after:"
+ order = 5,
+ mount = [[...HTML...]],
+ },
+ },
+}
+```
+
+### 4.3 `mount` 里可以带 `