Skip to content

Developing Tool Plugins

This guide covers how to create tools for PrimeThink agents — both inside the main repository and as external plugin packages.

Architecture Overview

PrimeThink uses a capability-based system to manage agent tools. There are three types of capabilities:

  • Internal — Python tools registered in the tool registry
  • MCP — External MCP (Model Context Protocol) servers connected via configuration
  • API — REST API endpoints wrapped as tools via configuration
┌─────────────────────────────────────────────────────────┐
│  Capabilities DB Table                                  │
│  ┌──────────────────────────────────────────────┐       │
│  │ id │ code         │ type     │ options (JSON) │      │
│  │  1 │ base         │ internal │ null           │      │
│  │  2 │ rag          │ internal │ null           │      │
│  │  3 │ deepwiki     │ mcp      │ {server_url..} │      │
│  │  4 │ get_weather  │ api      │ {url, method…} │      │
│  └──────────────────────────────────────────────┘       │
└─────────────┬───────────────────────────────────────────┘
              │ agent has capabilities
┌─────────────────────────────────────────────────────────┐
│  Tool Resolution                                        │
│                                                         │
│  1. Internal tools:                                     │
│     Collect active codes → resolve_tools(codes)         │
│     ↓                                                   │
│     tool_registry  ←── tool_registrations               │
│                    ←── external plugins (entry points)   │
│                    ←── @capability decorators             │
│                                                         │
│  2. MCP tools:                                          │
│     type == "mcp"  → mcp_tool_builder                   │
│     → returns dicts for llm.bind_tools()                │
│                                                         │
│  3. API tools:                                          │
│     type == "api"  → api_tool_builder                   │
│     → returns dynamic StructuredTool instances           │
└─────────────────────────────────────────────────────────┘

Capability Codes

A capability code is the string that links a DB capability row to a set of tools. When an agent has a capability with code = "my_tools", any tools registered under "my_tools" are attached to that agent.

Rules: - Codes are simple lowercase strings: "base", "rag", "obviously_manage" - A single DB capability row can reference multiple codes via comma separation: "base,rag" — this activates both tool groups - A single tool can be registered under multiple codes — it will be deduplicated at runtime

Writing a Tool (LangChain)

Tools are standard LangChain tools using the @tool decorator:

from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from pydantic import BaseModel, Field


class MyToolInput(BaseModel):
    query: str = Field(description="The search query")
    limit: int = Field(default=10, description="Max results to return")


@tool("my_tool_name", args_schema=MyToolInput)
async def my_tool(query: str, limit: int, config: RunnableConfig) -> str:
    """
    Short description of what this tool does.

    The LLM reads this docstring to decide when to use the tool.
    Be specific and concise.
    """
    user_id = config.get("configurable", {}).get("logged_user_id")
    group_id = config.get("configurable", {}).get("group_id")
    chat_id = config.get("configurable", {}).get("chat_id")

    result = f"Found results for '{query}' (limit={limit})"
    return result

Important conventions:

  • Always use async def — the agent runtime is async
  • Always accept config: RunnableConfig as the last parameter — LangGraph injects it automatically
  • The docstring is the tool description the LLM sees — make it clear and actionable
  • Return a string. The LLM reads this as the tool's output
  • Use args_schema with a Pydantic model for any tool with parameters

Available Config Values

The RunnableConfig object carries context about the current user and chat:

config.get("configurable", {}).get("logged_user_id")   # int — current user ID
config.get("configurable", {}).get("group_id")          # int — current group ID
config.get("configurable", {}).get("chat_id")           # int — current chat ID
config.get("configurable", {}).get("chat_uuid")         # str — chat UUID

Registering Tools in the Main Repo

Add your tool to tool_registrations.py:

from src.virtual_assistant_lc.tools.my_module.my_tool import my_tool

register_tools("my_capability_code", [my_tool],
    name="My Tool Group",
    description="Short human-friendly description of this capability"
)

The name and description keyword arguments are required for internal registrations. They provide capability-level metadata used by the Super Admin API to auto-fill fields when activating capabilities for a group.

Option 2: @capability Decorator

Apply the decorator directly in your tool file:

from src.virtual_assistant_lc.tool_registry import capability
from langchain_core.tools import tool


