GoShipped

πŸ”§ Tools

A tool does one specific action. Copy example_tool.py, implement execute(), register it, and teach the model when to call it.

A tool performs one specific action. Query a database. Call an API. Fetch a customer. Send something. Search external data.

The model decides when to call a registered tool. You write the function it is allowed to call.

Tool = do one specific thing.

If the AI needs to reach a goal across multiple steps, make decisions, or combine several tools, you probably want an agent instead.

User asks a question
        ↓
Planner or tool-call loop
        ↓
Your tool.execute(...)
        ↓
Model sees the JSON result
        ↓
Streamed answer

You do not enable MCP to add a Python tool. Native tools live in apps/api/app/ai/tools/.

What is a tool?

A GoShipped tool is a Python class that satisfies the Tool protocol in apps/api/app/ai/tools/types.py.

It has a stable name the model can call, a description that explains when it is useful, validated arguments, and an async execute() method that does the work.

The model never runs arbitrary code. It can only call names that get_available_tools returns for that request.

When should I use a tool?

Use a tool when one action is enough:

  • Look up the current user's plan or account
  • Fetch weather, a stock quote, or a domain check
  • Read one URL
  • Search memory or your own database
  • Call an internal API you control

Keep the tool focused. Return structured data the model can read. Let the model write the user-facing answer.

When should I use an agent instead?

If the task needs several tools, a plan, or a loop of β€œsearch β†’ read β†’ decide β†’ continue”, that is agent behavior.

See Agents.

Built-in tools

Registered in apps/api/app/ai/tools/registry.py:

ToolWhat it doesProduct flag
search_memoryRecall long-term user factsfeatures.memory
get_weatherCurrent weather + 7-day forecastfeatures.tools.weather
web_researchOpen-web search and ranked evidencefeatures.tools.webResearch
read_urlFetch and extract one http(s) pagefeatures.tools.urlReader
get_stock_quoteReal-time quote for a company or tickerfeatures.tools.stocks
get_stock_newsCompany news evidence with source URLsfeatures.tools.stocks
check_domain_availabilityRDAP domain checks / domain ideasfeatures.tools.domains
compare_product_pricesShopping offers for a specific productfeatures.tools.shopping

Some tools also have env kill switches (WEB_RESEARCH_ENABLED, STOCK_QUOTE_ENABLED, STOCK_NEWS_ENABLED, PRODUCT_SEARCH_ENABLED). Plans can still block MCP even when the product flag is on. Memory also needs the user's Settings β†’ AI toggle.

example_tool.py is a template. It is not in tool_registry until EXAMPLE_TOOL_ENABLED=true (local / docs only).

Set the matching features.tools.* flag to false if your product should not search the web, quote stocks, or compare shopping results. That removes the tool everywhere, not only from the UI.

Provider keys for Tavily, Serper, Finnhub, and shopping are optional. Those tools fail until configured; normal chat still works. Doctor warns, it does not fail the required set.

Create your first tool

The fastest workflow:

Copy. Rename. Implement. Register. Done.

Start from apps/api/app/ai/tools/tools/example_tool.py. Copy it to a new file, rename the class and export, implement execute(), then register the instance.

Here is a realistic first tool: look up the current user's plan. The user identity comes from ToolContext β€” do not ask the model for a user id.

import uuid
from typing import Any

from pydantic import BaseModel, Field

from app.ai.tools.types import ToolContext
from app.plans.service import get_effective_plan


class GetCustomerPlanArgs(BaseModel):
    include_limits: bool = Field(
        default=True,
        description="Whether to include monthly quota fields.",
    )


GET_CUSTOMER_PLAN_PARAMETERS: dict[str, Any] = {
    "type": "object",
    "properties": {
        "include_limits": {
            "type": "boolean",
            "description": "Whether to include monthly quota fields.",
            "default": True,
        },
    },
    "additionalProperties": False,
}


class GetCustomerPlanTool:
    name = "get_customer_plan"
    activity_label = "Looking up the customer plan…"
    description = (
        "Look up the current user's product plan and entitlements. "
        "Use when the user asks which plan they are on, what limits they have, "
        "or whether a feature such as Deep Research is included."
    )
    parameters = GET_CUSTOMER_PLAN_PARAMETERS
    args_model = GetCustomerPlanArgs

    async def execute(
        self, args: GetCustomerPlanArgs, context: ToolContext
    ) -> dict[str, Any]:
        plan = await get_effective_plan(context.db, uuid.UUID(str(context.user_id)))
        entitlements = plan.entitlements
        payload: dict[str, Any] = {
            "plan": plan.key,
            "plan_name": plan.name,
            "deep_research_enabled": entitlements.deep_research_enabled,
            "memory_enabled": entitlements.memory_enabled,
            "files_enabled": entitlements.files_enabled,
            "mcp_enabled": entitlements.mcp_enabled,
        }
        if args.include_limits:
            payload["limits"] = {
                "chat_messages_per_month": entitlements.chat_messages_per_month,
                "deep_research_runs_per_month": entitlements.deep_research_runs_per_month,
                "file_uploads_per_month": entitlements.file_uploads_per_month,
            }
        return payload


get_customer_plan_tool = GetCustomerPlanTool()

Return JSON the model can read. Do not put secrets in the payload. This tool is an example pattern β€” it is not shipped in the registry until you add it.

