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.


Table of Contents


Architecture Overview

PrimeThink agents get their tools from capabilities stored in the database. Tool plugins provide internal capabilities — Python tools registered in the tool registry, either built into the main repo or shipped as external packages.

This guide covers Python tool plugins only. Capabilities that wrap an external MCP server or a REST endpoint are configured purely through the database with no Python code — see MCP Capabilities and API Capabilities.

┌─────────────────────────────────────────────────────────┐
│  Capabilities DB Table (type = "internal")              │
│  ┌──────────────────────────────────────────────┐       │
│  │ id │ code             │ type     │ options    │      │
│  │  1 │ base             │ internal │ null       │      │
│  │  2 │ rag              │ internal │ null       │      │
│  │  3 │ obviously_manage │ internal │ null       │      │
│  └──────────────────────────────────────────────┘       │
└─────────────┬───────────────────────────────────────────┘
              │ agent has capabilities
┌─────────────────────────────────────────────────────────┐
│  Tool resolution (internal tools)                       │
│                                                         │
│     Collect active codes → resolve_tools(codes)         │
│     ↓                                                   │
│     tool_registry.py  ←── tool_registrations.py         │
│                        ←── external plugins (entry pts) │
│                        ←── @capability decorators        │
└─────────────────────────────────────────────────────────┘

Key files:

File Purpose
src/virtual_assistant_lc/tool_registry.py Core registry: @capability decorator, register_tools(), resolve_tools(), discover_plugins(), get_capability_meta()
src/virtual_assistant_lc/tool_registrations.py Central registration of all built-in tools by capability code
src/utils/capabilities_utils.py Orchestrates tool resolution at runtime

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 using comma separation: "base,rag" — this activates both groups of tools. - A single tool can be registered under multiple codes — it will be deduplicated at runtime.


Writing a Tool (LangChain)

Tools are standard LangChain tools. Use 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.
    """
    # Access user/chat context from the config
    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")

    # Your implementation
    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

Option 1: Central registration

Add your tool to tool_registrations.py. This is the recommended approach for most tools:

# In src/virtual_assistant_lc/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. The tool is registered when the module is imported:

# In src/virtual_assistant_lc/tools/my_module/my_tool.py

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: If you use @capability directly, the module must be imported at startup for the registration to take effect. Either import it in tool_registrations.py or ensure it's imported elsewhere during initialization.

Registering a toolkit (list of tools)

Group related tools into a list and register them together:

# At the bottom of your toolkit file
my_toolkit = [tool_a, tool_b, tool_c]

Then in 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"
)

Or using the decorator directly:

from src.virtual_assistant_lc.tool_registry import capability

my_toolkit = capability("my_capability_code")([tool_a, tool_b, tool_c])

Multi-code registration

A tool can belong to multiple capability groups:

# Via decorator — the tool is available when EITHER code is active
@capability("base", "rag")
@tool("get_document_text")
async def get_document_text(...): ...
# Via register_tools — register the same tool under multiple codes
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 — the tool appears only once even if multiple matching codes are active.


Creating an External Plugin Package

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

1. Scaffold the package

assist-manage-toolkit/
├── pyproject.toml
├── src/
│   └── assist_manage_toolkit/
│       ├── __init__.py          # register() function
│       ├── manage_toolkit.py    # Tool definitions
│       └── manage_utils.py      # Shared helpers
└── tests/
    └── test_registration.py

2. Write your tools

Tools follow the same LangChain patterns as in the main repo:

# src/assist_manage_toolkit/manage_toolkit.py

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


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


@tool("search_matters", args_schema=SearchMattersInput)
async def search_matters(query: str, limit: int, config: RunnableConfig) -> str:
    """
    Search for matters/cases by text query in the management system.
    Returns matching matters with their reference numbers and titles.
    """
    # Your implementation here
    return "results..."


class CreateTimerInput(BaseModel):
    matter_id: int = Field(description="The matter ID to log time against")
    description: str = Field(description="Description of the work performed")


@tool("create_timer", args_schema=CreateTimerInput)
async def create_timer(matter_id: int, description: str, config: RunnableConfig) -> str:
    """
    Create a new active timer for a matter in the management system.
    The timer starts immediately and can be stopped later.
    """
    # Your implementation here
    return "Timer created"


# Export as a toolkit list
manage_toolkit = [
    search_matters,
    create_timer,
]

3. Export a register function

The register function is called by the main API at startup. It receives register_tools as the first argument and an optional context as the second — use them to register your tools under capability codes:

# src/assist_manage_toolkit/__init__.py


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

    Args:
        registry_fn: The register_tools(code, tools_list, *, name, description)
                     function from the main API's tool_registry module.
        context: Optional object providing PT services (settings, document
                 storage, etc.). See "Plugin Context" section below.
    """
    from .manage_toolkit import manage_toolkit

    registry_fn("obviously_manage", manage_toolkit,
        name="Manage",
        description="Search and manage matters, timers, and billing"
    )

