Skip to content

Tool Authoring Guide

This is the vertical-facing guide for adding a tool to the Rewards Assistant. A tool is the seam the orchestrator LLM reaches through to act: search offers, look up purchase history, fetch a webpage. The guide covers the three authoring paths, the naming rules that keep the tool registry collision-free, the conformance gate every new tool must pass, and how to write a description the model can route on.

The framing that runs through all of it: a tool in this repo is a thin adapter. The real work (search, enrichment, ranking) lives in a server you do not own: rover-mcp or the consumer-context-service (CCS). Your wrapper carries the LLM-facing name, description, and args_schema, plus typed errors and observability. It never re-implements server logic. If you find yourself porting business rules into the wrapper, stop: that logic belongs in the server, and the wrapper should call it.

Pick the path by where the data lives and who owns the backend.

PathUse whenBase classExample
CCS canonical (Path-1)The data comes from CCS behind the enrichment envelopeCCSToolSearchOffersCCSTool
DirectYou call a single backend endpoint yourself, no shared MCP server, no envelopeBaseTool (LangChain)ProductSearchTool
Vertical-owned MCPYour vertical runs its own MCP server exposing server-owned toolsMCPToolSearchOffersTool

All three live in src/consumer_agent/utils/tools.py (or, for the direct path, under src/consumer_agent/tools/). All three are LangChain tools the registry builds and the orchestrator binds. The difference is the unwrap and error contract each base gives you for free.

This is the preferred path for catalog and user data. CCS returns data wrapped in an enrichment envelope (status plus an optional principal). CCSTool owns the unwrap so you do not: it parses the envelope, checks the principal as a security gate, and decides on status. An ok or partial status forwards the payload to the orchestrator; an error status, or a present-and-mismatched principal, raises a typed ToolException instead of leaking the raw envelope or an error string that reads like a successful result.

You override exactly two hooks: _ccs_call (the server call) and, optionally, _transform (reshape the unwrapped payload). You do not touch the call/error path. Here is the real offer-search twin:

class SearchOffersCCSTool(CCSTool):
name: str = "search_offers"
description: str = (
"Search for available offers based on a query. "
"Returns relevant offers matching the search criteria. "
"Can be personalized with user_id for better results."
)
args_schema: type[BaseModel] = SearchOffersInput
async def _ccs_call(
self,
invoking_user: str = "",
query: str = "",
user_id: str | None = None,
limit: int = 200,
**_: Any,
) -> dict:
return await self.ccs_client.search_offers_enriched(
query=query, user_id=user_id, limit=limit
)

When you need to reshape the unwrapped payload, override _transform rather than the call path. The product twin filters to Button partners this way:

class SearchProductsCCSTool(CCSTool):
name: str = "search_products"
args_schema: type[BaseModel] = SearchProductsInput
async def _ccs_call(self, invoking_user: str = "", descriptions=None, **_: Any) -> dict:
query = " ".join(descriptions or [])
return await self.ccs_client.search_products_enriched(query=query)
def _transform(self, payload: dict[str, Any]) -> str:
# CCS returns {query, results, result_count}; reshape to the MCP twin's
# {query: [products]} mapping that _filter_products_dict iterates over.
query = payload.get("query", "")
results = payload.get("results", [])
if not isinstance(results, list):
results = []
return json.dumps(_filter_products_dict({query: results}), ensure_ascii=False)

The principal gate is load-bearing for per-user data. GetUserPurchaseHistoryCCSTool relies on it: a mismatched principal suppresses the payload before it ever reaches the sub-agent’s context. You get that for free by subclassing CCSTool; do not re-implement it.

Register your twin in ccs_path1_tools, the enumerated builder that the feature flag flip targets when a CCS tool replaces its legacy counterpart.

Use the direct path when your tool calls one backend endpoint itself, with no shared MCP server and no enrichment envelope to unwrap. You subclass LangChain’s BaseTool directly and own the call and error handling yourself. ProductSearchTool is the worked example: it calls a CCS search-and-enrich endpoint over httpx, unwraps the envelope inline, and returns a sub-agent-facing payload.

class ProductSearchTool(BaseTool):
name: str = "product_search"
description: str = (
"Search for products available through Fetch Rewards partner retailers. "
"Returns product details with prices, images, retailer info, and Fetch "
"points-per-dollar (PPD). Use for product discovery, comparisons, price "
"checks, availability, and recommendations. Do NOT use for product "
"reviews, safety recalls, or non-grocery items."
)
args_schema: type[BaseModel] = ProductSearchInput
def __init__(self, ccs_client: CCSClient) -> None:
super().__init__()
self._ccs_client = ccs_client

The direct path gives you the most control and the least scaffolding. The cost is that you are responsible for typed errors and observability yourself. Prefer Path A when the data is behind the CCS envelope; reach for direct only when there is no shared base that fits.

If your vertical runs its own MCP server, your tools subclass MCPTool. The base owns the call, the result-text extraction, the typed error, and the observability span and metrics. You declare name, description, args_schema, and the server-side mcp_tool_name, and optionally override _transform to reshape the returned text. The legacy offer-search tool is the minimal shape:

class SearchOffersTool(MCPTool):
name: str = "search_offers"
description: str = (
"Search for available offers based on a query. "
"Returns relevant offers matching the search criteria. "
"Can be personalized with user_id for better results."
)
args_schema: type[BaseModel] = SearchOffersInput
mcp_tool_name: str = "search_offers"

A tool that reshapes output overrides _transform only. SearchProductsTool keeps the whole call/error contract from the base and adds just the partner filter:

class SearchProductsTool(MCPTool):
name: str = "search_products"
args_schema: type[BaseModel] = SearchProductsInput
mcp_tool_name: str = "search_products"
def _transform(self, raw: str) -> str:
return _filter_search_products(raw)

