* feat: add scheduled tasks MVP
* fix: harden scheduled task execution semantics
* feat(scheduled-tasks): preset-driven schedule form with timezone and live preview
Replace the raw cron input with a preset Select (hourly/daily/weekly/monthly/custom)
plus structured inputs (time picker, weekday toggles, day-of-month), datetime-local
for one-time tasks, a timezone selector defaulting to the browser timezone, and a
live human-readable preview. Reuses one ScheduledTaskScheduleInput for create and
edit; backend contract unchanged; zero new deps (pure Intl + DST-safe offset helpers).
* feat(scheduled-tasks): full-page i18n + recipe templates + E2E locale pin
Localize the rest of the scheduled-tasks page (filters, detail pane, actions,
edit form, run list, enum values) via t.scheduledTasks.* in en/zh. Add four
built-in recipe templates (GitHub Trending, news digest, issue triage, weekly
report) exposed as a chip row that pre-fills title + prompt + schedule. Pin
Playwright locale to en-US so E2E selectors stay stable against i18n. No backend
change, no new deps.
* fix(scheduled-tasks): idempotent 0003 migration, update head constants, future-date once test
Merge with main surfaced three CI failures:
- 0003_scheduled_tasks create_table collided with legacy test seeds that
build from full metadata; guard with inspector.has_table so the revision
no-ops when the table already exists (0004/0005 are already idempotent via
_helpers.py).
- persistence bootstrap concurrency/regression tests pinned HEAD to main's
0002_runs_token_usage; bump to the new head 0005_scheduled_task_thread_nullable.
- once-task router test used a fixed past run_at and tripped the
must-be-in-the-future validation; use a future date.
* address review: ok-check, 502 for trigger failure, mock fields, migration filename, doc fences
- fetchThreadScheduledTasks now checks response.ok like the other fetchers.
- trigger endpoint returns 502 (not 409) when dispatch fails outright, so
clients can distinguish a real conflict from a server-side failure.
- E2E mock normalizes scheduled-task objects with context_mode/last_thread_id
and nullable thread_id, matching the backend contract the UI renders against.
- Rename 0002_scheduled_tasks.py -> 0003_scheduled_tasks.py to match its
revision id (file was renamed in spirit already; filename now follows).
- CONFIGURATION.md: close the Tool Groups yaml fence and drop the stray fence
after the Scheduler notes so the sections render correctly.
* fix(scheduled-tasks): harden lease, poller, config, and frontend UX after review
* fix(scheduled-tasks): harden run lifecycle, overlap skip, non_interactive gating, and DST conversion after review
- defer a once task's terminal status to the run-completion hook; the task
stays running until the real outcome, and a startup sweep cancels once
tasks orphaned by a crash (launch-time 'completed' could stick forever)
- record interrupted runs as a distinct 'interrupted' run status with a
readable message; an interrupted once task ends 'cancelled', not 'failed'
- enforce overlap_policy=skip for fresh_thread_per_run via an active-run
pre-check (same-thread ConflictError can never fire across fresh threads)
- protect terminal run statuses from the late launch-path 'running' write
- honor context.non_interactive only for internally-authenticated callers;
arbitrary clients can no longer strip ask_clarification
- fix DST-stale timezone offset in zonedLocalToUtcIso by re-deriving the
offset at the resolved instant (once tasks fired an hour late around
spring-forward and the create->edit round-trip diverged)
- drop dead ScheduledTaskRunRepository.update_by_run_id; share one Gateway
API error helper between channels and scheduled-tasks frontends
* fix(scheduled-tasks): close review round-3 gaps in guards, concurrency, and API ergonomics
- scrub internal-only context keys (non_interactive) from the assembled run
config for non-internal callers: gating body.context alone left the same
key smuggle-able through the free-form body.config copied verbatim by
build_run_config
- guard update_after_launch with protect_terminal so the launch bookkeeping
write cannot clobber a once task already finalized by a fast-failing run's
completion hook (parent-row sibling of the run-row guard)
- reject a manual trigger while the task has an active run (409) instead of
launching a duplicate concurrent run on fresh_thread_per_run
- re-arm a terminal once task to enabled when PATCH pushes run_at into the
future; previously the endpoint returned 200 with a next_run_at that could
never be claimed
- make max_concurrent_runs a real global cap: each poll claims only into the
remaining budget of active (queued/running) scheduled runs
- paginate GET /scheduled-tasks/{id}/runs (limit<=200, offset) and push the
thread filter of /threads/{id}/scheduled-tasks into SQL
- stamp context.user_id on scheduler-launched runs, matching IM channels, so
user-scoped guardrail providers see the owning user
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
30 KiB
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_versionin your config is treated as version 0. - Run
make config-upgradeto auto-merge missing fields (your existing values are preserved, a.bakbackup is created). - When changing the config schema, bump
config_versioninconfig.example.yaml.
Configuration Sections
Models
Configure the LLM models available to the agent:
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:
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:
CodexChatModelloads Codex CLI auth from~/.codex/auth.json- The Codex Responses endpoint currently rejects
max_tokensandmax_output_tokens, soCodexChatModeldoes not expose a request-level token cap ClaudeChatModelacceptsCLAUDE_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.pyto export Claude Code auth explicitly when needed
To use OpenAI's /v1/responses endpoint with LangChain, keep using langchain_openai:ChatOpenAI and set:
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:
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:
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:
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).
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.
Tool Groups
Organize tools into logical groups:
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:
scheduler:
enabled: false
poll_interval_seconds: 5
lease_seconds: 120
max_concurrent_runs: 3
min_once_delay_seconds: 60
Notes:
enabled: falsekeeps background polling off by default.max_concurrent_runsis a global cap on active scheduled runs (queued/running run rows); each poll cycle claims only into the remaining budget, so long runs accumulating across cycles cannot exceed it.- All scheduler fields are restart-required; edits need a Gateway restart.
- Multi-worker deployments (
GATEWAY_WORKERS > 1) must use the Postgres database backend. SQLite silently ignores row-level locks, so multiple workers can double-fire the same task. - The MVP supports thread reuse and fresh-thread-per-run execution modes.
- The MVP supports only
onceandcron. - Manual trigger uses the same scheduled-task resource and run lifecycle.
- Scheduled task definitions and task-run history are persisted in the application database.
Tools
Configure specific tools available to the agent:
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, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute)web_fetch- Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless)web_capture- Capture rendered webpage screenshots as artifacts (Browserless)image_search- Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)ls- List directory contentsread_file- Read file contentswrite_file- Write file contentsstr_replace- String replacement in filesbash- Execute bash commands
Browserless can be configured as an opt-in visual capture tool:
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
(set BROWSERLESS_TOKEN) or run one locally with Docker:
# 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):
export BROWSERLESS_TOKEN=local-dev-token
Verify the instance is reachable before enabling the tool:
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 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):
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):
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider # Docker-based sandbox
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.
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.
See Provisioner Setup Guide for detailed configuration, prerequisites, and troubleshooting.
E2B Cloud Sandbox (runs sandbox code in E2B cloud micro-VMs):
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
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), so the same thread reuses the same sandbox across gateway restarts and across processes — no cross-process file lock is needed because the e2b control plane is the source of truth. - 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. mountsare 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 thedownload_filetool or write outputs under/mnt/user-data/outputs/(which is mapped tohome_dir/outputs/inside the sandbox and surfaced through the standard artifact pipeline) to ship files back to the gateway.
Choose between local execution or Docker-based isolation:
Option 1: Local Sandbox (default, simpler setup):
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:
# docker/docker-compose.yaml or an override file
services:
gateway:
volumes:
- ${DEER_FLOW_REPO_ROOT}/.deer-flow/knowledge:/app/.deer-flow/knowledge:ro
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):
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.
For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox HTTP port to 127.0.0.1 by default so it is not exposed on every host interface. Docker-outside-of-Docker deployments that connect through host.docker.internal keep the broad legacy bind for compatibility. Set DEER_FLOW_SANDBOX_BIND_HOST explicitly if your deployment needs a different bind address.
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:
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:
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.
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(), includinghome_dirshell.exec_command(...)bash.exec(...)— only exercised for per-command environment injection (skills that declarerequired-secrets). The/v1/bash/*routes exist since upstream all-in-one-sandbox1.9.3; on older images (including alatesttag still frozen on the1.0.0.xline) DeerFlow fails fast with an actionable error instead of surfacing the raw 404. Pinsandbox.imageto1.9.3or newer (e.g.1.11.0) and recreate the sandbox container to userequired-secretswith 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,
8080by default. /mnt/user-datamust remain writable because DeerFlow mounts thread workspace, uploads, and outputs there.home_dircomes from the sandbox context endpoint; do not assume DeerFlow hardcodes it.- Shell command handling must remain compatible with serialized
exec_commandcalls. 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:
skills:
# Host path (optional, default: ../skills)
path: /custom/path/to/skills
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
How Skills Work:
- Skills are stored in
deer-flow/skills/{public,custom}/ - Each skill has a
SKILL.mdfile with metadata - Skills are automatically discovered and loaded
- Available in both local and Docker sandbox via path mapping
Per-Agent Skill Filtering:
Custom agents can restrict which skills they load by defining a skills field in their config.yaml (located at workspace/agents/<agent_name>/config.yaml):
- Omitted or
null: Loads all globally enabled skills (default fallback). [](empty list): Disables all skills for this specific agent.["skill-name"]: Loads only the explicitly specified skills.
Title Generation
Automatic conversation title generation:
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:
- Uncomment the
GITHUB_TOKENline in the.envfile and add your personal access token - Restart the DeerFlow service to apply changes
Environment Variables
DeerFlow supports environment variable substitution using the $ prefix:
models:
- api_key: $OPENAI_API_KEY # Reads from environment
Common Environment Variables:
OPENAI_API_KEY- OpenAI API keyANTHROPIC_API_KEY- Anthropic API keyDEEPSEEK_API_KEY- DeepSeek API keyMIMO_API_KEY- Xiaomi MiMo API keyNOVITA_API_KEY- Novita API key (OpenAI-compatible endpoint)TAVILY_API_KEY- Tavily search API keyBRAVE_SEARCH_API_KEY- Brave Search API key forweb_searchandimage_searchSERPER_API_KEY- Serper (Google Search/Images API) key forweb_searchandimage_searchGROUNDROUTE_API_KEY- GroundRoute meta-search API key forweb_searchandweb_fetch(routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)BROWSERLESS_TOKEN- Browserless Cloud token forweb_capture(optional for self-hosted Browserless)DEER_FLOW_PROJECT_ROOT- Project root for relative runtime pathsDEER_FLOW_CONFIG_PATH- Custom config file pathDEER_FLOW_EXTENSIONS_CONFIG_PATH- Custom extensions config file pathDEER_FLOW_HOME- Runtime state directory (defaults to.deer-flowunder the project root)DEER_FLOW_SKILLS_PATH- Skills directory whenskills.pathis omittedGATEWAY_ENABLE_DOCS- Set tofalseto 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:
- Path specified in code via
config_pathargument - Path from
DEER_FLOW_CONFIG_PATHenvironment variable config.yamlunderDEER_FLOW_PROJECT_ROOT, or under the current working directory whenDEER_FLOW_PROJECT_ROOTis unset- 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
aiomode through the opt-indocker/docker-compose.dood.yamloverlay, whichscripts/deploy.shandscripts/docker.shappend automatically whendetect_sandbox_mode()returnsaio. - 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/.claudeand$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)
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.
Best Practices
- Place
config.yamlin project root - SetDEER_FLOW_PROJECT_ROOTif the runtime starts elsewhere - Never commit
config.yaml- It's already in.gitignore - Use environment variables for secrets - Don't hardcode API keys
- Keep
config.example.yamlupdated - Document all new options - Test configuration changes locally - Before deploying
- Use Docker sandbox for production - Better isolation and security
Troubleshooting
"Config file not found"
- Ensure
config.yamlexists 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_PATHenvironment 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.mdfiles - Check
skills.pathorDEER_FLOW_SKILLS_PATHif 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.