Dify apps and workflows
Use this when creating or editing apps, the canvas, DSL, or publishing. Login: Dify console API. After publish: Dify service API. Failures: Dify troubleshooting. Trigger URLs, webhook body size, schedule poller, sandbox timeouts: Dify compose and config. Plugin HTTP endpoints (/e/{hook_id}) are not workflow triggers: Dify workspace extras.
Community only. Triggers are workflow mode only (not chatflow). Quota / 429 "upgrade your plan" is Cloud billing — Community QuotaService.reserve is a no-op.
Canvas Loop/iteration/tools caps (LOOP_NODE_MAX_COUNT, MAX_ITERATIONS_NUM, MAX_TOOLS_NUM) are web env — Dify compose and config. Field-tested on 1.16.1 production canvases; this tree targets 1.17. DSL version is not the Dify software version — GET /console/api/app-dsl-version (1.17 exports 0.7.0; 1.16.1 exports were often 0.1.5). Never copy a foreign version.
Create
POST /console/api/apps
{"name":"发票助手","mode":"workflow","description":"optional, max 400"}
mode: chat | agent-chat | advanced-chat (chatflow) | workflow | completion. Agent Studio is POST /agent.
List GET /apps. Copy POST /apps/{id}/copy. Convert chat → workflow: POST /apps/{id}/convert-to-workflow.
Code-first loop (preferred)
Generate JSON (valid YAML) → validate → import or sync draft → publish → draft/run every branch. Do not only click the canvas.
- Login (Base64 password + cookie + CSRF). Tokens last ~1h (
unauthorized→ login again). GET /apps/{id}/workflows/draftand keephash.- Import
POST /apps/imports{"mode":"yaml-content","yaml_content":"..."}creates a new app every time. Callers must still sync in place to ship a new version — see the warning below. - Sync in place (keep URL / API key):
POST /apps/{id}/workflows/draft(not PATCH). 1.17 payload isgraph+features+hash+ optionalconversation_variables. Do not sendenvironment_variables— pydanticextra_forbidden. Env var edits useenvironment_variable_patch: {environment_variables:[...], deleted_environment_variable_ids:[...]}. ThenPOST /apps/{id}/workflows/publish{}. - Debug without WebApp:
- workflow:
POST /apps/{id}/workflows/draft/run - chatflow:
POST /apps/{id}/advanced-chat/workflows/draft/runwithquery+conversation_idParse SSEnode_finished(status/outputs/error) andworkflow_finished.
- workflow:
draft_workflow_not_sync → GET a fresh hash. Stale hash → 400.
NEVER delete-and-reimport to ship a new version
Do not "publish a new version" by deleting the old app and POST /apps/imports-ing the DSL again (and do not let a publish/bootstrap script do it). imports mints a brand-new app_id every time, but everything the caller depends on is bound to the old id:
Bound to old app_id |
What you lose on re-import |
|---|---|
POST /apps/{id}/api-keys Service API keys |
backend Authorization: Bearer … → 401/404; the key "disappears" |
POST /apps/{id}/site-enable WebApp |
share URL / access_token dead |
/apps/{id}/chat-conversations, /workflow-app-logs, /workflow-runs, annotations |
all test records / run history gone (not migrated to the new id) |
trigger rows (/apps/{id}/triggers, schedule plans, webhook webhook_id) |
public /triggers/webhook/{id} URL changes; callers break |
Correct "new version" flow = sync in place on the SAME id: GET .../workflows/draft → edit graph → POST .../workflows/draft (+ hash) → POST .../workflows/publish. The app_id never changes, so keys, site, logs, and triggers survive. ensure_app_key-style helpers must reuse an existing key (GET /apps/{id}/api-keys first, only POST if none) rather than regenerate.
Only use imports for a genuinely new app (different product/role), and then publish it as its own tool/endpoint. If you truly must move a graph to a new app, re-create the api-key, re-enable site/triggers, and re-point every caller — but the old test records are unrecoverable, so prefer in-place sync.
DSL top-level
{
"version": "<from GET /app-dsl-version>",
"kind": "app",
"app": {"name":"...","mode":"advanced-chat|workflow","icon":"🔧","icon_background":"#E0F2FE","description":""},
"workflow": {
"graph": {"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":0.5}},
"features": {},
"environment_variables": [],
"conversation_variables": []
}
}
advanced-chat:sys.query/sys.files(array) / answer nodes. Features: opening_statement, file_upload, retriever_resource.workflow: nosys.query. Files live on start variables (often a singlefile, notsys.files).- Write JSON with
json.dump; do not hand-edit YAML.
Node top-level (frontend) vs data.type (engine)
| Field | Required | Missing |
|---|---|---|
top-level type |
"custom" for almost every node |
canvas React #130 (component undefined) |
| loop start | "custom-loop-start" 44×48 |
same crash |
| iteration start | "custom-iteration-start" |
same class of crash |
position {x,y} |
loop children are relative to parent | layout junk |
width/height |
loop container must cover children | children clip |
data.type |
llm / code / loop / trigger-schedule / trigger-webhook / trigger-plugin / … no prefix |
engine invalid |
| Do not write | positionAbsolute, child extent, loop outputs |
unofficial |
Loop children: parentId=<loopId>, zIndex: 1002. Edges: type: custom, data: {isInIteration, isInLoop, sourceType, targetType}. If-else / classifier sourceHandle is true/false/fail-branch or the class id — not source.
Selectors: ["nodeId","field"], ["sys","query"], ["env","VAR"], loop vars ["loopId","var"]. Templates: {{#nodeId.field#}}, {{#sys.query#}}, {{#env.THINK_SWITCH#}}. Knowledge into LLM: put {{#context#}} in the system prompt (this generation does not inject context unless you write the placeholder).
Node gotchas (1.16 field-tested, still the checklist on 1.17)
LLM. Must include a user message (No user query found). vision.enabled: true requires configs.variable_selector (e.g. ["sys","files"]) + detail, else publish "视觉变量不能为空". Thinking models: max_tokens ≥ 16384 or the thought eats the budget and text is empty. Fast path: /no_think at the end of system. Strip <think>...</think> with a code node if the provider surfaces it. retry_config + error_strategy: default-value are default. Do not enable node structured_output on models that lack it — JSON schema in the prompt + code parse.
Code. Input field is value_selector, not variable_selector. There is no 2s sleep cap in 1.17 source — kill time is SANDBOX_WORKER_TIMEOUT (default 15s). Stdlib + optional /dependencies. Full recipe: Code node and sandbox. Nested generators: build newlines with chr(10) to avoid escape hell. compile(code, id, "exec") before import. Do not pass File objects into code — sandbox raises Type is not JSON serializable: File. Optional uploads: if-else not empty (varType: file) then a tool node; never value_selector a file into code.
If-else. Numeric operators are Unicode ≥ ≤ ≠, not >=. Cases use conditions[] + varType.
Knowledge-retrieval. Rerank needs four keys: provider + model (UI) and reranking_provider_name + reranking_model_name (engine). Wrong provider → credentials is not initialized. Rerank score may be None — gate on chunk count, not score. Output is result (array). Dataset UUIDs are this tenant — remap on another box.
Tool. Three name systems: API tools use OpenAPI operationId; builtin plugins use identity.name; MCP uses MCP names. Mixing them → Unknown error. API tool output is only text (whole JSON string) — parse in code. provider_id for API tools is the tool_api_providers.id UUID (remap on another box). Builtin provider_type: builtin uses a name, not a UUID.
HTTP request. Always send authorization: {"type":"no-auth","config":null} even when unused. Timeouts are three ints (connect/read/write). Body data is a string with {{#...#}} templates. Intranet hosts: Dify intranet (NO_PROXY / SSRF).
Loop. Official pattern: inject via loop_variables → children read [loopId, var] → in-loop assigner v2 writes back → break_conditions reference loop vars, not child outputs (publish "无效的变量"). Each break condition needs id + varType. No outputs on the loop node. Sleep inside a loop is still sandbox-bounded (SANDBOX_WORKER_TIMEOUT), not a 2s hard cap.
List-operator. Output field is result. Set var_type / item_var_type. Workflow start file is often a single file, not sys.files.
Variable-aggregator. Merges branches without failing the unused side. Output commonly output.
Document-extractor. Reads text PDFs / Office, not scans or photos. Empty extract → error_strategy: default-value with empty text. If several workflows need OCR, publish one reusable OCR workflow as a tool (provider_type: workflow) instead of dropping langgenius/mineru / HTTP-to-MinerU on every canvas. Do not hang MinerU nodes on a text-PDF graph behind an if-else either — that still ships OCR on that canvas. Text-PDF start file should use allowed_file_types: ["document"] only; including image invites scans the extractor cannot read.
Question-classifier. Must have a fail-branch edge. sourceHandle = class id. Keep temperature low.
Answer (chatflow). Template may include {{#nodeId.field#}}. Empty leftover placeholders show in the WebApp.
Assigner. version: "2", operation: over-write, loop_id set when inside a loop.
Prefer serial over join-heavy graphs: multiple inbound edges can stall as a join. Typical bug: document-extractor edges both into the LLM and into a cleaner/gate that also feeds the same LLM — the engine waits for every inbound edge, including the unused OCR branch. One chain: extractor → clean → LLM. Put retry_config + error_strategy on LLM/tool/HTTP.
Published vs draft. /v1 and product backends run the published graph. Canvas-only surgery does not change the demo. After a graph fix: draft/run → publish.
Prompt wording. Do not write “OCR后的正文” in a non-OCR workflow. It confuses operators into adding OCR nodes.
Human Input. Do not put a Human Input node inside a Loop (1.17 still stalls). Keep HITL on the main chain.
Bindings when moving DSL between instances
Remap before import: dataset_ids, API-tool provider_id, model display name (Dify entry, not the vLLM served-model-name). Same volume restore → UUIDs stay, do not rewrite. After remap, re-check the four rerank fields and tool operationId. Trigger subscription_id / plugin provider_id are this tenant — rebuild the plugin subscription, do not copy foreign IDs. Cross-instance import is a genuinely new app (fresh app_id, new keys, empty logs) — that is the sanctioned imports use case; the delete-and-reimport red line above is about shipping versions on the same instance.
Canvas collaboration (editor stuck on "同步数据中")
1.16+ uses Socket.IO (/socket.io/?EIO=4&transport=websocket), not /api/ws. nginx must proxy /socket.io/ to api_websocket with Upgrade headers. NEXT_PUBLIC_SOCKET_URL must be a host the browser can reach (not ws://localhost for remote users). After recreating api/web, nginx -s reload (cached upstream IPs → 502) — but start api_websocket first or nginx dies with host not found in upstream.
Logs / annotations / human input
Conversation logs, workflow-app-logs, node traces (triggered_from=app-run), statistics, annotations: Dify workspace extras. Do not duplicate those HTTP here.
Site: POST /apps/{id}/site-enable. MCP: POST /apps/{id}/server.
Triggers (workflow only)
data.type is one of trigger-schedule | trigger-webhook | trigger-plugin. They are start nodes. Publish rejects a graph that also has a start node (Start node and trigger nodes cannot coexist in the same workflow). Chatflow handlers no-op — do not put triggers on advanced-chat.
There is no delay queue. “Fire when a date arrives” = persist structured rows (dataset / JSON) + a daily trigger-schedule app. File-upload ingest stays on a start graph; the cron graph is a second app. Do not install a marketplace “cron plugin” unless listing shows one — 1.17 Schedule is a canvas node plus worker_beat.
Caps from source: <=5 webhook nodes and <=5 plugin-trigger nodes per workflow. Schedule sync takes the first trigger-schedule node only (one workflow_schedule_plans row per app).
Lifecycle
| Moment | What happens |
|---|---|
Draft save POST /apps/{id}/workflows/draft |
Syncs workflow_webhook_triggers and workflow_plugin_triggers (creates 24-char webhook_id if missing). Schedule plan is not written yet. |
Publish POST /apps/{id}/workflows/publish |
Writes app_triggers (status: enabled) and workflow_schedule_plans (cron + timezone + next_run_at). Removing a trigger node on the next publish deletes the app_triggers row / schedule plan. |
| Enable / disable | POST /console/api/apps/{id}/trigger-enable — production webhook/schedule/plugin skip when disabled. Debug webhook ignores this flag. |
GET /console/api/apps/{id}/triggers
POST /console/api/apps/{id}/trigger-enable
{"trigger_id":"<app_triggers.id>","enable_trigger":true}
List item: id, trigger_type (trigger-webhook / trigger-schedule / trigger-plugin), title, node_id, provider_name, icon, status (enabled | disabled | unauthorized | rate_limited). Plugin icons: /console/api/workspaces/current/tool-provider/builtin/{provider_name}/icon.
Public URLs — TRIGGER_URL vs ENDPOINT_URL_TEMPLATE
Set both on api/worker (.env / shared.env). Recreate api + worker. Callers must reach the nginx host, not the container name.
| Knob | Default (example) | Used for |
|---|---|---|
TRIGGER_URL |
http://localhost |
Workflow triggers. Concatenated as {TRIGGER_URL}/triggers/... |
ENDPOINT_URL_TEMPLATE |
http://localhost/e/{hook_id} |
Plugin HTTP Endpoint instances. {hook_id} is replaced. nginx /e/ -> plugin_daemon:5002 |
Generated trigger URLs (no console prefix):
| Kind | Path | nginx |
|---|---|---|
| production webhook | {TRIGGER_URL}/triggers/webhook/{webhook_id} |
location /triggers -> api:5001 |
| debug webhook | {TRIGGER_URL}/triggers/webhook-debug/{webhook_id} |
same |
| plugin trigger | {TRIGGER_URL}/triggers/plugin/{endpoint_id} (UUID) |
same |
| plugin Endpoint | ENDPOINT_URL_TEMPLATE with {hook_id} -> /e/{hook_id} |
location /e/ -> plugin_daemon |
Do not mix them. /e/{hook_id} never runs a workflow graph. /triggers/plugin/{uuid} never hits plugin_daemon's endpoint router. The public path is /triggers/webhook/{id}, not /webhook/{id}.
Set TRIGGER_URL to the origin external callers use (scheme + host + optional port). Leave http://localhost only when the caller is on the same machine as nginx.
Schedule (cron)
Node data.type: trigger-schedule. Default: mode: visual, frequency: daily, timezone: UTC, visual_config: {time: "12:00 AM", on_minute: 0, weekdays: ["sun"], monthly_days: [1]}.
{
"id": "sched-1",
"type": "custom",
"data": {
"type": "trigger-schedule",
"title": "Nightly",
"mode": "cron",
"cron_expression": "0 2 * * *",
"timezone": "Asia/Shanghai"
}
}
Visual mode (mode: visual) converts to 5-field cron:
frequency |
Required | Cron |
|---|---|---|
hourly |
visual_config.on_minute 0-59 |
{minute} * * * * |
daily |
time 12-hour ("2:30 PM") |
{minute} {hour} * * * |
weekly |
time + weekdays sun..sat |
{minute} {hour} * * {dow} (sun=0) |
monthly |
time + monthly_days 1-31 and/or "last" |
{minute} {hour} {days} * * (L for last) |
Cron mode: exactly 5 fields, or a predefined @hourly / @daily / @weekly / @monthly / @yearly. croniter also accepts L, ?, month/day names. Invalid -> publish ScheduleConfigError.
Poller (must be up or nothing fires): knobs in Dify compose and config.
ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true(default) on api/worker/worker_beat.- worker_beat (
MODE=beat) registersschedule.workflow_schedule_task.poll_workflow_scheduleseveryWORKFLOW_SCHEDULE_POLLER_INTERVALminutes (default 1). - worker consumes queues
schedule_pollerthenschedule_executor(Community default-Qalready includes both). - Due rows:
next_run_at <= now, matchingapp_triggerstrigger-scheduleenabled. BatchWORKFLOW_SCHEDULE_POLLER_BATCH_SIZE(100). Circuit breakerWORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK(0= unlimited). - Recreate worker_beat after toggling the flag; recreate worker if you overrode
CELERY_QUEUESand droppedschedule_*.
Schedule debug (canvas): POST /apps/{id}/workflows/draft/nodes/{node_id}/trigger/run may error or no-op. Full-graph debug: POST /apps/{id}/workflows/draft/trigger/run {"node_id":"..."} — if nothing is due yet you get {"status":"waiting","retry_in":2000} (normal). To simulate now: POST .../workflows/draft/run with inputs: {}, or published POST /v1/workflows/run. POST .../draft/trigger/run-all {"node_ids":[...]}.
Published run inputs are {}. Runs as the tenant owner (fallback admin).
Webhook
Node data.type: trigger-webhook. After draft save:
GET /console/api/apps/{id}/workflows/triggers/webhook?node_id=<nodeId>
Returns id, webhook_id (24 chars), webhook_url, webhook_debug_url, node_id.
DSL data (engine lowercases method):
{
"type": "trigger-webhook",
"title": "Inbound",
"method": "post",
"content_type": "application/json",
"headers": [{"name": "X-Token", "type": "string", "required": true}],
"params": [{"name": "order_id", "type": "string", "required": false}],
"body": [{"name": "payload", "type": "object", "required": true}],
"status_code": 200,
"response_body": ""
}
Allowed methods: get post head patch put delete. Content types: application/json, multipart/form-data, application/x-www-form-urlencoded, text/plain, application/octet-stream. Header types: string only. Query: string / number / boolean. Body: those plus object / arrays / file.
Caller (no cookie): GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS on /triggers/webhook/{webhook_id}. Must match configured method and Content-Type or 400 HTTP method mismatch / Content-type mismatch. Production also requires the app trigger enabled and a published workflow; otherwise 404.
Response is async: Dify enqueues Celery then returns status_code + response_body immediately. Empty body -> {"status":"success","message":"Webhook processed successfully"}. JSON-looking strings are parsed; otherwise {"message":"..."}.
Workflow inputs:
{
"webhook_data": {"method":"POST","headers":{},"query_params":{},"body":{},"files":{}},
"webhook_headers": {},
"webhook_query_params": {},
"webhook_body": {}
}
Header/query keys with - are sanitized to _ for variable names.
Body size. WEBHOOK_REQUEST_BODY_MAX_SIZE (bytes, default 10485760 / 10 MiB) is checked from Content-Length -> 413 Webhook request too large. Raise it and NGINX_CLIENT_MAX_BODY_SIZE (recreate nginx). Recreate api after the Dify knob.
Debug URL /triggers/webhook-debug/{id}: uses the draft graph, skips enabled check, does not enqueue production. Needs an active Variable Inspector listener; otherwise 409 No active debug listener (body includes execution_url of the production webhook). Canvas poller: POST /apps/{id}/workflows/draft/trigger/run {"node_id":"..."}.
Plugin trigger (workflow) vs plugin Endpoint
Plugin trigger = a trigger-plugin start node bound to a workspace trigger subscription.
- Install a trigger-capable plugin.
- Workspace APIs (cookie + CSRF):
GET /console/api/workspaces/current/triggersGET /console/api/workspaces/current/trigger-provider/{provider}/infoPOST /console/api/workspaces/current/trigger-provider/{provider}/subscriptions/builder/create{"credential_type":"unauthorized"|"api-key"|"oauth2"}- update / verify / build / logs under
.../subscriptions/builder/... - list:
GET .../trigger-provider/{provider}/subscriptions/list - OAuth:
GET .../subscriptions/oauth/authorize(callback/console/api/oauth/plugin/{provider}/trigger/callback)
- Put a node with
subscription_id(required — draft sync skips nodes without it):
{
"type": "trigger-plugin",
"title": "Repo event",
"plugin_id": "<plugin id>",
"provider_id": "<plugin_id/provider_name>",
"event_name": "<event>",
"subscription_id": "<subscription uuid>",
"plugin_unique_identifier": "<plugin unique identifier>",
"event_parameters": {
"repo": {"type": "constant", "value": "org/name"}
}
}
event_parameters values must be type: "constant" (engine rejects variable / mixed). Third party posts to {TRIGGER_URL}/triggers/plugin/{endpoint_id}. Dify dispatches matching events onto published workflows with that subscription_id.
Plugin Endpoint = HTTP hook implemented inside the plugin, not a workflow start node. Details: Dify workspace extras.
POST /console/api/workspaces/current/endpoints
{"plugin_unique_identifier":"...","name":"my-hook","settings":{}}
POST /console/api/workspaces/current/endpoints/enable
{"endpoint_id":"..."}
POST /console/api/workspaces/current/endpoints/disable
{"endpoint_id":"..."}
Public URL from ENDPOINT_URL_TEMPLATE (/e/{hook_id}). Enable/disable here does not toggle app_triggers.
Typical trigger errors
| Symptom | Cause |
|---|---|
publish Start node and trigger nodes cannot coexist |
Graph has both start and a trigger type |
| webhook 404 | unknown id, unpublished, or enable_trigger: false |
| webhook 400 method / content-type | caller != node method / content_type |
| webhook 413 | body > WEBHOOK_REQUEST_BODY_MAX_SIZE or nginx 413 |
| webhook-debug 409 | Variable Inspector not listening |
| schedule never fires | worker_beat down, flag false, trigger disabled, or worker missing schedule_poller/schedule_executor |
| plugin trigger silent | node missing subscription_id, or TRIGGER_URL not the origin the vendor can reach |
| 429 upgrade plan | Cloud quota — ignore on Community |
Code node and sandbox
data.type: code. Inputs are variables[].value_selector (array path), not variable_selector.
{
"id": "code",
"type": "custom",
"data": {
"type": "code",
"title": "Sum",
"code_language": "python3",
"variables": [
{"variable": "args1", "value_selector": ["start", "a"]},
{"variable": "args2", "value_selector": ["start", "b"]}
],
"outputs": {"result": {"type": "number", "children": null}},
"code": "def main(args1, args2):\n return {\"result\": args1 + args2}\n"
}
}
code_language: python3 (sandbox python3), javascript (sandbox nodejs). Jinja2 template-transform also hits the sandbox as python3. Entry is def main(**inputs) and must return a dict whose keys match outputs.
Timeouts — not "sleep <= 2s"
1.17 has no 2-second sleep limit in api/sandbox config. Real knobs (Dify compose and config):
| Knob | Default | Where | Meaning |
|---|---|---|---|
SANDBOX_WORKER_TIMEOUT |
15 | sandbox env WORKER_TIMEOUT |
Process kill inside langgenius/dify-sandbox. Recreate sandbox. |
volumes/sandbox/conf/config.yaml worker_timeout |
5 in the checked-in file | sandbox yaml | Same layer; set both this and the env if a short kill persists. |
CODE_EXECUTION_CONNECT_TIMEOUT |
10 | api+worker | HTTP connect to CODE_EXECUTION_ENDPOINT |
CODE_EXECUTION_READ_TIMEOUT |
60 | api+worker | HTTP read from sandbox |
CODE_EXECUTION_WRITE_TIMEOUT |
10 | api+worker | HTTP write |
CODE_EXECUTION_ENDPOINT |
http://sandbox:8194 |
api+worker | Must reach sandbox:8194 |
CODE_EXECUTION_API_KEY |
dify-sandbox |
api+worker | Must equal sandbox SANDBOX_API_KEY / API_KEY |
If sleep(20) with SANDBOX_WORKER_TIMEOUT=15, the sandbox kills the worker even though the API is still waiting up to 60s. Raise sandbox timeout first, then CODE_EXECUTION_READ_TIMEOUT so it stays larger. Recreate sandbox + api + worker.
Stdlib allowlist and /dependencies
Sandbox image langgenius/dify-sandbox:0.2.15. Compose mounts:
volumes/sandbox/conf->/confvolumes/sandbox/dependencies->/dependencies
Filesystem allowlist is python_lib_path in volumes/sandbox/conf/config.yaml.example (checked-in config.yaml omits the list and uses image defaults):
/usr/local/lib/python3.10,/usr/lib/python3.10,/usr/lib/python3,/usr/lib/x86_64-linux-gnu- certs / nsswitch / hosts / resolv / localtime / zoneinfo / timezone
That is a bind-mount allowlist (CPython stdlib + resolver/tz), not a Python sys.modules whitelist. Extra pip packages belong in the /dependencies volume; recreate sandbox after adding them. Dify 1.17 source does not name the requirements filename.
Output size caps (api, after stdout parse): CODE_MAX_STRING_LENGTH 400000, CODE_MAX_DEPTH 5, CODE_MAX_STRING_ARRAY_LENGTH 30, CODE_MAX_OBJECT_ARRAY_LENGTH 30, CODE_MAX_NUMBER_ARRAY_LENGTH 1000, CODE_MAX_PRECISION 20.
Network via SSRF
CodeExecutor always sends "enable_network": true. Sandbox SANDBOX_ENABLE_NETWORK default true. Egress is forced through SANDBOX_HTTP_PROXY / SANDBOX_HTTPS_PROXY -> http://ssrf_proxy:3128.
Private/internal URLs need SSRF_PROXY_ALLOW_PRIVATE_IPS (CIDR list) and matching NO_PROXY on api/worker — Dify intranet. Recreate ssrf_proxy (+ sandbox). This is not the HTTP Request node path; same proxy, different client.
Typical code-node errors
| Message | Fix |
|---|---|
Failed to execute code ... check if the sandbox service is running |
sandbox down, wrong CODE_EXECUTION_ENDPOINT, or API key mismatch |
Code execution service is unavailable |
sandbox 503 (workers busy / timeout storm) |
Got error code: ... Got error msg: ... |
sandbox rejected the run (import outside allowlist, seccomp, syntax) |
traceback in node_finished.error with sleep / killed |
hit SANDBOX_WORKER_TIMEOUT |
Output result must be a string, got int instead |
outputs type != returned value |
Depth limit 5 reached, object too deep |
raise CODE_MAX_DEPTH or flatten |
| publish/import OK but runtime empty | value_selector empty or wrong node id (frontend requires a non-empty selector) |
Publish checklist
- Every node has top-level
type. - Vision configs / rerank four fields / classifier fail-branch / loop break
id+varType. compileevery code node; edges reference existing ids.- Draft run every branch (intent / tool fail / empty retrieve / file / HTTP timeout).
- Publish →
api-enable/site-enable. Start variables match callerinputs. - Trigger graphs: no
startnode; webhookGET .../triggers/webhook?node_id=has a URL; schedule hasworker_beat+ poller flag; plugin node hassubscription_id. TRIGGER_URLis the origin callers actually hit.ENDPOINT_URL_TEMPLATEis only for/e/{hook_id}plugin Endpoints.- New version shipped by in-place draft sync on the SAME
app_id— not delete-and-reimport. Re-confirm the api-key, site, triggers and run/test logs still resolve to that id. See NEVER delete-and-reimport to ship a new version.
DSL proven facts (0.7.0, validated by import + run on 1.17 Community)
- Document-extractor node
typeisdocument-extractor(doc-extractor→ "No class mapping found"). It errors on images (.jpg); adderror_strategy: default-valueso the empty text falls through to an OCR branch via if-else. if-elsecomparison operators are the unicode forms:≥/≤/≠(not>=).- Tool node:
provider_id/provider_name/provider_type/tool_name/tool_label+tool_parameters(required, may be{}) +tool_configurations+tool_node_version: "2". - Parameters are split by the schema
form, not by value kind:form=llmparams go totool_parameters(variable binding or{type: mixed, value}constant),form=formparams go totool_configurations({type: mixed|constant, value}). Wrong placement = canvas shows the field empty and publish fails "cannot be empty" (e.g. md_exporterforce_text_value/enable_tocare form=form; echartstitle/data/x_axisare llm). Source of truth: plugin tool yamlparameters[].form— generate atool-param-forms.jsonmap so builders never guess. error_strategyenum is onlyfail-branch/default-value(continue-on-erroris rejected by pydantic).workflows/draft/runreturns SSE (data:/event:lines); parse theworkflow_finishedevent — a plain JSON read fails even though the run succeeded.- File params serialize as origin-free
/files/<id>/file-preview?...objects; tool nodes receive them as-is (dify_model_identity: __dify__file__), so consumers must join a base URL themselves (see compose-and-config Files URL split). - Code nodes cannot take File params;
listoutputs (e.g. KBresult) must be returned as JSON strings —type: objectwith a list value fails "Output result is not an object". - Never chain
.update()on a node-builder call inside a list literal (tool_node(...).update({...})returns None and silently corrupts graph validation). - Model JSON outputs are not trustworthy: LLMs emit unescaped quotes inside strings and duplicated bare keys (
"risk_description","risk_description":). Prefer a code node for parsing with chained repair (normalize → repair duplicate-key commas → escape in-string quotes) over thejson_processtool, and instruct prompts to use full-width quotes in Chinese content.