π§ 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 answerYou 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:
| Tool | What it does | Product flag |
|---|---|---|
search_memory | Recall long-term user facts | features.memory |
get_weather | Current weather + 7-day forecast | features.tools.weather |
web_research | Open-web search and ranked evidence | features.tools.webResearch |
read_url | Fetch and extract one http(s) page | features.tools.urlReader |
get_stock_quote | Real-time quote for a company or ticker | features.tools.stocks |
get_stock_news | Company news evidence with source URLs | features.tools.stocks |
check_domain_availability | RDAP domain checks / domain ideas | features.tools.domains |
compare_product_prices | Shopping offers for a specific product | features.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
| Piece | Why it exists |
|---|---|
name | Stable id the model and planner call (snake_case). This is what shows up as a function definition. |
description | How the model decides when the tool is useful. Be specific. |
args_model | Pydantic model. execute_tool_call validates arguments with it before execute() runs. |
parameters | JSON Schema passed to OpenAI / Anthropic. Keep it aligned with args_model (additionalProperties: false). |
activity_label | English 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.
- Export the instance from
apps/api/app/ai/tools/tools/__init__.py - Add it to
tool_registryinapps/api/app/ai/tools/registry.py - Gate it in
_builtin_tools()only if it should follow a product flag or env kill switch - Add
tools.activity.<name>andtools.done.<name>inapps/api/app/i18n/locales/en.json(andde/frif you ship those) - 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:
| Path | Used by |
|---|---|
apps/api/app/ai/tools/prompts.py | Classic tool-call loop (and the chat system prompt) |
apps/api/app/ai/planner/prompts.py | Planner, 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 languageChat 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 -qDo 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:
| Field | What you get |
|---|---|
user_id | Authenticated user id (string) |
conversation_id | Current conversation id, or None |
db | SQLAlchemy AsyncSession for this request |
locale | Resolved locale (en, de, fr, β¦) |
abort_signal | Reserved 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:
- Product flags in
starter.config.json(features.memory,features.tools.*) - Runtime kill switches in
.env(WEB_RESEARCH_ENABLED, β¦) - Plan entitlements (MCP, memory)
- 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.
β¨ AI Chat & Models
Chat already streams through FastAPI to OpenAI or Anthropic. Change identity, the system prompt, and which models each plan may use.
π€ Agents
An agent reaches a goal across multiple steps. GoShipped does that with the planner, the tool loop, and Deep Research β not a generic Agent class.