Implementing a Tool Renderer
May 17, 2026 ยท View on GitHub
See application_model.md for the system overview.
This guide walks through adding rendering support for a new Claude Code tool, using WebSearch as an example.
Overview
Tool rendering involves several components working together:
- Models (
models.py) - Type definitions for tool inputs and outputs - Factory (
factories/tool_factory.py) - Parsing raw JSON into typed models - HTML Formatters (
html/tool_formatters.py) - HTML rendering functions - Renderers - Integration with HTML and Markdown renderers
JSON output (json/renderer.py, since PR #36) needs no per-tool
integration: it serialises whatever typed input/output models the
factory produced via dataclasses.asdict (with a _json_default
shim for Pydantic models embedded inside the dataclasses). Add the
models in Step 1 and the factory hooks in Steps 2โ3, and your tool
shows up in JSON exports automatically. The HTML/Markdown formatter
work in Steps 4โ5 stays format-specific.
Step 1: Define Models
Tool Input Model
Add a Pydantic model for the tool's input parameters in models.py:
class WebSearchInput(BaseModel):
"""Input parameters for the WebSearch tool."""
query: str
Tool Output Model
Add a dataclass for the parsed output. Output models are dataclasses (not Pydantic) since they're created by our parsers, not from JSON:
@dataclass
class WebSearchLink:
"""Single search result link."""
title: str
url: str
@dataclass
class WebSearchOutput:
"""Parsed WebSearch tool output."""
query: str
links: list[WebSearchLink]
preamble: Optional[str] = None # Text before the Links
summary: Optional[str] = None # Markdown analysis after the Links
Note: Some tools have structured output with multiple sections. WebSearch is parsed as preamble/links/summary - text before Links, the Links JSON array, and markdown analysis after. This allows flexible rendering while preserving all content.
Update Type Unions
Add the new types to the ToolInput and ToolOutput unions:
ToolInput = Union[
# ... existing types ...
WebSearchInput,
ToolUseContent, # Generic fallback - keep last
]
ToolOutput = Union[
# ... existing types ...
WebSearchOutput,
ToolResultContent, # Generic fallback - keep last
]
Step 2: Implement Factory Functions
In factories/tool_factory.py:
Register Input Model
Add the input model to TOOL_INPUT_MODELS:
TOOL_INPUT_MODELS: dict[str, type[BaseModel]] = {
# ... existing entries ...
"WebSearch": WebSearchInput,
}
Implement Output Parser
Create a parser function that extracts structured data from the raw result. Some tools (like WebSearch) have structured toolUseResult data available on the transcript entry, which is cleaner than regex parsing:
def _parse_websearch_from_structured(
tool_use_result: ToolUseResult,
) -> Optional[WebSearchOutput]:
"""Parse WebSearch from structured toolUseResult data.
The toolUseResult for WebSearch has the format:
{
"query": "search query",
"results": [
{"tool_use_id": "...", "content": [{"title": "...", "url": "..."}]},
"Analysis text..."
]
}
"""
if not isinstance(tool_use_result, dict):
return None
query = tool_use_result.get("query")
results = tool_use_result.get("results")
# ... extract links from results[0].content, summary from results[1] ...
return WebSearchOutput(query=query, links=links, preamble=None, summary=summary)
def parse_websearch_output(
tool_result: ToolResultContent,
file_path: Optional[str],
tool_use_result: Optional[ToolUseResult] = None, # Extended signature
) -> Optional[WebSearchOutput]:
"""Parse WebSearch tool result from structured toolUseResult."""
del tool_result, file_path # Unused
if tool_use_result is None:
return None
return _parse_websearch_from_structured(tool_use_result)
Register Output Parser
Add to TOOL_OUTPUT_PARSERS and PARSERS_WITH_TOOL_USE_RESULT:
TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
# ... existing entries ...
"WebSearch": parse_websearch_output,
}
# Parsers that accept the extended signature with tool_use_result
PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch"}
Step 3: Implement HTML Formatters
In html/tool_formatters.py:
Input Formatter
def format_websearch_input(search_input: WebSearchInput) -> str:
"""Format WebSearch tool use content."""
escaped_query = escape_html(search_input.query)
return f'<div class="websearch-query">๐ {escaped_query}</div>'
Output Formatter
For tools with structured content like WebSearch, combine all parts into markdown then render:
def _websearch_as_markdown(output: WebSearchOutput) -> str:
"""Convert WebSearch output to markdown: preamble + links list + summary."""
parts = []
if output.preamble:
parts.extend([output.preamble, ""])
for link in output.links:
parts.append(f"- [{link.title}]({link.url})")
if output.summary:
parts.extend(["", output.summary])
return "\n".join(parts)
def format_websearch_output(output: WebSearchOutput) -> str:
"""Format WebSearch as single collapsible markdown block."""
markdown_content = _websearch_as_markdown(output)
return render_markdown_collapsible(markdown_content, "websearch-results")
Update Exports
Add functions to __all__:
__all__ = [
# ... existing exports ...
"format_websearch_input",
"format_websearch_output",
]
Step 4: Wire Up HTML Renderer
In html/renderer.py:
Import Formatters
from .tool_formatters import (
# ... existing imports ...
format_websearch_input,
format_websearch_output,
)
Add Format Methods
def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
return format_websearch_input(input)
def format_WebSearchOutput(self, output: WebSearchOutput, _: TemplateMessage) -> str:
return format_websearch_output(output)
Add Title Method (Optional)
For a custom title in the message header:
def title_WebSearchInput(self, input: WebSearchInput, message: TemplateMessage) -> str:
return self._tool_title(message, "๐", f'"{input.query}"')
Step 5: Implement Markdown Renderer
In markdown/renderer.py:
Import Models
from ..models import (
# ... existing imports ...
WebSearchInput,
WebSearchOutput,
)
Add Format Methods
def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
"""Format -> empty (query shown in title)."""
return ""
def format_WebSearchOutput(self, output: WebSearchOutput, _: TemplateMessage) -> str:
"""Format -> markdown list of links."""
parts = [f"Query: *{output.query}*", ""]
for link in output.links:
parts.append(f"- [{link.title}]({link.url})")
return "\n".join(parts)
def title_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
"""Title -> '๐ WebSearch `query`'."""
return f'๐ WebSearch `{input.query}`'
Step 6: Add Tests
Create test cases in the appropriate test files:
- Parser tests - Verify output parsing handles various formats
- Formatter tests - Verify HTML/Markdown output is correct
- Integration tests - Verify end-to-end rendering
JSON output is exercised by the broader test/test_json_rendering.py
/ test/test_json_real_projects.py suites; per-tool JSON output
typically needs no dedicated test because the dataclasses.asdict
serialisation is trivial. Add a JSON-specific case only if your tool
embeds a non-dataclass type the _json_default shim doesn't already
cover.
Renderer-set input fields driven by tool_result data
Most renderer passes set fields on the consumer's input model
based on what an earlier tool_result emitted โ e.g.
TaskOutputInput.creating_call_message_index is stamped by
_link_task_id_consumers from the matching BashOutput.background_task_id
so the consumer's title can back-link to the spawn (#154).
PR #158 introduced the forward counterpart: fields set on the
spawn's input model that are sourced from the spawn's own
tool_result. Concretely, BashInput.minted_background_task_id and
TaskInput.minted_agent_id are hoisted from BashOutput.background_task_id
/ the parsed launch confirmation so the spawn card's title can show
#<id> directly (instead of leaving the reader to scrape it out of
the result body). The same pass also stamps linked_consumer_message_index
on the spawn from the first consumer it finds.
This is the first "renderer-set input field driven by the same tool_use's tool_result" shape in the codebase. If you add another, keep these conventions:
- Field lives on the input model, not the output model โ title formatters read from the input, so the field has to be there to drive the title.
- Default
None, set only inside the renderer pass; never trust parser-side state for this. - Use
ctx.get(message_index)to navigate from the tool_result'spair_firstback to the spawn'sTemplateMessageโ that's the primary lookup, not iteratingctx.messagesagain. - First wins (e.g.
setdefault-style assignment guarded by anis Nonecheck) so re-running the pass is idempotent and document order remains deterministic. - Title formatter degrades gracefully: when the field is
None(no matching result observed, or the spawn lives outside the loaded slice), fall back to the plain title shape โ[async]without the id, plain#<id>without the anchor, etc.
Checklist
- Add input model to
models.py - Add output model to
models.py - Update
ToolInputunion - Update
ToolOutputunion - Add to
TOOL_INPUT_MODELSin factory - Implement output parser function
- Add to
TOOL_OUTPUT_PARSERSin factory - Add to
PARSERS_WITH_TOOL_USE_RESULTif using structured data (optional) - Add HTML input formatter
- Add HTML output formatter
- Wire up HTML renderer format methods
- Add HTML title method (if needed)
- Add Markdown format methods
- Add Markdown title method
- Add tests
- Update
__all__exports