Key points: - Use lazy imports (inside the function) to avoid import-time side effects. - registry_fn is register_tools(code: str, tools: list, *, name: str = "", description: str = "") from tool_registry.py. - The name and description kwargs provide human-friendly metadata for the Super Admin UI. They are optional for backward compatibility but recommended. - context is an optional object providing PT services — plugins that accept a second parameter receive it automatically (backward compatible). - You can call registry_fn multiple times for different codes.

To register tools under multiple codes:

def register(registry_fn, context=None):
    from .manage_toolkit import manage_toolkit
    from .search_tools import search_table_tool

    registry_fn("obviously_manage", manage_toolkit,
        name="Manage", description="Full management toolkit")
    registry_fn("manage_search", [search_table_tool],
        name="Manage Search", description="Search tables in the management system")

4. Declare the entry point

In pyproject.toml, declare your package under the primethink.tools entry-point group:

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

[project]
name = "assist-manage-toolkit"
version = "1.0.0"
description = "Management system tools for PrimeThink agents"
requires-python = ">=3.11"
dependencies = [
    "langchain-core>=0.3.0",
    "httpx>=0.28.0",
    "pydantic>=2.0",
]

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

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

The entry-point format is:

<plugin-name> = "<python_module_path>:<function_name>"
  • manage — a unique name for your plugin (used in logs)
  • assist_manage_toolkit:register — the register() function in assist_manage_toolkit/__init__.py

5. Install the plugin

Install from a private Git repository:

# Using SSH
pip install git+ssh://git@github.com/your-org/assist-manage-toolkit.git@v1.0.0

# Using HTTPS with a token
pip install git+https://${GITHUB_TOKEN}@github.com/your-org/assist-manage-toolkit.git@v1.0.0

# From a local directory (during development)
pip install -e /path/to/assist-manage-toolkit

6. Verify discovery

After installing, verify the entry point is registered:

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}')
"

Expected output:

manage -> assist_manage_toolkit:register

Plugin Context (PT Services Injection)

Some plugins need access to PrimeThink services (user settings, document storage, etc.) that live in the main API. Instead of importing from src.* directly (which would create a hard dependency), the API injects a context object into plugins at startup.

How it works

  1. At startup, capabilities_utils.py builds a PTPluginContext object that wraps real PT services.
  2. discover_plugins(context) passes this context to each plugin's register() function.
  3. The system uses inspect.signature to check if the plugin accepts a second parameter — if it does, the context is passed; if not, only registry_fn is passed. This ensures backward compatibility with simple plugins.

The context object

The context provides these services:

Attribute Type Description
get_user_setting_value async (value_name, user_id, group_id) -> str Reads per-user settings from the DB (e.g., API tokens, base URLs)
document_service DocumentServiceProtocol \| None Access to document storage (get by ID, fetch bytes, process content)

Using context in your plugin

Accept context as an optional second parameter in your register() function:

# src/my_plugin/__init__.py

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

    # Use context for per-user settings resolution
    if context is not None and hasattr(context, "get_user_setting_value"):
        set_settings_provider(PrimethinkSettingsProvider(context.get_user_setting_value))

    # Always register core tools
    registry_fn("my_code", core_toolkit,
        name="My Plugin", description="Core plugin tools")

    # Conditionally register tools that need PT services
    if context is not None and hasattr(context, "document_service") and context.document_service is not None:
        from .pt_tools import pt_toolkit, set_document_service
        set_document_service(context.document_service)
        registry_fn("my_code", pt_toolkit)

DocumentServiceProtocol

Plugins that work with documents can define a Protocol for type safety:

from typing import Protocol, Any, Tuple, Optional

class DocumentServiceProtocol(Protocol):
    async def get_document_by_id(self, document_id: int) -> Optional[Any]: ...
    async def fetch_document_bytes(self, document: Any) -> Tuple[str, bytes]: ...
    def process_content_by_format(self, content: str, format: Any) -> Tuple[str, str, bytes]: ...

The main API's PTDocumentService (built in capabilities_utils.py) satisfies this protocol by wrapping DocumentDatastore and fetch_document_bytes.

Settings abstraction pattern

For plugins that need per-user settings (e.g., API tokens that differ per user), use a provider pattern:

