Hyeonsang Cho 806a5bd427
fix(gateway): serve XML artifacts as attachments to block same-origin script (#5353)
* fix(gateway): serve XML artifacts as attachments to block same-origin script

GET /api/threads/{id}/artifacts/{path} forced only text/html,
application/xhtml+xml and image/svg+xml to download. Every other XML
document was served inline from the application origin: `.xml` guesses
to text/xml or application/xml depending on the host's mime.types, and
both fell through to the inline text branches. Browsers render any XML
MIME type as a document and run an XHTML-namespaced <script> inside it,
so a report.xml written by a prompt-injected agent and opened from a
chat link executed with the viewer's session: the HttpOnly access_token
rides same-origin fetches, and the double-submit csrf_token cookie is
JS-readable, so state-changing calls are reachable as well.

Treat HTML plus every WHATWG XML MIME type (text/xml, application/xml,
any +xml subtype) and text/xsl, which Blink also renders as XML, as
active content. A single helper owns the rule for both the regular-file
and the .skill-archive-member branches. The artifacts panel already
previews .xml as code through a ranged fetch, so preview and editing
keep working against the attachment response.

* docs(frontend): name XML among the artifacts the Gateway downloads

Review follow-up on #5353: resolveArtifactOpenURL's comment still named
only HTML/SVG as the active content the Gateway serves as a download.
XML documents now join that bucket, so the frontend note matches the
Gateway rule. Comment-only; no behavior change.
2026-09-12 09:15:51 +08:00

1276 lines
42 KiB
Markdown
Raw 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
This document provides a complete reference for the DeerFlow backend APIs.
## Overview
DeerFlow backend exposes two sets of APIs:
1. **LangGraph-compatible API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
All APIs are accessed through the Nginx reverse proxy at port 2026.
For agent conversations, clients can either pre-create a thread
(`POST /api/langgraph/threads`) or start immediately with the stateless stream
endpoint (`POST /api/langgraph/runs/stream`). The latter auto-creates a thread
and returns `thread_id` and `run_id` in the response `Content-Location` header.
## Authentication
Browser sessions authenticate with the `access_token` session cookie issued at
login. Programmatic clients can instead use a **personal access token (PAT)**
sent as a Bearer credential:
```http
POST /api/threads/search
Authorization: Bearer dfp_...
Content-Type: application/json
{}
```
PATs require a configured database backend (SQLite/PostgreSQL) — on the
memory-only backend, Bearer credentials are rejected and PAT management routes
return `503`.
### Personal Access Tokens
Base URL: `/api/v1/auth`
PAT management requires an **interactive session** (a PAT cannot manage PATs
or change passwords, so a leaked automation token cannot mint fresh
credentials). The raw token is returned **exactly once** at creation; only its
SHA-256 digest is stored server-side.
#### Create Token
```http
POST /api/v1/auth/pats
Content-Type: application/json
```
**Request Body:**
```json
{
"name": "ci-runner",
"scopes": ["threads:read", "runs:create", "runs:read"],
"expires_in_days": 90
}
```
- `scopes` — subset of the route permissions: `threads:read`, `threads:write`,
`threads:delete`, `runs:create`, `runs:read`, `runs:cancel`. A PAT can only
*narrow* its owning user's permissions, never widen them.
- `expires_in_days` — optional (`1``365`); omitted means the token never expires.
**Response (`201`):**
```json
{
"id": "0f0c6e6a-...",
"name": "ci-runner",
"scopes": ["runs:create", "runs:read", "threads:read"],
"expires_at": "2026-11-25T10:30:00Z",
"created_at": "2026-08-27T10:30:00Z",
"token": "dfp_..."
}
```
Save `token` immediately — it cannot be retrieved again.
#### List Tokens
```http
GET /api/v1/auth/pats
```
Returns the caller's tokens with `last_used_at` / `revoked_at` audit fields;
never returns digests or raw tokens.
#### Revoke Token
```http
DELETE /api/v1/auth/pats/{pat_id}
```
Revocation is immediate.
### PAT Constraints
- A request carrying an `Authorization` header that fails validation gets a
hard `401` — it never falls back to the session cookie.
- **Cancel capability requires `runs:cancel` on every request dimension that
carries it**, not just the dedicated cancel route: `?action=interrupt|rollback`
on `POST /api/threads/{thread_id}/runs/{run_id}/stream` (action-less joins
stay at `runs:read`), and `multitask_strategy=interrupt|rollback` on run
creation (the default `reject` stays at `runs:create`). Joining a run's
stream is pure observation — an observer disconnecting never cancels the run.
- **Route-level default-deny:** PAT requests are admitted only to the
thread/run lifecycle routes the v1 scopes govern — `POST /api/threads`
(create), `POST /api/threads/search` (list), `GET/PATCH/DELETE
/api/threads/{thread_id}`, the thread `goal`/`state`/`compact`/`history`/
`branches` subroutes, and exactly the implemented `/runs` subroutes
(`GET|POST /api/threads/{thread_id}/runs`, the POST-only `stream`, `wait`,
`regenerate/prepare`, and `edit-regenerate/prepare` collection endpoints,
`GET /api/threads/{thread_id}/runs/{run_id}` plus its `cancel` (POST),
`join`/`messages`/`events`/`workspace-changes` (GET), and
`GET|POST .../runs/{run_id}/stream`), plus `POST /api/runs/stream|wait` and
`GET /api/runs/{run_id}/messages|feedback`. A route added under `/runs` is
denied until explicitly added to the policy.
Every other authenticated route — memory, agents, models, MCP/skills
config, integrations, channels, uploads — answers `403` to PAT callers
regardless of scopes. Scope enforcement alone only constrains
permission-decorated routes, so the allowlist is the outer boundary;
session-cookie callers are unaffected.
- PAT credentials never carry admin capability, even when the owning user is
an admin. This includes extension-contributed admin routes: the extension
principal projection suppresses every admin signal for PAT callers.
- Revoking or deleting the owning user invalidates their PATs on the next
request.
## LangGraph-compatible API
Base URL: `/api/langgraph`
The public LangGraph-compatible API follows LangGraph SDK conventions. In the unified nginx deployment, Gateway owns `/api/langgraph/*` and translates those paths to its native `/api/*` run, thread, and streaming routers.
### Threads
#### Create Thread
```http
POST /api/langgraph/threads
Content-Type: application/json
```
**Request Body:**
```json
{
"metadata": {}
}
```
**Response:**
```json
{
"thread_id": "abc123",
"created_at": "2024-01-15T10:30:00Z",
"metadata": {}
}
```
#### Get Thread State
```http
GET /api/langgraph/threads/{thread_id}/state
```
**Response:**
```json
{
"values": {
"messages": [...],
"sandbox": {...},
"artifacts": [...],
"thread_data": {...},
"title": "Conversation Title"
},
"next": [],
"config": {...}
}
```
### Runs
#### Create Run
Execute the agent with input.
```http
POST /api/langgraph/threads/{thread_id}/runs
Content-Type: application/json
Idempotency-Key: <unique key for this logical request> # optional
```
The thread-scoped create, stream, and wait endpoints accept an optional
`Idempotency-Key` header. Retrying with the same authenticated user, `thread_id`,
and key reuses the existing run instead of executing the input again. The key is
shared across `/runs`, `/runs/stream`, and `/runs/wait` for a given user and
thread, so the same key string cannot back two different calls even across those
endpoints. Reuse is bound to the original `input` and `assistant_id`; a retry
that changes either returns 409. Generate a new key for every intentional user
action; reuse a key only when retrying that same action after an uncertain HTTP
result. Keys may be at most 255 characters. Stateless `/api/langgraph/runs/*`
endpoints do not support this header because requests without an explicit thread
create a new temporary conversation.
Retrying a still-running run that this worker cannot stream returns 409 from
`/runs/stream` (`Run ... is not active on this worker and cannot be streamed`)
with no `Retry-After`. The same shape on `/runs/wait` returns 200
`{"status": "<durable status>", "error": ...}` without blocking for a final
state. Retrying a finished run through `/runs/wait` also returns that durable
status payload rather than the latest thread checkpoint: a later run on the
same thread may have advanced the head, and `/wait` does not claim that head
as this run's result. That status is the durable row after completion, not
the hydrated record from admission time. The original creating `/wait` still
returns this run's checkpoint even if a retry overlaps while it is waiting. Retrying a finished run whose SSE log is gone emits a `gap` frame
(`stream_replay_gap`, `recovery: reload_durable_state`) on the creating
`/runs/stream` endpoint and closes without an `end` frame; reload durable
thread/run state instead of treating the stream as empty. Observer joins of
that same run still end with `end`. Stateless `/api/langgraph/runs/stream`
does not accept this header and keeps the existing missing-stream close of
`end`; the `gap` signal is only on a thread-scoped creating retry.
**Request Body:**
```json
{
"input": {
"messages": [
{
"role": "user",
"content": "Hello, can you help me?"
}
]
},
"config": {
"recursion_limit": 100,
"configurable": {
"model_name": "gpt-4",
"thinking_enabled": false,
"is_plan_mode": false
}
},
"stream_mode": ["values", "messages-tuple", "custom"]
}
```
**Stream Mode Compatibility:**
- Use: `values`, `messages-tuple`, `custom`, `updates`, `debug`, `tasks`, `checkpoints`
- Unsupported modes, including `messages`, `events`, and `tools`, return `422` before a run is created. DeerFlow never substitutes `values` for an unsupported mode.
**Run Option Compatibility:**
- Supported concurrency strategies: `reject`, `rollback`, and `interrupt`
- Compatibility default: `if_not_exists="create"`; this matches DeerFlow's current behavior
- Artifact delivery is enforced automatically when a run creates or modifies regular files under `/mnt/user-data/outputs`. `present_files` must present at least one path produced by the current run (or a directory containing it), and the terminal receipt must be persisted; presenting only an unrelated file does not satisfy delivery. Runs without changed outputs retain ordinary conversational behavior. `artifact_delivery` is not a client-settable run option.
- Unsupported options return `422`: `webhook`, `stream_resumable=true`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"`
- `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves
- Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded
When outputs changed during the run, `run.delivery` events retain the Slice 1
facts (`presented`, `paths`, and `by_tool`) and add `produced_paths`,
`presented_paths`, `matched_paths`, plus an explicit verdict: `verification`,
`stage` (`presented`, `mismatched`, or `not_started`), and `satisfied`. Receipts
for runs without changed outputs keep their existing shape.
**Recursion Limit:**
`config.recursion_limit` caps the number of graph steps LangGraph will execute
in a single run. The unified Gateway path defaults to `100` in
`build_run_config` (see `backend/app/gateway/services.py`), which is a safer
starting point for plan-mode or subagent-heavy runs. Clients can still set
`recursion_limit` explicitly in the request body; increase it if you run deeply
nested subagent graphs. Scheduled-task launches do not take a client body: they
use `scheduler.recursion_limit` from `config.yaml` (default `1000`, matching
the web UI). For safety, the Gateway clamps any supplied
value to a configurable server ceiling (`max_recursion_limit` in `config.yaml`,
default `1000`) so a single run cannot execute unbounded graph steps (runaway
LLM cost / DoS); invalid or non-positive values fall back to the `100` default.
**Configurable Options:**
- `model_name` (string): Override the default model
- `thinking_enabled` (boolean): Enable extended thinking for supported models
- `is_plan_mode` (boolean): Enable TodoList middleware for task tracking
**Response:** Server-Sent Events (SSE) stream
```
event: values
data: {"messages": [...], "title": "..."}
event: messages
data: {"content": "Hello! I'd be happy to help.", "role": "assistant"}
event: end
data: {}
```
#### Get Run History
```http
GET /api/langgraph/threads/{thread_id}/runs
```
**Response:**
```json
{
"runs": [
{
"run_id": "run123",
"status": "success",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
```
#### Stream Run
Stream responses in real-time.
```http
POST /api/langgraph/threads/{thread_id}/runs/stream
Content-Type: application/json
Idempotency-Key: <unique key for this logical request> # optional
```
Same request body as Create Run. Returns SSE stream.
#### Stateless Stream Run
Start a conversation without creating a thread first. Gateway auto-creates a
thread when `config.configurable.thread_id` is omitted, and returns both
identifiers in the response `Content-Location` header.
```http
POST /api/langgraph/runs/stream
Content-Type: application/json
Accept: text/event-stream
```
Through Nginx, `/api/langgraph/runs/stream` is rewritten to the native Gateway
path `POST /api/runs/stream`.
**Request Body:** Same as [Create Run](#create-run). Omit `thread_id` to start a
new conversation; include it to continue an existing one:
```json
{
"input": {
"messages": [
{
"role": "user",
"content": "Hello, can you help me?"
}
]
},
"config": {
"recursion_limit": 100,
"configurable": {
"model_name": "gpt-4",
"thinking_enabled": false,
"is_plan_mode": false
}
},
"stream_mode": ["values", "messages-tuple", "custom"]
}
```
**Response:** Server-Sent Events (SSE) stream with a `Content-Location` header:
```http
Content-Location: /api/threads/{thread_id}/runs/{run_id}
```
Clients should parse `thread_id` and `run_id` from this header (the path ends
with `/runs/{run_id}`). Persist `thread_id` and send it back on the next turn
via `config.configurable.thread_id` to keep conversation history.
**Continuing a conversation:**
```json
{
"input": {
"messages": [
{
"role": "user",
"content": "What did I just ask?"
}
]
},
"config": {
"configurable": {
"thread_id": "abc123",
"model_name": "gpt-4"
}
},
"stream_mode": ["values", "messages-tuple", "custom"]
}
```
---
## Gateway API
Base URL: `/api`
### Models
#### List Models
Get all available LLM models from configuration.
```http
GET /api/models
```
**Response:**
```json
{
"models": [
{
"name": "gpt-4",
"display_name": "GPT-4",
"supports_thinking": false,
"supports_vision": true
},
{
"name": "claude-3-opus",
"display_name": "Claude 3 Opus",
"supports_thinking": false,
"supports_vision": true
},
{
"name": "deepseek-v3",
"display_name": "DeepSeek V3",
"supports_thinking": true,
"supports_vision": false
}
]
}
```
#### Get Model Details
```http
GET /api/models/{model_name}
```
**Response:**
```json
{
"name": "gpt-4",
"display_name": "GPT-4",
"model": "gpt-4",
"max_tokens": 4096,
"supports_thinking": false,
"supports_vision": true
}
```
### MCP Configuration
#### Get MCP Config
Get current MCP server configurations.
```http
GET /api/mcp/config
```
Requires an authenticated admin session. Sensitive env/header/OAuth secret
values are masked in the response. Environment placeholders outside secret
containers are returned in their raw form so editing cannot expose or persist
their expanded values. Invalid operator-authored JSON/config shapes return
`400` instead of being reported as a Gateway fault.
**Response:**
```json
{
"mcp_servers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "***"
},
"description": "GitHub operations"
}
}
}
```
#### Update MCP Config
Update MCP server configurations.
```http
PUT /api/mcp/config
Content-Type: application/json
```
Requires an authenticated admin session. API-managed `stdio` MCP servers may
only use allowed executable names for `command` (default: `npx`, `uvx`). Set
`DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST` to a comma-separated list when a
deployment needs additional trusted launchers.
**Request Body:**
```json
{
"mcp_servers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "$GITHUB_TOKEN"
},
"description": "GitHub operations"
}
}
}
```
**Response:**
```json
{
"mcp_servers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "***"
},
"description": "GitHub operations"
}
}
}
```
#### Update One MCP Server State
Enable or disable one configured MCP server without replacing the full
extensions configuration.
```http
PATCH /api/mcp/config
Content-Type: application/json
```
Requires an authenticated admin session. Enabling a `stdio` server validates
that server's `command` against the same allowlist used by the full `PUT`
endpoint. Disabling a server does not require its command to be allowlisted, and
invalid commands on other servers do not block the update. The endpoint
preserves secrets, environment-variable placeholders, skills, custom server
fields, and other top-level extensions config. SSE/HTTP targets may use either
DeerFlow's `type` field or the MCP-spec `transport` field.
**Request Body:**
```json
{
"server_name": "semantic-scholar",
"enabled": false
}
```
The response is the full masked MCP configuration, matching `GET` and `PUT`.
An unknown `server_name` returns `404`; attempting to enable a server with a
disallowed `stdio` command returns `400`.
#### Add MCP Servers
Add one or more servers without replacing existing entries. The Gateway
re-reads the file under the shared configuration lock, so concurrent sibling
changes are preserved. Existing names return `409`.
```http
POST /api/mcp/config/servers
Content-Type: application/json
```
The request body uses the same `mcp_servers` map as the full `PUT` endpoint.
#### Replace One MCP Server
Completely replace one existing server while preserving sibling entries.
Omitted ordinary fields are deleted or reset; explicit `***` placeholders
restore the corresponding stored secret.
A disabled `stdio` replacement may keep a syntactically valid command outside
the allowlist for offline editing. Command-shape and code-injecting environment
variable checks still run when saving; the allowlist and executable-argument
policy run when the server is enabled.
```http
PUT /api/mcp/config/server
Content-Type: application/json
```
```json
{
"server_name": "github",
"server": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "***"}
}
}
```
#### Delete One MCP Server
Delete one server without replacing sibling entries. The server name is a
path parameter and the DELETE request has no body. Percent-encode names before
placing them in the URL; the path converter also keeps legacy empty and
slash-containing names addressable.
```http
DELETE /api/mcp/config/servers/{server_name}
```
All targeted mutations return the full masked MCP configuration. Before any
write, the Gateway resolves environment variables in a copy and validates the
same expanded document the runtime will load while persisting the original raw
placeholders.
#### Reset MCP Tools Cache
Clear cached MCP tools and persistent MCP sessions process-wide. This affects
all threads and users in the current Gateway process. Tools are loaded again
from configured MCP servers on the next agent run or tool lookup.
```http
POST /api/mcp/cache/reset
```
Requires an authenticated admin session.
**Response:**
```json
{
"success": true,
"message": "MCP tools cache reset. Tools will reload on next use."
}
```
### Skills
#### List Skills
Get all available skills.
```http
GET /api/skills
```
**Response:**
```json
{
"skills": [
{
"name": "pdf-processing",
"display_name": "PDF Processing",
"description": "Handle PDF documents efficiently",
"enabled": true,
"license": "MIT",
"path": "public/pdf-processing"
},
{
"name": "frontend-design",
"display_name": "Frontend Design",
"description": "Design and build frontend interfaces",
"enabled": false,
"license": "MIT",
"path": "public/frontend-design"
}
]
}
```
#### Get Skill Details
```http
GET /api/skills/{skill_name}
```
**Response:**
```json
{
"name": "pdf-processing",
"display_name": "PDF Processing",
"description": "Handle PDF documents efficiently",
"enabled": true,
"license": "MIT",
"path": "public/pdf-processing",
"allowed_tools": ["read_file", "write_file", "bash"],
"content": "# PDF Processing\n\nInstructions for the agent..."
}
```
#### Enable Skill
```http
POST /api/skills/{skill_name}/enable
```
**Response:**
```json
{
"success": true,
"message": "Skill 'pdf-processing' enabled"
}
```
#### Disable Skill
```http
POST /api/skills/{skill_name}/disable
```
**Response:**
```json
{
"success": true,
"message": "Skill 'pdf-processing' disabled"
}
```
#### Install Skill
Install a skill from a `.skill` file.
```http
POST /api/skills/install
Content-Type: multipart/form-data
```
**Request Body:**
- `file`: The `.skill` file to install
**Response:**
```json
{
"success": true,
"message": "Skill 'my-skill' installed successfully",
"skill": {
"name": "my-skill",
"display_name": "My Skill",
"path": "custom/my-skill"
}
}
```
#### Export a Custom Skill
Admin session authentication is required for both requests. PAT credentials cannot export. Only the current user's custom skill is eligible; public, legacy and integration fallback is never used. A disabled custom skill remains eligible.
1. `GET /api/skills/custom/{skill_name}/export-manifest` returns `skill_name`, `revision` (SHA-256 or null), `can_export`, `file_count`, `directory_count`, `total_bytes`, `files` (`path`, `type`, `size`, `executable`), `requirements` (`compatibility`, `allowed_tools`, `required_secrets` names and optional flags), and structured `warnings`/`blockers`. Paths are relative; `.` is the package root, counted in directory/entry totals. Structural blockers return a non-downloadable manifest. Declarations are not credential values or dependency verification.
2. `GET /api/skills/custom/{skill_name}/export?expected_revision=<64 lowercase hex characters>` recaptures content and rejects stale previews with 409 before sending ZIP headers. Successful responses carry `application/zip`, attachment `<skill_name>.skill`, accurate `Content-Length`, `Cache-Control: private, no-store`, and `X-Content-Type-Options: nosniff`.
Error `detail` contains a safe `code`, `message`, and optional relative `path`. Codes/statuses: `skill_not_found` 404, `skill_changed` 409, `skill_export_limit_exceeded` 413, `skill_export_unsupported` 422, `skill_export_busy` 429, `skill_export_timeout` 503, `skill_export_failed` 500; existing 401/403 auth behavior applies. Limits are 4096 entries including directories, 64 MiB/file, 100 MiB raw/ZIP, 1 MiB frontmatter, 1024 UTF-8 bytes per ZIP path and depth 32. Frontmatter preflight rejects YAML aliases and bounds structure to 32 nesting levels / 16384 parser events before constructing YAML objects. A 5-second lock wait and 60-second cooperative worker deadline bound work; blocking OS calls cannot be forcibly interrupted. Two export slots are shared across all users in each Gateway process; both previews and downloads use them, and 429 means that process-wide capacity is occupied. Slots remain held through worker drain and temporary-file cleanup. The streaming phase has a separate 120-second inactivity deadline, reset after each successful ASGI send. A continuously progressing transfer may exceed 120 seconds overall; a stalled send does not reset the deadline. Expiry aborts the incomplete download (no replacement JSON after ZIP headers); clients must retry. Client disconnect during preparation cancels and drains the worker, then exits the handler normally rather than leaking a synthetic task cancellation. No export cache, persistent job or sharing URL is created.
Raw skill files, sidecars and empty directories are preserved. No hooks/scripts run during export and no secrets are redacted from package files. Import still uses normal security scanning and conflict checks. Export requires no-follow descriptor-relative host filesystem operations; unsupported platforms receive 422 rather than following links unsafely.
#### Reload Skills
Invalidate the skill prompt caches for every user in the current Gateway
process. Subsequent runs rescan the configured public, custom, and legacy skill
directories; runs that have already started keep their existing skill snapshot.
```http
POST /api/skills/reload
```
The request has no body and requires an authenticated administrator. For a
cookie-authenticated request, send the CSRF cookie value in the matching header:
```bash
curl -X POST http://localhost:2026/api/skills/reload \
-b cookies.txt \
-H "X-CSRF-Token: <csrf_token-cookie-value>"
```
**Response:**
```json
{
"success": true,
"scope": "process",
"message": "Skill caches invalidated; subsequent runs in this Gateway process will rescan the latest skills."
}
```
`success` confirms cache invalidation, not that every file on disk was valid:
malformed skills retain the existing parser behavior of being skipped and
logged. The endpoint returns `401` for unauthenticated callers, `403` for
non-admin users, and a generic `500` if the invalidation mechanism itself
fails or the process-local background scan does not finish within the cache
refresh timeout. A loader-level failure, such as an unavailable mounted root,
does not publish an empty catalog: the last successfully loaded process cache
remains available. A timed-out scan continues in its daemon worker and can
still populate the process cache when it finishes.
The scope is deliberately process-local. Each Uvicorn worker or Kubernetes Pod
must be called directly; repeated requests through a load-balanced Service do
not guarantee that every instance is reached. External MinIO/NFS/CSI writes
bypass the validation, SkillScan, and history used by the install/edit APIs, so
the mounted directory must be writable only by trusted operators.
### File Uploads
#### Upload Files
Upload one or more files to a thread.
```http
POST /api/threads/{thread_id}/uploads
Content-Type: multipart/form-data
```
**Request Body:**
- `files`: One or more files to upload
**Response:**
```json
{
"success": true,
"files": [
{
"filename": "document.pdf",
"size": 1234567,
"path": ".deer-flow/threads/abc123/user-data/uploads/document.pdf",
"virtual_path": "/mnt/user-data/uploads/document.pdf",
"artifact_url": "/api/threads/abc123/artifacts/mnt/user-data/uploads/document.pdf",
"markdown_file": "document.md",
"markdown_path": ".deer-flow/threads/abc123/user-data/uploads/document.md",
"markdown_virtual_path": "/mnt/user-data/uploads/document.md",
"markdown_artifact_url": "/api/threads/abc123/artifacts/mnt/user-data/uploads/document.md"
}
],
"message": "Successfully uploaded 1 file(s)"
}
```
**Supported Document Formats** (auto-converted to Markdown):
- PDF (`.pdf`)
- PowerPoint (`.ppt`, `.pptx`)
- Excel (`.xls`, `.xlsx`)
- Word (`.doc`, `.docx`)
#### List Uploaded Files
```http
GET /api/threads/{thread_id}/uploads/list
```
**Response:**
```json
{
"files": [
{
"filename": "document.pdf",
"size": 1234567,
"path": ".deer-flow/threads/abc123/user-data/uploads/document.pdf",
"virtual_path": "/mnt/user-data/uploads/document.pdf",
"artifact_url": "/api/threads/abc123/artifacts/mnt/user-data/uploads/document.pdf",
"extension": ".pdf",
"modified": 1705997600.0
}
],
"count": 1
}
```
#### Delete File
```http
DELETE /api/threads/{thread_id}/uploads/{filename}
```
**Response:**
```json
{
"success": true,
"message": "Deleted document.pdf"
}
```
### Thread Cleanup
Remove DeerFlow-managed local thread files under `.deer-flow/threads/{thread_id}` after the LangGraph thread itself has been deleted.
```http
DELETE /api/threads/{thread_id}
```
**Response:**
```json
{
"success": true,
"message": "Deleted local thread data for abc123"
}
```
**Error behavior:**
- `422` for invalid thread IDs
- `500` returns a generic `{"detail": "Failed to delete local thread data."}` response while full exception details stay in server logs
### Artifacts
#### Get Artifact
Download or view an artifact generated by the agent.
```http
GET /api/threads/{thread_id}/artifacts/{path}
```
**Path Examples:**
- `/api/threads/abc123/artifacts/mnt/user-data/outputs/result.txt`
- `/api/threads/abc123/artifacts/mnt/user-data/uploads/document.pdf`
**Query Parameters:**
- `download` (boolean): If `true`, force download with Content-Disposition header
**Response:** File content with appropriate Content-Type. HTML and XML documents (`.html`, `.xml`, `.xhtml`, `.svg`, and other `+xml` types) are always returned as attachments, regardless of `download`, so generated markup never renders in the application origin.
---
## Error Responses
All APIs return errors in a consistent format:
```json
{
"detail": "Error message describing what went wrong"
}
```
**HTTP Status Codes:**
- `400` - Bad Request: Invalid input
- `404` - Not Found: Resource not found
- `422` - Validation Error: Request validation failed
- `500` - Internal Server Error: Server-side error
---
## Authentication
DeerFlow supports four HTTP identity sources. They share the same thread/run isolation rules but differ in whether a row is created in `users` and how external identities are mapped. See [AUTH_DESIGN.md](AUTH_DESIGN.md) for the full design.
| Model | Entry | `users` table | Isolation key |
|---|---|---|---|
| Browser session | `access_token` cookie after login/register | Yes | `users.id` |
| OIDC / SSO | OAuth callback → cookie | Yes | `users.id` (see [SSO.md](SSO.md)) |
| IM channel binding | Connect code + `channel_connections` | Bound to registered user | `channel_connections.owner_user_id` |
| **Internal Auth** | `X-DeerFlow-Internal-Token` + `X-DeerFlow-Owner-User-Id` | **No** | Owner string on `threads_meta.user_id` |
**IM channel binding** and **Internal Auth** are both *platform-trust* integrations: DeerFlow trusts the channel/platform to authenticate end users. IM bindings persist the mapping in `channel_connections` / `channel_conversations` and require a DeerFlow `users` row. Internal Auth lets a platform call the Gateway API directly with a deployment-shared token and a per-request owner header—no `users` row, but thread/run/checkpoint isolation works the same way.
### Browser session (default)
DeerFlow enforces authentication for all non-public HTTP routes. Public routes are limited to health/docs metadata and these public auth endpoints:
- `POST /api/v1/auth/initialize` creates the first admin account when no admin exists.
- `POST /api/v1/auth/login/local` logs in with email/password and sets an HttpOnly `access_token` cookie.
- `POST /api/v1/auth/register` creates a regular `user` account and sets the session cookie.
- `POST /api/v1/auth/logout` clears the session cookie.
- `GET /api/v1/auth/setup-status` reports whether the first admin still needs to be created.
The authenticated auth endpoints are:
- `GET /api/v1/auth/me` returns the current user.
- `POST /api/v1/auth/change-password` changes password, optionally changes email during setup, increments `token_version`, and reissues the cookie.
Protected state-changing requests also require the CSRF double-submit token: send the `csrf_token` cookie value as the `X-CSRF-Token` header. Login/register/initialize/logout are bootstrap auth endpoints: they are exempt from the double-submit token but still reject hostile browser `Origin` headers.
User isolation is enforced from the authenticated user context:
- Thread metadata is scoped by `threads_meta.user_id`; search/read/write/delete APIs only expose the current user's threads.
- Thread files live under `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/` and are exposed inside the sandbox as `/mnt/user-data/`.
- Memory and custom agents are stored under `{base_dir}/users/{user_id}/...`.
Note: MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers; that is separate from DeerFlow API authentication.
### Internal Auth (platform HTTP integration)
For server-to-server integrations (e.g. a Feishu or WeCom/Enterprise WeChat bot backend), configure:
```bash
export DEER_FLOW_INTERNAL_AUTH_TOKEN="<long-random-secret>"
```
| Header | Required | Description |
|---|---|---|
| `X-DeerFlow-Internal-Token` | Yes | Must match `DEER_FLOW_INTERNAL_AUTH_TOKEN`; missing/invalid → `401` |
| `X-DeerFlow-Owner-User-Id` | Yes for per-user isolation | Platform user id (e.g. `feishu_ou_alice`, `wecom_user_bob`); omit → `default` bucket |
Does **not** use browser cookies or CSRF tokens. Does **not** insert into `users`; sets `threads_meta.user_id` / `runs.user_id` from the owner header. DeerFlow validates only the platform token—not whether the owner id represents a real end user; user validity is entirely the platform's responsibility. See [AUTH_DESIGN.md — Internal Auth](AUTH_DESIGN.md#internal-auth-direct-http) for trust boundaries, persistence, and security notes.
Use the standard Gateway thread/run endpoints (`POST /api/threads`, `POST /api/threads/{thread_id}/runs/stream`, etc.) with the headers above on every request.
---
## Rate Limiting
No rate limiting is implemented by default. For production deployments, configure rate limiting in Nginx:
```nginx
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
}
```
---
## Streaming Support
Gateway's LangGraph-compatible API streams run events with Server-Sent Events (SSE).
**Thread-scoped streaming** (thread must exist):
```http
POST /api/langgraph/threads/{thread_id}/runs/stream
Accept: text/event-stream
```
**Stateless streaming** (no pre-created thread; Gateway auto-creates one):
```http
POST /api/langgraph/runs/stream
Accept: text/event-stream
```
Both endpoints return `Content-Location: /api/threads/{thread_id}/runs/{run_id}`.
The DeerFlow web UI and LangGraph SDK clients rely on this header to discover the
assigned `thread_id` and `run_id` on the first message of a new chat.
### SSE replay retention and gaps
Clients may reconnect to a run stream with `Last-Event-ID`. Replay history is
bounded by `stream_bridge.queue_maxsize` (default `256`) and, for Redis, by the
rolling `stream_ttl_seconds`. A retained cursor resumes after that event with no
additional control frame.
When a syntactically valid cursor is older than the retained watermark, the
server sends exactly one `gap` event before any retained data and closes that
subscription without an `end` event:
```text
event: gap
data: {"code":"stream_replay_gap","run_id":"run-123","requested_event_id":"1718000000000-1","earliest_available_event_id":"1718000000100-42","latest_available_event_id":"1718000000200-84","recovery":"reload_durable_state"}
```
The frame deliberately has no SSE `id:`. Both `earliest_available_event_id` and
`latest_available_event_id` are `string | null` (they are `null` when no events
are retained in the buffer). Consumers must reload durable thread state and
persisted run events/messages, then may reconnect from `latest_available_event_id`
to follow newer live events, or rejoin without a cursor when the buffer is empty
(`latest_available_event_id` is `null`). A gap does not cancel the active run.
The same signal applies when a no-cursor subscriber has already established an
empty-stream wait but the first Redis wake-up falls behind before delivery; in
that case `requested_event_id` is `null`. Malformed cursor handling is
backend-specific and is not the same as a valid cursor that was evicted.
---
## SDK Usage
### Python (LangGraph SDK)
```python
from langgraph_sdk import get_client
client = get_client(url="http://localhost:2026/api/langgraph")
run_meta: dict[str, str] = {}
def on_run_created(meta) -> None:
# langgraph-sdk 0.3.x parses Content-Location only when this callback is set.
if meta.thread_id:
run_meta["thread_id"] = meta.thread_id
run_meta["run_id"] = meta.run_id
# Option A: stateless stream — no thread pre-creation
# Gateway auto-creates a thread and returns thread_id/run_id in Content-Location.
async for event in client.runs.stream(
None,
"lead_agent",
input={"messages": [{"role": "user", "content": "Hello"}]},
config={"configurable": {"model_name": "gpt-4"}},
stream_mode=["values", "messages-tuple", "custom"],
on_run_created=on_run_created,
):
print(event)
thread_id = run_meta["thread_id"] # persist before the next turn
# Option A (continued): same thread on the next turn
async for event in client.runs.stream(
None,
"lead_agent",
input={"messages": [{"role": "user", "content": "What did I just ask?"}]},
config={"configurable": {"thread_id": thread_id, "model_name": "gpt-4"}},
stream_mode=["values", "messages-tuple", "custom"],
on_run_created=on_run_created,
):
print(event)
# Option B: thread-scoped stream — create thread first, then stream
thread = await client.threads.create()
async for event in client.runs.stream(
thread["thread_id"],
"lead_agent",
input={"messages": [{"role": "user", "content": "Hello"}]},
config={"configurable": {"model_name": "gpt-4"}},
stream_mode=["values", "messages-tuple", "custom"],
on_run_created=on_run_created,
):
print(event)
```
### JavaScript/TypeScript
```typescript
// Using fetch for Gateway API
const response = await fetch('/api/models');
const data = await response.json();
console.log(data.models);
function parseRunLocation(contentLocation: string | null) {
if (!contentLocation) return null;
const match = /\/threads\/([^/]+)\/runs\/([^/]+)/.exec(contentLocation);
if (!match) return null;
return { threadId: match[1], runId: match[2] };
}
// Option A: stateless stream — no thread pre-creation
let threadId: string | undefined;
const firstResponse = await fetch("/api/langgraph/runs/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
input: { messages: [{ role: "user", content: "Hello" }] },
stream_mode: ["values", "messages-tuple", "custom"],
}),
});
const created = parseRunLocation(firstResponse.headers.get("Content-Location"));
threadId = created?.threadId;
console.log("thread_id:", created?.threadId, "run_id:", created?.runId);
// Option B: continue the same thread on the next turn
const followUpResponse = await fetch("/api/langgraph/runs/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
input: { messages: [{ role: "user", content: "What did I just ask?" }] },
config: { configurable: { thread_id: threadId } },
stream_mode: ["values", "messages-tuple", "custom"],
}),
});
// Option C: thread-scoped stream when you already have a thread_id
const streamResponse = await fetch(`/api/langgraph/threads/${threadId}/runs/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
input: { messages: [{ role: "user", content: "Hello" }] },
stream_mode: ["values", "messages-tuple", "custom"],
}),
});
const reader = streamResponse.body?.getReader();
// Decode and parse SSE frames from reader in your client code.
```
### cURL Examples
```bash
# List models
curl http://localhost:2026/api/models
# Get MCP config
curl http://localhost:2026/api/mcp/config
# Upload file
curl -X POST http://localhost:2026/api/threads/abc123/uploads \
-F "files=@document.pdf"
# Enable skill
curl -X POST http://localhost:2026/api/skills/pdf-processing/enable
# Stateless stream — no thread pre-creation
curl -s -D - -N -X POST http://localhost:2026/api/langgraph/runs/stream \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"input": {"messages": [{"role": "user", "content": "Hello"}]},
"config": {
"recursion_limit": 100,
"configurable": {"model_name": "gpt-4"}
},
"stream_mode": ["values", "messages-tuple", "custom"]
}'
# Read Content-Location: /api/threads/{thread_id}/runs/{run_id} from the headers.
# Continue the same thread on the next turn
curl -s -N -X POST http://localhost:2026/api/langgraph/runs/stream \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"input": {"messages": [{"role": "user", "content": "What did I just ask?"}]},
"config": {
"configurable": {"thread_id": "abc123", "model_name": "gpt-4"}
},
"stream_mode": ["values", "messages-tuple", "custom"]
}'
# Thread-scoped flow — create thread first, then stream
curl -X POST http://localhost:2026/api/langgraph/threads \
-H "Content-Type: application/json" \
-d '{}'
curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs/stream \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"input": {"messages": [{"role": "user", "content": "Hello"}]},
"config": {
"recursion_limit": 100,
"configurable": {"model_name": "gpt-4"}
},
"stream_mode": ["values", "messages-tuple", "custom"]
}'
```
> The unified Gateway path defaults `config.recursion_limit` to 100 for
> plan-mode and subagent-heavy runs. Clients may still set
> `config.recursion_limit` explicitly — see the [Create Run](#create-run)
> section for details. Scheduled-task launches use
> `scheduler.recursion_limit` from `config.yaml` instead of a client body.
## Chat archive and restore
`POST /api/threads/search` accepts `archived: true` for archived chats or
`archived: false` for recent chats (including legacy rows without an archive flag).
Omit the field or use null to include both. Filtering applies before `limit` and
`offset` and is scoped to the authenticated user. Combine it with the existing
`metadata` and `status` filters when needed.
Archive with `PATCH /api/threads/{thread_id}` and body
`{"metadata":{"deerflow_archived":true}}`; use false to restore. The flag must be
a JSON boolean. Writes containing only boolean pin/archive flags preserve
`updated_at` and all other metadata. The owner-checked endpoint returns the normal
thread metadata response; original thread and artifact URLs remain available.
Archiving does not cancel runs, pause schedules, or change retention.