Chapter 14

Tool Ecosystem: Built-in Tools and Custom Tool Development

Chapter 14: Tool Ecosystem — Built-in Tools Deep Dive and Custom Tool Development

Master Dify's tool ecosystem completely — from configuring every built-in tool to building, testing, and productionizing custom tools that extend your Agent's capabilities.

Chapter Overview

An Agent's capability boundary is defined by its tools. Without tools, an Agent is merely a sophisticated chatbot; with the right tools, it can genuinely solve business problems. This chapter covers two dimensions: first, a systematic exploration of Dify's built-in tools (web search, code execution, knowledge base retrieval, etc.) with configuration and tuning guidance; second, a complete walkthrough of developing, testing, and publishing custom tools — including API wrapping, authentication handling, and error design.

After reading this chapter, you will be able to:


Level 1: Foundational Knowledge (1–3 Years Experience)

What Is a Dify Tool?

In Dify, a "tool" is an external capability unit that an Agent can invoke. Each tool has:

The quality of a tool's description is critical. If it's unclear, the LLM may not know when to use the tool — or may misuse it.

Dify Built-in Tool Categories

Search tools:

Code execution tools:

Knowledge tools:

Utility tools:

Image generation:

Enabling and Configuring Built-in Tools in Dify

  1. Go to Dify console → Tools → Built-in Tools
  2. Find the target tool and click "Configure"
  3. Enter the required API key (e.g., SerpAPI key for web search)
  4. Enable the tool in your Agent application

Web Search tool example:

tool: web_search
provider: serpapi
config:
  api_key: "your_serpapi_key"
  gl: "us"       # Country: United States
  hl: "en"       # Language: English
  num: 5         # Return 5 results per search

Your First Custom Tool: Hello World

Custom tools in Dify are added via "Custom API" — essentially wrapping an external REST API as a tool:

# Simple custom tool: exchange rate lookup
openapi: 3.1.0
info:
  title: Exchange Rate Tool
  version: 1.0.0
servers:
  - url: https://api.exchangerate.host

paths:
  /latest:
    get:
      operationId: get_exchange_rate
      summary: Get latest exchange rate
      description: Query the latest exchange rate for a currency pair. Use this for currency conversion questions.
      parameters:
        - name: base
          in: query
          required: true
          description: Base currency code, e.g. USD, CNY, EUR
          schema:
            type: string
        - name: symbols
          in: query
          required: false
          description: Target currency codes, comma-separated, e.g. CNY,EUR,JPY
          schema:
            type: string

Paste this OpenAPI spec into Dify's "Custom API Tool" configuration box, and Dify will automatically parse it and generate the tool description.


Level 2: Mechanism Deep Dive (3–5 Years Experience)

Built-in Tool Deep Dive: code_interpreter

The code interpreter is the most powerful and complex built-in tool in Dify. It executes code in an isolated Python sandbox environment, supporting:

Code interpreter example:

# Code generated by the Agent for execution in sandbox
code = """
import pandas as pd
import matplotlib.pyplot as plt
import io, base64

# Analyze sales data
data = {
    'Month': ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
    'Revenue': [120000, 135000, 98000, 156000, 178000]
}
df = pd.DataFrame(data)

# Calculate statistics
print(f"Total Revenue: ${df['Revenue'].sum():,}")
print(f"Monthly Average: ${df['Revenue'].mean():,.0f}")
print(f"Best Month: {df.loc[df['Revenue'].idxmax(), 'Month']}")
print(f"Growth Trend: {((df['Revenue'].iloc[-1] / df['Revenue'].iloc[0]) - 1) * 100:.1f}%")

# Generate chart
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(df['Month'], df['Revenue'] / 1000, color='steelblue', alpha=0.8)
ax.set_title('Monthly Revenue Trend', fontsize=14)
ax.set_ylabel('Revenue (thousands $)')
plt.tight_layout()

buf = io.BytesIO()
plt.savefig(buf, format='png', dpi=100)
buf.seek(0)
img_b64 = base64.b64encode(buf.read()).decode()
print(f"[Chart]\ndata:image/png;base64,{img_b64}")
"""

Security restrictions in the sandbox:

import subprocess  # ❌ Blocked: system command execution
import os          # ❌ Blocked: filesystem access (partial)
import socket      # ❌ Blocked: network access
import requests    # ❌ Blocked: HTTP requests (in most configs)

