Tool Authoring Reference
A detailed reference for authoring tools — including the Python executable-tool contract used by the handler runtime. For the basics of creating a tool in the UI, start with Tools.
Tool Kinds
A tool exposes a single capability the LLM can invoke. Every tool carries a name and a purpose; the body of the tool can take one of three forms.
- Text content — A natural-language prompt that describes the action. The LLM interprets it directly. Best for advisory or reasoning tools that don't produce structured output.
- JSON schema — An OpenAI-style function-calling schema declaring the parameters the LLM should pass. Best for structured calls into upstream systems where the response shape matters.
- Python handler — Executable Python code. The LLM calls a single
handler(event, context)entry point and receives whatever you return. This locks the Tool Content section, since the spec is generated from the handler.
Worked Example: Authoring a JSON Tool
JSON tools follow the OpenAI function-calling format. The LLM reads the schema below to decide when to call the tool, what arguments to pass, and which fields are required vs optional. The tree editor in the Tool Content section is the easiest way to author one — every property below corresponds to a node you can click into and edit directly.
{
"name": "search_documents",
"description": "Find engineering documents matching a topic. Returns the top results with title, summary, and a relevance score so the LLM can cite them in its reply.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Free-text query describing the topic, component, or behaviour to look for."
},
"document_type": {
"type": "string",
"enum": ["report", "specification", "drawing", "memo"],
"description": "Optional filter to a single document class. Omit to search all types."
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 25,
"description": "Upper bound on the result list. Defaults to 5 if omitted."
},
"tags": {
"type": "array",
"description": "Optional tags the document must carry — for example project codes or system names.",
"items": {
"type": "string"
}
}
},
"required": ["query"]
}
}
nameanddescriptionare the LLM's first read. The description should answer "when should I call this?" in one or two sentences — vague descriptions produce vague invocations.parametersis always an object schema. Insidepropertiesevery field carries atypeand adescription. Useenumfor closed sets,minimum/maximumfor bounded numbers, and nested objects when arguments naturally group.- Use
type: "array"with anitemssub-schema for lists. The tree editor has an Add list shortcut that seeds an empty array at the top level; nested arrays can be added through the row-hover+icon or in raw mode. requirednames the keys the LLM must supply. Keep this list tight — everything not inrequiredis implicitly optional, which gives the LLM room to skip fields it doesn't need.
The Python Handler Contract
Python tools follow a familiar serverless-style contract: a single handler entry point, a JSON-shaped input event, and a JSON-serializable return value. The runtime invokes one tool call per request and tears down the workspace afterwards.
- Single handler entry point — Define exactly one function named
handler. Its signature must behandler(event: dict, context: dict) -> dict. Other helpers can live in the same module, but the LLM only invokeshandler. - JSON-serializable input and output —
eventarrives as a plain dict of LLM-supplied arguments. The return value must be JSON-serializable — dicts, lists, strings, numbers, booleans, andNoneonly. - Docstrings drive the spec — Generate Docs from Python Code reads the handler's docstring and parameter annotations to build the JSON tool spec. Document every event field, its type, its units, and which
actionit belongs to. Sloppy docstrings produce sloppy LLM calls. - Comment the why, not the what — A module docstring explaining the file's purpose, helper docstrings for each solver, and inline comments only where the code's intent isn't obvious — boundary validation, dispatch-table rationale, magic numbers turned into named constants. Don't narrate the code line-by-line.
- Fail loudly, not silently — Raise an exception with a clear message on invalid input or upstream failure. The runtime catches it and surfaces the message to the LLM, which is more useful than a successful-looking empty result.
- Attached files are local — Files uploaded in the Attached Files section are staged into the handler's working directory under their original filename. Open them with a relative path — no storage client, no credentials.
- No long-running work — Treat the handler as a short-lived, stateless invocation. Don't spin up background threads, watch sockets, or rely on local disk persisting across calls — anything written outside the return value is discarded when the call ends.
Package Imports
Imports work the same way they do in any Python module — declare them at the top of the file and use them anywhere below. A few rules to keep in mind, since the runtime is a sandbox rather than a full developer environment.
- Standard library: the full Python 3 stdlib is available —
math,statistics,itertools,csv,json,datetime,re, and the rest. - Scientific stack: common engineering and data packages such as
numpy,scipy,pandas, andsympyare pre-installed. Import them at the top of the file like any other dependency. - No runtime
pip install: the sandbox cannot fetch new packages while a tool is running. If you need a library that isn't already available, request it before publishing the tool — bundling shell calls topipinside the handler will not work. - Custom modules: when a handler grows beyond a single file, package the directory as a
.zipand attach the archive — uploads accept exactly one.pyor one.zipper upload event. The zip is unpacked in the browser and uploaded under one shared folder, so the runtime preserves the directory structure and relative imports likefrom .solver import iterateresolve as expected. - No outbound network: the runtime is sealed from the public internet by default. Use attached files for fixtures and reference data; for live integrations, build a text/JSON tool that calls into a service your platform already authenticates against.
Worked Example: Multi-Solver Dispatcher
One Python file, two solvers — a structural beam deflection calculator and a chemistry rate-constant calculator — multiplexed behind a single handler. The LLM picks which calculation it wants by setting action in the event payload, and the dispatch table at the bottom of the module routes the call. Adding a third solver is two changes: a new helper function and a new entry in _SOLVERS.
Notice the discipline that pays off here: a module docstring at the top explains the file as a whole; helper docstrings document each solver's inputs, outputs, and the equation it implements; the handler docstring enumerates every action and its required fields — that's what Generate Docs from Python Code reads to produce the JSON spec. Inline comments are reserved for the why (boundary validation, dispatch-table rationale) rather than restating the code.
"""Engineering solver suite.
A single tool that exposes multiple calculators. The handler routes
the LLM to the right solver based on the `action` field in the
event payload — adding a new calculator means writing a new helper
and adding it to the dispatch table at the bottom of this module.
"""
import math
from typing import Callable
import numpy as np
# ── Beam deflection (mechanics) ───────────────────────────────────
def _moment_of_inertia(width: float, height: float) -> float:
"""Second moment of area for a solid rectangular cross-section."""
return (width * height ** 3) / 12.0
def _solve_beam_deflection(params: dict) -> dict:
"""Cantilever beam under a tip point load (Euler-Bernoulli).
The closed-form deflection is::
y(x) = (P * x^2 * (3*L - x)) / (6 * E * I)
Args:
params: The per-action parameter block. Must contain
"load_n", "length_m", "youngs_modulus_pa", "width_m",
"height_m", and optionally "samples".
Returns:
Dict with "max_deflection_m" and a sampled "curve" along the
beam length.
"""
load = float(params["load_n"])
length = float(params["length_m"])
e_modulus = float(params["youngs_modulus_pa"])
inertia = _moment_of_inertia(
float(params["width_m"]), float(params["height_m"])
)
samples = int(params.get("samples", 25))
# Validate at the boundary so failures surface to the LLM with a
# useful message rather than a numpy divide-by-zero deep in the math.
if length <= 0 or inertia <= 0 or e_modulus <= 0:
raise ValueError(
"length, cross-section, and modulus must all be positive."
)
ei = e_modulus * inertia
xs = np.linspace(0.0, length, samples)
ys = (load * xs ** 2 * (3 * length - xs)) / (6 * ei)
return {
"max_deflection_m": float(ys[-1]),
"curve": [
{"x_m": float(x), "y_m": float(y)} for x, y in zip(xs, ys)
],
}
# ── Arrhenius rate (chemistry) ────────────────────────────────────
# Universal gas constant in J/(mol·K). Pulled out as a module-level
# constant so the helper reads as physics rather than a magic number.
_GAS_CONSTANT = 8.314_462_618
def _solve_arrhenius(params: dict) -> dict:
"""Reaction rate constant via the Arrhenius equation.
k = A * exp(-Ea / (R * T))
Args:
params: The per-action parameter block. Must contain
"pre_exponential", "activation_energy_j_mol", and
"temperature_k".
Returns:
Dict with "rate_constant" in the same units as
"pre_exponential".
"""
a = float(params["pre_exponential"])
ea = float(params["activation_energy_j_mol"])
temperature = float(params["temperature_k"])
if temperature <= 0:
raise ValueError("temperature_k must be > 0.")
rate_constant = a * math.exp(-ea / (_GAS_CONSTANT * temperature))
return {"rate_constant": rate_constant}
# ── Dispatcher ────────────────────────────────────────────────────
# Map a stable string identifier the LLM passes in to the helper
# that fulfils it. Adding a new solver is a one-line change here
# plus the helper above; the LLM-facing contract stays identical.
_SOLVERS: dict[str, Callable[[dict], dict]] = {
"beam_deflection": _solve_beam_deflection,
"arrhenius_rate": _solve_arrhenius,
}
def handler(event: dict, context: dict) -> dict:
"""
Dispatch to one of the configured engineering solvers.
The event is shaped so each action's parameters live under their
own key, never co-mingled. That keeps the JSON spec the LLM sees
unambiguous: pick an `action`, fill in the matching params block,
nothing else.
Args:
event: Input parameters. Must contain:
- "action": Solver to invoke. One of "beam_deflection" or
"arrhenius_rate".
- A nested object under the same key as "action" carrying
that solver's parameters. For example::
{
"action": "beam_deflection",
"beam_deflection": {
"load_n": 500.0,
"length_m": 2.0,
"youngs_modulus_pa": 2.0e11,
"width_m": 0.05,
"height_m": 0.10
}
}
context: Runtime metadata (request id, user id, etc.).
Returns:
Dictionary with:
- "status": "success".
- "action": The action that was run, echoed so the LLM can
correlate response to request.
- All solver-specific result fields, merged at top level.
"""
action = event.get("action")
if action not in _SOLVERS:
valid = ", ".join(sorted(_SOLVERS))
raise ValueError(
f"Unknown action {action!r}. Expected one of: {valid}."
)
# Pull the params block scoped to this action. Validating shape
# at the boundary means each helper receives a clean dict and
# never has to know about the discriminator field.
params = event.get(action)
if not isinstance(params, dict):
raise ValueError(
f"Missing parameter block for action {action!r}: "
f"expected an object under event[{action!r}]."
)
result = _SOLVERS[action](params)
return {"status": "success", "action": action, **result}
Conveying the Action/Params Contract
The Python dispatcher above expects the LLM to set action AND populate a sibling object with the same name. JSON Schema's required only handles top-level keys, not conditional ones, so a flat schema lets the model send an action with no params and trip the handler's boundary check at runtime.
The natural fix would be oneOf, but Bedrock's Converse API explicitly rejects oneOf, allOf, and anyOf at the top level of tool input schemas. So the dispatch contract lives in two places: a vivid action description that anchors the model, and a runtime check in the handler that fails loudly when the model gets it wrong.
{
"schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "REQUIRED. Selects which solver to invoke. You MUST also include a sibling top-level field with the EXACT same name as this action, containing that action's parameters as an object. Example: action='count_range' requires {\"action\": \"count_range\", \"count_range\": {\"values\": [1, 2, 3]}}. Calls missing the matching params block will be rejected. The handler validates this pairing at runtime.",
"enum": ["count_range", "sum_range"]
},
"count_range": {
"type": "object",
"properties": { "values": { "type": "array", "items": {} } },
"required": ["values"]
},
"sum_range": {
"type": "object",
"properties": { "values": { "type": "array", "items": { "type": "number" } } },
"required": ["values"]
}
},
"required": ["action"]
}
}
- Lead the description with REQUIRED. Then state explicitly that a sibling field with the same name as the action MUST be present and contain the params. Show one worked example with both keys filled in — that example is what the model pattern-matches against when constructing the call.
- Use "will be rejected" framing. Models honor consequence-language more reliably than "remember to include". State that calls missing the params block fail.
- Validate at the handler boundary. Read the nested params block (
event[event["action"]]) and raiseValueErrorwith a specific message when it's missing or not a dict. The error message is fed back to the model on the next turn so it can self-correct.
Corresponding Tool Context
When you click Generate Docs from Python Code, the multi-solver handler above produces the JSON tool reference shown below. The LLM consults this spec to decide which arguments to pass. The shape mirrors the dispatcher exactly — a discriminator at the top, then one nested object per action carrying just that solver's parameters:
- The discriminator field —
action— is a string with anenumof every supported solver. The enum keys match the keys of_SOLVERSin the handler. - Each solver's parameters live in their own nested object, keyed by the same name as the action. There is no risk of parameter collisions between solvers, and an LLM looking at the spec can immediately see which fields belong to which calculation.
requiredis set per nested object. Insidebeam_deflectionthe load and dimensions are required; insidearrhenius_ratethe kinetic parameters are required. The top-levelrequiredonly carriesaction— the handler enforces the one-of relationship betweenactionand which nested block must be present.- The
descriptionat the top of the spec is the first thing the LLM reads when deciding whether to call the tool. Spell out the dispatch rule ("set action, then fill in the matching parameter object under the same key") so the LLM never invents flat parameters.
{
"name": "engineering_solver",
"description": "Dispatch to one of the configured engineering solvers. Set 'action' to pick a solver, then fill in the matching parameter object under the same key.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["beam_deflection", "arrhenius_rate"],
"description": "Which solver to invoke. The parameters for the selected solver go under the object keyed by this same value."
},
"beam_deflection": {
"type": "object",
"description": "Parameters for the cantilever beam deflection solver. Required when action='beam_deflection'.",
"properties": {
"load_n": {
"type": "number",
"description": "Tip load in newtons."
},
"length_m": {
"type": "number",
"description": "Beam length in metres."
},
"youngs_modulus_pa": {
"type": "number",
"description": "Young's modulus in pascals."
},
"width_m": {
"type": "number",
"description": "Rectangular cross-section width in metres."
},
"height_m": {
"type": "number",
"description": "Rectangular cross-section height in metres."
},
"samples": {
"type": "integer",
"description": "Optional number of points along the beam (default 25)."
}
},
"required": [
"load_n",
"length_m",
"youngs_modulus_pa",
"width_m",
"height_m"
]
},
"arrhenius_rate": {
"type": "object",
"description": "Parameters for the Arrhenius rate-constant solver. Required when action='arrhenius_rate'.",
"properties": {
"pre_exponential": {
"type": "number",
"description": "Pre-exponential factor A."
},
"activation_energy_j_mol": {
"type": "number",
"description": "Activation energy in joules per mole."
},
"temperature_k": {
"type": "number",
"description": "Absolute temperature in kelvin."
}
},
"required": [
"pre_exponential",
"activation_energy_j_mol",
"temperature_k"
]
}
},
"required": ["action"]
}
}
Attached Files
The Attached Files section above the code editor accepts one file per upload event — either a single .py file or a single .zip archive when the handler ships with helpers, fixtures, or model weights. Zip uploads are unpacked in the browser and re-uploaded under one shared folder so the runtime can stage them with their original directory structure intact.
import csv
def _load_material_table(path: str) -> dict:
"""Parse a material lookup CSV into {material: {E_pa, density}}."""
table = {}
with open(path, newline="") as fh:
for row in csv.DictReader(fh):
table[row["name"]] = {
"youngs_modulus_pa": float(row["E_pa"]),
"density_kg_m3": float(row["density_kg_m3"]),
}
return table
def handler(event: dict, context: dict) -> dict:
# Files attached to the tool are staged into the working directory
# by their original filename, so a relative path just works.
materials = _load_material_table("materials.csv")
name = event["material"]
if name not in materials:
raise ValueError(f"Unknown material: {name}")
return {"status": "success", "properties": materials[name]}
- One file per upload, .py or .zip. Use a zip when you need helpers, packages, or data files alongside the handler — the directory structure is preserved end-to-end so relative imports resolve at runtime.
- Cache and metadata noise is filtered.
__pycache__/,.pycbytecode,.git/, virtual-env directories, and OS metadata files (e.g..DS_Store) are dropped during extraction so they never reach storage. - Up to 100 files per tool, 50 MB per file. Larger payloads should be fetched at runtime from a backing store, not bundled with the tool.
- Files persist by reference. The tool record stores the storage prefix and metadata only, not the bytes. Removing a file from the form removes the reference; the underlying object stays until it is garbage collected.
Authoring Flow
The recommended order when building a Python tool from scratch:
- Sketch each solver as a private helper function with a focused docstring. Keep the math, validation, and unit conventions inside the helper so the handler stays a thin router.
- Write the
handlerlast. Document every action it dispatches to, the required fields per action, and the shape of the response. The docstring is what the LLM will read. - Upload any data files the handler depends on — CSVs, JSON fixtures, small model artifacts.
- Click Generate Docs from Python Code. The LLM produces a JSON schema (and a description) into the locked Tool Content section. Review the spec against the handler docstring — they should agree.
- Save the tool. Once saved, attach it to an Agent in the Agents tab to make it callable in conversations.
Ready to build one? Open the Tools tab in Create to start writing your handler.