@capability("my_capability_code")
@tool("my_tool_name")
async def my_tool(config: RunnableConfig) -> str:
    """Tool description."""
    return "result"

Note: The module must be imported at startup for the registration to take effect.

Registering a Toolkit (List of Tools)

Group related tools and register together:

# toolkit file
my_toolkit = [tool_a, tool_b, tool_c]
# tool_registrations.py
from src.virtual_assistant_lc.tools.my_module.my_toolkit import my_toolkit

register_tools("my_capability_code", my_toolkit,
    name="My Toolkit",
    description="Group of related tools for X"
)

Multi-Code Registration

A tool can belong to multiple capability groups:

# Via decorator
@capability("base", "rag")
@tool("get_document_text")
async def get_document_text(...): ...

# Via register_tools
register_tools("base", [shared_tool], name="Base Tools", description="Core agent tools")
register_tools("rag", [shared_tool], name="RAG", description="Retrieval-augmented generation")

In both cases, resolve_tools() deduplicates automatically.

Creating an External Plugin Package

External plugins are standard Python packages that register tools via the primethink.tools entry-point group. The API discovers them automatically at startup — no code changes needed in the main repo.

1. Scaffold the Package

my-plugin/
├── pyproject.toml
├── src/
│   └── my_plugin/
│       ├── __init__.py          # register() function
│       ├── my_toolkit.py        # Tool definitions
│       └── utils.py             # Shared helpers
└── tests/
    └── test_registration.py

2. Write Your Tools

Same LangChain patterns as in the main repo:

from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from pydantic import BaseModel, Field


class SearchInput(BaseModel):
    query: str = Field(description="Search text")
    limit: int = Field(default=20, description="Maximum results")


@tool("search_matters", args_schema=SearchInput)
async def search_matters(query: str, limit: int, config: RunnableConfig) -> str:
    """Search for matters/cases by text query."""
    return "results..."


my_toolkit = [search_matters]

3. Export a Register Function

# src/my_plugin/__init__.py

def register(registry_fn, context=None):
    """Called by PrimeThink API at startup via entry-point discovery."""
    from .my_toolkit import my_toolkit

    registry_fn("my_code", my_toolkit,
        name="My Plugin",
        description="What this plugin does"
    )

Key points: - Use lazy imports (inside the function) to avoid import-time side effects - registry_fn is register_tools(code, tools, *, name, description) from the tool registry - context is an optional object providing PT services (see Plugin Context below) - You can call registry_fn multiple times for different codes

4. Declare the Entry Point

In pyproject.toml:

[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-plugin"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
    "langchain-core>=0.3.0",
    "pydantic>=2.0",
]

[project.entry-points."primethink.tools"]
my_plugin = "my_plugin:register"

[tool.setuptools.packages.find]
where = ["src"]

5. Install the Plugin

# From Git
pip install git+ssh://git@github.com/your-org/my-plugin.git@v1.0.0

# Local development
pip install -e /path/to/my-plugin

6. Verify Discovery

python -c "
from importlib.metadata import entry_points
eps = entry_points().select(group='primethink.tools')
for ep in eps:
    print(f'{ep.name} -> {ep.value}')
"

Plugin Context

Some plugins need access to PrimeThink services (user settings, document storage) that live in the main API. Instead of importing directly, the API injects a context object into plugins at startup.

Available Services

Attribute Type Description
get_user_setting_value async (value_name, user_id, group_id) -> str Reads per-user settings from the DB
document_service DocumentServiceProtocol \| None Access to document storage

Using Context

def register(registry_fn, context=None):
    from .my_toolkit import core_toolkit
    from .settings import set_settings_provider, PrimethinkSettingsProvider

    if context is not None and hasattr(context, "get_user_setting_value"):
        set_settings_provider(PrimethinkSettingsProvider(context.get_user_setting_value))

    registry_fn("my_code", core_toolkit,
        name="My Plugin", description="Core plugin tools")

Settings Provider Pattern

For plugins needing per-user settings (API tokens, base URLs):

class ToolkitSettings:
    manage_base_url: str
    manage_token: str

class PrimethinkSettingsProvider:
    """Reads settings from the PT DB via the injected context."""
    def __init__(self, get_user_setting_value):
        self._get_setting = get_user_setting_value

    async def get_settings(self, config):
        configurable = config.get("configurable", {})
        user_id = configurable.get("logged_user_id")
        group_id = configurable.get("group_id")
        base_url = await self._get_setting("manage_base_url", user_id, group_id)
        token = await self._get_setting("manage_token", user_id, group_id)
        return ToolkitSettings(manage_base_url=base_url, manage_token=token)