# settings.py in your plugin

class ToolkitSettings:
    manage_base_url: str
    manage_token: str

class SettingsProvider(Protocol):
    async def get_settings(self, config: dict) -> ToolkitSettings: ...

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:
    """Returns 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 MCP/standalone mode (fixed env var settings).

Plugins without context

Simple plugins that don't need PT services can ignore the context entirely:

def register(registry_fn):
    from .my_toolkit import my_toolkit
    registry_fn("my_code", my_toolkit,
        name="My Plugin", description="What this plugin does")

The system detects the single-parameter signature and calls it without context.


Full External Plugin Example

Here is a complete, minimal plugin package that provides a "CRM" toolkit with two tools:

File structure

primethink-crm-tools/
├── pyproject.toml
├── src/
│   └── primethink_crm_tools/
│       ├── __init__.py
│       └── crm_toolkit.py
└── tests/
    └── test_crm_toolkit.py

pyproject.toml

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

[project]
name = "primethink-crm-tools"
version = "0.1.0"
description = "CRM integration tools for PrimeThink agents"
requires-python = ">=3.11"
dependencies = [
    "langchain-core>=0.3.0",
    "httpx>=0.28.0",
    "pydantic>=2.0",
]

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

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

src/primethink_crm_tools/__init__.py

def register(registry_fn):
    """Entry point called by PrimeThink API at startup."""
    from .crm_toolkit import crm_toolkit

    registry_fn("crm", crm_toolkit,
        name="CRM Integration",
        description="Search and manage contacts in the CRM system"
    )

src/primethink_crm_tools/crm_toolkit.py

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


CRM_BASE_URL = "https://api.example-crm.com/v2"


class SearchContactsInput(BaseModel):
    query: str = Field(description="Name, email, or phone number to search for")
    limit: int = Field(default=10, description="Maximum number of results")


@tool("crm_search_contacts", args_schema=SearchContactsInput)
async def crm_search_contacts(query: str, limit: int, config: RunnableConfig) -> str:
    """
    Search for contacts in the CRM by name, email, or phone number.
    Returns a list of matching contacts with their basic details.
    """
    user_id = config.get("configurable", {}).get("logged_user_id")

    async with httpx.AsyncClient(timeout=15.0) as client:
        resp = await client.get(
            f"{CRM_BASE_URL}/contacts/search",
            params={"q": query, "limit": limit},
            headers={"Authorization": f"Bearer {_get_api_key()}"},
        )
        resp.raise_for_status()
        return resp.text


class CreateContactInput(BaseModel):
    first_name: str = Field(description="Contact's first name")
    last_name: str = Field(description="Contact's last name")
    email: str = Field(description="Contact's email address")
    company: str = Field(default="", description="Company name (optional)")


@tool("crm_create_contact", args_schema=CreateContactInput)
async def crm_create_contact(
    first_name: str, last_name: str, email: str, company: str,
    config: RunnableConfig,
) -> str:
    """
    Create a new contact in the CRM system.
    Returns the created contact's ID and a confirmation message.
    """
    async with httpx.AsyncClient(timeout=15.0) as client:
        resp = await client.post(
            f"{CRM_BASE_URL}/contacts",
            json={
                "first_name": first_name,
                "last_name": last_name,
                "email": email,
                "company": company,
            },
            headers={"Authorization": f"Bearer {_get_api_key()}"},
        )
        resp.raise_for_status()
        return resp.text


def _get_api_key() -> str:
    """Read API key from environment. In production, prefer user settings."""
    import os
    return os.environ.get("CRM_API_KEY", "")


# Export as a toolkit list
crm_toolkit = [
    crm_search_contacts,
    crm_create_contact,
]

tests/test_crm_toolkit.py

"""Basic import and registration test."""


def test_register_function_exists():
    from primethink_crm_tools import register
    assert callable(register)


def test_register_calls_registry():
    """Verify register() calls registry_fn with the expected code and tools."""
    calls = []

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

    from primethink_crm_tools import register
    register(mock_registry_fn)

    assert len(calls) == 1
    code, tools, kwargs = calls[0]
    assert code == "crm"
    assert len(tools) == 2
    assert tools[0].name == "crm_search_contacts"
    assert tools[1].name == "crm_create_contact"
    assert kwargs["name"] == "CRM Integration"

Using it

  1. Install the plugin into the API environment:

    pip install git+https://github.com/your-org/primethink-crm-tools.git@v0.1.0
    

  2. Create a capability in the DB. Since the plugin provides name and description via register_tools(...), you can use the streamlined endpoint:

    POST /api/super-admin/capabilities/from-registered-tool
    
    {
      "code": "crm",
      "group_id": 5
    }
    
    This auto-fills name="CRM Integration" and description="Search and manage contacts in the CRM system" from the registry.

  3. Assign the capability to an agent — the tools are now available.


Creating DB Capabilities for Your Tools

Every tool group needs a matching capabilities row in the database. There are two approaches:

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

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

This creates the capability row using the name and description you provided in register_tools(...). You can optionally override them:

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

To see which codes are available and which are already activated for a group:

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

Option B: Manual creation (for MCP/API types or custom configurations)

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) — must match what you registered. 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 (see below)
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 (type: mcp)

MCP (Model Context Protocol) 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 — they are 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}"
    }
  }
}

The user must have a setting HA_REMOTE_TOKEN configured in their user settings for the placeholder to resolve.

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

Any extra keys in options are passed through to the MCP config as-is.


API Capabilities (type: api)

API capabilities turn any REST endpoint into a tool — no Python code needed. The system dynamically creates a LangChain StructuredTool from the options JSON.

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

Each parameter in params or body is defined as:

{
  "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 using PLACEHOLDER_RE in tool_builder_utils.py.
  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 (returns empty or None), an UnresolvedPlaceholderError is raised and the tool/MCP config is skipped with a logged error.

Setting up user settings:

Users configure settings via the Settings tool or the admin API. For example, to set up an API key:

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

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


How Tool Resolution Works at Runtime

When an agent is initialized, this is the flow:

1. Agent loads its capabilities from the DB
2. capabilities_utils.get_tools_from_capabilities() is called
   ├─── Internal tools (type: internal):
   │    a. Extract all capability codes (comma-separated supported)
   │    b. Apply runtime conditions (canvas page type, subchat direction, etc.)
   │    c. Call resolve_tools(active_codes) → deduplicated tool list
   │    d. Handle special cases (GoogleSerper, generic API requests)
   ├─── MCP tools (type: mcp):
   │    a. Build MCP config dict from cap.options
   │    b. Resolve ${...} placeholders in headers
   │    c. Return config dicts for llm.bind_tools()
   └─── API tools (type: api):
        a. Build Pydantic input model from params/body schemas
        b. Resolve ${...} placeholders in url/headers
        c. Create StructuredTool that makes HTTP requests
        d. Added to the internal tools list
3. Returns (internal_tools, mcp_tool_configs)
4. Agent's get_graph():
   a. If mcp_tools exist → llm.bind_tools(mcp_tools)
   b. create_react_agent(llm, tools=internal_tools)

Testing Your Plugin Locally

1. Install in development mode

cd /path/to/assist-manage-toolkit
pip install -e .

2. 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}')
"

3. Test the register function

def test_plugin_registration():
    calls = []

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

    from assist_manage_toolkit 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 isinstance(tools, list)
        assert len(tools) > 0
        # Verify metadata is provided
        assert kwargs.get("name"), f"Missing name for code '{code}'"

4. Test tools individually

import asyncio
from unittest.mock import MagicMock


async def test_my_tool():
    from assist_manage_toolkit.manage_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:

# Install external tool plugins
RUN pip install git+https://${GITHUB_TOKEN}@github.com/your-org/assist-manage-toolkit.git@v1.0.0
RUN pip install git+https://${GITHUB_TOKEN}@github.com/your-org/primethink-crm-tools.git@v0.1.0

Or use a requirements file:

# requirements-plugins.txt
git+https://github.com/your-org/assist-manage-toolkit.git@v1.0.0
git+https://github.com/your-org/primethink-crm-tools.git@v0.1.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 is registered: python -c "from importlib.metadata import entry_points; print(list(entry_points().select(group='primethink.tools')))"
  • Make sure the package is installed (pip list | grep assist-manage or pip list | grep primethink)
  • Check that 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 matching your registration
  • Verify the capability is assigned to the agent (via the admin UI or API)
  • Check the type field — use "internal" for Python tools, "mcp" for MCP, "api" for REST APIs

${SETTING_NAME} not resolving

  • Verify the user has the setting configured (check user_settings table or the Settings UI)
  • The setting name is case-sensitive — ${MY_KEY} looks for a setting named exactly MY_KEY
  • Check API logs for "placeholder could not be resolved" warnings

Import errors in plugin

  • The plugin should NOT import from src.* (the main API). It receives register_tools via injection.
  • Use only langchain-core, pydantic, and standard library imports in your plugin.
  • For shared utilities (DB access, etc.), pass them via config or environment variables.