The registry connects to the MCP server (transport is per-server) and, when the agent card names individual tools instead of the whole group, filters the returned set down to the named tools. A named tool the server does not expose fails loud at build time.

A tool’s name is its identity in the registry and the string the LLM emits to call it. Two rules:

  1. Use a vertical-prefixed name to avoid colliding with another vertical’s tool. search_offers is an unprefixed legacy name; a new restaurant-network tool should be restaurant_search_offers, not a bare search_offers. The registry binds tools into one flat name space, so an unprefixed name is a landmine for the next team.

  2. One name maps to one tool within a single build. The registry builds a frozen index from the final tool list and fails loud on a real collision rather than silently binding whichever tool won by build order.

The two error types you will meet, both in src/consumer_agent/composition/resolution/tool_registry.py:

  • RegistryCollisionError is raised at build time when two tools are registered under the same name simultaneously. It names both offending classes. This is a developer error: fix it by renaming one tool.
  • ToolResolutionError is raised when a tool id cannot be resolved, for example when an agent card names a tool the connected server does not provide. It names the unknown tool and lists what is available, before the orchestrator LLM is ever called.

Both subclass ValueError, so existing broad handlers keep working. The intentional exception to the one-name rule is the search_products A/B: two classes share the name search_products, but a feature flag selects exactly one per build, so only one is ever in the index at a time. That is selection, not collision.

Every tool must pass the conformance gate before it ships. Run it locally:

make lint-tool-conformance

It also runs in the make lint composite and as a pre-commit hook, so CI enforces it. The checker is static (it parses utils/tools.py and builds the registry against a stub client, so no network call is made). Legacy tools are grandfathered: their debt is reported but does not block; a new tool’s violation does block.

What each check requires of your tool:

  1. Declare both name and args_schema. The checker reads them straight off the class. The framework bases (MCPTool, CCSTool) and the eval StubTool are excluded; every concrete tool must declare both.
  2. Resolve through the registry index. The gate builds the tool set across the product_card flag combinations with product_search held false, and confirms every built tool resolves back to its own instance with no collision. It does not exercise the product_search true branch (that branch builds provider dicts and CCS tools that carry no name, so they add no resolution signal), so this is a partial flag sweep, not the full feature-flag matrix. You get this for free if your name is unique per build.
  3. Have a contract or availability test. Add a test under tests/unit/tools/ that references your tool class by name. This repo tests the framework contract and tool availability, not per-tool backend logic (that logic lives in the server repo). A contract test checks the wrapper shape: name and schema, that a failed call raises ToolException, that the observability span carries the right keys. The existing twin tests are the template to copy.
  4. List a user-facing tool in the eval manifest. If the LLM can select your tool on a user’s behalf, add its name to the tool allowlist in configs/evaluation_manifest.yaml. Internal tools (the ones the model uses to log a signal, not to serve a user request) are exempt. This check is driven by a hardcoded allowlist (USER_FACING_TOOL_NAMES in the checker), so it does not automatically catch a brand-new user-facing tool name: add your tool’s name to that frozenset as well, or the manifest requirement will not fire for it.

Observability: what you inherit, what you must not break

Section titled “Observability: what you inherit, what you must not break”

Subclass a framework base and the span and the connector.invocation.total, connector.invocation.errors_total, and connector.invocation.latency_ms metrics are emitted for you, keyed on the connector_id dimension (your tool’s name). The build-time collision tripwire is connector.registration.collision_total. These metric names and the connector_id dimension key are fixed: dashboards key on those exact strings, so do not rename them even though the rest of the code and this guide say “tool”.

Every per-traffic metric and span attribute carries the five mandatory platform slicing dimensions (vertical, sub_agent_id, dm_type, experiment_arm, agent_definition_version), imported from consumer_agent.platform.slicing_dimensions. The bases emit them as the empty string at the tool boundary (present-but-unset, which the telemetry SDK keeps, unlike a null which it drops); downstream layers fill in the real values. If you write a direct-path tool that emits its own metrics, you must carry these dimensions too, or annotate a non-traffic emission as a convention exception on the same line. The metric-dimension lint enforces this.

The description is not documentation: it is the only signal the orchestrator LLM has when it decides whether to route a request to your tool. A vague description gets your tool skipped or misfired. Treat it as load-bearing.

Make it capability-anchored. State what the tool does, what it returns, and the boundary of when not to use it. ProductSearchTool is the model to follow: it says what it returns (prices, images, retailer, points-per-dollar) and draws the line (“Do NOT use for product reviews, safety recalls, or non-grocery items”). That negative boundary is what stops the model from reaching for the wrong tool.

Guidance:

  • Lead with the capability in plain terms, not the backend name. The model routes on what the tool does, not which service answers.
  • Name the shape of what comes back, so the model knows whether the result will answer the question.
  • Add an explicit do-not-use boundary when a neighbouring tool could be confused for yours.
  • Keep argument semantics in the args_schema field descriptions, not crammed into the tool description.
  • Path chosen deliberately (CCS canonical, direct, or vertical-owned MCP).
  • name is vertical-prefixed and unique in the registry.
  • name and args_schema declared on the concrete class.
  • Description is capability-anchored with a do-not-use boundary.
  • Contract or availability test added under tests/unit/tools/.
  • User-facing tool listed in configs/evaluation_manifest.yaml.
  • make lint-tool-conformance passes.

CCS-owned tools have a second half: the server-side definition in the CCS repo, owned by the CCS team. That guide is coordinated separately and is not covered here. When your tool is backed by a new CCS endpoint, coordinate the server-side contract with the CCS team before wiring the wrapper.