class StaticSettingsProvider:
    """Fixed settings — useful for MCP servers or testing."""
    def __init__(self, base_url, token):
        self._settings = ToolkitSettings(manage_base_url=base_url, manage_token=token)

    async def get_settings(self, config):
        return self._settings

This lets the same tools work in both plugin mode (per-user DB settings) and standalone mode (fixed env var settings).

Creating DB Capabilities

Every tool group needs a matching capability row in the database.

If your tool is already registered, use the streamlined endpoint that auto-fills name and description:

POST /api/super-admin/capabilities/from-registered-tool
{
    "code": "my_capability_code",
    "group_id": 5
}

You can optionally override:

{
    "code": "my_capability_code",
    "group_id": 5,
    "name": "Custom Display Name",
    "description": "Custom description override",
    "is_default": true,
    "ordering": 10
}

To see available codes:

GET /api/super-admin/capabilities/registered-tools?group_id=5

Manual Creation

POST /api/super-admin/capabilities
{
    "name": "My Tool Group",
    "code": "my_capability_code",
    "type": "internal",
    "description": "Description shown in the UI",
    "is_default": false,
    "ordering": 10,
    "access_type": "user"
}

Fields:

Field Description
name Display name in the UI
code Capability code(s), comma-separated for multiple: "code_a,code_b"
type "internal" for Python tools, "mcp" for MCP servers, "api" for REST APIs
options JSON config — used by MCP and API types
is_default If true, auto-attached to new agents
access_type "system" (all groups), "group" (specific group), "user" (all users), "private" (single user)

MCP Capabilities

MCP capabilities connect agents to external MCP servers. No Python code needed — just a DB row with the right options.

Basic MCP Capability

{
    "name": "DeepWiki",
    "code": "deepwiki",
    "type": "mcp",
    "options": {
        "server_label": "deepwiki",
        "server_url": "https://mcp.deepwiki.com/mcp",
        "require_approval": "never"
    }
}

MCP with Authentication

Use ${SETTING_NAME} placeholders in headers — resolved from user/group settings at runtime:

{
    "name": "Home Assistant",
    "code": "ha_remote",
    "type": "mcp",
    "options": {
        "server_label": "ha-remote",
        "server_url": "https://your-instance.ui.nabu.casa/api/mcp",
        "require_approval": "never",
        "headers": {
            "Authorization": "Bearer ${HA_REMOTE_TOKEN}"
        }
    }
}

MCP Options Reference

Option Required Description
server_label Yes Unique label for the MCP server
server_url Yes MCP server endpoint URL
require_approval No "never", "always", or omit for default
headers No HTTP headers dict, supports ${SETTING_NAME} placeholders

API Capabilities

API capabilities turn any REST endpoint into a tool — no Python code needed.

GET API Example

{
    "name": "Weather API",
    "code": "get_weather",
    "type": "api",
    "options": {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "method": "GET",
        "url": "https://api.weather.com/v1/current",
        "params": {
            "location": {
                "type": "string",
                "description": "City name (e.g. 'London')",
                "required": true
            },
            "units": {
                "type": "string",
                "description": "Temperature units: 'celsius' or 'fahrenheit'",
                "required": false,
                "default": "celsius"
            }
        },
        "headers": {
            "Authorization": "Bearer ${WEATHER_API_KEY}"
        }
    }
}

POST API Example

{
    "name": "Send SMS",
    "code": "send_sms",
    "type": "api",
    "options": {
        "name": "send_sms",
        "description": "Send an SMS message to a phone number",
        "method": "POST",
        "url": "https://api.sms-provider.com/v1/messages",
        "body": {
            "to": {
                "type": "string",
                "description": "Recipient phone number in E.164 format",
                "required": true
            },
            "message": {
                "type": "string",
                "description": "The SMS message text",
                "required": true
            }
        },
        "headers": {
            "Authorization": "Bearer ${SMS_API_KEY}",
            "Content-Type": "application/json"
        }
    }
}

API Options Reference