import pandas as pd    # ✅ Allowed
import numpy as np     # ✅ Allowed
import matplotlib      # ✅ Allowed
import scipy           # ✅ Allowed
import sklearn         # ✅ Allowed

Built-in Tool Deep Dive: dataset_retrieval

This tool extends Dify's RAG capability into Agent scenarios. Unlike standard Q&A apps, an Agent can actively decide when to query the knowledge base and what keywords to use:

# Agent reasoning with knowledge base retrieval
"""
User: What is our company's vacation policy?

Thought: The user is asking about internal HR policy. I should retrieve from the knowledge base.
Action: dataset_retrieval
Action Input: {"dataset_ids": ["hr-policy-kb-001"], "query": "vacation policy days request process"}
Observation: [Retrieved result]
  Score 0.92: Employee Vacation Policy (2024)
  Employees with 1-10 years of service receive 5 days of annual leave...

Thought: Found the relevant policy. I can answer the user now.
Final Answer: According to company HR policy, employees with 1–10 years of service receive...
"""

Advanced knowledge base retrieval configuration:

tool: dataset_retrieval
config:
  top_k: 5              # Return top 5 most relevant chunks
  score_threshold: 0.6  # Similarity threshold — don't return below this
  search_method: hybrid # hybrid = semantic + keyword
  reranking: true       # Enable reranking for better precision

Custom Tool Development: Complete Walkthrough

Step 1: Design the Tool Interface

Good tool interface design follows these principles:

  1. Single responsibility: One tool does one thing
  2. Precise description: Tell the LLM exactly when and when NOT to use this tool
  3. Minimal parameters: Only expose necessary parameters; give defaults to optional ones
  4. Readable errors: Error messages should be understandable to the LLM for self-correction
# Example: Internal CRM query tool definition
tool_definition = {
    "name": "query_customer_info",
    "description": (
        "Query customer information from the CRM system. "
        "Use this when the user asks about a specific customer's basic info, "
        "contact details, purchase history, or contract status. "
        "NOT suitable for: product queries, inventory data, financial reports "
        "(use other tools for those). "
        "Query by customer ID (format: CUST-XXXXXX) or company name."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "customer_id": {
                "type": "string",
                "description": "Unique customer ID, format: CUST- followed by 6 digits, e.g. CUST-001234"
            },
            "customer_name": {
                "type": "string",
                "description": "Company name (alternative to customer_id)"
            },
            "fields": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Fields to return. Options: contact, contract, purchase_history, credit_score",
                "default": ["contact", "contract"]
            }
        }
    }
}

Step 2: Implement the Tool Backend (Python)

# tools/crm_tool.py
import httpx
from typing import Optional, List
from pydantic import BaseModel, validator

class CRMQueryInput(BaseModel):
    customer_id:   Optional[str] = None
    customer_name: Optional[str] = None
    fields:        List[str]     = ["contact", "contract"]

    @validator("customer_id")
    def validate_id(cls, v):
        if v and not v.startswith("CUST-"):
            raise ValueError(f"customer_id must start with CUST-, got: {v}")
        return v

    def model_post_init(self, *args):
        if not self.customer_id and not self.customer_name:
            raise ValueError("Must provide either customer_id or customer_name")

ALLOWED_FIELDS = {"contact", "contract", "purchase_history", "credit_score"}

class CRMTool:
    def __init__(self, crm_base_url: str, api_key: str):
        self.client = httpx.AsyncClient(
            base_url=crm_base_url,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10.0
        )

    async def query(self, raw_params: dict) -> str:
        try:
            params = CRMQueryInput(**raw_params)
        except ValueError as e:
            return f"Parameter error: {e}\nPlease check the parameter format and retry."

        valid_fields = [f for f in params.fields if f in ALLOWED_FIELDS]

        try:
            if params.customer_id:
                resp = await self.client.get(
                    f"/api/customers/{params.customer_id}",
                    params={"fields": ",".join(valid_fields)}
                )
            else:
                resp = await self.client.get(
                    "/api/customers/search",
                    params={"name": params.customer_name,
                            "fields": ",".join(valid_fields)}
                )

            if resp.status_code == 404:
                return "Customer not found. Please verify the customer ID or name."
            if resp.status_code == 403:
                return "Insufficient permissions to query this customer. Contact your administrator."

            resp.raise_for_status()
            return self._format(resp.json())

        except httpx.TimeoutException:
            return "CRM system timed out (>10s). Please try again later."
        except httpx.HTTPStatusError as e:
            return f"CRM query failed: HTTP {e.response.status_code}"

    def _format(self, data: dict) -> str:
        lines = [f"Customer: {data.get('name', 'Unknown')} (ID: {data.get('id')})"]
        if "contact" in data:
            c = data["contact"]
            lines.append(f"Contact: {c.get('name')} / {c.get('phone')} / {c.get('email')}")
        if "contract" in data:
            ct = data["contract"]
            lines.append(f"Contract: {ct.get('status')} / Expires: {ct.get('expire_date')}")
            lines.append(f"Contract Value: ${ct.get('amount'):,}")
        if "credit_score" in data:
            lines.append(f"Credit Score: {data['credit_score']}/100")
        return "\n".join(lines)

