diff --git a/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java b/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java
new file mode 100644
index 000000000..ccc8b1962
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java
@@ -0,0 +1,98 @@
+package vip.mate.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+/**
+ * SpringDoc OpenAPI 全局配置。
+ *
+ * 项目已依赖 springdoc-openapi-starter-webmvc-ui(见根 pom 的
+ * {@code springdoc.version}),但此前没有任何 OpenAPI 配置 Bean,导致
+ * Swagger UI 缺少标题/描述、缺少安全方案(Authorize 按钮不可用)。
+ * 本类补齐这些全局元信息,让现有 Controller 上已有的
+ * {@code @Tag} / {@code @Operation} 注解直接可用。
+ *
+ *
安全方案
+ * 两种 token 都通过标准 {@code Authorization: Bearer } 头传入,
+ * {@link JwtAuthFilter} 按 token 前缀分发:JWT 以 {@code eyJ} 开头,
+ * Personal Access Token 以 {@code mc_} 开头(PAT_PREFIX)。因此 Swagger
+ * UI 的 Authorize 按钮只需填入任意一种 token 即可。
+ *
+ * 注意:Swagger 当前公开可访问
+ * {@link SecurityConfig#filterChain} 中 {@code /api/**} 要求认证,但
+ * {@code /swagger-ui*}、{@code /v3/api-docs*}、{@code /webjars/**} 落到
+ * {@code .anyRequest().permitAll()},即 Swagger UI 当前是公开的。如生产环境
+ * 需要收口,应在 SecurityConfig 显式加规则,而不是改本类。
+ *
+ * 未做的事(与「全局配置 + 安全方案」范围一致)
+ * 不逐个 Controller 补 {@code @Parameter} / {@code @ApiResponse} /
+ * {@code @Schema} / 公开端点的 {@code @SecurityRequirements({})} opt-out。
+ * 这些属于「关键端点注解」增强档,留作后续。
+ *
+ * @author MateClaw Team
+ */
+@Configuration
+public class OpenApiConfig {
+
+ /** HTTP Bearer 安全方案的引用键,与 {@link Components#getSecuritySchemes()} 中的登记名一致。 */
+ public static final String BEARER_AUTH = "bearerAuth";
+
+ @Bean
+ public OpenAPI mateclawOpenAPI(
+ @Value("${mateclaw.openapi.title:MateClaw REST API}") String title,
+ @Value("${mateclaw.openapi.description:#{null}}") String description,
+ @Value("${mateclaw.openapi.version:1.0}") String version,
+ @Value("${mateclaw.openapi.server-url:}") String serverUrl) {
+
+ Info info = new Info()
+ .title(title)
+ .version(version)
+ .description(defaultIfBlank(description, ""
+ + "MateClaw 多用户 AI Agent 平台的 REST API。"
+ + "所有业务端点使用 /api/v1 前缀,绝大多数 JSON 响应走 {code,msg,data} 统一信封。"
+ + "点击右上角 Authorize 并粘贴 JWT 或 Personal Access Token (mc_) 即可调试受保护端点。"
+ + "完整人读文档见部署地址的 /docs 页面。"));
+
+ Components components = new Components()
+ .addSecuritySchemes(BEARER_AUTH, bearerScheme(
+ "JWT 或 Personal Access Token。两种都通过 Authorization: Bearer 头传入,"
+ + "服务端按前缀分发:JWT 以 eyJ 开头,PAT 以 mc_ 开头。"
+ + "EventSource 不支持自定义请求头,SSE 流式端点可用 ?token= 查询参数替代。"));
+
+ OpenAPI openAPI = new OpenAPI()
+ .info(info)
+ .components(components)
+ // 默认所有端点需要鉴权;公开端点(登录、SSE 等)在 SecurityConfig 中放行,
+ // Swagger 上会仍标注锁图标,但不影响实际调用。
+ .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH));
+
+ if (serverUrl != null && !serverUrl.isBlank()) {
+ openAPI.servers(List.of(new Server().url(serverUrl)));
+ }
+ // serverUrl 为空时不显式配置 —— SpringDoc 默认从请求 host 推导,
+ // 避免「Try it out」打到错误地址。
+
+ return openAPI;
+ }
+
+ private SecurityScheme bearerScheme(String description) {
+ return new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("bearer")
+ .bearerFormat("JWT")
+ .description(description);
+ }
+
+ private static String defaultIfBlank(String value, String fallback) {
+ return (value == null || value.isBlank()) ? fallback : value;
+ }
+}
diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml
index 005d440b3..32a479bed 100644
--- a/mateclaw-server/src/main/resources/application.yml
+++ b/mateclaw-server/src/main/resources/application.yml
@@ -116,11 +116,16 @@ mybatis-plus:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
# SpringDoc OpenAPI
+# 注意:/swagger-ui*、/v3/api-docs*、/webjars/** 落在 SecurityConfig 的
+# .anyRequest().permitAll(),即 Swagger UI 当前公开可访问。如生产需要收口,
+# 在 SecurityConfig 加显式规则(不要在此处配)。
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
+ # 把单个嵌套 query 参数对象(如分页 wrapper)拍平成独立字段,减少 schema 噪音
+ default-flat-param-object: true
# MateClaw 自定义配置
mateclaw:
@@ -131,6 +136,15 @@ mateclaw:
# Set this when agents deliver download links to channels/clients that cannot
# resolve a relative URL (IM messages, copied links, external downloads).
public-base-url: ${MATECLAW_PUBLIC_BASE_URL:}
+ openapi:
+ # SpringDoc OpenAPI 元信息(驱动 /swagger-ui.html 与 /v3/api-docs)。
+ # server-url 留空时由 SpringDoc 从请求 host 推导,避免 Try it out 打到错误地址。
+ # 生产若需固定,通过 MATECLAW_OPENAPI_SERVER_URL 覆盖(如 https://mate.example.com)。
+ title: ${MATECLAW_OPENAPI_TITLE:MateClaw REST API}
+ version: ${MATECLAW_OPENAPI_VERSION:1.0}
+ server-url: ${MATECLAW_OPENAPI_SERVER_URL:}
+ # description 留空则使用 OpenApiConfig 中的内置默认描述
+ description: ${MATECLAW_OPENAPI_DESCRIPTION:}
jwt:
secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}
expiration: 86400000
diff --git a/mateclaw-server/src/main/resources/docs/en/api.md b/mateclaw-server/src/main/resources/docs/en/api.md
index 7879cf700..70746b09b 100644
--- a/mateclaw-server/src/main/resources/docs/en/api.md
+++ b/mateclaw-server/src/main/resources/docs/en/api.md
@@ -2,6 +2,8 @@
This page is source-aligned with the Spring MVC controllers under `mateclaw-server/src/main/java`. The route inventory below was rebuilt from controller annotations; when it conflicts with an older feature page, this page and the source code are the contract.
+> Want a machine-readable OpenAPI doc (import into Postman / Apifox, online debugging)? See the [OpenAPI / Swagger guide](./openapi.md) — visit `/swagger-ui.html` on your deployment.
+
## Contract
All application REST endpoints use the `/api/v1` prefix unless explicitly noted. Most JSON responses use the project envelope:
@@ -34,6 +36,119 @@ Public routes from `SecurityConfig` include login, first-run setup, webhook/webc
Workspace-scoped APIs usually accept `X-Workspace-Id`. If omitted, many handlers fall back to workspace `1` for desktop/local compatibility.
+## Conventions
+
+Structural contracts shared by every endpoint. Read this section first, then the flagship endpoint examples and the full route inventory below will line up.
+
+### Response envelope `R`
+
+Source: `vip.mate.common.result.R` (`R.java`). Three fields:
+
+| Field | Type | Meaning |
+|---|---|---|
+| `code` | `int` | Status code. `200` = success; see "Status codes" below |
+| `msg` | `string` | Message. **Note: `msg`, not `message`** |
+| `data` | `T` | Payload; `null` on failure |
+
+Success example:
+
+```json
+{ "code": 200, "msg": "success", "data": { "id": "1", "name": "Agent A" } }
+```
+
+Failure example:
+
+```json
+{ "code": 401, "msg": "Token expired or invalid", "data": null }
+```
+
+**HTTP status mirrors `code`**: `RHttpStatusAdvice` (`ResponseBodyAdvice`) sets the HTTP status to `HttpStatus.resolve(code)` whenever `code != 200`. So business code `401` → HTTP 401, `404` → HTTP 404. Business codes that are not valid HTTP statuses (e.g. `1001`, `2001`) fall back to HTTP `500`.
+
+### Status codes
+
+Source: `vip.mate.common.result.ResultCode`. Two categories:
+
+| Code | Meaning | Maps to HTTP status directly? |
+|---|---|---|
+| `200` | Success | Yes |
+| `400` | Param error | Yes |
+| `401` | Unauthorized | Yes |
+| `403` | Forbidden | Yes |
+| `404` | Not found | Yes |
+| `500` | System error | Yes |
+| `1001` | Agent not found | No (HTTP 500) |
+| `1002` | Agent busy | No (HTTP 500) |
+| `2001` | LLM error | No (HTTP 500) |
+| `3001` | Tool not found | No (HTTP 500) |
+| `4001` | Channel error | No (HTTP 500) |
+
+### Error model
+
+Errors are handled centrally by `GlobalExceptionHandler` (`@RestControllerAdvice`):
+
+| HTTP | Trigger | Body |
+|---|---|---|
+| 400 | `@Valid` / `BindException` validation failure | `{code:400, msg:"field: defaultMessage"}` — only the **first** field error is returned |
+| 400 | `MethodArgumentTypeMismatchException` (e.g. non-numeric `/{id}`) | `{code:400, msg:"Invalid value for parameter 'X': expected Long"}` |
+| 401 | Unauthenticated / invalid token (`SecurityConfig` `authenticationEntryPoint`) | `{code:401, msg:"Token expired or invalid"}` |
+| 403 | Insufficient workspace role (`WorkspaceAccessInterceptor` writes the response directly) | `{code:403, msg:"...", data:null}` |
+| 404 | No route match (`NoResourceFoundException`) | `{code:404, msg:"Resource not found"}` |
+| 405 | Method not supported (`HttpRequestMethodNotSupportedException`) | `{code:405, msg:"Method not allowed"}` |
+| 409 | Confirmation required (`ConfirmRequiredException`) | **Breaks the envelope**: `{code, message, boundAgents}` (field is `message`, **not** `msg`) — the only non-`R` response in the API |
+| 500 | Catch-all (`Exception`) | `{code:500, msg:"Internal server error"}` — stack trace is not leaked |
+| 503 | Async timeout (non-SSE) | `{code:503, msg:"Request timeout, please try again"}` |
+
+> SSE endpoints (`/chat/stream`, etc.) do **not** emit a JSON envelope on error; they send an SSE `error` event instead: `event: error` / `data: {"message":"..."}`. See the [WebChat guide](./webchat.md#sse-event-protocol).
+
+### Pagination
+
+Paginated endpoints return `R>` directly — the MyBatis Plus `Page` serialization:
+
+```json
+{
+ "code": 200,
+ "data": {
+ "records": [ /* current page rows */ ],
+ "total": 128,
+ "size": 20,
+ "current": 1,
+ "pages": 7
+ }
+}
+```
+
+| Field | Meaning |
+|---|---|
+| `records` | Current page rows (**field name is `records`**, not `list`/`items`) |
+| `total` | Total record count |
+| `size` | Page size |
+| `current` | Current page number, 1-based |
+| `pages` | Total page count |
+
+Common query params: `page` (default 1), `size` (default 20). Examples: `GET /api/v1/audit/events`, `GET /api/v1/conversations/page`.
+
+### ID & type conventions
+
+- **Snowflake `Long` serialized as JSON string**: all backend PKs are `Long`, but Jackson serializes them as strings. Clients (especially JS) should **treat IDs as strings end-to-end** to avoid `Number.MAX_SAFE_INTEGER` precision loss.
+- **Password is write-only**: `UserEntity.password` is annotated `@JsonProperty(access = WRITE_ONLY)` — accepted on login/create, never present in any response.
+
+### Auth model
+
+`JwtAuthFilter` supports three token forms, all via the `Authorization` header:
+
+1. **JWT**: `Authorization: Bearer `. Starts with `eyJ` (base64 header). The `token` field returned by login is exactly this.
+2. **Personal Access Token (PAT)**: `Authorization: Bearer `. Prefixed with `mc_`, for headless / CI / SDK use. The plaintext is returned **only once** at `POST /api/v1/auth/tokens` creation; only the hash is stored afterwards. The filter dispatches by the `mc_` prefix to the PAT verification path.
+3. **SSE `?token=` query param**: the native browser `EventSource` cannot set custom headers, so SSE streaming endpoints additionally accept `?token=` (JWT or PAT).
+
+**Sliding renewal**: when a JWT is near expiry (default < 2h remaining), the response header returns a fresh token — `X-New-Token: ` (with `Access-Control-Expose-Headers: X-New-Token`). Clients should watch for and replace the locally stored token. JWT TTL defaults to 24h (`mateclaw.jwt.expiration=86400000`).
+
+### How `X-Workspace-Id` works
+
+- **No ThreadLocal / request-context holder.** The workspace id is consumed two ways:
+ 1. **RBAC enforcement**: `WorkspaceAccessInterceptor` reads `X-Workspace-Id` for methods annotated `@RequireWorkspaceRole` (roles owner > admin > member > viewer) or `@RequireGlobalAdmin`, falling back to workspace `1` when absent/unparseable. Insufficient permission writes a 403 JSON response directly.
+ 2. **Business reads**: many controllers take it via `@RequestHeader(value="X-Workspace-Id", required=false) Long workspaceId` for query scoping, also defaulting to `1`.
+- So workspace isolation is enforced by "interceptor auth + controller self-read" together; clients should pass `X-Workspace-Id` explicitly for workspace-scoped calls.
+
## Frequently Used APIs
### Login
@@ -72,6 +187,217 @@ Image, video, music, and 3D generation are agent tools (`image_generate`, `video
`/api/v1/talk/ws` is registered by `WebSocketConfig` for Talk Mode. It is intentionally listed in `SecurityConfig` as a public WebSocket route, but it is not counted in the controller route inventory below.
+## Flagship Endpoint Reference
+
+Full request/response reference for the most-used endpoints. Every field maps 1:1 to the source DTO. The 406-row route inventory further down is the complete index; this section is the human-readable walkthrough for high-traffic endpoints.
+
+### Login: `POST /api/v1/auth/login`
+
+Public endpoint (no auth required). Exchanges credentials for a JWT.
+
+**Request body** `LoginRequest` (`AuthController.java`):
+
+| Field | Type | Description |
+|---|---|---|
+| `username` | string | Username |
+| `password` | string | Password |
+
+**Response** `R`:
+
+```json
+{
+ "code": 200,
+ "data": {
+ "id": "1",
+ "token": "eyJhbGciOi...",
+ "username": "admin",
+ "nickname": "Admin",
+ "role": "admin"
+ }
+}
+```
+
+| Field | Type | Description |
+|---|---|---|
+| `id` | string | User ID (Snowflake, as a string) |
+| `token` | string | **JWT** — send as `Authorization: Bearer ` on subsequent requests. There is no separate expiry field; expiry lives in the JWT's `exp` claim |
+| `username` | string | Username |
+| `nickname` | string | Display name |
+| `role` | string | `admin` or `user` |
+
+**Errors**: wrong username/password → HTTP 401, `{code:401, msg:"..."}`.
+
+```bash
+curl -X POST http://localhost:18088/api/v1/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin123"}'
+```
+
+### Streaming chat: `POST /api/v1/chat/stream`
+
+> Public endpoint (`SecurityConfig` permits `/api/v1/chat/stream`), but in practice you still need a token to resolve the user and permissions — pass `?token=` or the `Authorization` header.
+
+Returns `text/event-stream`; **not** the JSON envelope. The SSE event protocol (`meta` / `content_delta` / `done` / `error`, etc.) is documented in the [WebChat guide](./webchat.md#sse-event-protocol).
+
+**Request body** `ChatController.ChatStreamRequest` (`ChatController.java:1211`):
+
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `agentId` | string | — | Required, target Agent ID |
+| `message` | string | — | This turn's user message (mutually exclusive with `contentParts`) |
+| `contentParts` | array | — | Multimodal message parts (text + image); mutually exclusive with `message` |
+| `conversationId` | string | `"default"` | Conversation ID; for a new conversation use a client-generated unique string |
+| `reconnect` | boolean | — | `true` = reconnect to an in-flight stream, send no new message |
+| `lastEventId` | string | — | Only meaningful with `reconnect=true`: skip events with id ≤ this value to avoid duplicate replay |
+| `thinkingLevel` | string | null | Reasoning depth: `off` / `low` / `medium` / `high` / `max`; null follows the Agent default |
+| `modelProvider` | string | null | Per-conversation provider override (paired with `modelName`) |
+| `modelName` | string | null | Per-conversation model-name override |
+| `endUserId` | string | null | Third-party end-user ID, isolates memory when one MateClaw account fronts many end-users |
+
+The native browser `EventSource` cannot send a POST body — use `fetch()` with a streaming reader.
+
+```bash
+curl -N -X POST "http://localhost:18088/api/v1/chat/stream?token=$TOKEN" \
+ -H "Content-Type: application/json" \
+ -H "Accept: text/event-stream" \
+ -d '{"agentId":"1","message":"Hello","conversationId":"conv-abc123"}'
+```
+
+Related endpoints: `POST /api/v1/chat/{conversationId}/stop` (stop generation), `POST /api/v1/chat/{conversationId}/interrupt` (queue a follow-up without interrupting the current stream).
+
+### Agent management
+
+Mounted at `/api/v1/agents`, `@Tag("Agent管理")`. Every method requires `@RequireWorkspaceRole` (at least `viewer`; writes need `member`).
+
+**List** `GET /api/v1/agents?enabled=true` — request header `X-Workspace-Id`; returns `R>`.
+
+**Create** `POST /api/v1/agents` — request body is an `AgentEntity` (key fields below); the backend force-injects `workspaceId` and `creatorUserId`. Returns the full created entity.
+
+Key `AgentEntity` fields (`AgentEntity.java`):
+
+| Field | Type | Description |
+|---|---|---|
+| `id` | string | Agent ID (ignored on create, assigned by the backend) |
+| `name` | string | Name |
+| `description` | string | Description |
+| `agentType` | string | `react` or `plan_execute` |
+| `systemPrompt` | string | System prompt |
+| `modelName` | string | Per-Agent model override (model name); empty = global default |
+| `maxIterations` | int | Max iterations |
+| `enabled` | boolean | Enabled flag |
+| `icon` | string | Icon (emoji or URL) |
+| `tags` | string | Tags (comma-separated) |
+| `defaultThinkingLevel` | string | Default reasoning depth |
+| `primaryKbId` | string | Primary knowledge base ID |
+| `skillsDisabled` | boolean | Explicitly disable all skills |
+| `toolsDisabled` | boolean | Explicitly disable all non-system tools |
+
+**Delete** `DELETE /api/v1/agents/{id}` — three-way auth: system admin / workspace admin+ / the creator. Otherwise 403.
+
+```bash
+# List
+curl http://localhost:18088/api/v1/agents?enabled=true \
+ -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1"
+
+# Create
+curl -X POST http://localhost:18088/api/v1/agents \
+ -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Support Agent","agentType":"react","systemPrompt":"You are a support agent","enabled":true}'
+```
+
+> The same tree also has `GET /api/v1/agents/{id}/chat/stream` (a GET form of SSE, coexisting with the POST `/chat/stream` above), `POST /api/v1/agents/{id}/chat` (synchronous chat), and `POST /api/v1/agents/{id}/execute` (Plan-Execute).
+
+### Conversation management
+
+Mounted at `/api/v1/conversations`, `@Tag("会话管理")`. Isolated per logged-in user (JWT principal).
+
+**List** `GET /api/v1/conversations` — returns `R>`. `ConversationVO` adds display fields on top of the conversation entity:
+
+| Field | Description |
+|---|---|
+| `conversationId` | Conversation ID (a string, not a Snowflake) |
+| `title` | Title |
+| `agentId` / `agentName` / `agentIcon` | Associated Agent |
+| `username` | Owning user |
+| `messageCount` | Message count |
+| `lastMessage` / `lastActiveTime` | Last message and time |
+| `pinned` / `archived` | Pinned / archived (0/1) |
+| `modelProvider` / `modelName` | Per-conversation model override |
+| `status` | `active` (active within 24h) / `closed` |
+| `streamStatus` | `idle` / `running` |
+| `source` | Source channel: `web` / `feishu` / `dingtalk` / `telegram` / `discord` / `wecom` / `qq` / `weixin` / `cron` |
+
+**Paginated** `GET /api/v1/conversations/page?page=1&size=20&keyword=xxx` — returns `R>` (pagination shape in "Conventions").
+
+**Message history** `GET /api/v1/conversations/{conversationId}/messages` — supports three modes:
+- No `limit`: returns all messages (`R>`, backward compatible).
+- With `limit`: returns the latest `limit` messages + a `hasMore` flag: `R<{messages: MessageVO[], hasMore: boolean}>`.
+- With `beforeId` + `limit`: pull up to load earlier messages.
+
+Key `MessageVO` fields: `id`, `role`, `content`, `toolName`, `status`, `metadata` (object, contains toolCalls etc.), `promptTokens` / `completionTokens`, `runtimeModel` / `runtimeProvider`, `contentParts`, `createTime`.
+
+**Per-conversation ops**: `PUT .../title` (rename), `PUT .../pin` (`{pinned:bool}`), `PUT .../model` (switch model `{modelProvider, modelName}`), `DELETE .../messages` (clear messages, keep the conversation), `DELETE .../{conversationId}` (delete conversation), `POST /batch-delete` (`{conversationIds: [...]}`), `GET .../status` (stream status `{streamStatus}`).
+
+> Every op first checks `isConversationOwner(conversationId, username)`; non-owners get 403.
+
+### Model configuration
+
+Mounted at `/api/v1/models`, `@Tag("模型配置管理")`. `GET /` and `GET /catalog` require `@RequireGlobalAdmin` (they include sensitive info like API keys); `/enabled`, `/default`, `/active` only need `viewer`.
+
+- `GET /api/v1/models` — enabled provider list (`R>`, includes keys, admin only).
+- `GET /api/v1/models/enabled` — enabled model list (`R>`, no keys).
+- `GET /api/v1/models/default` — global default model (`R`).
+- `GET /api/v1/models/active` — current active model `{activeLlm: {provider, modelName}}`.
+- `PUT /api/v1/models/active` — set the active model.
+
+### Audit events (pagination example)
+
+`GET /api/v1/audit/events` — `@RequireWorkspaceRole("admin")`, returns `R>`. The canonical example of "pagination + workspace header".
+
+| Query param | Default | Description |
+|---|---|---|
+| `action` | — | Action filter (e.g. `CREATE` / `UPDATE` / `DELETE`) |
+| `resourceType` | — | Resource type filter (e.g. `AGENT`) |
+| `startTime` | — | ISO 8601 start time |
+| `endTime` | — | ISO 8601 end time |
+| `page` | 1 | Page number |
+| `size` | 20 | Page size |
+
+```bash
+curl "http://localhost:18088/api/v1/audit/events?page=1&size=20&resourceType=AGENT" \
+ -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1"
+```
+
+### Change password: `PUT /api/v1/auth/users/{id}/password`
+
+Three things to note (they differ from intuition):
+
+1. Params go via **`@RequestParam`, not a request body**: both `oldPassword` and `newPassword` are query params.
+2. The `{id}` in the path is **informational only**: the user actually operated on is resolved from the JWT principal (`auth.getName()`); a user can only change their own password.
+3. Requires login (not `@RequireGlobalAdmin`).
+
+```bash
+curl -X PUT "http://localhost:18088/api/v1/auth/users/1/password?oldPassword=admin123&newPassword=newPass456" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+### Personal Access Token
+
+Mounted at `/api/v1/auth/tokens`, `@Tag("Personal Access Tokens")`. For headless / CI / SDK use.
+
+- `GET /api/v1/auth/tokens` — list my PATs (metadata only; **plaintext is never returned**).
+- `POST /api/v1/auth/tokens` — create a PAT: **the plaintext appears only in this response**, afterwards only the SHA-256 hash is stored and cannot be recovered. Save it immediately.
+- `DELETE /api/v1/auth/tokens/{id}` — soft-delete revoke; subsequent auth with this token fails.
+
+A created PAT (`mc_` prefix) goes straight into `Authorization: Bearer mc_...`; `JwtAuthFilter` dispatches by prefix to the PAT verification path, behaving identically to a JWT.
+
+### Tool approval (important clarification)
+
+There is **no** standalone approval REST endpoint like `POST /api/v1/approvals/{id}/resolve`. Web-side approve / deny happens by sending `/approve` or `/deny` in the waiting conversation, going through the chat-stream replay flow. The read-only "hydration" endpoint after a page refresh is `GET /api/v1/chat/{conversationId}/pending-approvals`. Auto-approval policies are managed under `/api/v1/approval/grants`.
+
+> Dangerous operations requiring a second confirmation raise `ConfirmRequiredException` — returning **HTTP 409** and **breaking the `R` envelope**: `{code, message, boundAgents}` (the field is `message`, not `msg`). Clients should branch on the 409 status and render a confirmation dialog.
+
## Source-Aligned Route Inventory
Total routes extracted: 406.
diff --git a/mateclaw-server/src/main/resources/docs/en/openapi.md b/mateclaw-server/src/main/resources/docs/en/openapi.md
new file mode 100644
index 000000000..21422f999
--- /dev/null
+++ b/mateclaw-server/src/main/resources/docs/en/openapi.md
@@ -0,0 +1,72 @@
+# OpenAPI / Swagger Guide
+
+The MateClaw backend integrates [SpringDoc OpenAPI](https://springdoc.org/) (`springdoc-openapi-starter-webmvc-ui`), which auto-generates an OpenAPI 3 document from every `@RestController` and serves an interactive debugging UI. This page covers how to access and use it.
+
+> This is the **machine-readable** doc entry point. For the human-readable endpoint details, conventions, and the full route inventory, see the [API Reference](./api.md). Relationship: Swagger = the auto-generated, machine-readable contract from source annotations; `api.md` = flagship endpoint walkthroughs + shared conventions.
+
+## URLs
+
+After deploying the backend, relative to the server address (default local port `18088`):
+
+| URL | Purpose |
+|---|---|
+| `/swagger-ui.html` | Swagger UI — browse + debug (Authorize, Try it out) |
+| `/v3/api-docs` | OpenAPI 3 JSON (import into Postman / Apifox / Insomnia) |
+| `/v3/api-docs.yaml` | OpenAPI 3 YAML (download, commit, or import) |
+
+Local examples:
+
+```bash
+# Open in a browser
+open http://localhost:18088/swagger-ui.html
+
+# Download the YAML
+curl http://localhost:18088/v3/api-docs.yaml -o mateclaw-openapi.yaml
+```
+
+## Authentication (Authorize)
+
+Use the **Authorize** button at the top right. In the `bearerAuth` field, paste a token **without** the `Bearer ` prefix (the UI adds it automatically):
+
+- **JWT**: the `token` field returned by `POST /api/v1/auth/login` (starts with `eyJ...`).
+- **Personal Access Token**: a `mc_...` token created via `POST /api/v1/auth/tokens`.
+
+Both go through the standard `Authorization: Bearer ` header; the backend `JwtAuthFilter` dispatches by prefix (JWT → JWT verification, `mc_` → PAT verification). Once authorized, protected `@RequireWorkspaceRole` / `@RequireGlobalAdmin` endpoints can be called directly via Try it out.
+
+> Debugging SSE streaming endpoints (`/chat/stream`, etc.) in Swagger UI is limited — the UI buffers `text/event-stream` responses. For real SSE integration, follow the [API Reference](./api.md) and use `curl -N` or a `fetch()` streaming reader.
+
+## Endpoint coverage
+
+SpringDoc auto-scans every `@RestController`. About 85% of controllers already carry `@Tag` (grouping) and `@Operation(summary)` (method summary), so the UI's grouping and endpoint descriptions are largely complete.
+
+**Annotation enhancements not yet done** (out of scope for this pass, left for later):
+
+- No `@Parameter` descriptions, `@ApiResponse` error codes, or request-body `@Schema` — for field-level docs, defer to the human-readable `api.md` walkthroughs.
+- Public endpoints (login, SSE, etc.) are not individually opted out with `@SecurityRequirements({})`, so they show a lock icon in Swagger even though `SecurityConfig` already permits them — actual calls are unaffected.
+
+## Configuration
+
+Global OpenAPI metadata (title, description, version, server URL) is driven by the `OpenApiConfig` bean and overridable via `mateclaw.openapi.*` in `application.yml`:
+
+```yaml
+mateclaw:
+ openapi:
+ title: ${MATECLAW_OPENAPI_TITLE:MateClaw REST API}
+ version: ${MATECLAW_OPENAPI_VERSION:1.0}
+ server-url: ${MATECLAW_OPENAPI_SERVER_URL:} # empty → derived from request host
+ description: ${MATECLAW_OPENAPI_DESCRIPTION:} # empty → built-in default
+```
+
+When `server-url` is empty, SpringDoc derives it from the request host so "Try it out" hits the right address; for fixed production URLs (e.g. behind a reverse proxy), set `MATECLAW_OPENAPI_SERVER_URL=https://mate.example.com`.
+
+## ⚠️ Security note: Swagger is currently public
+
+In `SecurityConfig.filterChain`, `/api/**` requires authentication, but `/swagger-ui*`, `/v3/api-docs*`, and `/webjars/**` fall through to `.anyRequest().permitAll()` — meaning **Swagger UI is publicly accessible** without login; anyone can browse the full endpoint surface (including request/response schemas).
+
+- Local dev / intranet deployments: usually acceptable.
+- Public production deployments: consider adding an explicit auth rule for the Swagger paths in `SecurityConfig` (e.g. require `@RequireGlobalAdmin`). Don't rely on the network layer alone. When locking it down, edit `SecurityConfig`, not `OpenApiConfig`.
+
+## See also
+
+- [API Reference (human-readable)](./api.md) — flagship endpoint walkthroughs + conventions + full route inventory
+- [WebChat integration guide](./webchat.md) — external-site HTTP / SSE integration (incl. the SSE event protocol)
diff --git a/mateclaw-server/src/main/resources/docs/zh/api.md b/mateclaw-server/src/main/resources/docs/zh/api.md
index e295942ec..1c81926c7 100644
--- a/mateclaw-server/src/main/resources/docs/zh/api.md
+++ b/mateclaw-server/src/main/resources/docs/zh/api.md
@@ -2,6 +2,8 @@
本页以 `mateclaw-server/src/main/java` 下的 Spring MVC Controller 注解为准。下面的路由索引由源码注解重建;如果它和旧功能页冲突,以本页和源码为接口契约。
+> 想要机读的 OpenAPI 文档(导入 Postman / Apifox、在线调试)?见 [OpenAPI / Swagger 指南](./openapi.md) —— 部署后访问 `/swagger-ui.html`。
+
## 全局契约
应用 REST 端点默认使用 `/api/v1` 前缀。大多数 JSON 响应使用项目统一信封:
@@ -34,6 +36,119 @@ Authorization: Bearer
工作空间接口通常接受 `X-Workspace-Id`。省略时,很多 handler 会为了桌面/本地兼容回退到 workspace `1`。
+## 通用约定
+
+下面是所有端点共用的结构约定。读完这一节,再看后面的旗舰端点示例和完整路由表就能对上。
+
+### 统一响应信封 `R`
+
+源码:`vip.mate.common.result.R`(`R.java`)。三个字段:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `code` | `int` | 状态码。`200` 为成功;其余见下方「状态码」 |
+| `msg` | `string` | 提示信息。**注意是 `msg` 不是 `message`** |
+| `data` | `T` | 业务数据;失败时为 `null` |
+
+成功示例:
+
+```json
+{ "code": 200, "msg": "success", "data": { "id": "1", "name": "助手A" } }
+```
+
+失败示例:
+
+```json
+{ "code": 401, "msg": "Token expired or invalid", "data": null }
+```
+
+**HTTP 状态码与 `code` 对齐**:`RHttpStatusAdvice`(`ResponseBodyAdvice`)在 `code != 200` 时把 HTTP 状态设成 `HttpStatus.resolve(code)`。即业务码 `401` → HTTP 401,业务码 `404` → HTTP 404。业务码若不是合法 HTTP 状态(如 `1001`、`2001`),则 HTTP 回落到 `500`。
+
+### 状态码
+
+源码:`vip.mate.common.result.ResultCode`。注意区分两类:
+
+| 码 | 含义 | 是否直接作 HTTP 状态 |
+|---|---|---|
+| `200` | 成功 | 是 |
+| `400` | 参数错误 | 是 |
+| `401` | 未认证 | 是 |
+| `403` | 无权限 | 是 |
+| `404` | 资源不存在 | 是 |
+| `500` | 系统错误 | 是 |
+| `1001` | Agent 不存在 | 否(HTTP 500) |
+| `1002` | Agent 忙碌 | 否(HTTP 500) |
+| `2001` | LLM 调用错误 | 否(HTTP 500) |
+| `3001` | 工具不存在 | 否(HTTP 500) |
+| `4001` | 渠道错误 | 否(HTTP 500) |
+
+### 错误模型
+
+错误统一由 `GlobalExceptionHandler`(`@RestControllerAdvice`)处理。下表覆盖常见情况:
+
+| HTTP | 触发 | 响应体 |
+|---|---|---|
+| 400 | `@Valid` / `BindException` 校验失败 | `{code:400, msg:"字段名: 默认信息"}` — 只返回**首个**字段错误 |
+| 400 | `MethodArgumentTypeMismatchException`(如 `/{id}` 传了非数字) | `{code:400, msg:"Invalid value for parameter 'X': expected Long"}` |
+| 401 | 未认证 / token 无效(`SecurityConfig` 的 `authenticationEntryPoint`) | `{code:401, msg:"Token expired or invalid"}` |
+| 403 | 工作区角色不足(`WorkspaceAccessInterceptor` 直接写响应) | `{code:403, msg:"...", data:null}` |
+| 404 | 路由不匹配(`NoResourceFoundException`) | `{code:404, msg:"Resource not found"}` |
+| 405 | 方法不允许(`HttpRequestMethodNotSupportedException`) | `{code:405, msg:"Method not allowed"}` |
+| 409 | 需二次确认(`ConfirmRequiredException`) | **打破信封**:`{code, message, boundAgents}`(字段是 `message`,**不是** `msg`)—— 全 API 唯一非 `R` 响应 |
+| 500 | 兜底(`Exception`) | `{code:500, msg:"Internal server error"}` — 栈不外泄 |
+| 503 | 异步超时(非 SSE 请求) | `{code:503, msg:"Request timeout, please try again"}` |
+
+> SSE 端点(`/chat/stream` 等)出错时**不发 JSON 信封**,而是发送 SSE `error` 事件:`event: error` / `data: {"message":"..."}`。详见 [WebChat 指南](./webchat.md#sse-事件协议)。
+
+### 分页结构
+
+分页端点直接返回 `R>`,`data` 是 MyBatis Plus 的 `Page` 序列化结构:
+
+```json
+{
+ "code": 200,
+ "data": {
+ "records": [ /* 当前页数据 */ ],
+ "total": 128,
+ "size": 20,
+ "current": 1,
+ "pages": 7
+ }
+}
+```
+
+| 字段 | 含义 |
+|---|---|
+| `records` | 当前页数据数组(**字段名是 `records`**,不是 `list`/`items`) |
+| `total` | 总记录数 |
+| `size` | 每页条数 |
+| `current` | 当前页码,从 `1` 开始 |
+| `pages` | 总页数 |
+
+常见分页查询参数:`page`(默认 1)、`size`(默认 20)。示例端点见 `GET /api/v1/audit/events`、`GET /api/v1/conversations/page`。
+
+### ID 与类型约定
+
+- **Snowflake `Long` 序列化为 JSON 字符串**:后端所有主键是 `Long`,但 Jackson 序列化成字符串。客户端(尤其 JS)应**全程按 `string` 处理**,避免 `Number.MAX_SAFE_INTEGER` 精度丢失。
+- **密码字段只写不读**:`UserEntity.password` 标注 `@JsonProperty(access = WRITE_ONLY)`,登录/建用户时接受写入,但任何响应里都不会出现。
+
+### 认证模型
+
+`JwtAuthFilter` 支持三种 token 形态,全部走 `Authorization` 头:
+
+1. **JWT**:`Authorization: Bearer `。以 `eyJ` 开头(base64 头)。登录接口返回的 `token` 字段即此。
+2. **Personal Access Token (PAT)**:`Authorization: Bearer `。以 `mc_` 前缀开头,用于 headless / CI / SDK 场景。PAT 在 `POST /api/v1/auth/tokens` 创建时**明文只返回一次**,之后只存哈希。filter 按 `mc_` 前缀分发到 PAT 校验路径。
+3. **SSE 的 `?token=` 查询参数**:浏览器原生 `EventSource` 不能设置自定义请求头,SSE 流式端点额外接受 `?token=`(JWT 或 PAT 均可)。
+
+**滑动续期**:JWT 接近过期(默认剩余 < 2 小时)时,响应头回传新 token —— `X-New-Token: `(同时设 `Access-Control-Expose-Headers: X-New-Token`)。客户端应监听并替换本地存储的 token。JWT TTL 默认 24 小时(`mateclaw.jwt.expiration=86400000`)。
+
+### `X-Workspace-Id` 的工作机制
+
+- **无 ThreadLocal / 请求上下文持有**。工作区 ID 通过两种途径消费:
+ 1. **RBAC 拦截**:`WorkspaceAccessInterceptor` 对标注了 `@RequireWorkspaceRole`(角色 owner > admin > member > viewer)或 `@RequireGlobalAdmin` 的方法,读取 `X-Workspace-Id` 做权限校验。缺失或无法解析时回落 workspace `1`。权限不足直接写 403 JSON 响应。
+ 2. **业务读取**:许多 Controller 用 `@RequestHeader(value="X-Workspace-Id", required=false) Long workspaceId` 直接取值用于数据查询范围;缺失时同样回落 `1`。
+- 因此工作区隔离由「拦截器鉴权 + Controller 自取」两段配合实现,客户端调用工作区相关接口时应显式传 `X-Workspace-Id`。
+
## 常用接口
### 登录
@@ -72,6 +187,217 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \
`/api/v1/talk/ws` 由 `WebSocketConfig` 注册,用于 Talk Mode。它会出现在 `SecurityConfig` 的公共 WebSocket 路由里,但不计入下面的 controller 路由索引。
+## 旗舰端点参考
+
+下面是接入最频繁的端点的完整请求/响应说明。字段均与源码 DTO 一一对齐。下面的 406 行路由表是完整索引,本节是高频端点的「人读」详解。
+
+### 登录:`POST /api/v1/auth/login`
+
+公开端点(无需认证)。换取 JWT。
+
+**请求体** `LoginRequest`(`AuthController.java`):
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `username` | string | 用户名 |
+| `password` | string | 密码 |
+
+**响应** `R`:
+
+```json
+{
+ "code": 200,
+ "data": {
+ "id": "1",
+ "token": "eyJhbGciOi...",
+ "username": "admin",
+ "nickname": "管理员",
+ "role": "admin"
+ }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | string | 用户 ID(Snowflake,字符串) |
+| `token` | string | **JWT**,后续请求放 `Authorization: Bearer `。响应里没有独立 expiry 字段,过期时间在 JWT 的 `exp` claim 内 |
+| `username` | string | 用户名 |
+| `nickname` | string | 昵称 |
+| `role` | string | `admin` 或 `user` |
+
+**错误**:用户名/密码错误 → HTTP 401,`{code:401, msg:"用户名或密码错误"}`。
+
+```bash
+curl -X POST http://localhost:18088/api/v1/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin123"}'
+```
+
+### 流式对话:`POST /api/v1/chat/stream`
+
+> 公开端点(`SecurityConfig` 放行 `/api/v1/chat/stream`),但实际使用时仍需 token 才能定位用户与权限——传 `?token=` 或 `Authorization` 头。
+
+返回 `text/event-stream`,**不走 JSON 信封**。SSE 事件协议(`meta` / `content_delta` / `done` / `error` 等)见 [WebChat 指南](./webchat.md#sse-事件协议)。
+
+**请求体** `ChatController.ChatStreamRequest`(`ChatController.java:1211`):
+
+| 字段 | 类型 | 默认 | 说明 |
+|---|---|---|---|
+| `agentId` | string | — | 必填,目标 Agent ID |
+| `message` | string | — | 本轮用户消息(与 `contentParts` 二选一) |
+| `contentParts` | array | — | 多模态消息分片(图文混合),与 `message` 二选一 |
+| `conversationId` | string | `"default"` | 会话 ID;新会话用客户端生成的唯一串 |
+| `reconnect` | boolean | — | `true` = 断线重连,不发新消息,只附着到已有流 |
+| `lastEventId` | string | — | 仅 `reconnect=true` 有意义:跳过 id ≤ 此值的事件,避免重放重复 |
+| `thinkingLevel` | string | null | 思考深度:`off` / `low` / `medium` / `high` / `max`;null 跟随 Agent 默认 |
+| `modelProvider` | string | null | 本会话模型 provider 覆盖(与 `modelName` 配对) |
+| `modelName` | string | null | 本会话模型名覆盖 |
+| `endUserId` | string | null | 第三方终端用户 ID,用于一个 MateClaw 账号下的记忆隔离 |
+
+浏览器原生 `EventSource` 不支持 POST body,必须用 `fetch()` + 流式 reader。
+
+```bash
+curl -N -X POST "http://localhost:18088/api/v1/chat/stream?token=$TOKEN" \
+ -H "Content-Type: application/json" \
+ -H "Accept: text/event-stream" \
+ -d '{"agentId":"1","message":"你好","conversationId":"conv-abc123"}'
+```
+
+相关端点:`POST /api/v1/chat/{conversationId}/stop`(停止生成)、`POST /api/v1/chat/{conversationId}/interrupt`(排队后续消息,不打断当前流)。
+
+### Agent 管理
+
+挂在 `/api/v1/agents`,`@Tag("Agent管理")`。所有方法需 `@RequireWorkspaceRole`(至少 `viewer`,写入需 `member`)。
+
+**列表** `GET /api/v1/agents?enabled=true` — 请求头 `X-Workspace-Id`;返回 `R>`。
+
+**创建** `POST /api/v1/agents` — 请求体是 `AgentEntity`(关键字段见下),后端强制注入 `workspaceId` 与 `creatorUserId`。返回创建后的完整实体。
+
+`AgentEntity` 关键字段(`AgentEntity.java`):
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | string | Agent ID(创建时忽略,后端分配) |
+| `name` | string | 名称 |
+| `description` | string | 描述 |
+| `agentType` | string | `react` 或 `plan_execute` |
+| `systemPrompt` | string | 系统提示词 |
+| `modelName` | string | Per-Agent 模型覆盖(模型名);空则用全局默认 |
+| `maxIterations` | int | 最大迭代次数 |
+| `enabled` | boolean | 是否启用 |
+| `icon` | string | 图标(emoji 或 URL) |
+| `tags` | string | 标签(逗号分隔) |
+| `defaultThinkingLevel` | string | 默认思考深度 |
+| `primaryKbId` | string | 主知识库 ID |
+| `skillsDisabled` | boolean | 显式禁用所有 Skill |
+| `toolsDisabled` | boolean | 显式禁用所有非系统工具 |
+
+**删除** `DELETE /api/v1/agents/{id}` — 三选一鉴权:系统 admin / 工作区 admin+ / 创建者本人。否则 403。
+
+```bash
+# 列表
+curl http://localhost:18088/api/v1/agents?enabled=true \
+ -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1"
+
+# 创建
+curl -X POST http://localhost:18088/api/v1/agents \
+ -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" \
+ -H "Content-Type: application/json" \
+ -d '{"name":"客服助手","agentType":"react","systemPrompt":"你是一名客服","enabled":true}'
+```
+
+> 同目录下还有 `GET /api/v1/agents/{id}/chat/stream`(GET 形式的 SSE,与上面 POST `/chat/stream` 并存)、`POST /api/v1/agents/{id}/chat`(同步对话)、`POST /api/v1/agents/{id}/execute`(Plan-Execute)。
+
+### 会话管理
+
+挂在 `/api/v1/conversations`,`@Tag("会话管理")`。按当前登录用户(JWT principal)隔离。
+
+**列表** `GET /api/v1/conversations` — 返回 `R>`。`ConversationVO` 在会话实体基础上补充展示字段:
+
+| 字段 | 说明 |
+|---|---|
+| `conversationId` | 会话 ID(字符串,非 Snowflake) |
+| `title` | 标题 |
+| `agentId` / `agentName` / `agentIcon` | 关联 Agent |
+| `username` | 会话归属用户 |
+| `messageCount` | 消息数 |
+| `lastMessage` / `lastActiveTime` | 最近消息与时间 |
+| `pinned` / `archived` | 置顶 / 归档(0/1) |
+| `modelProvider` / `modelName` | 会话级模型覆盖 |
+| `status` | `active`(24h 内活跃)/ `closed` |
+| `streamStatus` | `idle` / `running` |
+| `source` | 来源渠道:`web` / `feishu` / `dingtalk` / `telegram` / `discord` / `wecom` / `qq` / `weixin` / `cron` |
+
+**分页** `GET /api/v1/conversations/page?page=1&size=20&keyword=xxx` — 返回 `R>`(分页结构见「通用约定」)。
+
+**消息历史** `GET /api/v1/conversations/{conversationId}/messages` — 支持三种模式:
+- 不传 `limit`:返回全部消息(`R>`,向后兼容)。
+- 传 `limit`:返回最新 `limit` 条 + `hasMore` 标志:`R<{messages: MessageVO[], hasMore: boolean}>`。
+- 传 `beforeId` + `limit`:上拉加载更早消息。
+
+`MessageVO` 关键字段:`id`、`role`、`content`、`toolName`、`status`、`metadata`(对象,含 toolCalls 等)、`promptTokens` / `completionTokens`、`runtimeModel` / `runtimeProvider`、`contentParts`、`createTime`。
+
+**会话级操作**:`PUT .../title`(重命名)、`PUT .../pin`(置顶 `{pinned:bool}`)、`PUT .../model`(切换模型 `{modelProvider, modelName}`)、`DELETE .../messages`(清空消息保留会话)、`DELETE .../{conversationId}`(删除会话)、`POST /batch-delete`(`{conversationIds: [...]}`)、`GET .../status`(查流状态 `{streamStatus}`)。
+
+> 所有操作都先校验 `isConversationOwner(conversationId, username)`,非归属者返回 403。
+
+### 模型配置
+
+挂在 `/api/v1/models`,`@Tag("模型配置管理")`。`GET /` 与 `GET /catalog` 需 `@RequireGlobalAdmin`(含 API Key 等敏感信息);`/enabled`、`/default`、`/active` 仅需 `viewer`。
+
+- `GET /api/v1/models` — 已启用 Provider 列表(`R>`,含密钥,admin only)。
+- `GET /api/v1/models/enabled` — 已启用模型列表(`R>`,无密钥)。
+- `GET /api/v1/models/default` — 全局默认模型(`R`)。
+- `GET /api/v1/models/active` — 当前激活模型 `{activeLlm: {provider, modelName}}`。
+- `PUT /api/v1/models/active` — 设置激活模型。
+
+### 审计事件(分页示范)
+
+`GET /api/v1/audit/events` — `@RequireWorkspaceRole("admin")`,返回 `R>`,是「分页 + 工作区头」的标准示范。
+
+| 查询参数 | 默认 | 说明 |
+|---|---|---|
+| `action` | — | 动作过滤(如 `CREATE` / `UPDATE` / `DELETE`) |
+| `resourceType` | — | 资源类型过滤(如 `AGENT`) |
+| `startTime` | — | ISO 8601 起始时间 |
+| `endTime` | — | ISO 8601 结束时间 |
+| `page` | 1 | 页码 |
+| `size` | 20 | 每页条数 |
+
+```bash
+curl "http://localhost:18088/api/v1/audit/events?page=1&size=20&resourceType=AGENT" \
+ -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1"
+```
+
+### 修改密码:`PUT /api/v1/auth/users/{id}/password`
+
+注意三点(与直觉不同):
+
+1. 参数走 **`@RequestParam` 而非请求体**:`oldPassword` 和 `newPassword` 都是 query 参数。
+2. 路径里的 `{id}` **仅信息性**:实际操作的用户从 JWT principal 解析(`auth.getName()`),用户只能改自己的密码。
+3. 需登录(非 `@RequireGlobalAdmin`)。
+
+```bash
+curl -X PUT "http://localhost:18088/api/v1/auth/users/1/password?oldPassword=admin123&newPassword=newPass456" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+### Personal Access Token
+
+挂在 `/api/v1/auth/tokens`,`@Tag("Personal Access Tokens")`。用于 headless / CI / SDK。
+
+- `GET /api/v1/auth/tokens` — 列出我的 PAT(仅元数据,**明文永不返回**)。
+- `POST /api/v1/auth/tokens` — 创建 PAT:**明文只在此响应里出现一次**,之后只存 SHA-256 哈希,无法找回。创建后立即保存。
+- `DELETE /api/v1/auth/tokens/{id}` — 软删除吊销,此后用该 token 鉴权会失败。
+
+创建出的 PAT(`mc_` 前缀)可直接放 `Authorization: Bearer mc_...`,`JwtAuthFilter` 按前缀分发到 PAT 校验路径,与 JWT 行为一致。
+
+### 工具审批(重要澄清)
+
+**没有** `POST /api/v1/approvals/{id}/resolve` 这样的独立审批 REST 端点。Web 端的批准 / 拒绝通过在等待中的会话里发送 `/approve` 或 `/deny` 走 chat stream replay 流程。刷新页面后的只读「补水」接口是 `GET /api/v1/chat/{conversationId}/pending-approvals`。自动批准策略在 `/api/v1/approval/grants` 下管理。
+
+> 需二次确认的危险操作会触发 `ConfirmRequiredException` —— 返回 **HTTP 409** 且**打破 `R` 信封**:`{code, message, boundAgents}`(字段是 `message` 不是 `msg`),客户端应按 409 状态码分支渲染确认弹窗。
+
## 源码对齐路由索引
抽取到的路由总数:406。
diff --git a/mateclaw-server/src/main/resources/docs/zh/openapi.md b/mateclaw-server/src/main/resources/docs/zh/openapi.md
new file mode 100644
index 000000000..b076811de
--- /dev/null
+++ b/mateclaw-server/src/main/resources/docs/zh/openapi.md
@@ -0,0 +1,72 @@
+# OpenAPI / Swagger 指南
+
+MateClaw 后端集成了 [SpringDoc OpenAPI](https://springdoc.org/)(`springdoc-openapi-starter-webmvc-ui`),自动把所有 `@RestController` 的端点生成为 OpenAPI 3 文档,并提供在线调试 UI。本页说明如何访问和使用。
+
+> 本文是**机读文档**入口。人读的端点详解、通用约定与完整路由索引见 [API 参考](./api.md)。两者关系:Swagger = 由源码注解自动生成的机读契约;`api.md` = 旗舰端点人读详解 + 通用约定。
+
+## 访问地址
+
+部署后端后,相对服务地址(本地默认端口 `18088`):
+
+| 地址 | 用途 |
+|---|---|
+| `/swagger-ui.html` | Swagger UI 在线浏览 + 调试(Authorize、Try it out) |
+| `/v3/api-docs` | OpenAPI 3 JSON(可导入 Postman / Apifox / Insomnia) |
+| `/v3/api-docs.yaml` | OpenAPI 3 YAML(可下载、纳入版本库或导入工具) |
+
+本地示例:
+
+```bash
+# 浏览器打开
+open http://localhost:18088/swagger-ui.html
+
+# 下载 YAML
+curl http://localhost:18088/v3/api-docs.yaml -o mateclaw-openapi.yaml
+```
+
+## 鉴权(Authorize)
+
+页面右上角 **Authorize** 按钮。在 `bearerAuth` 输入框填入 token(不带 `Bearer ` 前缀,UI 会自动加):
+
+- **JWT**:登录 `POST /api/v1/auth/login` 拿到的 `token` 字段(`eyJ...` 开头)。
+- **Personal Access Token**:在 `POST /api/v1/auth/tokens` 创建的 `mc_...` token。
+
+两种 token 都走标准 `Authorization: Bearer ` 头,后端 `JwtAuthFilter` 按前缀自动分发(JWT → JWT 校验,`mc_` → PAT 校验)。授权后,受保护的 `@RequireWorkspaceRole` / `@RequireGlobalAdmin` 端点即可在 UI 内直接 Try it out。
+
+> SSE 流式端点(`/chat/stream` 等)在 Swagger UI 里调试体验有限 —— UI 对 `text/event-stream` 的渲染是缓冲式的。正式集成 SSE 请按 [API 参考](./api.md#流式对话-post-apiv1chatstream) 用 `curl -N` 或 `fetch()` 流式 reader。
+
+## 端点覆盖范围
+
+SpringDoc 自动扫描所有 `@RestController`,约 85% 的 Controller 已标注 `@Tag`(分组)与 `@Operation(summary)`(方法摘要),所以 Swagger UI 的分组与端点说明基本齐全。
+
+**当前未做的注解增强**(不在本次范围,留作后续):
+
+- 没有 `@Parameter` 描述、`@ApiResponse` 错误码、请求体 `@Schema` —— 这些字段文档以 `api.md` 人读详解为准。
+- 公开端点(登录、SSE 等)没有逐个加 `@SecurityRequirements({})` opt-out,所以 Swagger 上会显示锁图标,但实际调用不受影响(`SecurityConfig` 已放行)。
+
+## 配置项
+
+全局 OpenAPI 元信息(标题、描述、版本、服务器地址)由 `OpenApiConfig` Bean 驱动,可通过 `application.yml` 的 `mateclaw.openapi.*` 覆盖:
+
+```yaml
+mateclaw:
+ openapi:
+ title: ${MATECLAW_OPENAPI_TITLE:MateClaw REST API}
+ version: ${MATECLAW_OPENAPI_VERSION:1.0}
+ server-url: ${MATECLAW_OPENAPI_SERVER_URL:} # 留空则从请求 host 推导
+ description: ${MATECLAW_OPENAPI_DESCRIPTION:} # 留空则用内置默认描述
+```
+
+`server-url` 留空时由 SpringDoc 从请求 host 推导,避免 "Try it out" 打到错误地址;生产若需固定(如反代后),设 `MATECLAW_OPENAPI_SERVER_URL=https://mate.example.com`。
+
+## ⚠️ 安全提示:Swagger 当前公开可访问
+
+`SecurityConfig.filterChain` 中 `/api/**` 要求认证,但 `/swagger-ui*`、`/v3/api-docs*`、`/webjars/**` 落到 `.anyRequest().permitAll()` —— 即 **Swagger UI 当前是公开可访问的**,无需登录即可浏览全部端点结构(含请求/响应 schema)。
+
+- 本地开发 / 内网部署:通常可接受。
+- 公网生产部署:建议在 `SecurityConfig` 显式给 Swagger 路径加鉴权规则(如要求 `@RequireGlobalAdmin`),**不要**仅靠网络层防护。收口时应改 `SecurityConfig`,而非 `OpenApiConfig`。
+
+## 关联
+
+- [API 参考(人读)](./api.md) —— 旗舰端点详解 + 通用约定 + 完整路由索引
+- [WebChat 接入指南](./webchat.md) —— 外部网站 HTTP / SSE 集成(含 SSE 事件协议)