Option Required Description
name Yes Tool name (what the LLM sees)
description Yes Tool description (LLM uses this to decide when to call it)
method No HTTP method: GET, POST, PUT, PATCH. Default: GET
url Yes Endpoint URL, supports ${SETTING_NAME} placeholders
params No Query parameters schema (for GET requests)
body No Request body schema (for POST/PUT/PATCH requests)
headers No HTTP headers dict, supports ${SETTING_NAME} placeholders

Parameter Schema Format

{
    "param_name": {
        "type": "string",
        "description": "What this parameter does",
        "required": true,
        "default": "optional default value"
    }
}

Supported types: "string", "integer", "number", "boolean".

Settings Placeholder Resolution

Both MCP and API capabilities support ${SETTING_NAME} placeholders in URLs, headers, and other string values. These are resolved at runtime from the current user's settings.

How it works:

  1. The system finds all ${...} tokens in the capability options
  2. For each token, it calls get_user_setting_value(name, user_id, group_id)
  3. The returned value replaces the placeholder
  4. If any setting cannot be resolved, the tool/MCP config is skipped with a logged error

Setting up user settings:

Users configure settings via the Settings page or the admin API:

Setting name:  WEATHER_API_KEY
Setting value: sk-abc123...

This value is then available as ${WEATHER_API_KEY} in any capability's options.

Tool Resolution at Runtime

When an agent is initialized:

  1. Agent loads its capabilities from the DB
  2. Internal tools: Extract capability codes → resolve_tools(codes) → deduplicated tool list
  3. MCP tools: Build MCP config from options → resolve placeholders → llm.bind_tools()
  4. API tools: Build Pydantic input model from schemas → resolve placeholders → create StructuredTool
  5. Agent graph is created with the combined tools

Testing Your Plugin

Install in Development Mode

cd /path/to/my-plugin
pip install -e .

Verify Entry-Point Registration

python -c "
from importlib.metadata import entry_points
eps = entry_points().select(group='primethink.tools')
for ep in eps:
    print(f'{ep.name} -> {ep.value}')
"

Test the Register Function

def test_plugin_registration():
    calls = []

    def mock_registry(code, tools, **kwargs):
        calls.append((code, tools, kwargs))

    from my_plugin import register
    register(mock_registry)

    assert len(calls) >= 1
    for code, tools, kwargs in calls:
        print(f"Code: {code}, Tools: {[t.name for t in tools]}, Meta: {kwargs}")
        assert isinstance(code, str)
        assert len(tools) > 0
        assert kwargs.get("name"), f"Missing name for code '{code}'"

Test Tools Individually

import asyncio

async def test_my_tool():
    from my_plugin.my_toolkit import search_matters

    config = {"configurable": {"logged_user_id": 1, "group_id": 1, "chat_id": 1}}
    result = await search_matters.ainvoke({"query": "test", "limit": 5}, config=config)
    print(result)

asyncio.run(test_my_tool())

Deploying to Kubernetes

Add the plugin to your Dockerfile:

RUN pip install git+https://${GITHUB_TOKEN}@github.com/your-org/my-plugin.git@v1.0.0

Or use a requirements file:

# requirements-plugins.txt
git+https://github.com/your-org/my-plugin.git@v1.0.0
git+https://github.com/your-org/another-plugin.git@v0.2.0
COPY requirements-plugins.txt .
RUN pip install -r requirements-plugins.txt

The API discovers plugins automatically at startup — no configuration changes needed.

Troubleshooting

Plugin Not Discovered

  • Verify the entry point: python -c "from importlib.metadata import entry_points; print(list(entry_points().select(group='primethink.tools')))"
  • Confirm the package is installed: pip list | grep my-plugin
  • Check the entry-point group name is exactly "primethink.tools" in pyproject.toml

Tools Not Appearing on the Agent

  • Check that a capabilities DB row exists with the correct code
  • Verify the capability is assigned to the agent
  • Check the type field — use "internal" for Python tools

${SETTING_NAME} Not Resolving

  • Verify the user has the setting configured
  • The setting name is case-sensitive
  • Check API logs for "placeholder could not be resolved" warnings

Import Errors in Plugin

  • The plugin should NOT import from src.* (the main API). Use the injected context instead
  • Only depend on langchain-core, pydantic, and standard library imports
  • For shared utilities, pass them via config or environment variables