Step 3: Write the OpenAPI Specification

openapi: 3.1.0
info:
  title: CRM Customer Query Tool
  description: Query customer information from the enterprise CRM system
  version: 1.2.0

servers:
  - url: https://crm-api.company.internal

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer

paths:
  /api/customers/{customer_id}:
    get:
      operationId: query_customer_by_id
      summary: Query customer by ID
      description: |
        Exact lookup of customer info by unique ID.
        Use for: precise queries when you know the customer ID.
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
            pattern: '^CUST-\d{6}$'
        - name: fields
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Success
        '404':
          description: Customer not found
        '403':
          description: Insufficient permissions

  /api/customers/search:
    get:
      operationId: search_customer_by_name
      summary: Search customer by name
      parameters:
        - name: name
          in: query
          required: true
          schema:
            type: string

Step 4: Register the Tool in Dify

  1. Dify Console → Tools → Custom Tools → Create Tool
  2. Fill in basic info (name, description)
  3. Select authentication method (Bearer Token, API Key, OAuth2, etc.)
  4. Paste the OpenAPI specification
  5. Click "Test" to validate the tool works
  6. Save and enable in your Agent application
# Register tool programmatically via Dify API (for CI/CD)
import httpx

def register_tool(spec: dict, dify_url: str, admin_token: str) -> dict:
    resp = httpx.post(
        f"{dify_url}/console/api/workspaces/tools",
        headers={"Authorization": f"Bearer {admin_token}"},
        json={
            "name":        spec["name"],
            "description": spec["description"],
            "openapi":     spec["openapi_yaml"],
            "auth_type":   "bearer",
            "auth_config": {"token": spec["api_key"]}
        }
    )
    resp.raise_for_status()
    return resp.json()

Level 3: Source Code and Principles (5+ Years Experience)

Dify Tool System Internal Architecture

api/core/tools/
├── tool.py                   # Tool abstract base class
├── tool_engine.py            # Tool execution engine
├── tool_manager.py           # Tool registry, discovery, permissions
├── builtin/                  # Built-in tool implementations
│   ├── web_search/
│   │   ├── tools/
│   │   │   └── google_search.py
│   │   └── web_search.yaml
│   ├── code_interpreter/
│   └── ...
├── api_tools/                # Custom API tools
│   ├── api_tool.py
│   └── api_tool_bundle.py
└── provider/                 # Tool provider management

Tool base class core design:

from abc import ABC, abstractmethod
from typing import Any, Generator, Union

class ToolInvokeMessage:
    def __init__(self, type: str, message: Any, meta: dict = None):
        self.type    = type    # "text" | "image" | "link" | "blob"
        self.message = message
        self.meta    = meta or {}

class Tool(ABC):

    @property
    @abstractmethod
    def identity(self) -> "ToolIdentity":
        raise NotImplementedError

    @property
    @abstractmethod
    def parameters(self) -> list:
        raise NotImplementedError

    @abstractmethod
    def _invoke(self, user_id: str, tool_parameters: dict) -> Union[
        ToolInvokeMessage, list, Generator
    ]:
        raise NotImplementedError

    def invoke(self, user_id: str, tool_parameters: dict) -> list:
        """Public entry point with validation and error handling"""
        validated = self._validate_parameters(tool_parameters)
        result    = self._invoke(user_id, validated)
        if isinstance(result, Generator):
            return list(result)
        return result if isinstance(result, list) else [result]

    def _validate_parameters(self, params: dict) -> dict:
        validated = {}
        for p in self.parameters:
            value = params.get(p.name)
            if p.required and value is None:
                raise ValueError(f"Missing required parameter: {p.name}")
            if value is not None:
                validated[p.name] = p.validate_value(value)
            elif p.default is not None:
                validated[p.name] = p.default
        return validated

