# Dify plugin development

> Use this when writing, packing, debugging, or publishing a Dify plugin (tool, model, agent strategy, endpoint).

- Skill: `kabishou11/dify-plugin-development` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kabishou11/dify-plugin-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kabishou11/dify-plugin-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: kabishou11 (https://skillmd.com/u/kabishou11)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kabishou11/dify-plugin-development

---

# Dify plugin development

Use this when writing, packing, debugging, or publishing a Dify plugin. Installing the result: [Dify plugin install](sand-workflow:dify-plugin-install).

## Layout

SDK: `dify-plugin`. Pack: `dify-plugin plugin package ./your-plugin` → `.difypkg`.

```
manifest.yaml
main.py
provider/my_tool.yaml
provider/my_tool.py
tools/lookup.yaml
tools/lookup.py
_assets/icon.svg
requirements.txt
```

`manifest.yaml`:

```yaml
version: 0.0.1
type: plugin
author: myorg
name: my-tool
label: {en_US: My Tool, zh_Hans: 我的工具}
description: {en_US: lookup, zh_Hans: 查询}
icon: _assets/icon.svg
resource:
  memory: 268435456
  permission:
    tool: {enabled: true}
    model: {enabled: false, llm: false}
    endpoint: {enabled: false}
    app: {enabled: false}
    storage: {enabled: false, size: 1048576}
plugins:
  tools: [provider/my_tool.yaml]
meta:
  version: 0.0.1
  arch: [amd64, arm64]
  runner: {language: python, version: "3.12", entrypoint: main}
```

`plugins:` keys are `tools` | `models` | `agent_strategies` | `endpoints` | `datasources` — match `type`. `resource.memory` is bytes (256 MiB default is tight for PDF stacks).

Tool provider yaml points at tools; each tool yaml `identity.name` is the **`tool_name`** Agents call (not the marketplace title).

```python
from collections.abc import Generator
from dify_plugin import Tool
from dify_plugin.entities.tool import ToolInvokeMessage

class LookupTool(Tool):
    def _invoke(self, tool_parameters: dict) -> Generator[ToolInvokeMessage, None, None]:
        q = tool_parameters.get("query") or ""
        if not q:
            yield self.create_json_message({"ok": False, "error": "query required"})
            return
        yield self.create_text_message(q)
        yield self.create_json_message({"ok": True, "query": q})
```

`main.py` is the runner entry (`from dify_plugin import Plugin; Plugin(...).run()`). Pin `dify-plugin` in `requirements.txt`. Do not use unbounded extras (`markitdown[all]`).

## Types

| Type | Role | `plugins:` key |
|---|---|---|
| Tool | Agent / Tool node callable | `tools` |
| Model | llm / embedding / rerank / stt / tts | `models` |
| Agent strategy | ReAct, function-calling | `agent_strategies` |
| Endpoint | HTTP the plugin serves | `endpoints` |
| Datasource | knowledge ingest | `datasources` |

## Debug against a running Dify

1. `.env`: `FORCE_VERIFYING_SIGNATURE=false`. Recreate **plugin_daemon** (+ api if the flag is read there).
2. Publish daemon debug port (`PLUGIN_DEBUGGING_PORT`, default 5003). Host shown to the CLI: `EXPOSE_PLUGIN_DEBUGGING_HOST` / `EXPOSE_PLUGIN_DEBUGGING_PORT`.
3. Plugin dir `.env` with the daemon debug URL, then `dify-plugin plugin run` (flag name: `dify-plugin --help` — it moved across CLI versions).
4. Console shows a debugging plugin; invoke without packing.

Install the packed file: `POST /console/api/workspaces/current/plugin/upload/pkg` then `install/pkg`. If plugin_daemon cannot reach PyPI, vendor wheels into the local index (plugin install skill).

## Design

- Prefer calling an **internal HTTP service** over shipping GPU models in the plugin process.
- Tools should timeout and return structured JSON errors the Agent can read (`create_json_message`).
- Endpoint plugins serve `/plugin/{endpoint_id}` (not `/console/api`).

## Failure patterns

| Error | Cause | Fix |
|---|---|---|
| plugin missing after pack | `plugins:` key ≠ type | tools vs models vs agent_strategies |
| Agent `Unknown error` | `identity.name` ≠ node `tool_name` | use the yaml name |
| uv `exit status 1` | extra not mirrored | pin tiny `requirements.txt` |
| debug never appears | daemon debug port unpublished / signature on | `FORCE_VERIFYING_SIGNATURE=false`, publish 5003 |
| 413 on upload | package > `PLUGIN_MAX_PACKAGE_SIZE` | raise it **and** `NGINX_CLIENT_MAX_BODY_SIZE` |

## Marketplace

Publish at the public Marketplace (org + signing). Self-host can load unsigned `.difypkg` when signature verification is off.

## Field addendum (1.17, from a full tool-plugin run)

**Schema validation happens daemon-side at launch, not at pack.** A package that zips fine can fail install with `Error loading plugin configuration`. Rules:

- Every `label:` and `description.human_description:` in provider **and** tool yaml needs **both `en_US` and `zh_Hans`** — missing `en_US` = runtime launch failure (`ToolParameter ... label.en_US Field required`).
- Scalar values containing `: ` (e.g. English sentences) must be quoted or the daemon yaml parse dies (`mapping values are not allowed in this context`).
- Icons live in **`_assets/`** and `manifest.yaml` + provider `identity.icon` reference the **bare filename** (`icon: icon.png`). A root-level icon file → install 400 `file not found: icon.png / failed to remap assets`. PNG works; SVG optional.
- Pack **without directory entries**: `find . -type f -not -path '*__pycache__*' | zip -q out.difypkg -@`. Zip built with `zip -r dir/` breaks install (`read provider: is a directory`).
- Include `.verification.dify.json` (`{"authorized_category":"community"}`) and a `PRIVACY.md`; add `meta.runner` (python 3.12) + `minimum_dify_version` to manifest.

**Reuse Dify-configured models instead of shipping keys.** Declare a param `type: model-selector, scope: llm, form: form`; at runtime it arrives as an `LLMModelConfig` dict. Invoke through the session (keys stay in Dify, zero secrets in the plugin):

```python
from dify_plugin.entities.model.message import TextPromptMessageContent, ImagePromptMessageContent, UserPromptMessage
resp = self.session.model.llm.invoke(
    model_config=model_config,
    prompt_messages=[UserPromptMessage(content=[
        TextPromptMessageContent(data=PROMPT),
        ImagePromptMessageContent(base64_data=b64, format="png",
                                  mime_type="image/png", detail=ImagePromptMessageContent.DETAIL.HIGH),
    ])],
    stream=False)
text = resp.message.content  # str or list[parts]; join part.data for multimodal
```

**File params** arrive as `dify_plugin.file.file.File`; `.blob` lazily downloads. The api hands over origin-free `/files/...` urls — rewrite before first access: `if f.url.startswith("/files/"): f.url = (os.environ.get("INTERNAL_FILES_URL") or os.environ.get("FILES_URL") or "http://api:5001") + f.url`.

**Version-switching the same plugin id** does not hot-swap: the workspace keeps the old installation active. Uninstall (`POST .../plugin/uninstall {"plugin_installation_id": <id from plugin/list>}`) then install the new identifier; only then do you see new behavior. Runtime truth: `docker logs plugin_daemon` → `local runtime ready plugin=<id@hash>`.

**Yield both views** for tool nodes: `create_text_message(text)` feeds node output `text`; `create_json_message({...})` feeds `json` — workflows can bind either.

**Renderer trap**: PyMuPDF (fitz) can render embedded-CJK PDFs as garbage glyphs (test PDF extracted only digits/symbols); `pypdfium2` rendered the same file perfectly. For contract-class PDFs prefer `pypdfium2` (`page.render(scale=150/72).to_pil()`).

