Tool¶
A Tool is anything an Agent can call — a Python function, another
agent, an MCP server, an external-tools kit. They all flow through the
same tools=[...] list and you almost never construct one yourself:
the framework normalises the list inside Agent.__init__ and registers
each entry under a unique name.
Signature¶
from lazybridge import Tool
# Canonical constructor (rarely needed — drop the function in directly).
Tool(
func, # required: the callable
*,
name=None, # defaults to func.__name__
description=None, # defaults to the function's docstring
mode="signature", # "signature" | "llm" | "hybrid"
schema_llm=None, # engine for mode="llm" / "hybrid"
# NB: the legacy mode="auto" graceful-fallback ladder was removed
# in 0.7.9. Pass an explicit mode; mode="auto" raises ValueError.
strict=False, # provider-strict JSON schema validation
returns_envelope=False, # set automatically by agent.as_tool()
)
# Pre-built JSON Schema (MCP, OpenAPI, third-party registries).
Tool.from_schema(
name, # required
description, # required
parameters, # JSON Schema dict
func, # the callable to dispatch
*,
strict=False,
returns_envelope=False,
)
For the public Tool.wrap(...) factory and agent.as_tool(...) method —
both are sugar with non-trivial differences — see
Canonical vs sugar.
tools= is not native_tools=¶
Two parameters, two runtimes — don't conflate them:
| Parameter | Who executes the tool | What goes in it |
|---|---|---|
tools=[...] |
LazyBridge runtime (this process) | Python callables, sub-Agents, MCP* servers, Tool instances |
native_tools=[...] |
The LLM provider's servers | NativeTool enum values (e.g. NativeTool.WEB_SEARCH) |
native_tools is for server-side tools the provider implements
itself — Anthropic web search, OpenAI image generation, Google
grounding, etc. Dangerous server-side tools
(NativeTool.CODE_EXECUTION, NativeTool.COMPUTER_USE) additionally
require allow_dangerous_native_tools=True on the Agent — a
deliberately noisy opt-in, since the executor is no longer in your
process. See Reference → Providers
for the per-provider native-tool support matrix.
The rest of this page documents tools= only.
Synopsis¶
LazyBridge accepts six things in tools=[...] and normalises them all
to Tool instances at construction time:
from lazybridge import Agent, LLMEngine, Tool
from lazytools.connectors.mcp import MCP # pip install lazytoolkit (import name: lazytools)
from lazytools.documents import read_docs_tools
agent = Agent(
engine=LLMEngine("gpt-5.4-mini"),
tools=[
plain_function, # 1. plain Python function
Tool.wrap(plain_function, name="custom", strict=True), # 2. function + overrides via factory
other_agent, # 3. sub-agent (auto-wrapped)
other_agent.as_tool(verify=judge), # 4. sub-agent + judge/retry
MCP.stdio("fs", command="npx",
args=["@modelcontextprotocol/server-filesystem", "."],
allow=["fs.read_*", "fs.list_*"]), # 5. MCPServer (allow= required)
*read_docs_tools(), # 6. lazytools docs kit (list[Tool])
],
)
The common case is path 1: drop the function in. Type hints +
docstring drive the JSON schema. Reach for Tool.wrap(fn, name=..., ...)
when you need to override the name / description / strictness / mode;
reach for Tool.from_schema(...) when you already have a JSON schema
(MCP, OpenAPI, third-party registry). The bare Tool(...) constructor
is still public for advanced use cases (e.g. typing annotations,
isinstance checks) but the Tool.wrap() factory is the canonical form
for new code.
Schema modes — signature, hybrid, llm¶
Every Tool carries a JSON Schema that the LLM uses to call it.
The schema is built once on first use and cached for the lifetime
of the process (an ArtifactStore interface lets you persist the
cache across runs if you need to). The mode controls how that
schema is generated.
| Mode | What's inspected | Extra LLM call? | Determinism | Use when |
|---|---|---|---|---|
"signature" (default) |
Type hints + docstring of func |
no | full | The function already has type hints and a useful docstring |
"hybrid" |
Signature for types + an LLM for parameter descriptions | one, on first build | high — types are fixed, only descriptions vary | Types exist but docstrings are sparse / outdated / wrong |
"llm" |
The function's source code (or stub), interpreted by an LLM | one, on first build | medium — both types and descriptions come from the model | Legacy code with no annotations, **kwargs-only signatures, third-party callables you can't modify |
Both hybrid and llm modes require schema_llm=<engine> — usually
a cheap-tier LLM dedicated to schema work — and emit a one-shot
warning if the model returns an empty or under-specified result.
signature — the canonical default¶
from lazybridge import Tool
def get_weather(city: str, units: str = "c") -> str:
"""Return current weather for ``city``.
Args:
city: City name (e.g. "Paris").
units: "c" for Celsius (default) or "f" for Fahrenheit.
"""
...
tool = Tool.wrap(get_weather, name="get_weather")
The generated schema (abbreviated):
{
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name (e.g. \"Paris\")."},
"units": {"type": "string", "description": "\"c\" for Celsius (default) or \"f\" for Fahrenheit.", "default": "c"}
},
"required": ["city"]
}
This is deterministic and free. Reach for the other modes only when this one can't produce a useful schema.
hybrid — types from signature, descriptions from an LLM¶
The signature has the truth about parameter types and which are required. The LLM only fills in the descriptions — the parts the LLM sees when it's deciding whether and how to call the tool.
from lazybridge import LLMEngine, Tool
schema_llm = LLMEngine("claude-haiku-4-5") # cheap-tier; runs once per tool
def get_weather(city: str, units: str = "c") -> str:
# Docstring is missing or unhelpful.
...
tool = Tool.wrap(
get_weather,
name="get_weather",
mode="hybrid",
schema_llm=schema_llm,
)
Resulting schema: same {"type": "string"} types as the signature
path, but "description" fields now come from the LLM analysing
the function source. Required-vs-optional is still decided by
the signature (the units: str = "c" default keeps it optional).
When to prefer hybrid over signature:
- You inherited a function whose docstring lies about the parameters.
- The team is migrating to type-hinted code and isn't ready to re-document every helper.
- You want consistent LLM-facing descriptions across a large tool kit without hand-writing each one.
When not to use it:
- You're shipping a security-sensitive tool. An LLM-generated
description could understate the risk (e.g. describe a shell-exec
tool as "runs a command") and bias the model toward calling it.
Keep
mode="signature"and own the description.
llm — full LLM-inferred schema¶
The signature has no useful information — def legacy(**kwargs),
an undocumented third-party callable, or a function defined inside
a lambda you can't annotate. Hand the source (or a stub) to an
LLM and let it produce the whole tool definition.
from lazybridge import LLMEngine, Tool
schema_llm = LLMEngine("claude-haiku-4-5")
def legacy_lookup(*args, **kwargs):
"""Best-effort lookup against the v1 reporting API. Accepts
a record id and an optional output format. Returns a JSON
string."""
...
tool = Tool.wrap(
legacy_lookup,
name="legacy_lookup",
mode="llm",
schema_llm=schema_llm,
)
The framework keeps two invariants the LLM cannot break:
- Required parameters come from the signature first. If the
signature says a parameter has no default, it stays required even
if the LLM forgot it. A
UserWarningis logged if the LLM omits a signature-required parameter. - Strict mode still applies. Passing
strict=Trueadds"additionalProperties": falseto the generated schema regardless of what the LLM produced.
Calibrate your expectations: this mode is the slowest to bootstrap
(one extra LLM round-trip on first use), the least deterministic
across runs, and the only mode that can produce a schema the function
can't actually accept (e.g. an extra parameter the LLM invented).
Production use cases should treat it as a one-off migration tool —
once it generates a schema that works, copy the result into
Tool.from_schema(...) and pin it.
Caching¶
All three modes cache the built schema per Tool instance for the
lifetime of the process. For cross-process persistence — e.g. so a
serverless function doesn't pay the LLM-bootstrap cost on every cold
start — pass an ArtifactStore to the tool builder; the
InMemoryArtifactStore is the in-process default, and the protocol
is small enough to back with Redis / SQLite / S3 in two methods
(get / put). See lazybridge/core/tool_schema.py for the
protocol and the in-memory reference implementation.
Pinning the result¶
Once an llm/hybrid mode produces a schema you're happy with,
copy the generated JSON into a Tool.from_schema(...) call and
remove the schema_llm= dependency. This is the canonical pattern
for moving from "discover the schema" to "ship the schema":
weather_tool = Tool.from_schema(
name="get_weather",
description="Return current weather for a city.",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name."},
"units": {"type": "string", "enum": ["c", "f"], "default": "c"},
},
"required": ["city"],
},
func=get_weather,
)
Now the tool is deterministic, free to load, and reviewable in a PR.
When to construct a Tool explicitly¶
- You need a different name than
func.__name__. Useful for shadowing a third-party function with a clearer LLM-facing name. - You need
strict=Truefor provider-strict JSON-schema validation (Anthropic / OpenAI strict mode). - You need
mode="llm"or"hybrid"because the function lacks type hints or annotations (legacy code, third-party callables,**kwargs-only signatures). - You're shipping a tool kit. Library authors return
list[Tool]from a factory so callers can splat it intotools=[...](e.g.read_docs_tools()).
When NOT to construct a Tool explicitly¶
- For ordinary Python functions in your own code. Just pass the
function —
Agent(tools=[my_function])is the canonical form. - For an
Agentyou want to use as a sub-agent. Pass it directly:Agent(tools=[other_agent]). The agent'sname=becomes the tool name. Useagent.as_tool("alias")only to rename or to attachverify=(see Canonical vs sugar). - For an MCP server. Pass the
MCPServerdirectly — it's aToolProviderand the framework expands it into per-server-toolToolinstances at construction.
Example¶
from lazybridge import Agent, LLMEngine, Tool
from pydantic import BaseModel
# 1) Plain function — type hints + docstring drive the schema.
def calculate(expression: str) -> float:
"""Evaluate a basic arithmetic expression and return the result.
Supports +, -, *, /, parentheses.
"""
return eval(expression) # noqa: S307 (trusted inputs only)
calc_agent = Agent(
engine=LLMEngine("gpt-5.4-mini"),
tools=[calculate],
)
result = calc_agent("what is 17 * 23?")
print(result.text())
# 2) Function + explicit configuration — override the name and turn on strict.
calc_tool = Tool.wrap(
calculate,
name="calc",
description="Evaluate an arithmetic expression and return the numeric result.",
strict=True,
)
strict_agent = Agent(
engine=LLMEngine("gpt-5.4-mini"),
tools=[calc_tool],
)
# 3) Pydantic-typed parameters — coerced from the LLM's raw dict to a typed instance.
class SearchInput(BaseModel):
query: str
limit: int = 10
def search(input: SearchInput) -> list[str]:
"""Search the web and return the top ``input.limit`` URLs for ``input.query``."""
return [f"https://example.com/{input.query}/{i}" for i in range(input.limit)]
researcher = Agent(
engine=LLMEngine("gpt-5.4-mini"),
tools=[search],
)
# 4) Pre-built schema — Tool.from_schema for MCP / OpenAPI bridges.
schema = {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"units": {"type": "string", "enum": ["c", "f"], "default": "c"},
},
"required": ["city"],
}
def weather_dispatch(**kwargs):
"""Forward the validated args to a downstream HTTP call."""
return f"weather for {kwargs['city']}"
weather_tool = Tool.from_schema(
name="weather",
description="Get current weather for a city.",
parameters=schema,
func=weather_dispatch,
)
weather_agent = Agent(
engine=LLMEngine("gpt-5.4-mini"),
tools=[weather_tool],
)
Pitfalls¶
- No type hints → empty schema. A function with bare parameters produces an empty JSON schema and the LLM will not know how to call it. Always annotate every parameter; defaults are fine.
- Docstring is the contract. "Returns the weather" is much
weaker than "Returns the current temperature in Celsius and a
one-word condition (sunny / cloudy / rainy) for
city." The LLM reads the docstring; treat it as the spec. strict=Truerejects optional / defaulted args under some providers. If a call fails with "unknown parameter", trystrict=False.- Name collisions trigger a
UserWarning. The second registration replaces the first. Pick stable, distinct names — especially when mixing your tools with MCP-namespaced ones (fs.read,fs.write, …). Pydantic BaseModelparameters are coerced from the LLM's raw dict to a typed instance before your function is called. You always receive the model, not the dict — don't write defensivedict(...)conversions inside the function body.returns_envelope=Trueis set for you when the framework wraps anAgentas a tool viaagent.as_tool(...). Don't set it manually on a plain function — engines that respect the hint will try to readresult.metadata.cost_usdand crash on a non-Envelope return value.
See also¶
- Agent — the surface that consumes tools.
- Native tools — provider-hosted alternatives
passed via
native_tools=[...]instead oftools=[...]. - Envelope — the result type when a tool is an
Agent(returns_envelope=True). - Everything is a tool — the composition rule that makes all six paths uniform.
- Canonical vs sugar —
Tool.wrap(...)factory,agent.as_tool(...), andTool.from_schema(...)with their canonical equivalents. BothTool.wrap(...)andTool(...)default tomode="signature";mode="hybrid"/mode="llm"are the explicit opt-ins for LLM-driven schema generation.