Built-in tool implementation (Google Search):

# api/core/tools/builtin/web_search/tools/google_search.py
from serpapi import GoogleSearch as SerpApiSearch
from core.tools.tool.builtin_tool import BuiltinTool

class GoogleSearchTool(BuiltinTool):

    def _invoke(self, user_id: str, tool_parameters: dict):
        query  = tool_parameters.get("query", "")
        num    = tool_parameters.get("num_results", 5)
        gl     = tool_parameters.get("gl", "us")
        hl     = tool_parameters.get("hl", "en")

        try:
            results = SerpApiSearch({
                "q":       query,
                "num":     num,
                "gl":      gl,
                "hl":      hl,
                "api_key": self.runtime.credentials.get("serpapi_api_key"),
            }).get_dict()
        except Exception as e:
            return self.create_text_message(f"Search failed: {e}")

        organic = results.get("organic_results", [])
        if not organic:
            return self.create_text_message("No results found")

        lines = []
        for i, r in enumerate(organic[:num], 1):
            lines.append(f"{i}. **{r.get('title')}**\n{r.get('snippet')}\n{r.get('link')}")

        return self.create_text_message("\n\n".join(lines))

Tool Execution Engine: Concurrency Control

# api/core/tools/tool_engine.py — parallel execution logic
import asyncio

class ToolEngine:

    TOOL_TIMEOUT = 30  # seconds per tool

    async def invoke_tools_parallel(
        self, tool_calls: list, user_id: str
    ) -> list:
        """Execute multiple tool calls in parallel with timeout and error isolation"""

        async def invoke_one(tc) -> dict:
            try:
                tool   = self.get_tool(tc.tool_name)
                result = await asyncio.wait_for(
                    asyncio.get_event_loop().run_in_executor(
                        None, tool.invoke, user_id, tc.parameters
                    ),
                    timeout=self.TOOL_TIMEOUT
                )
                return {"id": tc.id, "output": result, "success": True}
            except asyncio.TimeoutError:
                return {"id": tc.id, "output": "Tool timed out", "success": False}
            except Exception as e:
                return {"id": tc.id, "output": f"Tool error: {e}", "success": False}

        return await asyncio.gather(*[invoke_one(tc) for tc in tool_calls])

Custom Tool Authentication Internals

class APIToolAuthManager:

    def inject_auth(self, request: "httpx.Request", auth_config: dict):
        auth_type = auth_config.get("type")

        if auth_type == "api_key":
            location = auth_config.get("in", "header")
            name     = auth_config.get("name", "X-API-Key")
            value    = auth_config.get("value")
            if location == "header":
                request.headers[name] = value
            else:  # query param
                request = request.copy_with(
                    url=request.url.copy_with(params={**dict(request.url.params), name: value})
                )

        elif auth_type == "bearer":
            request.headers["Authorization"] = f"Bearer {auth_config['token']}"

        elif auth_type == "oauth2":
            token = self._get_oauth2_token(auth_config)
            request.headers["Authorization"] = f"Bearer {token}"

        elif auth_type == "basic":
            import base64
            creds = base64.b64encode(
                f"{auth_config['username']}:{auth_config['password']}".encode()
            ).decode()
            request.headers["Authorization"] = f"Basic {creds}"

        return request

    def _get_oauth2_token(self, cfg: dict) -> str:
        cache_key = f"oauth2:{cfg['client_id']}"
        cached    = self.token_cache.get(cache_key)
        if cached:
            return cached["access_token"]

        resp = httpx.post(cfg["token_url"], data={
            "grant_type":    "client_credentials",
            "client_id":     cfg["client_id"],
            "client_secret": cfg["client_secret"],
        })
        resp.raise_for_status()
        data = resp.json()
        self.token_cache.set(cache_key, data,
                             ttl=data.get("expires_in", 3600) - 60)
        return data["access_token"]

Level 4: Production Pitfalls and Decision-Making (Expert Perspective)

Pitfall 1: Vague Tool Descriptions Causing Misuse

Tool descriptions are the LLM's only basis for deciding which tool to call and when. Vague descriptions lead to:

Bad example (too vague):

name: data_query
description: Query data

Good example (includes scope, use cases, and exclusions):