The shipped ExampleTool in example_tool.py is the same shape: args_model, parameters, name, description, activity_label, execute().

What each piece does

PieceWhy it exists
nameStable id the model and planner call (snake_case). This is what shows up as a function definition.
descriptionHow the model decides when the tool is useful. Be specific.
args_modelPydantic model. execute_tool_call validates arguments with it before execute() runs.
parametersJSON Schema passed to OpenAI / Anthropic. Keep it aligned with args_model (additionalProperties: false).
activity_labelEnglish fallback shown in chat while the tool runs. Localized labels live in i18n files.
execute()Your business logic. Use context for the user, DB session, and locale.

Invalid JSON or schema mismatches never reach execute(). The user sees a generic tool error instead.

Register the tool

Registration makes the tool available. It does not yet teach the model when to use it.

  1. Export the instance from apps/api/app/ai/tools/tools/__init__.py
  2. Add it to tool_registry in apps/api/app/ai/tools/registry.py
  3. Gate it in _builtin_tools() only if it should follow a product flag or env kill switch
  4. Add tools.activity.<name> and tools.done.<name> in apps/api/app/i18n/locales/en.json (and de / fr if you ship those)
  5. Add tests under apps/api/tests/ai/tools/
# apps/api/app/ai/tools/registry.py
from app.ai.tools.tools import get_customer_plan_tool

tool_registry: dict[str, Tool] = {
    # ...existing tools...
    get_customer_plan_tool.name: get_customer_plan_tool,
}
"activity": {
  "get_customer_plan": "Looking up the customer plan…"
},
"done": {
  "get_customer_plan": "Looked up the customer plan"
}

Missing i18n keys fall back to tools.default_activity / tools.default_done. Failures use the shared tools.failed message.

Where it becomes available

If it is in tool_registry and not filtered by a flag or plan, it is available on the next chat turn. No extra β€œenable this tool” screen. Users do not install it.

tool_registry is the allowlist for built-in execution. Unknown names are rejected. get_available_tools(context) then filters that list for the current request.

Teach the AI when to use it

Registration makes the tool callable. Prompting teaches the AI when it should call it.

Add a short routing line in both places:

PathUsed by
apps/api/app/ai/tools/prompts.pyClassic tool-call loop (and the chat system prompt)
apps/api/app/ai/planner/prompts.pyPlanner, when AGENT_PLANNER_ENABLED=true (the code default)

In build_tools_system_section():

Use get_customer_plan when the user asks which plan they are on, what their
limits are, or whether a feature is included. Do not guess entitlements β€”
always call this tool. The user identity comes from request context.

In build_planner_system_prompt(), under Tool routing:

- get_customer_plan: the user asks about their own plan, quotas, or whether
  they have a feature. Pass include_limits=true when they ask about monthly caps.

Keep these hints tight. The model also reads description and the JSON schema.

The planner may only name tools in the current available list. If the planner is off or fails, the classic loop still sees the same tools.

Deep Research is a separate workflow with its own tool allowlist. A new registry tool is not automatically available there. See Agents.

Test the tool

Manual path:

User: What plan am I on, and do I get Deep Research?
        ↓
Model selects get_customer_plan
        ↓
execute() runs with ToolContext (user_id + db)
        ↓
Model sees {"plan": "pro", "deep_research_enabled": true, ...}
        ↓
Streamed answer in the user's language

Chat shows tool_call_start / tool_call_done in the stream (and plan_created when the planner handled the turn).

Automated tests live under apps/api/tests/ai/tools/. Mirror test_example_tool.py: call execute() with a fake ToolContext, mock HTTP and plan lookups, and assert invalid args fail Pydantic validation. For planner routing, pass a fake provider to create_agent_plan(...).

cd apps/api && .venv/bin/pytest tests/ai/tools tests/ai/planner -q

Do not call real Tavily, Serper, Finnhub, or MCP servers in tests.

Using ToolContext

ToolContext (apps/api/app/ai/tools/types.py) is passed into every execute() call:

FieldWhat you get
user_idAuthenticated user id (string)
conversation_idCurrent conversation id, or None
dbSQLAlchemy AsyncSession for this request
localeResolved locale (en, de, fr, …)
abort_signalReserved for cancellation; usually unused

Reuse this context. Do not open a second database session or re-resolve the user from thin air. Use context.locale with t(..., locale=context.locale) for user-facing tool errors.

search_memory is the built-in example of doing this well: it reads user_id, conversation_id, and db from context instead of taking them as model arguments.

Feature flags and availability

get_available_tools(context) filters in this order:

  1. Product flags in starter.config.json (features.memory, features.tools.*)
  2. Runtime kill switches in .env (WEB_RESEARCH_ENABLED, …)
  3. Plan entitlements (MCP, memory)
  4. Per-user memory preference

Entitlement lookup is fail-closed. If the plan cannot be loaded, restricted tools stay unavailable.

MCP tools are appended only when features.mcp is true and the user's plan allows MCP. Built-in tools still run when MCP is off.

You usually do not need a new starter.config.json flag for a custom product tool. Add a gate in _builtin_tools() only when you want to turn that capability off for the whole product.

Master switches: TOOLS_ENABLED (default true) and TOOL_MAX_STEPS (default 16) in apps/api/.env.

On this page