Skip to content

Creating Plugins

Step 1: Define your argument schema

Subclass ToolArgs (a Pydantic BaseModel) to describe your tool's inputs:

from pydantic import Field
from aifred_tk.core.interfaces import ToolArgs

class MyToolArgs(ToolArgs):
    query: str = Field(..., description="The search query")
    limit: int = Field(10, ge=1, description="Maximum number of results")

Step 2: Implement your tool

Subclass Tool and implement all abstract properties and execute():

from typing import Any
from aifred_tk.core.interfaces import Tool, ToolArgs, ToolResult

class MyTool(Tool):
    @property
    def tool_id(self) -> str:
        return "my_tool"

    @property
    def name(self) -> str:
        return "My Tool"

    @property
    def description(self) -> str:
        return "Does something useful."

    @property
    def args_schema(self) -> type[MyToolArgs]:
        return MyToolArgs

    def execute(self, args: ToolArgs) -> ToolResult:
        assert isinstance(args, MyToolArgs)
        # ... your logic here ...
        return ToolResult(output={"result": args.query})

Step 3: Implement your plugin

Subclass Plugin and return your tools from get_tools():

from dynaconf import Dynaconf
from aifred_tk.core.interfaces import Plugin, Tool

class MyPlugin(Plugin):
    @property
    def plugin_id(self) -> str:
        return "my_plugin"

    @property
    def name(self) -> str:
        return "My Plugin"

    def get_tools(self) -> list[Tool]:
        return [MyTool()]


def create_plugin(settings: Dynaconf) -> MyPlugin:
    return MyPlugin()

Step 4: Register the entry point

In your package's pyproject.toml:

[project.entry-points."aifred_tk.plugins"]
my_plugin = "my_package.plugin:create_plugin"

After installing your package (e.g. pip install -e .), aifred-tk discovers your tool automatically:

aifred-tk my_tool --query "hello"

Accessing settings

The settings argument in create_plugin provides access to the full configuration chain. Use it to read plugin-specific configuration:

# ~/.config/aifred-tk/settings.yml
tools:
  my_tool:
    api_key: secret
def create_plugin(settings: Dynaconf) -> MyPlugin:
    api_key = settings.get("TOOLS__MY_TOOL__API_KEY", None)
    return MyPlugin(api_key=api_key)

Using LLM Agents

If your tool needs to interact with an LLM, use the build_agent_from_settings facility. This helper automatically resolves the LLM configuration from the tool's settings, handles provider-specific models, and populates model settings like max_tokens.

lazy instantiation

It is a best practice to instantiate the agent lazily (on first use) within your tool's execute method or a private property. This ensures that the plugin can be registered even if required environment variables (like API keys) are missing at startup.

Note that build_agent_from_settings returns an AgentRunner rather than a plain pydantic_ai.Agent. This interface is shared by both the standard agent and the FallbackAgent, allowing your tool to transparently support fallback chains.

from typing import Any
from aifred_tk.core.interfaces import Tool, ToolArgs
from aifred_tk.core.llm import build_agent_from_settings, AgentRunner

class MyLlmTool(Tool):
    def __init__(self, settings: Dynaconf) -> None:
        self._settings = settings
        self._agent: AgentRunner[str] | None = None

    @property
    def tool_id(self) -> str:
        return "my_llm_tool"

    def _get_agent(self) -> AgentRunner[str]:
        if self._agent is None:
            # Build the agent using the tool's unique ID and settings.
            # Returns an AgentRunner (either a pydantic-ai Agent or a FallbackAgent).
            self._agent = build_agent_from_settings(
                self.tool_id,
                self._settings,
                output_type=str,
                system_prompt="You are a helpful assistant.",
            )
        return self._agent

    def execute(self, args: ToolArgs) -> ToolResult:
        agent = self._get_agent()
        result = agent.run_sync("Hello world")
        return ToolResult(
            output={"response": result.output},
            llm_name=agent.model_name
        )

Customizing Agent Behavior (Capabilities and Retries)