name: crm_customer_query
description: |
  Query customer basic info, contact details, and contract status from the CRM system.
  Use when: user asks about a specific customer, needs to review contracts,
            or wants to check purchase history.
  Do NOT use for: product information, inventory data, financial reports.
  Query by: customer ID (format CUST-XXXXXX) or company name (exact match only).

Pitfall 2: Tool Output Too Long — Overflowing Context

When a tool returns large volumes of data (20 search results, 100 records), the LLM's context window fills up rapidly, causing performance degradation or outright errors.

Fix: Output truncation and summarization

class OutputTruncator:
    MAX_CHARS = 2000

    def truncate(self, output: str, strategy: str = "smart") -> str:
        if len(output) <= self.MAX_CHARS:
            return output

        if strategy == "tail":
            return output[:self.MAX_CHARS] + f"\n...[Truncated. Original length: {len(output)} chars]"

        elif strategy == "smart":
            half = self.MAX_CHARS // 2
            return (
                output[:half]
                + f"\n\n...[{len(output) - self.MAX_CHARS} chars omitted]...\n\n"
                + output[-half:]
            )

        elif strategy == "summarize":
            return self._summarize_with_llm(output)

    def _summarize_with_llm(self, text: str) -> str:
        prompt = f"Extract the key information from the following (max 500 words):\n\n{text[:5000]}"
        return cheap_llm.call(prompt)  # Use a small/cheap model for summarization

Pitfall 3: Non-Idempotent Tools Getting Retried

When an Agent encounters an error, it may retry the tool call. If the tool is not idempotent (sending emails, writing to a database), this causes duplicate operations.

import hashlib, json

class IdempotentToolWrapper:
    """Add idempotency protection to non-idempotent tools"""

    def __init__(self, tool, store, ttl: int = 3600):
        self.tool  = tool
        self.store = store  # Redis or database
        self.ttl   = ttl

    def invoke(self, user_id: str, params: dict) -> str:
        key_data = json.dumps(
            {"user": user_id, "tool": self.tool.name, "params": params},
            sort_keys=True
        )
        idem_key = f"idem:{hashlib.sha256(key_data.encode()).hexdigest()}"

        cached = self.store.get(idem_key)
        if cached:
            return f"[Deduplicated] {cached}"

        result = self.tool.invoke(user_id, params)
        self.store.setex(idem_key, self.ttl, str(result))
        return str(result)

Tool Performance Benchmark and Optimization

Production environment measurements:

Tool Type Avg Latency P99 Latency Main Bottleneck Optimization
Web Search (SerpAPI) 1.2s 3.5s External API Cache hot queries
Code Interpreter 2.8s 8s Container boot + execution Pre-warm container pool
Knowledge Base Retrieval 0.3s 0.8s Vector DB query Pre-load hot KBs
Custom API Tool 0.5–5s Variable Target API performance Set reasonable timeout
Calculator <10ms <50ms Local compute No optimization needed

Tool result caching:

import functools, hashlib, json

def cacheable_tool(ttl: int = 300):
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(self, user_id: str, params: dict) -> str:
            key    = f"tool:{self.name}:{hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest()}"
            cached = await redis.get(key)
            if cached:
                return cached.decode()
            result = await fn(self, user_id, params)
            await redis.setex(key, ttl, str(result))
            return result
        return wrapper
    return decorator

class WeatherTool(Tool):
    @cacheable_tool(ttl=600)  # Cache weather for 10 minutes
    async def _invoke(self, user_id: str, params: dict) -> str:
        city = params["city"]
        resp = await weather_api.get(city)
        return f"{city}: {resp['weather']}, {resp['temperature']}°C"

Chapter Summary

This chapter provided a comprehensive view of the Dify tool ecosystem, from built-in tool configuration to the complete custom tool development pipeline:

Key takeaways:

  1. Tool descriptions are the LLM's sole basis for tool selection — descriptions must include applicable scenarios and explicit exclusions
  2. The code interpreter is the most powerful tool for data analysis tasks — safely executes Python in a sandbox with pandas/matplotlib
  3. Four-step custom tool development: Design interface → Implement backend → Write OpenAPI spec → Register in Dify
  4. Tool authentication supports API Key, Bearer Token, OAuth2, and Basic Auth — all automatically injected by Dify's auth manager

Production essentials:

Key numbers:

Next chapter: Chapter 15 enters the world of multi-Agent collaboration, exploring how Dify Workflow orchestrates multiple Agents to tackle complex tasks together.

Rate this chapter
4.6  / 5  (19 ratings)

💬 Comments