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: RunnableConfigas 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_schemawith 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 (Recommended)¶
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:
# 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.
From Registered Tool (Recommended)¶
If your tool is already registered, use the streamlined endpoint that auto-fills name and description:
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:
Manual Creation¶
{
"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:
- The system finds all
${...}tokens in the capability options - For each token, it calls
get_user_setting_value(name, user_id, group_id) - The returned value replaces the placeholder
- 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:
This value is then available as ${WEATHER_API_KEY} in any capability's options.
Tool Resolution at Runtime¶
When an agent is initialized:
- Agent loads its capabilities from the DB
- Internal tools: Extract capability codes →
resolve_tools(codes)→ deduplicated tool list - MCP tools: Build MCP config from
options→ resolve placeholders →llm.bind_tools() - API tools: Build Pydantic input model from schemas → resolve placeholders → create
StructuredTool - Agent graph is created with the combined tools
Testing Your Plugin¶
Install in Development Mode¶
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:
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
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"inpyproject.toml
Tools Not Appearing on the Agent¶
- Check that a
capabilitiesDB row exists with the correctcode - Verify the capability is assigned to the agent
- Check the
typefield — 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 injectedcontextinstead - Only depend on
langchain-core,pydantic, and standard library imports - For shared utilities, pass them via config or environment variables
Related Topics¶
- Capabilities - Managing agent capabilities
- Working with AI Agents - Agent configuration
- Sandbox Execution - Ephemeral sandbox runtime