Plugins

Plugins are the extension mechanism of jaato. They provide tools, garbage collection strategies, and model provider implementations.

Plugin Types

jaato has three categories of plugins:

Type Purpose Examples
Tool Provide capabilities the model can invoke cli, mcp, file_edit, todo
GC Manage context window limits gc_truncate, gc_summarize
Provider Connect to AI services google_genai

This page focuses on Tool Plugins, which are the most common type you'll work with.

Plugin discovery
from jaato import PluginRegistry

registry = PluginRegistry(model_name="claude-sonnet-4-20250514")

# Discover each type
tools = registry.discover(plugin_kind="tool")
gc = registry.discover(plugin_kind="gc")
providers = registry.discover(plugin_kind="model_provider")

print(f"Tool plugins: {tools}")
print(f"GC plugins: {gc}")
print(f"Providers: {providers}")

Registry vs Direct Configuration

Not all plugins are managed through the PluginRegistry. There's an important distinction:

Management Plugin Types How to Enable
Registry Tool plugins registry.expose_tool()
Client GC, Session, Permission client.set_*_plugin()
Constructor Model Providers JaatoClient(provider_name=)

Why the Difference?

Tool plugins provide capabilities to the model and can be mixed and matched freely. GC, session, and permission plugins affect client behavior directly and require explicit configuration.

Common Mistake
Don't look for GC or session plugins in the registry's list_available(). They need to be created and attached to the client directly.
Tool plugins (via registry)
# Tool plugins: discover → expose → configure
registry = PluginRegistry(model_name="claude-sonnet-4-20250514")
registry.discover()
registry.expose_tool("cli")
registry.expose_tool("file_edit")

client.configure_tools(registry)
Other plugins (direct on client)
# GC plugin: create → initialize → set
from shared.plugins.gc_truncate import create_plugin

gc = create_plugin()
gc.initialize({"preserve_recent_turns": 5})
client.set_gc_plugin(gc)

# Session plugin: create → initialize → set
from shared.plugins.session import create_plugin

session = create_plugin()
session.initialize({"storage_path": ".sessions"})
client.set_session_plugin(session)

# Permission plugin: passed to configure_tools
from shared import PermissionPlugin

perm = PermissionPlugin()
perm.initialize({"config_path": "perms.json"})
client.configure_tools(registry, permission_plugin=perm)
Provider (at construction)
# Provider selected when creating client
client = JaatoClient(provider_name="google_genai")

# Cannot be changed after construction
# Create a new client for different provider

Plugin Discovery

Plugins are discovered via two mechanisms:

Method Use Case Location
Entry Points External packages, production pyproject.toml
Directory Built-ins, development shared/plugins/

Entry Points (Recommended)

External packages register plugins via Python entry points in pyproject.toml. This is the recommended approach for distributing plugins.

Directory Scanning

For development and built-in plugins, the registry scans shared/plugins/ for modules with a create_plugin() factory function.

pyproject.toml entry point
# In pyproject.toml of external package
[project.entry-points."jaato.plugins"]
my_plugin = "my_package.plugins:create_plugin"

# The entry point must reference a factory function
# that returns a ToolPlugin instance
Discovery options
from jaato import PluginRegistry

registry = PluginRegistry()

# Both entry points + directory (default)
registry.discover()

# Entry points only (installed packages)
registry.discover(include_directory=False)

# Check what was found
print(registry.list_available())
# ['cli', 'mcp', 'my_plugin', ...]

The Plugin Registry

The PluginRegistry is the central manager for tool plugins. It handles discovery, registration, and exposure.

Key Concepts

Term Meaning
Discovered Found and available for use
Registered Loaded into the registry
Exposed Tools are available to the model

Discovery finds plugins, but they're not active until exposed. This gives you control over what capabilities the model has.

Why Expose?
Separating discovery from exposure lets you inspect available plugins before deciding which to enable. You might want different tools for different tasks.
Registry workflow
from jaato import PluginRegistry

registry = PluginRegistry(model_name="claude-sonnet-4-20250514")

# Step 1: Discover what's available
registry.discover()
print(f"Available: {registry.list_available()}")
# ['cli', 'mcp', 'file_edit', 'todo', ...]

# Step 2: Expose what you need
registry.expose_tool("cli")
registry.expose_tool("file_edit")