You can pass optional parameters capabilities and retries to build_agent_from_settings to customize the underlying agent's execution behavior:

  • capabilities (Sequence[Any] | None): A sequence of Pydantic-AI capabilities, such as Hooks, to register on the agent. These will be appended after any system-provided debug hooks if debug mode is active.
  • retries (int): The number of attempts for both model failures and tool execution failures (defaults to 3).

[!NOTE] When a fallback LLM configuration is resolved, these capabilities and retry settings are automatically and recursively propagated to all leaf agents in the fallback chain.

from pydantic_ai import Hooks
from aifred_tk.core.llm import build_agent_from_settings

# Define a custom hook to intercept agent events
class MyCustomHook(Hooks):
    def on_llm_request(self, request):
        print(f"LLM Prompt: {request}")

self._agent = build_agent_from_settings(
    self.tool_id,
    self._settings,
    output_type=str,
    system_prompt="Analyze code style.",
    capabilities=[MyCustomHook()],
    retries=5,  # Allow up to 5 validation / tool retries
)

Configuration

The user must map an LLM to your tool in their settings file using the llm key under your tool's namespace:

# ~/.config/aifred-tk/settings.yml
llms:
  gpt4:
    provider: openai
    model: gpt-4o

tools:
  my_llm_tool:
    enabled: true
    llm:
      type: ref
      ref: gpt4

See LLMs for more details on LLM configuration.

Tool Results and State Mutation

Every tool must return a ToolResult object. This container holds the tool's output and metadata used for governance and reporting.

from aifred_tk.core.interfaces import ToolResult

# Simple return
return ToolResult(output={"status": "ok"})

# Return with change tracking
return ToolResult(
    output={"status": "committed"},
    made_changes=True,
    llm_name="openai/gpt-4o"
)
Field Description
output The primary result (JSON-serializable dict or a raw str).
made_changes Set to True if the tool performed a persistent side effect (file write, commit, etc.).
llm_name The "provider/model" string of the agent that drove the change.

The readonly property

By default, all tools are assumed to be read-only (readonly = True). This is reflected in the MCP readOnlyHint annotation. If your tool modifies external state, you must override this property:

class MyMutationTool(Tool):
    @property
    def readonly(self) -> bool:
        return False

    def execute(self, args: ToolArgs) -> ToolResult:
        # ... performing changes ...
        return ToolResult(output={"ok": True}, made_changes=True)

When made_changes=True, the presentation layer automatically logs the llm_name to the AI-assisted guard file.

Safe File Access

If your tool reads files from disk, you MUST ensure that access is both secure (preventing path traversal attacks) and ethical (respecting the user's .aiignore rules).

The aifred_tk.core.paths module provides two utilities to handle this automatically.

Validating a single file

Use sanitize_file_path() when your tool takes a specific file path as input. It resolves the path, ensures it is a regular file, and checks it against .aiignore. If the file is inaccessible or ignored, it raises a FileAccessError.

from aifred_tk.core.paths import sanitize_file_path, FileAccessError

def execute(self, args: ToolArgs) -> ToolResult:
    try:
        # Resolves traversal, checks existence, and enforces .aiignore
        safe_path = sanitize_file_path(args.file_path)
    except FileAccessError as e:
        return ToolResult(output={"status": "error", "message": str(e)})

    content = safe_path.read_text()
    # ...
    return ToolResult(output={"content": content})

Filtering multiple paths

Use filter_accessible_paths() when handling a collection of paths (for example, from a glob search). It resolves each path and silently drops any that are missing, are not regular files, or match an .aiignore pattern.

from aifred_tk.core.paths import filter_accessible_paths

def execute(self, args: ToolArgs) -> ToolResult:
    raw_paths = ["src/main.py", "secrets.key", "missing.txt"]

    # Returns only the resolved Paths that exist and are NOT ignored
    accessible_paths = filter_accessible_paths(raw_paths)

    # accessible_paths will likely only contain [Path(".../src/main.py")]
    for path in accessible_paths:
        # ... process file ...

    return ToolResult(output={"processed": len(accessible_paths)})