Files
58327d2ebb feat(skills): shared packages, file tree editor, and BOM-safe frontmatter (#2226)
* feat(skills): shared packages, file tree editor, and BOM-safe frontmatter

Add skill-package sharing (private by default), package skill file-tree APIs
aligned with personalization, and strip UTF-8 BOM when parsing SKILL.md.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tests): bump backup schema assertions to v22

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 21:16:08 +08:00

704 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Reference
Every route is mounted under `/api`. JSON unless otherwise noted; SSE
endpoints emit `text/event-stream`; chat turns use a WebSocket
described in [Chat (WebSocket)](#chat-websocket).
> **Interactive docs** (Scalar) live at `/api/docs` when
> `enable_api_docs=true` (or `OCTOP_ENABLE_API_DOCS=1`). The schema
> is at `/api/openapi.json`.
## Authentication
| Header | Value |
|--------|-------|
| `Authorization` | `Bearer <access_token>` from `POST /api/auth/login` |
Tokens expire after `OCTOP_ACCESS_TOKEN_TTL` seconds (default 24 h).
Rotating the JWT secret (`octop admin rotate-jwt-secret`) invalidates
every outstanding token immediately. Login attempts are rate-limited
(`OCTOP_LOGIN_MAX_ATTEMPTS` / `OCTOP_LOGIN_LOCKOUT_SECONDS`); the
admin can clear the lockout with `POST /api/users/{id}/unlock-login`.
### Auth column legend
- **public** — no token required.
- **user** — any logged-in account.
- **owner** — same user that owns the resource (or admin).
- **admin** — admin role required.
### Public endpoints (no token)
`/api/docs`, `/api/openapi.json`, `/api/health`, `/api/setup/*`,
`/api/auth/login`, `/api/auth/captcha`, `/api/auth/oidc/status`, `/api/auth/oidc/start`,
`/api/auth/oidc/callback`, `/api/auth/oidc/exchange`, `/api/auth/oauth/status`,
`/api/auth/oauth/start`, `/api/auth/oauth/callback`, `/api/auth/oauth/exchange`,
`/api/auth/ldap/status`,
`/api/connectors/oauth/callback`, and `/api/internal/mcp/*`. All other routes are JWT-gated by
`api/middleware/jwt_auth.py`; the setup lockdown middleware
(`api/middleware/setup_lockdown.py`) additionally blocks non-setup
routes until the wizard finishes.
Password login may require a vendor captcha token. `GET /api/auth/captcha`
returns `{provider: "slider"}` by default (dashboard slider only; no server
check). When a strong provider is active (`turnstile`, `hcaptcha`,
`recaptcha`, `recaptcha-v3`, `tencent`), `POST /api/auth/login` must include
`captcha_token` (max 4096 characters; for `tencent` the dashboard sends the
callback pair as `ticket:randstr`, verified via GET with the client IP).
Captcha failures do not increment
login lockout. OIDC login is unchanged. A request for an unknown username
with a garbage token still triggers one outbound siteverify request (bounded
by a 10s timeout and the token length limit); a known locked user is
rejected before siteverify.
Offline recovery: delete the `captcha.settings` row via local CLI /
`settings_repo` if a settings-sourced strong provider is unreachable. If
the process refused to start because `OCTOP_CAPTCHA_PROVIDER` is strong
without both keys and no readable settings blob, edit `~/.octop/env` on
disk — the dashboard and `PUT /api/envs` are unreachable until the
process starts. `GET /api/envs` redacts `OCTOP_CAPTCHA_SECRET` as
`********`; sending that sentinel on PUT keeps the on-disk value.
If you serve the dashboard behind a CSP, allow
`challenges.cloudflare.com`; `js.hcaptcha.com`, `newassets.hcaptcha.com`,
`api.hcaptcha.com`; `www.google.com`, `www.gstatic.com`;
`turing.captcha.qcloud.com`, `captcha.qq.com`, `ssl.captcha.qq.com`. Octop
does not set these headers itself.
## Setup & auth
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/setup/status` | public | `{required, completed, has_admin}` |
| `GET` | `/setup/presets` | public | Provider templates for the wizard |
| `POST` | `/setup/begin` | public | Begin a wizard session (no password) |
| `POST` | `/setup/verify-password` | public | Exchange the wizard password for a session token |
| `GET` | `/setup/validate-token` | public | Check a wizard session token |
| `POST` | `/setup/initial-admin` | public | body `{username, password, display_name?, email?}` → `201` |
| `POST` | `/setup/test-provider` | public | Ping a provider draft (kind/base_url/api_key/model) |
| `POST` | `/setup/finish` | public | Finalise setup and unlock the rest of the API |
| `GET` | `/auth/captcha` | public | `{provider, site_key?}` for the login widget; 503 while setup is required |
| `POST` | `/auth/login` | public | body `{username, password, captcha_token?}` (`username` may be email) → `{access_token, role, user, ...}`. When no local password matches, the credentials are retried against the configured LDAP directory; a first directory login provisions an Octop account (unless `auto_provision` is off) and derives the role from `admin_groups` |
| `GET` | `/auth/oidc/status` | public | OIDC login availability and provider display name |
| `POST` | `/auth/oidc/start` | public | body `{redirect_after?}` → identity-provider authorization URL |
| `GET` | `/auth/oidc/callback` | public | Identity-provider callback; redirects to dashboard login completion |
| `POST` | `/auth/oidc/exchange` | public | body `{code}` → same JWT response as `/auth/login` |
| `GET` | `/auth/oidc/config` | admin | OIDC provider configuration and callback URL; client secret is omitted |
| `PUT` | `/auth/oidc/config` | admin | Write OIDC provider configuration; `client_secret` is write-only |
| `POST` | `/auth/oidc/config/test` | admin | Verify configured discovery metadata and JWKS endpoint |
| `GET` | `/auth/oauth/status` | public | `{providers:[{kind, display_name, enabled}]}` |
| `POST` | `/auth/oauth/start` | public | body `{kind, redirect_after?}` → authorization URL |
| `GET` | `/auth/oauth/callback` | public | App OAuth callback (`code` or DingTalk `authCode`); same completion page as OIDC |
| `POST` | `/auth/oauth/exchange` | public | Same one-time code exchange as `/auth/oidc/exchange` |
| `POST` | `/auth/oauth/bind/start` | user | body `{kind, redirect_after?}` → bind the identity to the current user |
| `POST` | `/auth/oauth/unbind` | user | Unlink one SSO identity by `kind` (requires a local password when it is the last login method) |
| `GET` | `/auth/oauth/providers/{kind}` | admin | Provider config; `kind` is `oidc`, `feishu`, `dingtalk`, or `wecom` |
| `PUT` | `/auth/oauth/providers/{kind}` | admin | Upsert provider config; `client_secret` is write-only; WeCom uses `extra.agent_id` |
| `POST` | `/auth/oauth/providers/{kind}/test` | admin | Test provider credentials |
| `GET` | `/auth/ldap/status` | public | `{enabled, display_name}` for the login page hint |
| `GET` | `/auth/ldap/config` | admin | Directory settings; `bind_password` is never returned (`has_bind_password` instead) |
| `PUT` | `/auth/ldap/config` | admin | Upsert directory settings; `bind_password` is write-only (omit to keep the stored one) |
| `POST` | `/auth/ldap/config/test` | admin | Service bind + probe of `user_base_dn` → `{ok, detail}` |
| `POST` | `/auth/logout` | user | `204` |
| `GET` | `/auth/me` | user | `{id, username, role, display_name, locale, ...}` |
| `PATCH` | `/auth/me` | user | body `{display_name?, locale?, ...}` |
| `POST` | `/auth/change-password` | user | body `{old_password, new_password}` → `204`. Accounts without a local password (directory/SSO-provisioned) get `PASSWORD_NOT_SET` |
| `GET` | `/health` | public | `{status: "ok", version}` |
## Users (admin)
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/users` | admin | `[{id, username, role, display_name, email, enabled, ...}]` |
| `POST` | `/users` | admin | body `{username, password, role, display_name?, email?, permissions?, workspace_root_dir?, token_quota?}` → `201` |
| `GET` | `/users/{id}` | admin | full user row |
| `PATCH` | `/users/{id}` | admin | body subset of `{role, display_name, email, enabled, locale}` |
| `POST` | `/users/{id}/reset-password` | admin | body `{new_password}` → `204` |
| `POST` | `/users/{id}/unlock-login` | admin | `204` (clears the lockout) |
| `DELETE` | `/users/{id}` | admin | `204` |
## Agents
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/agents` | user | `[{id, agent_id, name, persona_mbti, state, unread_count, ...}]` |
| `POST` | `/agents` | user | body `{name, persona_mbti?, default_model?, system_prompt?, description?, icon?, template_name?, config?}` → `201` |
| `GET` | `/agents/{id}` | owner | full agent row |
| `PATCH` | `/agents/{id}` | owner | body subset of create body |
| `DELETE` | `/agents/{id}` | owner | `204` |
| `POST` | `/agents/{id}/start` | owner | `204` |
| `POST` | `/agents/{id}/stop` | owner | `204` |
| `POST` | `/agents/{id}/reload` | owner | `204` (rebuild harness runtime) |
| `POST` | `/agents/{id}/read` | owner | `204` (mark unread badge cleared) |
| `GET` | `/agents/{id}/status` | owner | `{state, last_error?, memory_maintenance?, ...}` |
| `POST` | `/agents/from-expert/{expert_id}` | user | body `{name, ...}` → `201` (creates from bundled expert template) |
| `GET` | `/agents/{id}/tool-settings` | owner | built-in + installed plugin tools with enable / disableable / available flags |
| `PUT` | `/agents/{id}/tool-settings` | owner | body `{disabled_builtin: string[], plugins?}` — persists denylist + plugin flags (hot-sync, no reload) |
| `PATCH` | `/agents/{id}/tool-settings/{tool_name}` | owner | body `{enabled, source, plugin_id?}` — toggle one tool (hot-sync) |
## Skills and skill packages
Skill copy operations create snapshots; they do not keep the source and
destination synchronized after the request completes.
Packages default to private (`shared=false`): only the creator (and admins) can
list or open them. When `shared=true`, other users with the `skill_packages`
permission can view and mount them; only the creator/admin can edit. `copy_policy`
and `allow_download` still apply to users who can already view the package.
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/skill-packages` | user (`skill_packages`) | Own packages + shared packages; admins see all |
| `GET` | `/skill-packages?writable_only=true` | user (`skill_packages`) | List only packages the current user may modify; admins may modify all packages |
| `POST` | `/agents/{id}/skill-packages/{package_id}/copy` | owner (`skill_packages`) | body `{skill_slugs: string[], overwrite?: boolean}`; copy selected package skills into the workspace |
| `POST` | `/agents/{id}/skills/{slug}/push-to-package` | owner (`skill_packages`) | body `{package_id, overwrite?: boolean}`; copy a workspace skill into a package created by the user (or any package for admins) |
## Chat (WebSocket)
| Path | Auth | Notes |
|------|------|-------|
| `WS /agents/{id}/chat/ws?token=<jwt>` | owner | Primary dashboard turn endpoint. Send `{"type":"user_turn", ...}` frames (optional `hitl_policy` is a thread-scoped tool-approval bypass); server replies with harness stream chunks ending in `{"type":"done"}` or `{"type":"error","message":"..."}`. `{"type":"ping"}` → `{"type":"pong"}`. `{"type":"subscribe","thread_id"}` → `{"type":"turn_status","thread_id","active"}` plus a `steer.queue_snapshot` baseline (attach to an in-flight turn without cancelling on disconnect). `{"type":"cancel","thread_id"}` stops the active turn and discards its queued steers (explicit stop; disconnect alone does **not** cancel). See [Mid-turn steering](#mid-turn-steering) for steer frames. |
| `GET /agents/{id}/chat/welcome` | agent access | `{welcome_message, quick_prompts, task_examples}`; `task_examples` is `null` when the workspace field is absent |
| `POST /agents/{id}/chat/polish` | owner | body `{text, default_model?}` → `{text}` (one-shot prompt refinement) |
| `POST /agents/{id}/chat/hitl/resume` | owner | body `{thread_id, decisions: [...], hitl_policy?}` → SSE chunk stream; finishes with `{"type":"done"}`. When the resume stops on another approval, the done frame is `{"type":"done","hitl_paused":true}` and parked steers stay queued. Optional `hitl_policy` (`ask` / `allow_all` / `allow_tools`) is a thread-scoped bypass and does not change global tool-approval settings. |
Model calls retain LangChain's default retry eligibility and the configured
retry budget/backoff. Exhausted calls raise and remain failed
checkpoint tasks, without a synthetic assistant reply. WebUI, IM and team tasks
display the failure separately. Team rooms publish an `error` frame followed by
`done`; stored error bubbles carry `additional_kwargs.octop_stream_error` for
history display. These diagnostics and automatic team failure notifications do
not become long-term memory.
### Legacy SSE
The previous `POST /agents/{id}/chat/stream` is gone. The dashboard
streams turns over the WebSocket above; HITL resume stays on SSE
because each request is a one-shot continuation.
### Mid-turn steering
While a turn is active, a `user_turn` frame may carry `delivery`
(`auto` \| `steer` \| `follow_up`), `client_pending_id` (client UUID for
correlation) and `expected_turn_id` (the `turn.started` id, for optimistic
concurrency). The server is authoritative: busy `auto`/`steer` steers the
active turn (queued server-side, bounded by depth and byte caps); idle `auto`
starts a normal new turn; `follow_up` while busy is rejected; new steers are
rejected while a HITL card is pending. Replies arrive on the thread:
| Frame | Meaning |
|-------|---------|
| `turn.started` / `turn.resumed` / `turn.ended` | logical turn lifecycle; `turn.ended` carries `hitl_paused: true` when steers stay parked across an approval pause |
| `steer.ack` | direct reply on the sending socket (`queued` / `rejected` / `removed`, with `reason`) |
| `steer.queued` | steer accepted into the active turn queue (`pending_id`, `queue_length`, `preview`) |
| `steer.drained` | queued steers injected as a user message; the same turn continues (`turn.resumed` follows) |
| `steer.discarded` | queue dropped (`turn_cancelled` / `turn_failed` / `continuation_exhausted` / `park_expired`) |
| `steer.removed` / `steer.rejected` | `steer_remove` confirmation / structured rejection |
| `steer.queue_snapshot` | server-authoritative queue baseline (on subscribe) |
Drain happens only at safe boundaries: when tools have run, the first model
output after the completed tool batch preempts the stream (that chunk and
anything after it, except `done` / `error`, are not forwarded) and the queued
steers continue the same logical turn; after a pure-text answer steers drain
at the natural turn end. Image and file blocks on the `user_turn` are queued
with the text and replayed on drain. Steers parked across an HITL pause drain
after the approval resumes, and only when that resume does not stop on a new
approval card. A park older than 30 minutes is discarded (`park_expired`).
`{"type":"steer_remove",...}` withdraws one queued item before it drains. The client puts the draft back only after `steer.ack` with `status: "removed"` and without `already_drained`; an already-drained item stays in the continuation.
## Threads & history
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/agents/{id}/chat/sessions` | owner | `[{id, thread_id, title, archived, last_active, unread, ...}]` |
| `POST` | `/agents/{id}/chat/sessions` | owner | body `{session_key?}` → `{thread_id, session_key}` |
| `PATCH` | `/agents/{id}/chat/sessions/{thread_id}` | owner | body `{title?, pinned?}` → updated row |
| `DELETE` | `/agents/{id}/chat/sessions/{thread_id}` | owner | `204` (archives the active row) |
| `GET` | `/agents/{id}/chat/sessions/{thread_id}/history` | owner | paginated message history; `turn_active` tells a reconnecting client whether to re-`subscribe` over the chat WebSocket |
**Tool result blocks.** `tool_result` blocks in history (and `tool_result`
frames on the chat WebSocket) carry the tool's return value in `output`. When a
plugin returns a large `octop_ui` payload, the backend offloads the envelope's
`data` field: `output` then contains the slim envelope with
`data_ref: "artifact"`, and the full payload is on the block's `artifact` key
(absent otherwise). An explicit `data` key always takes precedence over
`data_ref` when both appear. Clients that render plugin UIs must resolve
`data_ref` from `artifact`; clients that only read `output` keep working.
### Trajectory ledger
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/agents/{id}/threads/{thread_id}/trajectory` | owner | paginated event summaries; use `before_seq` for older rows |
| `GET` | `/agents/{id}/threads/{thread_id}/trajectory/events/{event_id}` | owner | full event payload |
| `GET` | `/agents/{id}/threads/{thread_id}/trajectory/metrics` | owner | aggregated turn, timing, and token metrics |
| `GET` | `/agents/{id}/threads/{thread_id}/trajectory/stream` | owner | live SSE events; resume with `after_seq` or `Last-Event-ID` |
| `GET` | `/agents/{id}/threads/{thread_id}/trajectory/export` | owner | full ledger download as JSONL (default) or JSON |
## Channels
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/agents/{aid}/channels` | owner | list of channel rows |
| `POST` | `/agents/{aid}/channels` | owner | body `{kind, name, config}` → `201` |
| `GET` | `/agents/{aid}/channels/{cid}` | owner | channel row |
| `PATCH` | `/agents/{aid}/channels/{cid}` | owner | body subset → updated row |
| `DELETE` | `/agents/{aid}/channels/{cid}` | owner | `204` |
| `POST` | `/agents/{aid}/channels/{cid}/test` | owner | `{ok, error?}` (instantiate → start → stop) |
| `POST` | `/agents/{aid}/channels/probe` | owner | `{ok, reason?, detail?}` — preflight a candidate config |
| `POST` | `/agents/{aid}/channels/{platform}/qrcode/generate` | owner | platform-specific bot creator (wecom, weixin, feishu, yuanbao) |
| `POST` | `/agents/{aid}/channels/{platform}/qrcode/poll` | owner | poll bot creator state |
| `POST` | `/agents/{aid}/channels/{platform}/bot-creator/start` | owner | start a bot-creator flow |
| `POST` | `/agents/{aid}/channels/{platform}/bot-creator/poll` | owner | poll progress |
| `POST` | `/agents/{aid}/channels/{platform}/bot-creator/stop` | owner | stop an in-flight bot creator |
## Cron
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/settings/timezone` | user | process-level `{timezone}` from `default_timezone` |
| `GET` | `/settings/upload` | user | `{max_upload_mb, max_upload_bytes}` from `max_upload_mb` |
| `GET` | `/settings/hitl` | user | `{enabled, tool_guard_require_approval, show_approval_ui}` — `show_approval_ui` is true when tool HITL is on or command guard is `require_approval` |
| `GET` | `/settings/captcha` | `captcha` | `{active, available, providers, source, v3_min_score}`; secrets omitted |
| `PUT` | `/settings/captcha` | `captcha` | merge `{active?, providers?}`; empty secret keeps ciphertext; `null` removes a pair |
| `GET` | `/cron/settings` | user | compat alias of `/settings/timezone` |
| `GET` | `/agents/{aid}/cron/examples` | agent access | `{task_examples}` from workspace `.octop/manifest.json` (`{zh,en}` string arrays, display-normalized to 3 or 6); `null` if the field is absent (dashboard keeps default cards). Prefer `GET /agents/{aid}/chat/welcome` which includes the same field. |
| `GET` | `/agents/{aid}/cron` | owner only | list cron rows; non-owners (including admin) get `[]` |
| `POST` | `/agents/{aid}/cron` | owner only | body `{name?, trigger, prompt, session_key?, fresh_thread?, enabled?, model?, task_type?}` → `201` |
| `GET` | `/agents/{aid}/cron/{cid}` | owner only | cron row |
| `PATCH` | `/agents/{aid}/cron/{cid}` | owner only | body subset → updated row |
| `DELETE` | `/agents/{aid}/cron/{cid}` | owner only | `204` |
| `POST` | `/agents/{aid}/cron/{cid}/run-now` | owner only | `204` (fire immediately, off-schedule) |
`task_type` is `"text"` (push prompt directly to the session) or
`"agent"` (run the prompt through the LLM and push the reply).
Default: `"agent"`. `trigger` accepts cron expressions
(`"0 9 * * *"`) plus the `interval:N` / `date:ISO8601` aliases
documented in `infra/cron/trigger.py`. A `date:` job runs once and
then disables itself, whether the run succeeds or fails; the row stays
in the list. If the process is down for more than 24 hours past that
time and the job never ran, `last_status` is `missed` and the job is
disabled. `prompt` must be non-empty and ≤ 2000 characters. `name` is
an optional display label; when omitted, the server derives one from
`prompt`.
## Providers
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/providers` | user | providers visible to the user (own + shared) |
| `POST` | `/providers` | user | body `{name, kind, base_url?, api_key?, model?, ...}` → `201` |
| `PATCH` | `/providers/{id}` | owner | body subset → updated row |
| `DELETE` | `/providers/{id}` | owner | `204` (refuses if any agent references it) |
| `POST` | `/providers/{id}/test` | user | `{ok, latency_ms?, error?}` (one-token ping with 10 s timeout) |
| `POST` | `/admin/providers` | admin | same body as user POST; row has `user_id = NULL` |
| `PATCH` | `/admin/providers/{id}` | admin | as user PATCH but works on shared rows |
| `DELETE` | `/admin/providers/{id}` | admin | `204` |
## Models
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/models/presets` | user | provider templates from `octop-harness` |
| `GET` | `/models` | user | resolved models across enabled providers |
| `GET` | `/models/active` | user | `{provider_name, model}` |
| `PUT` | `/models/active` | admin | body `{provider_name, model}` |
### Media generation models
These instance-wide endpoints require the `providers` permission (administrators bypass
permission checks). The Ark API key is write-only and encrypted at rest. Saving settings
reloads running agents so the image and video tools receive the new configuration.
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/admin/media-generation` | providers | Return Volcengine Ark image/video settings; never returns the API key |
| `PUT` | `/admin/media-generation` | providers | Save enabled tools and Seedream/Seedance model IDs; an included API key is verified before saving |
| `POST` | `/admin/media-generation/test` | providers | Test credentials or a selected image/video model; model tests submit real, potentially billable requests |
## Voice
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/voice/presets` | user | voice provider presets |
| `GET` | `/voice/providers` | user | user's voice providers |
| `GET` | `/voice/active` | user | active TTS / STT configuration |
| `PUT` | `/voice/active` | user | update active voice configuration |
| `POST` | `/voice/stt` | user | body `{audio, format?, language?}` → `{text, segments?}` |
| `POST` | `/voice/tts` | user | body `{text, voice?, format?}` → audio bytes |
| `GET`/`POST`/`PATCH`/`DELETE` | `/admin/voice/providers` | admin | admin voice provider CRUD |
## MBTI & personas
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/mbti/codes` | user | `[{code: "INTJ", ...}, ...]` (16 codes + `_default`) |
| `GET` | `/mbti/codes/{code}` | user | full profile (dimensions, behaviour, UI metadata) |
| `GET` | `/mbti/preview/{code}` | user | rendered persona template (legacy `/api/personas/{code}`) |
| `PUT` | `/agents/{aid}/mbti` | owner | body `{code}` → apply persona and reload |
| `GET` | `/personas` | user | `[{code}, ...]` (compat shim) |
| `GET` | `/personas/{code}` | user | rendered template (compat shim) |
Persona content lives in `src/octop/infra/agents/persona/mbti_profiles.py` —
see [Personas](./personas.md).
## Experts
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/experts` | user | bundled expert catalog (includes `task_examples` `{zh,en}` when present) |
| `GET` | `/experts/{expert_id}` | user | full expert template (SOUL.md, skills, files, `task_examples`) |
| `POST` | `/agents/from-expert/{expert_id}` | user | body `{name, locale?, quick_prompts?, ...}` → `201`; optional `quick_prompts` overwrites workspace cards after seed |
Bundled experts live in `src/octop/infra/agents/experts/library/`
(en/zh divisions); the catalog is locale-aware via
`Accept-Language` / user preference.
## Workspace, skills, subagents, memory, files
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/agents/{aid}/workspace/tree` | owner | list dir (default `.`) |
| `GET` | `/agents/{aid}/workspace/file` | owner | read a content file |
| `PUT` | `/agents/{aid}/workspace/file` | owner | write a content file (via `BackendWorkspace`) |
| `DELETE` | `/agents/{aid}/workspace/file` | owner | delete a content file |
| `POST` | `/agents/{aid}/workspace/rename` | owner | rename / move |
| `POST` | `/agents/{aid}/workspace/upload` | owner | multipart upload → backend (not `max_upload_mb`) |
| `GET` | `/agents/{aid}/workspace/download` | owner | download a file |
| `GET` | `/agents/{aid}/workspace/glob` | owner | glob backend paths |
| `GET` | `/agents/{aid}/workspace/grep` | owner | grep backend files |
| `GET` | `/agents/{aid}/workspace/...` | owner | see `api/routers/workspace.py` |
| `GET` | `/agents/{aid}/skills` | owner | list installed skills |
| `GET` | `/agents/{aid}/skills/{slug}/export` | owner | download one workspace skill as a ZIP (`{slug}/SKILL.md` plus siblings) |
| `POST` | `/agents/{aid}/skills/export` | owner | body `{slugs: string[]}`; one ZIP, each slug a top-level folder |
| `GET` | `/skill-packages/{id}/export` | user (`skill_packages`) | download every skill in the package as one ZIP. `allow_download=false` returns 403 for users other than the creator and admins |
| `PUT` | `/agents/{aid}/skills/{slug}` | owner | enable / disable a skill |
| `GET` | `/agents/{aid}/skills/hub/search` | user | Skill Hub search |
| `GET` | `/agents/{aid}/skills/hub/rankings` | user | Skill Hub rankings |
| `POST` | `/agents/{aid}/skills/hub/install` | owner | body `{slug, version?}` → `201` |
| `GET` | `/subagent-catalog/divisions` | user | bundled subagent divisions |
| `GET` | `/subagent-catalog` | user | bundled subagent catalog |
| `GET` | `/subagent-catalog/{slug}` | user | full subagent definition |
| `GET` | `/agents/{aid}/subagents` | owner | installed subagents for an agent |
| `POST` | `/agents/{aid}/subagents` | owner | install a bundled subagent |
| `GET` | `/agents/{aid}/heartbeat-config` | owner | read heartbeat YAML |
| `PUT` | `/agents/{aid}/heartbeat-config` | owner | write heartbeat YAML |
| `GET` | `/agents/{aid}/memory/daily` | owner | list daily memory files |
| `GET`/`PUT` | `/agents/{aid}/memory/strong` | owner | read/edit user-maintained MEMORY.md, including for stopped agents; new sessions snapshot full content once, existing sessions keep their snapshot |
| `GET`/`PUT` | `/agents/{aid}/memory/user` | owner | read/edit user-maintained USER.md with the same session snapshot policy; distinct from the derived profile |
| `GET` | `/agents/{aid}/memory/manual-review` | owner | read persisted reconciliation status against current documents; no model call or memory mutation |
| `POST` | `/agents/{aid}/memory/manual-review` | owner | reconcile saved USER.md + MEMORY.md against up to 100 unchecked atoms; duplicates remain stored, proven conflicts retire the old atom and create a replacement review candidate with evidence |
| `GET` | `/agents/{aid}/memory/daily/{filename}` | owner | read one daily memory |
| `DELETE` | `/agents/{aid}/memory/daily/{filename}` | owner | delete one daily memory |
| `GET`/`POST` | `/memory/...` | user | memory API (dashboard memory tab) |
### Manual document reconciliation
The manual editors save files first, then request reconciliation. The response reports
`complete`, `partial` (continue checking), `uncertain`, `unavailable`, `failed`, `stale`,
`disabled`, `too_large`, or `not_checked`, plus conflict/duplicate counts and candidate IDs.
Semantic checks use the configured memory auxiliary model and require literal document
evidence; unavailable or uncertain checks never silently retire facts. Combined document
input above 100,000 characters is explicitly refused. Current documents are checked again
when accepting a document-derived candidate; stale candidates require review of the new
version. The file save remains successful if the subsequent semantic check fails.
### Current-expert memory storage maintenance
SQLite restore requires a harness runtime with `MemoryMiddleware.pause_background`
([octop-harness #69](https://github.com/TencentCloud/octop-harness/pull/69)). Older runtimes
reject restore before stopping the expert or changing its database. Independent retry
controls also require this runtime; the extraction config API reports supported options.
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/agents/{aid}/memory/storage/check` | owner/admin | Read-only inspection; backend, scope, per-expert statistics and cleanup preview |
| `GET` | `/agents/{aid}/memory/storage/maintenance` | owner/admin | Current progress and this expert's last result, retained until host restart |
| `POST` | `/agents/{aid}/memory/storage/maintenance` | owner/admin | Body `{confirmed: true}` → `202`; run one host-owned maintenance job |
| `GET` | `/agents/{aid}/memory/storage/backups` | owner/admin | List this expert’s maintenance and pre-restore SQLite backups from disk |
| `DELETE` | `/agents/{aid}/memory/storage/backups` | owner/admin | Body `{confirmed: true, backup_id: "server-generated filename"}` → `204`; permanently delete only the selected backup; rejected while maintenance/restore is active |
| `POST` | `/agents/{aid}/memory/storage/restore` | owner/admin | Body `{confirmed: true, backup_id: "server-generated filename"}` → `202`; back up current state, restore and reload only this expert |
The job waits for the selected expert to become idle and reserves only that
expert's turn admission. Ownership and storage isolation are checked again after
waiting. Other experts' conversations can continue. Shared SQLite files and
colliding PostgreSQL namespaces reject maintenance.
SQLite creates a full file backup before checkpoint deduplication and compaction.
SQLite restore accepts only regular sibling backup files matching the host’s naming scheme; arbitrary paths and symlinks are rejected. It validates SQLite integrity, namespace ownership and checkpoint references, drains this expert’s background workers, closes its runtime, and creates and fsyncs a full current-state backup before overwriting. SQLite’s backup API manages the database and WAL; files are not blindly replaced. A failed post-restore validation rolls back from the safety snapshot. The expert is recreated before waiting conversations are released. Backups remain discoverable after restart. The control-plane conversation lists/message archives, attachments and configuration are not restored.
Its inspection uses a separate read-only connection and a consistent snapshot.
`memory_counts` reports only the selected namespace's logical categories, excluding
full-text index duplicates. `checkpoint_counts` reports checkpoints, writes, shared
content blocks and distinct session IDs in a dedicated expert file. Shared files
omit checkpoint counts because those tables have no expert namespace, and disable
file maintenance, including when residual foreign namespaces are detected.
Each count has `rows`, `status` (`ok`, `missing`, `timed_out`) and `elapsed_ms`;
unavailable counts are null, never zero. Count queries share a five-second budget.
`elapsed_ms` and `file_check_ms` measure server-side inspection only, excluding
network transfer; they are not a performance benchmark. Completed SQLite jobs
include `inspection_before` and `inspection_after` snapshots in `report`, alongside
file/WAL totals, deduplication statistics and backup size. Counts may remain stable
because maintenance preserves history; concurrent background writes may affect them.
PostgreSQL limits every read/delete to the live expert's verified namespace and
backs up exact target rows to a private JSONL file under
`OCTOP_HOME/backups/memory-maintenance/` before committing a single transaction:
- Rejected candidates decided more than 30 days ago, with no atom referencing them.
- Operational journal entries older than 14 days (`extract_run`, `gc_*`,
`page_regen`, `page_regen_failed`; only the fixed action allowlist is eligible).
- At most 500 rows per category per pass; locked rows are skipped and another pass
can process remaining eligible records.
Active memories, raw evidence, decision journals (including consolidation), and
all checkpoints remain intact. PostgreSQL results report deleted rows and backup
size, **not physical disk reclamation**. No shared-table `VACUUM` or host-wide
orphan sweep runs from this page. Row-byte statistics are estimates that exclude
indexes, shared overhead and checkpoints. The JSONL header identifies the format,
namespace and row count; subsequent records contain table names and original rows
excluding generated search columns. PostgreSQL JSONL restore is not supported by this endpoint.
To verify the PostgreSQL isolation/backup/rollback gate against a dedicated test
database, set `OCTOP_MEMORY_MAINTENANCE_TEST_DSN` and run
`uv run pytest tests/integration/test_postgres_memory_storage.py -q`. Never point
this gate at production: it creates test namespaces and a temporary failure trigger.
## ACP (Agent Client Protocol)
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/acp` | user | current user's global runner list |
| `PUT` | `/acp` | user | replace global runners |
| `GET` | `/acp/{runner_name}` | user | one runner |
| `PUT` | `/acp/{runner_name}` | user | upsert one runner |
| `DELETE` | `/acp/{runner_name}` | user | delete a custom runner (built-ins are protected) |
| `GET` | `/agents/{aid}/acp` | owner | global runners + this agent's `tool_enabled` flag |
| `PUT` | `/agents/{aid}/acp` | owner | update `tool_enabled` and optionally the global list |
| `PUT` | `/agents/{aid}/acp/tool` | owner | toggle the `acp_runner` tool only |
See [ACP integration](./acp.md) for the runner object schema and the
Zed setup example.
## Storage backends
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET`/`POST`/`PATCH`/`DELETE` | `/storage-backends` | user | per-user remote backend connections |
| `GET`/`POST`/`PATCH`/`DELETE` | `/admin/storage-backends` | admin | admin-managed backends |
## Host filesystem (dashboard)
Browse the host OS directory tree when configuring a local backend
`root_dir` (`local_shell` / `filesystem`). Authenticated users only;
sensitive mounts (`/proc`, `/sys`, `/dev`, `/etc`, `/root` on POSIX)
are rejected, except the process home and its subdirectories (so a
server running as root may use `/root` as the default `root_dir`).
Listing is single-level and capped; write probe runs only
for non-`/` paths.
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/filesystem/dirs?path=<abs>` | user | `{path, entries: [{path, name}]}` — one directory level |
| `POST` | `/filesystem/probe` | user | body `{path}` → `{ok, path?}` or `{ok: false, code, detail?}` (`not_directory`, `permission_denied`, `write_failed`, `not_allowed`) |
| `POST` | `/filesystem/mkdir` | user | body `{path, base_name?}` → `{path, name}` — create child dir (`base_name` defaults to `New Folder`; collisions become `Name (2)`, …) |
| `POST` | `/filesystem/rename` | user | body `{path, new_name}` → `{path, name}` — rename basename only |
## Connectors & OAuth
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/connectors/catalog` | user | connector catalog (Notion, Figma, …) |
| `GET` | `/connectors/test-credentials` | user | preflight credentials |
| `GET` | `/connector-instances` | user | list instances |
| `POST` | `/connector-instances` | user | create instance |
| `GET`/`PATCH`/`DELETE` | `/connector-instances/{id}` | user | CRUD on an instance |
| `POST` | `/connector-instances/{id}/test` | user | test a configured instance |
| `POST` | `/connector-instances/{id}/refresh` | user | refresh OAuth tokens |
| `GET` | `/connectors/auth/{kind}/info` | user | auth flow info |
| `GET` | `/connectors/auth/{kind}/authorize-url` | user | build the authorize URL |
| `POST` | `/connectors/auth/{kind}/exchange-code` | user | exchange auth code |
| `POST` | `/connectors/oauth/start` | user | start OAuth (catalog or custom MCP via `target`) |
| `POST` | `/connectors/oauth/{kind}/start` | user | legacy catalog OAuth start |
| `PUT` | `/connectors/custom-mcp` | user | save custom MCP server map |
| `PATCH` | `/connectors/custom-mcp/servers/{name}` | user | patch `enabled` / `default_open` on one server |
| `POST` | `/connectors/custom-mcp/test` | user | probe a custom MCP server (inline spec or saved name) |
| `GET` | `/connectors/oauth/callback` | public | OAuth redirect target |
| `GET` | `/connectors/oauth/pending/{state_id}` | user | poll the OAuth result |
Custom MCP OAuth (streamable HTTP, public HTTPS URL only): Octop discovers the authorization
server from the MCP URL (401 / RFC 9728 protected-resource metadata), requires dynamic client
registration (DCR), stores encrypted tokens in the custom MCP spec, and injects `Authorization:
Bearer` when loading tools. Loopback and LAN MCP URLs may use HTTP and do not use remote OAuth
discovery.
## Internal MCP (octop-harness agents)
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `POST`/`GET`/… | `/internal/mcp/*` | public (mTLS / network-isolated) | MCP gateway used by octop-harness agents (not the dashboard) |
## Octop Agent MCP Gateway
External agents call installed Octop experts through the authenticated
Streamable HTTP endpoint:
`POST/GET/DELETE /mcp/agents`
Use the same JWT bearer token as the REST API. Use HTTPS when the endpoint is
available outside the local host. The default token lifetime is 24 hours.
The server exposes three tools:
| Tool | Purpose |
|------|---------|
| `list_octop_experts` | List experts visible to the authenticated user |
| `call_octop_expert` | Start a new isolated expert conversation, or continue one with `thread_id` |
| `resume_octop_expert` | Resume a call that stopped for tool approval or user input |
`call_octop_expert` returns `thread_id`. Store this value in the external
agent's working state. Pass it only when the next task must use the same Octop
conversation. Omit it for a new task. MCP threads cannot attach to dashboard,
CLI, or IM threads.
When a tool needs approval, the call returns `status: "approval_required"` and
an `approval` object. Read `approval.request.action_requests` and
`approval.request.review_configs`. Send one allowed decision for each action to
`resume_octop_expert`. A resumed call can return another approval request.
Example client configuration:
```json
{
"mcpServers": {
"octop-agents": {
"type": "streamable_http",
"url": "http://127.0.0.1:8090/api/mcp/agents",
"headers": {
"Authorization": "Bearer <octop-access-token>"
}
}
}
}
```
The exact transport key differs by MCP client. Some clients use `http`
instead of `streamable_http`.
## Observability & security
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET`/`PUT` | `/admin/observability` | admin | Langfuse configuration (host, project, env) |
| `GET` | `/admin/security` | admin | global security policy |
| `PUT` | `/admin/security` | admin | update global policy |
| `GET` | `/admin/security/tool-guard/rules` | admin | active command guard rules |
| `GET` | `/admin/security/tool-guard/rules/raw` | admin | editable YAML |
| `PUT` | `/admin/security/tool-guard/rules/raw` | admin | save YAML |
| `POST` | `/admin/security/tool-guard/rules/reset` | admin | reset to shipped defaults |
| `GET` | `/admin/security/defaults` | admin | defaults + rule catalogs |
## TLS (Let's Encrypt)
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/admin/tls/status` | admin | current cert + issuance task status |
| `POST` | `/admin/tls/preflight` | admin | preflight (port 80, DNS) |
| `POST` | `/admin/tls/issue` | admin | start HTTP-01 issuance |
`GET /.well-known/acme-challenge/{token}` is the HTTP-01 challenge
endpoint (public, mounted directly in `api/app.py`).
## Browser, terminal, uploads
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `WS`/`POST`/`GET`/… | `/agents/{aid}/terminal` | owner | AI-assisted remote PTY |
| `GET` | `/agents/{aid}/terminal/context` | owner | recent terminal context for the AI helper |
| `WS`/`POST`/`GET`/… | `/browser/...` | user | octop-browser sessions, live stream, record/replay |
| `POST` | `/browser/shutdown` | user | stop the current user's Octop-managed Chrome |
| `POST` | `/agents/{aid}/upload` | user | multipart upload → `{workspace}/inbound/` |
| `POST` | `/agents/{aid}/files/access-urls` | user | refresh inbound media URLs (signed) |
| `GET` | `/agents/{aid}/files/{path}` | owner | read an inbound file |
| `POST` | `/downloads/link` | user | multipart `file` → `{url, filename, expires_in}`: short-lived link for in-app browsers (WeChat, …) that cannot save `blob:` downloads; capped at `max_upload_mb` |
| `GET` | `/downloads/link/{token}` | public (token) | serve that file as an attachment until it expires (10 min); the token is the only credential |
## Updates, ollama, i18n, plugins, slash, preferences
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET`/`POST` | `/update/status`, `/check`, `/upgrade`, `/progress`, `/restart` | admin | in-place server update flow |
| `GET`/`POST`/`DELETE` | `/ollama-models/...` | `ollama_models` | Ollama model discovery + downloads |
| `GET`/`PUT` | `/ollama-models/service` | `ollama_models` | Local daemon toggle; omit `enabled` to set `models_dir` only |
| `GET` | `/i18n/tools` | user | server-owned tool display names (locale-aware) |
| `GET` | `/i18n/locales` | public | available locales + fallback chain |
| `GET` | `/i18n/locales/{locale}/{namespace}` | public | one namespace bundle (errors, tools, channel, slash) |
| `GET`/`POST` | `/preferences` | user | UI preferences (per-user key/value) |
| `GET` | `/slash/commands` | user | slash command catalog for the composer menu |
| `GET`/`POST` | `/plugins` | user | installed plugin list / install flow |
| `POST` | `/plugins/reload` | admin (`plugins`) | reload plugins from disk into process |
| `PATCH` | `/plugins/{id}` | admin (`plugins`) | enable/disable plugin (`{ "enabled": bool }`) |
| `GET` | `/plugins/{id}/ui/{path}` | user | serve prebuilt plugin UI assets (`ui/dist/…`) |
| `DELETE` | `/plugins/{id}` | admin (`plugins`) | uninstall plugin |
## Usage & admin
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/usage/summary` | user | token usage summary for the current user |
| `GET` | `/admin/usage` | admin | global token usage summary |
| `GET` | `/admin/overview` | admin | `{user_count, agent_count, ...}` |
| `GET` | `/admin/audit-log` | admin | recent audit rows |
| `GET` | `/admin/metrics` | admin | `{messages_total, stream_errors_total, cron_runs_total, cron_errors_total, agent_active, ...}` |
## Envs
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET`/`PUT` | `/agents/{aid}/envs` | owner | env-var preset for an agent's tool calls |
| `GET`/`POST`/`DELETE` | `/envs/presets` | user | reusable presets |
## Error envelope
Every non-2xx JSON response uses:
```json
{ "error": { "code": "AGENT_NOT_FOUND", "message": "...", "details": { } } }
```
`code` matches the `ErrorCode` enum in `octop.infra.errors`; the
server-localized `message` is rendered by `OctopError.to_envelope`
with the locale from `Accept-Language` (falling back to `en`). The
dashboard mirrors every code under `apiErrors.*` in
`dashboard/src/locales/{en,zh}.json`.
| Code | HTTP | Meaning |
|------|------|---------|
| `AUTH_FAILED` | 401 | Bad credentials |
| `TOKEN_EXPIRED` | 401 | JWT past its TTL |
| `LOGIN_LOCKED` | 423 | Too many failed attempts — wait `login_lockout_seconds` or call `/users/{id}/unlock-login` |
| `SETUP_REQUIRED` | 409 | Initial admin not yet created (or wizard not finished) |
| `FORBIDDEN` | 403 | Authenticated but not allowed |
| `NOT_FOUND` | 404 | No such row / route |
| `USER_DISABLED` | 403 | Account flag flipped off |
| `USERNAME_TAKEN` | 409 | Conflict on `users.username` |
| `AGENT_NOT_FOUND` | 404 | Agent row missing or owned by another user |
| `AGENT_FAILED` | 500 | Runtime errored during a call |
| `AGENT_BUSY` | 409 | Operation refused while another is in flight |
| `PROVIDER_NAME_TAKEN` | 409 | Conflict on `providers.name` |
| `PROVIDER_NOT_VISIBLE` | 400 | Agent config references a provider the user can't see |
| `PROVIDER_REFERENCED` | 409 | Delete blocked because agents still reference the row |
| `PROVIDER_TEST_FAILED` | 400 | `/providers/{id}/test` failed |
| `CHANNEL_KIND_UNSUPPORTED` | 400 | `kind` not in registered builders |
| `CHANNEL_INVALID_CREDENTIALS` | 400 | Channel config rejected by the platform |
| `CHANNEL_PROBE_INCOMPLETE` | 400 | Probe couldn't reach the platform |
| `CRON_TRIGGER_INVALID` | 400 | Trigger string did not parse |
| `CRON_PROMPT_INVALID` | 400 | Empty or too-long prompt |
| `SLASH_UNKNOWN` | 400 | `/<cmd>` is not a registered handler |
| `SLASH_BAD_ARGS` | 400 | Slash handler rejected its arguments |
| `ATTACHMENT_UNSUPPORTED_TYPE` | 400 | Chat / inbound attachment rejected (media type not allowed) |
| `ATTACHMENT_TOO_LARGE` | 413 | Chat / inbound attachment exceeds the configured size limit (`max_mb` in details) |
| `WORKSPACE_PATH_INVALID` | 400 | Path outside the agent's workspace |
| `STORAGE_BACKEND_UNREACHABLE` | 502 | Remote backend connect / list failed |
| `CONNECTOR_OAUTH_FAILED` | 400 | OAuth flow could not complete |
| `INTERNAL_ERROR` | 500 | Unhandled exception (logged with traceback) |