print(f"Exposed: {registry.list_exposed()}")
# ['cli', 'file_edit']

# Step 3: Check if specific plugin is exposed
if registry.is_exposed("cli"):
    print("CLI tools are active")

# Step 4: Get tools for the client
schemas = registry.get_exposed_tool_schemas()
executors = registry.get_exposed_executors()

Built-in Tool Plugins

jaato includes several tool plugins out of the box:

cli

Execute shell commands. Supports auto-backgrounding for long-running commands.

mcp

Connect to Model Context Protocol servers. Auto-discovers tools from MCP servers defined in .mcp.json.

file_edit

Read, write, and edit files. Supports diff-based editing for precise changes.

todo

Manage task lists. Persistent storage with filtering and status tracking.

web_search

Search the web. Returns structured search results.

permission

Control tool execution permissions. Approve/deny tool calls based on rules.

Using built-in plugins
# Expose with default config
registry.expose_tool("cli")
registry.expose_tool("file_edit")
registry.expose_tool("todo")

# Expose with custom config
registry.expose_tool("cli", config={
    "extra_paths": ["/opt/bin"],
    "max_output_chars": 10000,
    "auto_background_threshold": 5.0
})

# Expose MCP with specific servers
registry.expose_tool("mcp", config={
    "servers": ["Atlassian", "GitHub"]
})

# Expose all at once
registry.expose_all()

# Expose all with per-plugin config
registry.expose_all(config={
    "cli": {"max_output_chars": 5000},
    "todo": {"storage_path": ".tasks.json"}
})
Check available tools
# List tools from exposed plugins
schemas = registry.get_exposed_tool_schemas()

for schema in schemas:
    print(f"{schema.name}")
    print(f"  {schema.description}")
    print()

# Example output:
# execute_command
#   Execute a shell command
#
# read_file
#   Read contents of a file
#
# edit_file
#   Edit a file with diff

Tool Plugin Protocol

Tool plugins implement a simple protocol. At minimum, they provide:

  • name str
    Unique identifier for the plugin
  • get_tool_schemas() List[ToolSchema]
    Declare available tools
  • get_executors() Dict[str, Callable]
    Map tool names to functions

Optional Methods

  • initialize(config)
    Setup with configuration
  • shutdown()
    Cleanup resources
  • get_system_instructions()
    Add to system prompt
  • get_auto_approved_tools()
    Tools that don't need permission
  • get_model_requirements()
    Required model patterns
Minimal plugin
from jaato import ToolSchema

class WeatherPlugin:
    name = "weather"

    def get_tool_schemas(self):
        return [
            ToolSchema(
                name="get_weather",
                description="Get weather for a city",
                parameters={
                    "type": "object",
                    "properties": {
                        "city": {"type": "string"}
                    },
                    "required": ["city"]
                }
            )
        ]

    def get_executors(self):
        return {
            "get_weather": self._get_weather
        }

    def _get_weather(self, city: str) -> str:
        # Implementation here
        return f"Weather in {city}: Sunny, 72°F"
Full-featured plugin
class AdvancedPlugin:
    name = "advanced"

    def initialize(self, config):
        self.api_key = config.get("api_key")

    def shutdown(self):
        # Cleanup connections
        pass

    def get_tool_schemas(self):
        return [...]

    def get_executors(self):
        return {...}

    def get_system_instructions(self):
        return "Use advanced_tool for X..."

    def get_auto_approved_tools(self):
        return ["safe_tool"]

    def get_model_requirements(self):
        return ["gemini-*"]  # Gemini only

Executor Metadata

Executors can return a (result_dict, metadata_dict) tuple instead of a plain dict to pass UI hints to the presentation layer. The ToolExecutor merges the metadata into the result before forwarding it.

Metadata Keys

Key Type Default Description
continuation_id str Groups tool calls into a single popup session
show_output bool True Controls main output panel visibility
show_popup bool True Controls popup panel tracking/visibility

When metadata is not needed, return a plain dict as usual. The tuple form is only required when you need to communicate presentation hints.

Returning executor metadata
def _exec_input(self, args):
    session_id = args.get("session_id")
    text = args.get("text", "")

    session = self._sessions[session_id]
    output = session.send_input(text)

    result = {
        "session_id": session_id,
        "output": output,
    }

    if session.is_alive:
        # Group into popup, hide from main panel
        return (result, {
            "continuation_id": session_id,
            "show_output": False,
        })

    # Session ended — return plain result
    return result
