Adding Tools to Modules

Any module can expose tools to the AI agent by declaring them in module.json and implementing a bus handler. The agent discovers tools automatically — no changes to the AI module or Python service required.

How It Works

module.json (api.provides) → ModuleApiRegistry → AgentGatewayService → Python Agent → Tool Call → ModuleBus → Handler
  1. Module declares tools in module.json under api.provides
  2. ModuleBusServiceProvider registers them in ModuleApiRegistry on boot
  3. When a user chats with the agent, AgentGatewayService reads all registered tools
  4. Tools are filtered by role patterns and user RBAC permissions
  5. Matching tools are sent to the Python agent as ToolPermission objects
  6. When the agent decides to call a tool, it POSTs back to Laravel
  7. AgentBridgeController routes the call through ModuleApiBus to the handler

Step 1: Define Tools in module.json

Add an api.provides section to your module's module.json:

{
  "name": "Customers",
  "alias": "customers",
  "api": {
    "provides": {
      "customers.get": {
        "description": "Get customer details by ID including contact info and order history summary",
        "handler": "Modules\\Customers\\App\\Services\\CustomerBusHandler",
        "method": "getCustomer",
        "mode": "sync"
      },
      "customers.search": {
        "description": "Search customers by name, email, phone, or segment",
        "handler": "Modules\\Customers\\App\\Services\\CustomerBusHandler",
        "method": "searchCustomers",
        "mode": "sync"
      },
      "customers.getOrderHistory": {
        "description": "Get a customer's recent order history with status and totals",
        "handler": "Modules\\Customers\\App\\Services\\CustomerBusHandler",
        "method": "getOrderHistory",
        "mode": "sync"
      }
    }
  }
}

Tool naming convention

Use {module_alias}.{action} format:

  • Read tools: customers.get, customers.search, customers.getStats
  • Write tools: customers.create, customers.update
  • Action tools: orders.processReturn, orders.reserveInventory

The name determines what RBAC permission is inferred (see Permission Inference).

Writing good descriptions

The description is the primary way the LLM decides whether to call a tool. Write descriptions that:

  • Explain what data the tool returns or what action it performs
  • Mention the parameters it accepts (the LLM uses this to construct arguments)
  • Are specific enough to differentiate from similar tools
// Bad — too vague
"description": "Get customer"

// Good — LLM knows what it gets and what to pass
"description": "Get customer details by ID including contact info, address, segment, VIP status, and recent order count"

Step 2: Implement the Bus Handler

Create a service class that handles the tool calls:

<?php

namespace Modules\Customers\App\Services;

use App\Models\Tenant;

class CustomerBusHandler
{
    public function getCustomer(array $params): array
    {
        $customer = \Modules\Customers\App\Models\Customer::findOrFail($params['id']);

        return [
            'id' => $customer->id,
            'name' => $customer->name,
            'email' => $customer->email,
            'phone' => $customer->phone,
            'segment' => $customer->segment,
            'vip' => $customer->is_vip,
            'total_orders' => $customer->orders()->count(),
            'lifetime_value' => $customer->lifetime_value,
            'created_at' => $customer->created_at->toIso8601String(),
        ];
    }

    public function searchCustomers(array $params): array
    {
        $query = \Modules\Customers\App\Models\Customer::query();

        if (isset($params['q'])) {
            $q = $params['q'];
            $query->where(function ($qb) use ($q) {
                $qb->where('name', 'ilike', "%{$q}%")
                   ->orWhere('email', 'ilike', "%{$q}%")
                   ->orWhere('phone', 'like', "%{$q}%");
            });
        }

        if (isset($params['segment'])) {
            $query->where('segment', $params['segment']);
        }

        return $query->limit(10)->get()->map(fn ($c) => [
            'id' => $c->id,
            'name' => $c->name,
            'email' => $c->email,
            'segment' => $c->segment,
        ])->toArray();
    }

    public function getOrderHistory(array $params): array
    {
        $customer = \Modules\Customers\App\Models\Customer::findOrFail($params['customer_id']);

        return $customer->orders()
            ->latest()
            ->limit($params['limit'] ?? 10)
            ->get()
            ->map(fn ($o) => [
                'id' => $o->id,
                'order_number' => $o->order_number,
                'status' => $o->status,
                'total' => $o->total,
                'created_at' => $o->created_at->toIso8601String(),
            ])->toArray();
    }
}

Handler rules

  • Method receives a single array $params argument (the tool arguments from the LLM)
  • Return an array — it's JSON-encoded and sent back to the Python agent
  • Keep responses concise — large payloads waste LLM context tokens
  • Handle missing/invalid parameters gracefully with clear error messages
  • The handler runs in the tenant's database context (tenancy is already initialized)

Step 3: Register in Composer Autoload

Ensure your module has a PSR-4 autoload entry in backend/composer.json:

{
  "autoload": {
    "psr-4": {
      "Modules\\Customers\\": "modules/Customers/backend/"
    }
  }
}

Run composer dump-autoload after adding.

Step 4: Assign to Agent Roles

Update agent roles to include the new tools. Either:

Via API:

PUT /api/v1/ai/agent/roles/support
{
  "allowed_tools": ["orders.get", "orders.getStats", "customers.*"]
}

Or via default role config in AgentRoleController.php.

Wildcard customers.* matches all tools with the customers. prefix.

Parameter Flow

When the LLM calls a tool, parameters flow through this chain:

LLM generates JSON arguments
  → Python Tool.execute(**kwargs)
    → ToolBridge.execute_tool(name, kwargs)
      → POST /api/v1/internal/agent/tools/{name}  { arguments: kwargs }
        → AgentBridgeController::executeTool()
          → ModuleApiBus::call(module, method, arguments)
            → Handler::method(array $params)

The LLM constructs the kwargs based on the tool description. There is no schema validation between the LLM output and the handler — the handler should validate/default parameters defensively.

Adding parameter hints

To help the LLM pass correct parameters, describe them in the tool description:

{
  "customers.search": {
    "description": "Search customers. Parameters: q (string, search query), segment (string, optional: vip/regular/new), limit (int, optional, default 10)"
  }
}

RBAC Integration

Tools automatically integrate with the existing tenant permission system:

Tool Name Inferred Permission Who Can Use
customers.get customers.view Users with customers.view
customers.search customers.view Users with customers.view
customers.create customers.create Users with customers.create
customers.update customers.manage Users with customers.manage

No additional permission configuration needed — the Python agent infers requirements from tool names automatically.

Testing a Tool

  1. Add the tool to module.json and create the handler
  2. Run php artisan module:sync to update the database
  3. Assign the tool pattern to a role (e.g., customers.* for support)
  4. Open the AI chat widget and ask a question that would trigger the tool
  5. Check Laravel logs for ModuleBus: Non-module caller accessing to confirm execution
  6. The agent's response should include data from your handler

Existing Module Tools Reference

See the full list of registered tools in Roles & Tools — Modules with Tools.