deer-flow/backend/docs/CONFIGURATION.md
wutongyuonce 48a8978b7b
feat(scheduler): add interval schedule type (#5291)
* feat(scheduler): add interval schedule type

Allow scheduled tasks to fire every N seconds from last dispatch, not
only wall-clock cron or a single run_at. Cadence is UTC now+N with no
missed-beat catch-up, bounded by min_once_delay_seconds and 30 days.

* fix(scheduler): let interval tasks create, edit, and keep next run

Create/edit now keep every_seconds. Unchanged interval spec no longer
resets next_run_at, including timezone-only PATCH.

* fix(scheduler): keep non-minute intervals on edit

Stop rounding every_seconds to whole minutes in the form. Values that
are not whole minutes or hours now use a seconds unit so edit/duplicate
round-trips the stored cadence instead of rewriting it and resetting
next_run_at. Document that min_once_delay_seconds is also the interval
floor.

* fix(scheduler): clamp interval seconds to the default 60s floor

The new seconds unit allowed 1–59, which the API rejects under the
default min_once_delay_seconds. Clamp the form to >= 60 and show the
floor next to the preview. Also mention interval in the scheduler
field_doc, matching config.example.yaml.

* fix(scheduler): do not clamp interval amount while typing

Keystroke clamp made 90 become 9 -> 60, then 600, and backspace could
not leave 60. Keep the raw field text and apply the 60s floor on blur
and emit only.

* test(scheduler): cover interval input editing

* fix(frontend): preserve saved interval cadence until edited

* style(tests): format scheduled task router tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 16:46:01 +08:00

1120 lines
62 KiB
Markdown

# Configuration Guide
This guide explains how to configure DeerFlow for your environment.
## Config Versioning
`config.example.yaml` contains a `config_version` field that tracks schema changes. When the example version is higher than your local `config.yaml`, the application emits a startup warning:
```
WARNING - Your config.yaml (version 0) is outdated — the latest version is 1.
Run `make config-upgrade` to merge new fields into your config.
```
- **Missing `config_version`** in your config is treated as version 0.
- Run `make config-upgrade` to auto-merge missing fields (your existing values are preserved, a `.bak` backup is created).
- When changing the config schema, bump `config_version` in `config.example.yaml`.
## Configuration Sections
### Extensions
MCP servers and skill enabled states live in `extensions_config.json`, separate
from `config.yaml`. Use `mcpServers.<server>.routing` to add soft MCP tool
preference hints for requests that should prefer a specific MCP server or tool.
See [MCP Server Configuration](MCP_SERVER.md#routing-hints) for the schema,
example, and soft-vs-hard routing boundary.
### Models
Configure the LLM models available to the agent:
```yaml
models:
- name: gpt-4 # Internal identifier
display_name: GPT-4 # Human-readable name
use: langchain_openai:ChatOpenAI # LangChain class path
model: gpt-4 # Model identifier for API
api_key: $OPENAI_API_KEY # API key (use env var)
max_tokens: 4096 # Max tokens per request
temperature: 0.7 # Sampling temperature
```
**Supported Providers**:
- OpenAI (`langchain_openai:ChatOpenAI`)
- Anthropic (`langchain_anthropic:ChatAnthropic`)
- DeepSeek (`langchain_deepseek:ChatDeepSeek`)
- Xiaomi MiMo (`deerflow.models.patched_mimo:PatchedChatMiMo`)
- Claude Code OAuth (`deerflow.models.claude_provider:ClaudeChatModel`)
- Codex CLI (`deerflow.models.openai_codex_provider:CodexChatModel`)
- Any LangChain-compatible provider
CLI-backed provider examples:
```yaml
models:
- name: gpt-5.4
display_name: GPT-5.4 (Codex CLI)
use: deerflow.models.openai_codex_provider:CodexChatModel
model: gpt-5.4
supports_thinking: true
supports_reasoning_effort: true
- name: claude-sonnet-4.6
display_name: Claude Sonnet 4.6 (Claude Code OAuth)
use: deerflow.models.claude_provider:ClaudeChatModel
model: claude-sonnet-4-6
max_tokens: 4096
supports_thinking: true
```
**Auth behavior for CLI-backed providers**:
- `CodexChatModel` loads Codex CLI auth from `~/.codex/auth.json`
- The Codex Responses endpoint currently rejects `max_tokens` and `max_output_tokens`, so `CodexChatModel` does not expose a request-level token cap
- `ClaudeChatModel` accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, `CLAUDE_CODE_CREDENTIALS_PATH`, or plaintext `~/.claude/.credentials.json`
- On macOS, DeerFlow does not probe Keychain automatically. Use `scripts/export_claude_code_oauth.py` to export Claude Code auth explicitly when needed
To use OpenAI's `/v1/responses` endpoint with LangChain, keep using `langchain_openai:ChatOpenAI` and set:
```yaml
models:
- name: gpt-5-responses
display_name: GPT-5 (Responses API)
use: langchain_openai:ChatOpenAI
model: gpt-5
api_key: $OPENAI_API_KEY
use_responses_api: true
output_version: responses/v1
```
For OpenAI-compatible gateways (for example Novita or OpenRouter), keep using `langchain_openai:ChatOpenAI` and set `base_url`:
> **Note:** for `langchain_openai:ChatOpenAI` the endpoint override key is `base_url` (not `api_base`). If you write `api_base` it is automatically normalized to `base_url`, and unrecognized keys are logged with a warning at model-build time. Some other model classes (e.g. `PatchedChatDeepSeek`) do use `api_base` — match the key to the class you configured.
```yaml
models:
- name: novita-deepseek-v3.2
display_name: Novita DeepSeek V3.2
use: langchain_openai:ChatOpenAI
model: deepseek/deepseek-v3.2
api_key: $NOVITA_API_KEY
base_url: https://api.novita.ai/openai
supports_thinking: true
when_thinking_enabled:
extra_body:
thinking:
type: enabled
- name: minimax-m3
display_name: MiniMax M3
use: langchain_openai:ChatOpenAI
model: MiniMax-M3
api_key: $MINIMAX_API_KEY
base_url: https://api.minimax.io/v1
max_tokens: 4096
temperature: 1.0 # MiniMax requires temperature in (0.0, 1.0]
supports_vision: true
- name: minimax-m2.7
display_name: MiniMax M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: $MINIMAX_API_KEY
base_url: https://api.minimax.io/v1
max_tokens: 4096
temperature: 1.0 # MiniMax requires temperature in (0.0, 1.0]
supports_vision: false # M2.7 is text-only; M3 supports vision
- name: minimax-m2.7-highspeed
display_name: MiniMax M2.7 Highspeed
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7-highspeed
api_key: $MINIMAX_API_KEY
base_url: https://api.minimax.io/v1
max_tokens: 4096
temperature: 1.0 # MiniMax requires temperature in (0.0, 1.0]
supports_vision: false # M2.7 is text-only; M3 supports vision
- name: openrouter-gemini-2.5-flash
display_name: Gemini 2.5 Flash (OpenRouter)
use: langchain_openai:ChatOpenAI
model: google/gemini-2.5-flash-preview
api_key: $OPENAI_API_KEY
base_url: https://openrouter.ai/api/v1
```
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
**Thinking Models**:
Some models support "thinking" mode for complex reasoning:
```yaml
models:
- name: deepseek-v3
supports_thinking: true
when_thinking_enabled:
extra_body:
thinking:
type: enabled
```
**Gemini with thinking via OpenAI-compatible gateway**:
When routing Gemini through an OpenAI-compatible proxy (Vertex AI OpenAI compat endpoint, AI Studio, or third-party gateways) with thinking enabled, the API attaches a `thought_signature` to each tool-call object returned in the response. Every subsequent request that replays those assistant messages **must** echo those signatures back on the tool-call entries or the API returns:
```
HTTP 400 INVALID_ARGUMENT: function call `<tool>` in the N. content block is
missing a `thought_signature`.
```
Standard `langchain_openai:ChatOpenAI` silently drops `thought_signature` when serialising messages. Use `deerflow.models.patched_openai:PatchedChatOpenAI` instead — it re-injects the tool-call signatures (sourced from `AIMessage.additional_kwargs["tool_calls"]`) into every outgoing payload:
```yaml
models:
- name: gemini-2.5-pro-thinking
display_name: Gemini 2.5 Pro (Thinking)
use: deerflow.models.patched_openai:PatchedChatOpenAI
model: google/gemini-2.5-pro-preview # model name as expected by your gateway
api_key: $GEMINI_API_KEY
base_url: https://<your-openai-compat-gateway>/v1
max_tokens: 16384
supports_thinking: true
supports_vision: true
when_thinking_enabled:
extra_body:
thinking:
type: enabled
```
For Gemini accessed **without** thinking (e.g. via OpenRouter where thinking is not activated), the plain `langchain_openai:ChatOpenAI` with `supports_thinking: false` is sufficient and no patch is needed.
**MiMo with thinking via OpenAI-compatible API**:
MiMo returns `reasoning_content` on assistant messages in thinking mode. In multi-turn agent conversations with tool calls, subsequent requests must preserve that historical `reasoning_content` on assistant messages or the MiMo API can return HTTP 400. Standard `langchain_openai:ChatOpenAI` drops this provider-specific field, so use `deerflow.models.patched_mimo:PatchedChatMiMo`:
For pay-as-you-go API keys (`sk-...`), use `https://api.xiaomimimo.com/v1`. For Token Plan keys (`tp-...`), use the regional Token Plan Base URL shown in the MiMo console, such as `https://token-plan-cn.xiaomimimo.com/v1`. MiMo documents these key types as separate and non-interchangeable.
`PatchedChatMiMo` is model-id agnostic. Use it for every MiMo thinking model entry you configure, including model entries referenced by `subagents.*.model` overrides (for example `mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2-pro`, `mimo-v2-omni`, or `mimo-v2-flash`).
```yaml
models:
- name: mimo-v2.5-pro
display_name: MiMo V2.5 Pro
use: deerflow.models.patched_mimo:PatchedChatMiMo
model: mimo-v2.5-pro
api_key: $MIMO_API_KEY
base_url: https://api.xiaomimimo.com/v1
max_tokens: 8192
supports_thinking: true
supports_vision: false
when_thinking_enabled:
extra_body:
thinking:
type: enabled
when_thinking_disabled:
extra_body:
thinking:
type: disabled
```
`PatchedChatMiMo` preserves MiMo's `choices[].message.reasoning_content`, streaming `delta.reasoning_content`, and request-history assistant `reasoning_content` fields. It does not reuse the DeepSeek provider.
### RAGFlow Knowledge Retrieval
RAGFlow integration is disabled by default. It adds one read-only Agent tool,
`knowledge_search`. DeerFlow does not persist a copy of dataset or document
metadata; RAGFlow is the sole source of truth. The configured API key is
tenant-scoped. An optional operator-controlled `datasets` list restricts every
Agent on this deployment to the same dataset-ID allowlist; omitting it searches
all datasets visible to that tenant API key. An explicitly empty `datasets: []`
is rejected rather than being treated as tenant-wide access.
```yaml
tool_groups:
- name: knowledge
tools:
- name: knowledge_search
group: knowledge
use: deerflow.community.ragflow.tools:knowledge_search_tool
base_url: http://localhost:9380
api_key: $RAGFLOW_API_KEY
datasets:
- 0123456789abcdef0123456789abcdef
- fedcba9876543210fedcba9876543210
timeout: 30
page_size: 8
similarity_threshold: 0.2
vector_similarity_weight: 0.3
top_k: 256
max_chars_per_chunk: 800
max_total_chars: 8000
```
The tool is opt-in through the normal `tools:` list. `datasets` is optional but,
when present, must contain at least one ID. If
it contains RAGFlow dataset IDs selected by the deployment operator, DeerFlow
does not validate their existence while loading configuration; on each search
it verifies them with ID-filtered requests. If `datasets` is omitted, each
search paginates through the tenant-visible dataset catalog. Both paths resolve
current names, embedding models, and chunk counts. Empty datasets are ignored;
an empty dataset that has no embedding-model metadata is also skipped with a
server warning. The remaining datasets are grouped by the exact embedding-model
identifier and each group is sent to RAGFlow with a non-empty `dataset_ids`
list. At most four groups are retrieved concurrently. Because raw similarity
scores from different embedding spaces are not globally comparable, DeerFlow
preserves each group's RAGFlow ranking, interleaves equal rank positions, omits
score labels when more than one group is searched, and applies `page_size` as a
single global chunk limit. If any searchable group fails, the whole tool call
fails rather than silently omitting part of the configured scope. A deleted or
inaccessible configured dataset identifies its ordinal entry in
`knowledge_search.datasets` and produces guidance to check `config.yaml`.
Dataset IDs and catalog listing are not exposed to the Agent.
Use an allowlist to narrow the tenant-wide scope; compatible embedding models
are no longer required across selected datasets. `base_url` must not contain
embedded username or password information. For Docker or Kubernetes, it must be
reachable from the Gateway container or Pod; `localhost` refers to that
container or Pod, not the host machine.
This integration is retrieval-only. Dataset creation, uploads, parsing, and
deletion remain in RAGFlow and are not exposed as Agent tools or DeerFlow APIs.
### LightRAG Knowledge Retrieval
LightRAG integration is disabled by default. It is an alternative provider for
the same read-only `knowledge_search` tool: an operator picks RAGFlow or
LightRAG by which entry appears in the `tools:` list — the two entries share
one name, and on duplicate names DeerFlow keeps the **first** configured
entry, so configure exactly one. Requires LightRAG v1.4.9 or newer: v1.4.8
introduced the data-retrieval endpoint but returned a pre-envelope response
shape, and the `status`/`data` envelope plus the citation fields consumed
here shipped in v1.4.9. DeerFlow does not persist any index
metadata; LightRAG stays the sole source of truth, and the deployment's
single indexed workspace is always searched.
```yaml
tool_groups:
- name: knowledge
tools:
- name: knowledge_search
group: knowledge
use: deerflow.community.lightrag.tools:knowledge_search_tool
base_url: http://localhost:9621
api_key: $LIGHTRAG_API_KEY
mode: mix
timeout: 30
top_k: 60
chunk_top_k: 8
max_chars_per_chunk: 800
max_total_chars: 8000
```
The tool is opt-in through the normal `tools:` list. Retrieval uses LightRAG's
`POST /query/data` endpoint, which performs no LLM generation and returns
structured entities, relationships, chunks, and references; DeerFlow keeps the
chunks — the document text the selected mode already ranked as relevant — and
formats them as citation-numbered text, dropping the graph objects to stay
compact and keep the citation shape shared with the RAGFlow provider. `mode`
selects the retrieval strategy (`naive`, `local`, `global`, `hybrid`, or
`mix`; default `mix`, matching the LightRAG API's own `QueryRequest` default;
`bypass` is rejected because it skips the index entirely). `top_k` bounds the
entities retrieved in `local` mode or relationships in `global` mode, and the
optional `chunk_top_k` bounds the text chunks retrieved and kept after
reranking; both are capped at 1000 by the LightRAG server. Short queries that
fail LightRAG's minimum-length validation surface the server's readable
message. `max_chars_per_chunk` / `max_total_chars` bound the model-visible
output size.
`api_key` is optional because LightRAG may run without authentication. Only
omit it for loopback or trusted-network deployments — a network-exposed
LightRAG must have authentication enabled, and then the key is sent as the
`X-API-Key` header and redacted from every model-visible error and from
server logs. Blank values are treated as unauthenticated. `base_url` must not
contain embedded username or password information, and for Docker or
Kubernetes it must be reachable from the Gateway container or Pod.
Internal identifiers (chunk IDs and the response-local reference IDs) are
never exposed to the Agent; citations use the operator-readable `file_path`.
This integration is retrieval-only. Document insertion, indexing, and graph
mutation remain in LightRAG and are not exposed as Agent tools or DeerFlow
APIs.
### Tool Groups
Organize tools into logical groups:
```yaml
tool_groups:
- name: web # Web browsing and search
- name: file:read # Read-only file operations
- name: file:write # Write file operations
- name: bash # Shell command execution
```
### Scheduler
The scheduled-task MVP adds a scheduler section to `config.yaml`:
```yaml
scheduler:
enabled: false
multi_instance: false
poll_interval_seconds: 5
lease_seconds: 120
max_concurrent_runs: 3
queue_timeout_seconds: 3600
min_once_delay_seconds: 60
recursion_limit: 1000
```
Notes:
- `enabled: false` keeps background polling off by default.
- `multi_instance: true` opts into lease-aware scheduler recovery across Gateway instances. It requires Postgres, `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; otherwise startup fails fast. Leave it false for the default single-instance scheduler.
- `max_concurrent_runs` is a shared global execution cap in multi-instance mode. Waiting `queued` rows do not consume capacity; an atomic `queued``launching` claim counts `launching`/`running` rows under a Postgres advisory lock so concurrent Pods cannot exceed the cap.
- `queue_timeout_seconds` limits how long a persisted occurrence may wait for capacity or a reused thread to become available. Expired occurrences are marked `failed`; queued rows otherwise survive Gateway restarts.
- A task definition is immutable while an occurrence is `queued`, `launching`, or `running`. This prevents a durable occurrence from mixing its admitted thread with a later prompt or schedule edit. Transitioning a task to paused or deleting it cancels a waiting row; PATCH and resume return a conflict until the active occurrence finishes or is cancelled.
- A manual trigger remains explicit even while the recurring schedule is paused: it may wait in the durable queue and run later, while the task itself stays paused. Transitioning an enabled task to paused still cancels its waiting occurrence atomically.
- Queue admission, PATCH/resume, pause, and delete serialize on the parent task row. Per-thread FIFO spans all active states, so an older `launching` or `running` occurrence blocks a newer queued occurrence on the same reused thread as well as an older `queued` occurrence.
- Multi-instance reconciliation uses the run ownership lease: a live peer run is preserved, an expired lease is atomically taken over before its scheduled row is interrupted, and a stale Pod cannot overwrite a newer Pod's parent-task bookkeeping.
- `recursion_limit` is the LangGraph super-step cap for scheduler-launched runs (default 1000, matching the web UI's interactive budget). Values above `max_recursion_limit` (default 1000) are clamped. This field is read at dispatch, so a YAML edit applies to the next scheduled run without a Gateway restart.
- Poller fields (`enabled`, `multi_instance`, `poll_interval_seconds`, `lease_seconds`, `max_concurrent_runs`, `queue_timeout_seconds`, `min_once_delay_seconds`) are restart-required; edits need a Gateway restart.
- **Upgrade note:** before upgrading a deployment with `GATEWAY_WORKERS > 1` and `scheduler.enabled: true`, either run the scheduler on exactly one Gateway worker or enable `scheduler.multi_instance: true` with shared Postgres, `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`. The startup gate now rejects the unsafe combination instead of allowing it to start silently.
- **Upgrade note:** in multi-instance mode, `max_concurrent_runs` is cluster-wide rather than per Pod and counts `launching`/`running` occurrences. Waiting `queued` rows remain outside the execution cap; capacity does not multiply with the replica count.
- **Upgrade note:** `scheduler.multi_instance` and its related scheduler, ownership, and run-event settings are startup-only. Restart all Gateway Pods together after changing them; a ConfigMap update without a coordinated restart leaves the running service on its previous mode.
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend, enable run ownership heartbeats, and set `run_events.backend: db`. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
- The MVP supports thread reuse and fresh-thread-per-run execution modes.
- Create/update accept optional `assistant_id` (`lead_agent` by default, or an existing custom agent for the task owner).
- Create/update accept `once`, `cron`, and `interval`. Interval uses `schedule_spec.every_seconds` (UTC `now + N`, no missed-beat catch-up). N is at least `min_once_delay_seconds` (default 60) and at most 30 days.
- Manual trigger uses the same scheduled-task resource and run lifecycle.
- Scheduled task definitions and task-run history are persisted in the application database.
### Agent Storage
Custom agent **definitions** (`config.yaml` + `SOUL.md`) are stored per-user on
local disk by default. This is separate from the `database` backend (which holds
run/thread/event data) and from agent memory.
```yaml
agent_storage:
backend: file # file (default) | db
```
- `backend: file` — the historical layout under `{base_dir}/users/{user_id}/agents/`. Single-node by construction: an agent created on one node is not visible to other nodes without a shared mount.
- `backend: db` — one row per agent in the shared SQL persistence layer (a new `agents` table), so every node in a multi-instance deployment sees the same agents. Requires `database.backend` to be `sqlite` or `postgres`; the Gateway **fails fast at startup** if it is `memory` (a per-process database cannot share definitions).
- `agent_storage` is restart-required (the backend is captured at Gateway lifespan startup).
- In a multi-worker Postgres deployment (`GATEWAY_WORKERS > 1`), leaving `agent_storage.backend: file` logs a startup warning — agents written to one node's local disk are invisible to the others, which is exactly the divergence the `db` backend fixes.
Migrating an existing install from `file` to `db`:
```bash
python backend/scripts/migrate_agents_to_db.py # copy on-disk agents into the db
python backend/scripts/migrate_agents_to_db.py --dry-run # preview without writing
```
The importer is idempotent (already-present agents are skipped) and leaves the source files untouched, so reverting `agent_storage.backend` to `file` is a clean rollback. Agent *memory* (`memory.json`) is unaffected by this switch.
### Tools
Configure specific tools available to the agent:
```yaml
tools:
- name: web_search
group: web
use: deerflow.community.tavily.tools:web_search_tool
max_results: 5
# api_key: $TAVILY_API_KEY # Optional
```
**Built-in Tools**:
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya)
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya)
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)
- `ls` - List directory contents
- `read_file` - Read file contents
- `write_file` - Write file contents
- `str_replace` - String replacement in files
- `bash` - Execute bash commands
Browserless can be configured as an opt-in visual capture tool:
```yaml
tools:
- name: web_capture
group: web
use: deerflow.community.browserless.tools:web_capture_tool
base_url: http://localhost:3032
# token: $BROWSERLESS_TOKEN
output_format: png
full_page: true
viewport_width: 1280
viewport_height: 720
# allow_private_addresses: false # SSRF guard; keep false in production
```
`web_capture` writes screenshots to the current thread's `/mnt/user-data/outputs`
directory and presents the image path through the standard artifact mechanism. By
default it refuses URLs that resolve to private, loopback, link-local, or
cloud-metadata addresses; set `allow_private_addresses: true` only when you
intentionally point the tool at an internal target.
Both `web_fetch` (Browserless provider) and `web_capture` need a running
Browserless instance. You can point `base_url` at [Browserless Cloud](https://www.browserless.io/)
(set `BROWSERLESS_TOKEN`) or run one locally with Docker:
```bash
# Browserless listens on port 3000 inside the container; map it to 3032 to
# match the default base_url (http://localhost:3032). Recent Browserless
# images always require a token — if you don't pass one, a random token is
# generated and requests without it are rejected — so set it explicitly.
docker run -d --name browserless -p 3032:3000 -e "TOKEN=local-dev-token" ghcr.io/browserless/chromium
```
Then set the same token so the tool sends it (uncomment `token: $BROWSERLESS_TOKEN`
in the config above):
```bash
export BROWSERLESS_TOKEN=local-dev-token
```
Verify the instance is reachable before enabling the tool:
```bash
curl -sS "http://localhost:3032/screenshot?token=local-dev-token" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "options": {"type": "png"}}' \
-o /tmp/browserless-check.png # writes a PNG on success
```
For Docker Compose deployments, run Browserless as a service and point `base_url`
at the service name (e.g. `http://browserless:3000`) instead of `localhost`. See
the [Browserless project](https://github.com/browserless/browserless) for full
deployment and configuration options.
### Sandbox
DeerFlow supports multiple sandbox execution modes. Configure your preferred mode in `config.yaml`:
**Local Execution** (runs sandbox code directly on the host machine):
```yaml
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider # Local execution
allow_host_bash: false # default; host bash is disabled unless explicitly re-enabled
```
**Docker Execution** (runs sandbox code in isolated Docker containers):
```yaml
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider # Docker-based sandbox
```
**BoxLite micro-VM Sandbox** (runs sandbox code in daemonless OCI micro-VMs):
```yaml
sandbox:
use: deerflow.community.boxlite:BoxliteProvider
image: python:3.12-slim
memory_mib: 1024 # optional per-box memory cap
cpus: 2 # optional per-box vCPUs
replicas: 3 # max active + warm VMs per gateway process
idle_timeout: 600 # warm VM idle seconds before stop; 0 disables idle reaping
environment:
PYTHONUNBUFFERED: "1"
```
Install the optional runtime before selecting this provider:
```bash
pip install "deerflow-harness[boxlite]"
```
BoxLite boxes are named from the effective `(user_id, thread_id)` scope and are
released into an in-process warm pool after each turn. The same user/thread can
reclaim its warm VM on the next acquire; different threads cannot share a VM.
`replicas` caps active plus warm VMs. When the cap is reached only warm VMs are
evicted; active VMs continue and the provider may temporarily exceed the cap if
all boxes are active.
**Docker Execution with Kubernetes** (runs sandbox code in Kubernetes pods via provisioner service):
This mode runs each sandbox in an isolated Kubernetes Pod on your **host machine's cluster**. Requires Docker Desktop K8s, OrbStack, or similar local K8s setup.
```yaml
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
provisioner_url: http://provisioner:8002
```
When using Docker development (`make docker-start`), DeerFlow starts the `provisioner` service only if this provisioner mode is configured. In local or plain Docker sandbox modes, `provisioner` is skipped.
Remote/provisioner backends default to explicit file synchronization because
DeerFlow cannot infer whether their `/mnt/user-data` mount points reference the
same storage as the Gateway. When the deployment guarantees that both sides use
the same thread user-data directories, opt out of that extra transfer:
```yaml
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
provisioner_url: http://provisioner:8002
thread_data_mounts: true
```
Leave `thread_data_mounts` unset to retain backend auto-detection. Set it to
`false` to force explicit synchronization even for a local container backend.
Only set it to `true` after verifying the Gateway's
`users/{user_id}/threads/{thread_id}/user-data` directory and the sandbox's
`/mnt/user-data` are the same storage; a false positive skips synchronization
and makes newly uploaded files unavailable inside the sandbox.
See [Provisioner Setup Guide](../../docker/provisioner/README.md) for detailed configuration, prerequisites, and troubleshooting.
**E2B Cloud Sandbox** (runs sandbox code in [E2B](https://e2b.dev) cloud micro-VMs):
```yaml
sandbox:
use: deerflow.community.e2b_sandbox:E2BSandboxProvider
api_key: $E2B_API_KEY # required; or set the E2B_API_KEY env var
template: code-interpreter-v1 # e2b sandbox template id
# domain: e2b.dev # optional; for self-hosted e2b deployments
home_dir: /home/user # /mnt/user-data is remapped under this directory
idle_timeout: 600 # forwarded to e2b's server-side set_timeout()
replicas: 3 # max concurrent sandboxes per gateway process
mount_upload_deadline_seconds: 120 # per-sandbox time budget for mount uploads (seconds)
ownership: # use Redis when more than one gateway shares E2B
type: redis
redis_url: $REDIS_URL
reconciliation_interval_seconds: 60
reconciliation_grace_seconds: 120
reconciliation_orphan_ttl_seconds: 3600
reconciliation_max_pages: 10
reconciliation_max_items: 200
reconciliation_max_seconds: 15
mounts: # one-shot upload of host files at sandbox start
- host_path: /path/on/host
container_path: /home/user/shared
read_only: false
environment: # forwarded to the sandbox at create time
OPENAI_API_KEY: $OPENAI_API_KEY
```
`e2b-code-interpreter` is bundled as a core dependency of `deerflow-harness`,
so no extra install step is needed; just supply your API key and switch the
provider in `config.yaml`.
Notes specific to `E2BSandboxProvider`:
- Each DeerFlow thread is bound to its E2B sandbox via metadata
(`deer_flow_user`, `deer_flow_thread`, `deer_flow_skills_root`). Startup and
periodic reconciliation probe every bounded candidate, adopt one healthy
canonical sandbox, and reap duplicates after a grace period. A sandbox whose
skills root differs from the provider's startup snapshot is never adopted and
is reaped after the same grace period once no live peer owns it.
Provider-tagged entries without a complete user/thread identity are reaped
only after the orphan TTL.
- Ownership leases prevent one gateway from adopting or destroying a sandbox
another live gateway is responsible for. The default in-memory store is safe
only for one gateway process. Multi-worker/load-balanced deployments must use
`sandbox.ownership.type: redis`; an existing Redis stream bridge configuration
is inferred automatically.
- Reconciliation is bounded by page, item, and wall-clock limits. Its summary log
exposes discovered, adopted, duplicate, deferred, killed, dead, and budget-exhausted
counts for operational monitoring.
- Idle expiry is enforced server-side by e2b's `set_timeout()`. The provider
refreshes the timeout on every release so warm sandboxes stay alive long
enough for the next acquire.
- `mounts` are uploaded once when the sandbox starts; e2b cannot host bind-mount
the gateway filesystem, so changes inside the sandbox are not reflected back
on disk automatically. Use the `download_file` tool or write outputs under
`/mnt/user-data/outputs/` (which is mapped to `home_dir/outputs/` inside the
sandbox and surfaced through the standard artifact pipeline) to ship files
back to the gateway.
- `mount_upload_deadline_seconds` sets the per-sandbox time budget for mount
uploads. The provider checks it before each mount, during directory preflight,
and before each SDK write. The deadline does not interrupt active filesystem or
E2B SDK calls. Omitting the key preserves the 120-second default. Values below
1 are clamped to 1; non-numeric or null values fall back to the default.
**OpenSandbox Remote Sandbox** (runs code through an OpenSandbox deployment):
```yaml
sandbox:
use: deerflow.community.opensandbox:OpenSandboxProvider
image: python:3.11
api_key: $OPEN_SANDBOX_API_KEY # optional when the SDK env var is set
domain: localhost:8080 # OPEN_SANDBOX_DOMAIN fallback
protocol: http
request_timeout: 30 # management request timeout seconds
ready_timeout: 30 # create/readiness timeout seconds
use_server_proxy: false # proxy execd/file traffic through server
sandbox_timeout: 14400 # remote lifetime; 0 = explicit cleanup
bash_command_timeout: 600 # default remote command timeout seconds
replicas: 3 # active + warm cap per gateway process
idle_timeout: 600 # warm seconds before destroy; 0 disables
environment:
PYTHONUNBUFFERED: "1"
```
Install the optional SDK before selecting this provider:
```bash
pip install "deerflow-harness[opensandbox]"
```
The provider creates a sandbox per effective user/thread scope and parks it in
an in-process warm pool after each turn. The same scope can reclaim it after a
health check; another user or thread cannot. Create-time readiness and
`/mnt/user-data/{workspace,uploads,outputs}` bootstrap failures are cleaned up
before `acquire()` returns. Each remote owns an independent SDK transport.
Operations renew the configured server-side lifetime, and commands without an
explicit timeout use `bash_command_timeout`; a longer explicit timeout extends
the renewal horizon to cover the command. Operations on one remote are
serialized so a shorter renewal cannot overwrite an in-flight command's
horizon. File transfer uses OpenSandbox's native filesystem API; bounded
`find`/`grep` commands implement the directory and content-search surface.
Downloads are restricted to `/mnt/user-data` and all file paths reject
traversal. Multi-process discovery and ownership coordination are not yet
implemented, so `replicas` is a per-Gateway-process soft cap.
Choose between local execution or Docker-based isolation:
**Option 1: Local Sandbox** (default, simpler setup):
```yaml
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: false
```
`allow_host_bash` is intentionally `false` by default. DeerFlow's local sandbox is a host-side convenience mode, not a secure shell isolation boundary. If you need `bash`, prefer `AioSandboxProvider`. Only set `allow_host_bash: true` for fully trusted single-user local workflows.
When `LocalSandboxProvider` runs under `make up`, it runs inside the `deer-flow-gateway` container. In that mode, `sandbox.mounts[].host_path` is resolved from the gateway container's filesystem, not from your Docker host. If you need a local-sandbox custom mount in production Docker, bind the host directory into the gateway service first, then use the in-container path in `config.yaml`:
```yaml
# docker/docker-compose.yaml or an override file
services:
gateway:
volumes:
- ${DEER_FLOW_REPO_ROOT}/.deer-flow/knowledge:/app/.deer-flow/knowledge:ro
```
```yaml
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
mounts:
- host_path: /app/.deer-flow/knowledge
container_path: /mnt/knowledge
read_only: true
```
If the configured `host_path` is not visible to the gateway process, DeerFlow logs an error and ignores that mount.
**Option 2: Docker Sandbox** (isolated, more secure):
```yaml
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
port: 8080
auto_start: true
container_prefix: deer-flow-sandbox
# Optional: Additional mounts
mounts:
- host_path: /path/on/host
container_path: /path/in/container
read_only: false
```
When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` values in the agent prompt so the agent can discover and operate on mounted directories directly instead of assuming everything must live under `/mnt/user-data`.
#### Sandbox network policy
Local Docker AIO sandboxes can opt into an outbound policy:
```yaml
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
network:
mode: allowlist
allow_domains:
- pypi.org
- files.pythonhosted.org
- registry.npmjs.org
approval: prompt
temporary_grant_ttl: 300
```
`open` (the compatibility default) keeps normal Docker egress. `isolated`
places each sandbox on a per-sandbox internal bridge and denies all outbound
traffic. `allowlist` uses the same bridge and a trusted sidecar that supports
HTTP and HTTPS CONNECT only. Exact domains and leading wildcards such as
`*.pythonhosted.org` are accepted; URLs, ports, and a catch-all `*` are
rejected. Traffic that ignores proxy environment variables still has no route
out of the internal bridge. DeerFlow also sets the upstream AIO image's
`PROXY_SERVER`/`PROXY_EXCLUDE` variables so its Chromium service uses the same
policy sidecar; standard upper/lower-case HTTP, HTTPS, and ALL proxy variables
cover shell and package-manager clients.
The sidecar is dual-homed between the sandbox's internal bridge and a separate
per-sandbox egress bridge with inter-container communication disabled. It is
never attached to Docker's shared default `bridge`, so unrelated containers
cannot reach its container address directly. Its published sandbox-API relay
also requires a cryptographically random per-sandbox token, so containers on
other bridge networks cannot use Docker's host-port mapping to bypass that
separation. Only the sidecar is attached to the egress bridge; outbound traffic
still goes through Docker NAT.
Plain HTTP connections carry exactly one fully framed, policy-checked request;
the sidecar closes the upstream connection afterward so a pipelined request
cannot reuse the first request's decision. HTTP field names are parsed once,
must use the RFC token grammar with the colon immediately following the name,
and malformed or ambiguous fields are rejected before any policy lookup or
forwarding. HTTPS CONNECT validates both the
CONNECT authority and the TLS ClientHello SNI without intercepting TLS. Because
the encrypted HTTP `Host`/`:authority` remains invisible, a deliberately
malicious client may still reach another virtual host co-located on an allowed
endpoint if that server accepts a mismatched inner authority. Deployments that
require strict origin-level HTTPS isolation should use `isolated` mode or an
operator-managed TLS-inspecting egress gateway.
With `approval: prompt`, a denied public domain becomes a Human Input card with
**Deny**, **Allow temporarily**, and **Allow for this sandbox** choices. DeerFlow
does not replay the failed command after approval because it may already have
performed local side effects; the agent must retry it explicitly. Non-interactive
runs auto-deny without opening a card or waiting for input. The sidecar rejects
hostnames that policy does not allow before DNS resolution; allowed hostnames
that resolve to loopback, private, link-local, multicast, IPv6 ULA/site-local,
or cloud metadata destinations are rejected and can never be approved. Raw
TCP/UDP, Git-over-SSH, and other non-HTTP protocols remain unavailable in
restricted modes.
Restricted modes currently require the local Docker backend and Docker Engine
28 or newer. They fail closed on Apple Container, provisioner mode, and older
engines. DeerFlow applies Engine 28's isolated bridge gateway mode to both IPv4
and IPv6 so the sandbox cannot reach services bound to either host-side bridge
address. The sandbox, sidecar, internal network, and egress network carry a
digest of the effective policy, proxy source, and image reference; startup and
reconciliation destroy and recreate a persisted resource set when that identity
or its required network properties no longer match. Docker sandboxes in every
mode also carry stable identity and mode labels. After a Gateway restart,
changing between `open` and a restricted mode is therefore reported as an
incompatible persisted sandbox and replaced only after the normal ownership,
orphan-grace, and teardown fences. Unlabelled open containers from older
DeerFlow versions are recognized when they use the configured image and retain
their published API port. Docker Desktop is detected
from the daemon, not the Gateway process, so Docker-outside-of-Docker deployments
handle its synthetic DNS range correctly. The policy sidecar publishes only its
fixed sandbox-API relay back to the Gateway; the sandbox API itself is not
published. DeerFlow generates a separate relay token for each sandbox, requires
it on every new relay connection, reconstructs it from Docker during discovery,
and injects it only into Gateway control-plane clients. The token is excluded
from `SandboxInfo` serialization, representations, and command logs.
Mirror or digest-pin `network.proxy_image` in production environments that
require supply-chain pinning.
#### Sandbox container network exposure and hardening
The sandbox HTTP API (`/v1/shell/*` and friends) has no authentication: anyone who can reach a published sandbox port can execute arbitrary commands in that sandbox. For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox port to `127.0.0.1` so it is not exposed on other host interfaces. For Docker-outside-of-Docker deployments that connect through `host.docker.internal`, the port is bound to the address that hostname actually resolves to — the daemon's `host-gateway-ip` mapping (customizable, possibly IPv6) — so the published port and the address the gateway connects to always match, and the port is no longer published on external network interfaces (previously it was bound to `0.0.0.0`). If resolution fails, the Docker default bridge gateway (via `docker network inspect bridge`, falling back to `172.17.0.1`) is used as a best-effort bind and a warning is logged. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address; setting it to `0.0.0.0` restores the legacy broad bind, which re-exposes the unauthenticated exec API on every interface and should be paired with an external firewall.
Local Docker sandbox containers are also hardened by default: all Linux capabilities are dropped (`--cap-drop=ALL`) except a five-capability compatibility allowlist — `CHOWN`, `FOWNER`, `SETUID`, `SETGID`, and `DAC_OVERRIDE` — while privilege escalation across exec stays blocked with `no-new-privileges` and CPU/memory/PID resources are bounded. `CHOWN`/`SETUID`/`SETGID` support the runtime user handoff and `DAC_OVERRIDE` supports the root nginx master's writes to gem-owned logs. `FOWNER` is specifically required by the newer AIO 1.11.x startup path (regression-tested against the recommended 1.11.0 image), which runs `chmod /run/user/1000` after capabilities are dropped. Images that do not perform that `chmod` do not need `FOWNER`; DeerFlow deliberately does not guess a smaller set from mutable tags, digests, or arbitrary custom images, so the default compatibility allowlist remains version-agnostic.
A custom image that is already fully initialized as a non-root user and needs none of those compatibility capabilities should set `DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS=0` to drop the whole set. This is an all-or-nothing opt-out, not a per-capability selector: an older or custom root-initialized image that does not need `FOWNER` may still require `CHOWN`, `SETUID`, `SETGID`, or `DAC_OVERRIDE` and should therefore leave the compatibility set enabled. Retained capabilities remain available for the container's lifetime and can let sandboxed code change ownership or mode on accessible bind-mounted paths, impersonate mounted-file UIDs/GIDs, or bypass discretionary access checks. `no-new-privileges` does **not** mitigate that existing-capability risk — it only blocks gaining new privileges across exec. One hardening knob is relaxed by default: the shipped AIO image runs with `seccomp=unconfined` because its Chromium browser does not start under Docker's default seccomp profile (syscall filtering is disabled — see the two seccomp variables below to change that). The following environment variables (set them in the gateway process, e.g. via `.env` loaded by docker-compose, or the gateway service `environment:`) tune or disable each knob:
| Environment variable | Default | Purpose |
| --- | --- | --- |
| `DEER_FLOW_SANDBOX_BIND_HOST` | loopback / bridge gateway (see above) | Host interface for the sandbox `-p` publish. Must be an IP literal (bare or bracketed IPv6) or a hostname, which is resolved to an address first — Docker publish specs do not accept hostnames. `0.0.0.0` restores the legacy broad bind (risky). |
| `DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED` | on | The shipped AIO image's Chromium browser does not start under Docker's default seccomp profile (see the upstream agent-infra sandbox FAQ), so `seccomp=unconfined` remains the default. Set to `0` to run with the built-in profile — passed explicitly as `seccomp=builtin`, so a daemon configured with a different default cannot weaken the opt-out — and only for images verified to start and pass browser checks with it. |
| `DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS` | on | Keeps the five-capability compatibility set (`CHOWN`/`FOWNER`/`SETUID`/`SETGID`/`DAC_OVERRIDE`). `FOWNER` specifically covers the newer AIO 1.11.x startup `chmod /run/user/1000` path (tested with 1.11.0); images without that step do not need `FOWNER`, but DeerFlow does not infer per-image capability subsets from tags/digests/custom images. Set to `0` only for images that need none of the five — the switch drops the entire set. |
| `DEER_FLOW_SANDBOX_SECCOMP_PROFILE` | unset | Path to a custom seccomp profile (e.g. a restricted, Chromium-compatible one built from Docker's default plus the namespace syscalls Chromium needs). Takes precedence over the unconfined default. |
| `DEER_FLOW_SANDBOX_MEMORY` | `2g` | `--memory` limit per sandbox container. `0`/`none` disables the limit. |
| `DEER_FLOW_SANDBOX_CPUS` | `2` | `--cpus` limit per sandbox container. `0`/`none` disables the limit. |
| `DEER_FLOW_SANDBOX_PIDS_LIMIT` | `512` | `--pids-limit` per sandbox container (fork-bomb guard). `0`/`none` disables the limit. |
| `DEER_FLOW_SANDBOX_CONTAINER_USER` | unset (image default) | Passed through as `--user` (e.g. `1000:1000`). The default AIO image's user is upstream-controlled, so DeerFlow does not force one; set this only if you know your image's runtime user. |
| `DEER_FLOW_SANDBOX_NETWORK` | unset (daemon default network) | Legacy `open`-mode escape hatch passed through as `--network`. Prefer `sandbox.network` for managed isolation. `host`, `container:<name>`, and `none` are rejected at startup. Restricted modes ignore this variable and use their own per-sandbox internal network. |
These hardening flags are Docker-only; Apple Container (`container` runtime) keeps its previous, unhardened invocation and therefore supports only `network.mode: open`. On macOS, an `open` Gateway normally prefers Apple Container, but it keeps using Docker while the configured sandbox prefix has managed Docker sandboxes so startup reconciliation can safely replace resources left by a restricted-mode deployment before the runtime changes.
Sandbox control-plane HTTP calls to loopback/private IPs, single-label cluster
hosts, and Docker/Podman internal hostnames bypass `HTTP_PROXY`/`HTTPS_PROXY`
inside the client. This prevents an inherited proxy from returning a misleading
502 for a healthy local sandbox. Externally hosted sandbox FQDNs and public IPs
continue to use the normal environment proxy configuration.
### Building a Custom AIO Sandbox Image
`AioSandboxProvider` talks to the sandbox container through the `agent-sandbox` SDK. The Dockerfile for the default `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest` image is not part of this repository; DeerFlow treats that image as an upstream AIO sandbox runtime.
For persistent system or language dependencies, extend the published image and keep its startup command intact:
```dockerfile
FROM enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
USER root
# Example user dependency; not required by DeerFlow itself.
RUN apt-get update \
&& apt-get install -y --no-install-recommends graphviz \
&& rm -rf /var/lib/apt/lists/*
# Example Python dependency for work done inside the sandbox.
RUN python -m pip install --no-cache-dir pandas
# Do not override ENTRYPOINT or CMD; keep the upstream sandbox server startup.
```
Use the custom image in local Docker or Apple Container mode with `sandbox.image`:
```yaml
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
image: your-registry/your-aio-sandbox:tag
```
In provisioner mode, sandbox Pods are created by the provisioner service, so configure the provisioner `SANDBOX_IMAGE` environment variable instead of `sandbox.image`. See the [Provisioner Setup Guide](../../docker/provisioner/README.md#custom-sandbox-image).
If you rebuild the runtime from scratch instead of extending the published image, it must expose the same HTTP API used by `agent-sandbox`. DeerFlow currently depends on:
- `sandbox.get_context()`, including `home_dir`
- `shell.exec_command(...)`
- `bash.exec(...)` — only exercised for per-command environment injection (skills that declare `required-secrets`). The `/v1/bash/*` routes exist since upstream all-in-one-sandbox `1.9.3`; on older images (including a `latest` tag still frozen on the `1.0.0.x` line) DeerFlow fails fast with an actionable error instead of surfacing the raw 404. Pin `sandbox.image` to `1.9.3` or newer (e.g. `1.11.0`) and recreate the sandbox container to use `required-secrets` with the AIO sandbox.
- `file.read_file(...)`
- `file.write_file(...)`, including base64 writes for binary content
- streamed `file.download_file(...)`
- `file.find_files(...)`
- `file.list_path(...)`
- `file.search_in_file(...)`
Custom images must also keep these compatibility constraints:
- The container should listen on the configured sandbox port, `8080` by default.
- `/mnt/user-data` must remain writable because DeerFlow mounts thread workspace, uploads, and outputs there.
- `home_dir` comes from the sandbox context endpoint; do not assume DeerFlow hardcodes it.
- Shell command handling must remain compatible with serialized `exec_command` calls. DeerFlow serializes shell access on the host side to avoid corrupting the sandbox's persistent shell session.
### Skills
Configure the skills directory for specialized workflows:
```yaml
skills:
# Host path (optional, default: ../skills)
path: /custom/path/to/skills
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
```
For the AIO provider (including the Kubernetes provisioner) and E2B,
`skills.container_path` is captured when the provider starts and must be one
canonical absolute, non-root POSIX path. Do not use redundant separators,
`.`/`..`, or a path that contains or sits below DeerFlow's reserved mounts
(`/mnt/user-data`, `/mnt/acp-workspace`, or `/mnt/integrations/lark-cli`).
Restart the Gateway after changing it so sandbox identities and mounts use the
same root. E2B also records the root in remote metadata and refuses to adopt a
VM created for another root.
**How Skills Work**:
- Skills are stored in `deer-flow/skills/{public,custom}/`
- Each skill has a `SKILL.md` file with metadata
- Skills are automatically discovered and loaded
- Available in both local and Docker sandbox via path mapping
Skill installs and agent-managed skill writes also run through native deterministic SkillScan before the LLM scanner:
```yaml
skill_scan:
enabled: true
```
Set `skill_scan.enabled: false` to disable only the deterministic analyzers. Safe archive extraction and the LLM-based skill scanner still run.
**Per-Agent Skill Filtering**:
Custom agents can restrict which skills they discover and activate by defining a `skills` field in their `config.yaml` (located at `workspace/agents/<agent_name>/config.yaml`):
- **Omitted or `null`**: Makes all globally enabled skills available (default fallback).
- **`[]` (empty list)**: Disables all skills for this specific agent.
- **`["skill-name"]`**: Makes only the explicitly specified skills available.
This field is a discovery and activation allowlist; it does not activate every listed skill's `allowed-tools` policy when the agent is constructed. Use `tool_groups` to define the agent's baseline tools. A listed skill's policy applies only after slash activation or an actual `SKILL.md` load.
The same semantics apply to `subagents.agents.<name>.skills` and `subagents.custom_agents.<name>.skills`: omitted or `null` exposes all enabled skills, `[]` exposes none, and a list limits discovery and activation. A passive subagent skill never removes baseline tools; its `allowed-tools` declaration becomes active only after slash activation or a completed `SKILL.md` read.
`LocalSandboxProvider` enforces this filesystem view through its managed virtual
path mappings only. Explicit per-Agent skill policies therefore fail closed when
`sandbox.allow_host_bash` is enabled, because host subprocesses can bypass those
mappings. Keep host bash disabled (the default), or use AIO/provisioner/E2B when
shell access and filesystem isolation are both required.
### Title Generation
Automatic conversation title generation:
```yaml
title:
enabled: true
max_words: 6
max_chars: 60
model_name: null # null = fast local fallback; set a model name to use LLM title generation
```
### GitHub API Token (Optional for GitHub Deep Research Skill)
The default GitHub API rate limits are quite restrictive. For frequent project research, we recommend configuring a personal access token (PAT) with read-only permissions.
**Configuration Steps**:
1. Uncomment the `GITHUB_TOKEN` line in the `.env` file and add your personal access token
2. Restart the DeerFlow service to apply changes
## Environment Variables
DeerFlow supports environment variable substitution using the `$` prefix:
```yaml
models:
- api_key: $OPENAI_API_KEY # Reads from environment
```
**Common Environment Variables**:
- `OPENAI_API_KEY` - OpenAI API key
- `ANTHROPIC_API_KEY` - Anthropic API key
- `DEEPSEEK_API_KEY` - DeepSeek API key
- `MIMO_API_KEY` - Xiaomi MiMo API key
- `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint)
- `TAVILY_API_KEY` - Tavily search API key
- `BRAVE_SEARCH_API_KEY` - Brave Search API key for `web_search` and `image_search`
- `SERPER_API_KEY` - Serper (Google Search/Images API) key for `web_search` and `image_search`
- `SERPLY_API_KEY` - [Serply](https://serply.io) key for `web_search` (Google Search, plus Google News and Google Scholar via `vertical`)
- `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)
- `SOFYA_API_KEY` - [Sofya](https://sofya.co) key for `web_search` and `web_fetch`
- `BROWSERLESS_TOKEN` - Browserless Cloud token for `web_capture` (optional for self-hosted Browserless)
- `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths
- `DEER_FLOW_CONFIG_PATH` - Custom config file path
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Custom extensions config file path
- `DEER_FLOW_HOME` - Runtime state directory (defaults to `.deer-flow` under the project root)
- `DEER_FLOW_SKILLS_PATH` - Skills directory when `skills.path` is omitted
- `GATEWAY_ENABLE_DOCS` - Set to `false` to disable Swagger UI (`/docs`), ReDoc (`/redoc`), and OpenAPI schema (`/openapi.json`) endpoints (default: `true`)
## Configuration Location
The configuration file should be placed in the **project root directory** (`deer-flow/config.yaml`). Set `DEER_FLOW_PROJECT_ROOT` when the process may start from another working directory, or set `DEER_FLOW_CONFIG_PATH` to point at a specific file.
## Configuration Priority
DeerFlow searches for configuration in this order:
1. Path specified in code via `config_path` argument
2. Path from `DEER_FLOW_CONFIG_PATH` environment variable
3. `config.yaml` under `DEER_FLOW_PROJECT_ROOT`, or under the current working directory when `DEER_FLOW_PROJECT_ROOT` is unset
4. Legacy backend/repository-root locations for monorepo compatibility
## Security Notes
### Sandbox Isolation and the Docker Socket (DooD)
DeerFlow executes agent-generated shell/code through a configurable sandbox
(`sandbox.use` in `config.yaml`). The isolation guarantees differ by mode, and
one mode requires mounting the host Docker socket. Understand the trade-offs
before exposing an instance to untrusted input.
| Mode | `config.yaml` | Host Docker socket | Isolation |
|------|---------------|--------------------|-----------|
| `local` (default) | `deerflow.sandbox.local:LocalSandboxProvider` | Not mounted | Commands run **inside the gateway container** on its filesystem. Not a strong boundary — `allow_host_bash` is `false` by default and should stay off for untrusted workloads. |
| `aio` (pure DooD) | `deerflow.community.aio_sandbox:AioSandboxProvider` (no `provisioner_url`) | **Mounted** (opt-in overlay) | Sandbox containers are started via the host Docker daemon. |
| `provisioner` (Kubernetes) | `AioSandboxProvider` + `provisioner_url` | Not mounted | Sandbox pods are created through the provisioner's K8s API over HTTP. Strongest isolation. |
#### The Docker socket is host root
Mounting `/var/run/docker.sock` into a container grants that container
**root-equivalent control of the host**: anything able to reach the socket can
start a new container that bind-mounts the host filesystem and escape. This
matters for DeerFlow because the gateway executes model-generated commands, so a
prompt injection or any in-container code-execution primitive could pivot to the
host through the socket.
To keep this off the default attack surface:
- The host Docker socket is **not** mounted by the default Compose stack. It is
added only for `aio` mode through the opt-in `docker/docker-compose.dood.yaml`
overlay, which `scripts/deploy.sh` and `scripts/docker.sh` append
automatically when `detect_sandbox_mode()` returns `aio`.
- Prefer **provisioner/Kubernetes mode** for multi-tenant or internet-exposed
deployments — it isolates sandboxes without handing the gateway the host
daemon.
- If you must use `aio`/DooD, treat the host as part of the gateway's trust
boundary: run it on a dedicated host, and consider a scoped Docker API proxy
instead of the raw socket.
> Note: the gateway bind-mounts `$HOME/.claude` and `$HOME/.codex` (read-only)
> for CLI auto-auth in **all** modes. These hold long-lived CLI credentials;
> scope or omit them when the gateway runs untrusted workloads.
### CLI Credential Mounts (Claude Code / Codex / MiniMax Code)
DeerFlow can reuse your Claude Code / Codex CLI subscription login as a model
provider (`ClaudeChatModel`, the Codex provider) or for ACP agents that run the
CLI in-container. The Compose stack used to bind-mount the **entire** `~/.claude`
and `~/.codex` directories (read-only) into the gateway container in **every**
configuration — exposing not just credentials but full conversation history,
per-project session data, and global CLI config. A gateway compromise (prompt
injection, tool/MCP misuse, RCE) would leak all of it.
These directories are **no longer mounted by default**. Supply CLI credentials
with the least exposure that fits your setup:
| Need | How | Exposure |
|------|-----|----------|
| Claude model provider | env `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_AUTH_TOKEN` (via `.env`), or `CLAUDE_CODE_CREDENTIALS_PATH` → a single mounted `.credentials.json` | none / one file |
| Codex model provider | env `CODEX_AUTH_PATH` pointing at a single mounted `auth.json` | one file |
| ACP agent | the adapter's own auth — many ACP adapters take an env API key (e.g. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) and need no mount; use the opt-in `docker/docker-compose.cli-auth.yaml` overlay only if your adapter reads the full CLI config dir | none / full dir |
The Gateway credential loader checks environment variables **before** the
default credential files, so the env-token paths need no bind mount at all. ACP
adapters authenticate independently of DeerFlow via their own documented env —
for example the common `claude-code-acp` adapter starts as
`ANTHROPIC_API_KEY=… claude-code-acp` and honors `CLAUDE_CONFIG_DIR` to redirect
its config directory, so it needs no `~/.claude` mount at all. Prefer the
adapter's documented env auth, and reach for the
`docker-compose.cli-auth.yaml` overlay only as a fallback for an adapter that
genuinely reads the full CLI config directory.
MiniMax Code is a native ACP agent, so it does not need an adapter. For local
Gateway runs, install it with `npm install --global @minimax-ai/code`, run
`mcode login`, and configure `acp_agents.mcode` with `command: mcode` and
`args: ["acp"]`. The executable and its authenticated runtime must be available
inside the Gateway environment; a host-only installation is not visible to a
Docker container. DeerFlow forwards enabled MCP servers to the MCode session.
Leave `auto_approve_permissions` disabled for untrusted tasks, and enable it
only when the agent is expected to edit files or run commands for a trusted
task.
## Best Practices
1. **Place `config.yaml` in project root** - Set `DEER_FLOW_PROJECT_ROOT` if the runtime starts elsewhere
2. **Never commit `config.yaml`** - It's already in `.gitignore`
3. **Use environment variables for secrets** - Don't hardcode API keys
4. **Keep `config.example.yaml` updated** - Document all new options
5. **Test configuration changes locally** - Before deploying
6. **Use Docker sandbox for production** - Better isolation and security
## Troubleshooting
### "Config file not found"
- Ensure `config.yaml` exists in the **project root** directory (`deer-flow/config.yaml`)
- If the runtime starts outside the project root, set `DEER_FLOW_PROJECT_ROOT`
- Alternatively, set `DEER_FLOW_CONFIG_PATH` environment variable to custom location
### "Invalid API key"
- Verify environment variables are set correctly
- Check that `$` prefix is used for env var references
### "Skills not loading"
- Check that `deer-flow/skills/` directory exists
- Verify skills have valid `SKILL.md` files
- Check `skills.path` or `DEER_FLOW_SKILLS_PATH` if using a custom path
### "Docker sandbox fails to start"
- Ensure Docker is running
- Check port 8080 (or configured port) is available
- Verify Docker image is accessible
## Examples
See `config.example.yaml` for complete examples of all configuration options.