No metadata (default)
def _get_weather(self, args):
    city = args.get("city")
    # Plain dict — no metadata needed
    return {"city": city, "temp": "72°F"}

Registering Custom Plugins

You can register custom plugins manually using register_plugin().

Parameters

  • plugin ToolPlugin
    Your plugin instance
  • expose bool
    Expose immediately (default: False)
  • enrichment_only bool
    Only use for prompt enrichment
  • config Dict
    Plugin configuration
Register custom plugin
from my_plugins import WeatherPlugin

registry = PluginRegistry(model_name="claude-sonnet-4-20250514")
registry.discover()  # Discover built-ins

# Register custom plugin
weather = WeatherPlugin()
registry.register_plugin(
    weather,
    expose=True,
    config={"api_key": "..."}
)

# Now available alongside built-ins
print(registry.list_exposed())
# ['cli', 'weather']
Enrichment-only plugin
# Plugin that modifies prompts but
# doesn't provide tools
class ContextPlugin:
    name = "context"

    def subscribes_to_prompt_enrichment(self):
        return True

    def enrich_prompt(self, prompt):
        cwd = os.getcwd()
        return PromptEnrichmentResult(
            prompt=f"[CWD: {cwd}]\n{prompt}",
            original_prompt=prompt,
            enrichments=["added_cwd"]
        )

    def get_tool_schemas(self):
        return []  # No tools

# Register for enrichment only
registry.register_plugin(
    ContextPlugin(),
    enrichment_only=True
)

System Instructions

Plugins can contribute to the system prompt via get_system_instructions(). The registry combines instructions from all exposed plugins.

Use system instructions to:

  • Explain when to use your tools
  • Provide usage guidelines
  • Set behavioral constraints
Keep It Concise
System instructions consume context tokens. Keep them focused and avoid redundancy with tool descriptions.
System instructions
class DatabasePlugin:
    name = "database"

    def get_system_instructions(self):
        return """
When working with the database:
- Always use transactions for writes
- Prefer query_database over raw_sql
- Never expose connection strings
""".strip()

    def get_tool_schemas(self):
        return [
            ToolSchema(
                name="query_database",
                description="Run a safe query",
                parameters={...}
            )
        ]
Get combined instructions
# Get all system instructions
instructions = registry.get_system_instructions()

if instructions:
    print("Combined system prompt:")
    print(instructions)

# The client uses this automatically
# when configuring tools

Model Tools vs User Commands

Plugins can provide two types of capabilities:

Capability Invoked By Declared Via
Model Tools AI model (function calling) get_tool_schemas()
User Commands User directly (bypasses model) get_user_commands()

When to Use Which?

  • Model tools: When the AI should decide when to use it
  • User commands: For direct control, admin tasks, or quick actions
Who is "User"?
"User" means whoever is directly interfacing with the client — this could be a human operator OR another AI agent in agent-to-agent scenarios. User commands bypass the model's function calling and execute directly.
Model tools vs user commands
class SearchPlugin:
    name = "search"

    def get_tool_schemas(self):
        # MODEL TOOLS: AI decides when to use
        return [
            ToolSchema(
                name="search_index",
                description="Search the index",
                parameters={...}
            )
        ]

    def get_user_commands(self):
        # USER COMMANDS: User invokes directly
        return [
            UserCommand(
                "search",
                "Search directly",
                share_with_model=True
            ),
            UserCommand(
                "reindex",
                "Rebuild index",
                share_with_model=False
            )
        ]
Execution flow
Model Tool Flow User "Find X" Model FunctionCall Executor Result Model Response User Command Flow User /search X Executor Result User (model not involved) Model

Dual Exposure: Same Capability, Two Interfaces

Sometimes you want the same capability available both ways:

  • As a model tool the AI can invoke
  • As a user command for direct access

share_with_model

The share_with_model flag controls whether user command output is added to conversation history:

  • True: Output goes to history, model can see/use it on subsequent turns
  • False: Output only shown to user, model never sees it
Important: share_with_model != Tool Exposure

The share_with_model flag does not expose the command as a tool. Tool exposure requires declaring the capability in get_tool_schemas().

These are independent declarations:

  • A user command with share_with_model=True is not automatically a tool
  • A tool is not automatically a user command
  • For dual exposure, you must declare both explicitly

Think of share_with_model as: "when the user runs this command, should the model know about it?" — not "should this also be a tool".

Auto-Approval

User commands typically should be auto-approved since the user invokes them directly. List them in get_auto_approved_tools() to avoid unexpected permission prompts.

Don't Forget Auto-Approval
If a user command isn't in get_auto_approved_tools(), the permission system will prompt for approval — confusing since the user explicitly requested it.
Dual exposure example
class TodoPlugin:
    name = "todo"

    def get_tool_schemas(self):
        # Model can add/list todos
        return [
            ToolSchema(name="add_todo", ...),
            ToolSchema(name="list_todos", ...),
        ]

    def get_user_commands(self):
        # User can also do it directly
        return [
            UserCommand(
                "todo_add",
                "Add a todo",
                share_with_model=True  # Model sees what was added
            ),
            UserCommand(
                "todo_list",
                "List todos",
                share_with_model=True  # Model sees the list
            ),
            UserCommand(
                "todo_clear",
                "Clear all todos",
                share_with_model=False  # Admin only
            )
        ]

    def get_auto_approved_tools(self):
        # User commands don't need permission prompts
        return ["todo_add", "todo_list", "todo_clear"]
share_with_model in action
# share_with_model=True:
# User: /todo_list
# → Shows list to user
# → Adds to conversation history
# → Model: "I see you have 3 pending todos..."

# share_with_model=False:
# User: /todo_clear
# → Clears todos
# → Shows "Cleared" to user
# → NOT in history, model doesn't know

UserCommand Reference

The UserCommand type declares a user-facing command.

Fields

  • name str
    Command name (e.g., "list_todos")
  • description str
    Help text for autocomplete
  • share_with_model bool
    Add output to conversation history (default: False)
  • parameters List[CommandParameter]
    Optional parameter definitions

CommandParameter

  • name str
    Parameter name
  • description str
    Help text
  • required bool
    Is this parameter required?
  • capture_rest bool
    Capture all remaining args (last param only)
Define user commands with parameters
from jaato import (
    UserCommand,
    CommandParameter
)

def get_user_commands(self):
    return [
        # Simple command, no parameters
        UserCommand(
            name="status",
            description="Show status"
        ),

        # Command with parameters
        UserCommand(
            name="search",
            description="Search for pattern",
            share_with_model=True,
            parameters=[
                CommandParameter(
                    name="pattern",
                    description="Search pattern",
                    required=True
                ),
                CommandParameter(
                    name="description",
                    description="Optional notes",
                    capture_rest=True  # Gets remaining args
                )
            ]
        )
    ]
Execute user commands
# List available commands
commands = client.get_user_commands()
for name, cmd in commands.items():
    print(f"/{name}: {cmd.description}")

# Execute a command
result, share = client.execute_user_command("status")

# With arguments
result, share = client.execute_user_command(
    "search",
    args={"pattern": "TODO", "description": "Find todos"}
)

Model Requirements

Some plugins only work with specific models. Use get_model_requirements() to specify required models using glob patterns.

If the current model doesn't match, the plugin is skipped during discovery.

Pattern Examples

Pattern Matches
gemini-* All Gemini models
gemini-2.5-* Gemini 2.5 family
*-pro Any "pro" model
claude-* Claude models
Model requirements
class GeminiOnlyPlugin:
    name = "gemini_features"

    def get_model_requirements(self):
        # Only works with Gemini
        return ["gemini-*"]

    def get_tool_schemas(self):
        return [...]

# Check skipped plugins
registry = PluginRegistry(model_name="claude-3")
registry.discover()

skipped = registry.list_skipped_plugins()
for name, requirements in skipped.items():
    print(f"{name} requires: {requirements}")
# gemini_features requires: ['gemini-*']

Next Steps

Plugin directory structure
# Built-in plugins location:
# shared/plugins/
#   ├── cli/
#   │   ├── __init__.py
#   │   └── plugin.py
#   ├── mcp/
#   │   ├── __init__.py
#   │   └── plugin.py
#   ├── file_edit/
#   ├── todo/
#   ├── web_search/
#   ├── permission/
#   ├── session/
#   ├── gc_truncate/
#   ├── gc_summarize/
#   └── model_provider/
#       ├── google_genai/
#       └── types.py