Skip to main content

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 Variants

A tool exposes a single capability the LLM can invoke. Every tool carries a name, a purpose, and a schema. Tools come in two variants, selected from the Tool variant cards in the editor.

  • Structured — A JSON schema in the OpenAI function-calling format declaring the parameters the LLM should pass. Best for advisory tools and for structured calls into upstream systems where the argument shape matters.
  • Code — A schema plus an uploaded Python bundle that actually runs. The LLM calls a single handler(event, context) entry point and receives whatever you return. Generate from Code derives the schema from your handler's docstring, and the schema stays editable afterward.

Both variants carry a schema — it is what the LLM reads to decide how to call the tool. The rest of this page focuses on code tools, since structured tools are just the schema.

Worked Example: Authoring a Schema

Schemas 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 Schema card 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"]
}
}
  • name and description are the LLM's first read. The description should answer "when should I call this?" in one or two sentences — vague descriptions produce vague invocations.
  • parameters is always an object schema. Inside properties every field carries a type and a description. Use enum for closed sets, minimum / maximum for bounded numbers, and nested objects when arguments naturally group.
  • Use type: "array" with an items sub-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.
  • required names the keys the LLM must supply. Keep this list tight — everything not in required is 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 be handler(event: dict, context: dict) -> dict. Other helpers can live in the same module, but the LLM only invokes handler.
  • JSON-serializable input and outputevent arrives as a plain dict of LLM-supplied arguments. The return value must be JSON-serializable — dicts, lists, strings, numbers, booleans, and None only.
  • Docstrings drive the specGenerate from 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 action it 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.
  • Bundled files are local — Data files shipped inside your .zip are staged into the handler's working directory under their original names. 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, and sympy are 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 to pip inside the handler will not work.
  • Custom modules: a handler is always packaged as a .zip with handler.py at the archive root, and uploads accept exactly one .zip per tool. The zip is unpacked in the browser and uploaded under one shared folder, so the runtime preserves the directory structure and relative imports like from .solver import iterate resolve as expected.
  • Outbound network is opt-in: the runtime is sealed from the public internet by default. To call an external service, turn on Allow internet access in the Runtime access card and add the specific hostnames you need — requests to anything outside that list are blocked. Entries matching your organization's block list disable Save until removed, as does enabling internet access without listing any hostname.

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 from Code reads to produce the JSON spec. Inline comments are reserved for the why (boundary validation, dispatch-table rationale) rather than restating the code.

Start from a template

The Multi-solver dispatcher starter template in the Python Executable Tool card is a smaller version of this example. Download the starter .zip, replace the solvers with your own, and re-upload.

"""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 raise ValueError with 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 from Code — or simply hit Save on a new code tool with an empty schema, which runs the same generator automatically — 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 an enum of every supported solver. The enum keys match the keys of _SOLVERS in 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.
  • required is set per nested object. Inside beam_deflection the load and dimensions are required; inside arrhenius_rate the kinetic parameters are required. The top-level required only carries action — the handler enforces the one-of relationship between action and which nested block must be present.
  • The description at 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"]
}
}

Bundled Files

Anything the handler needs at runtime ships inside the same .zip — helpers, sub-packages, CSV or JSON fixtures, small model artifacts. The archive is unpacked in the browser and re-uploaded under one shared folder, so the runtime stages the files 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 bundled with 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]}

The Reads attached files starter template is this pattern in miniature — a good place to begin if your tool is mostly a lookup.

  • One .zip per tool. Bare .py uploads are rejected, because every tool needs a requirements.txt alongside its handler. handler.py must sit at the archive root; requirements.txt may sit at the root or one directory down.
  • Validated before upload. The archive is unpacked and checked in the browser: a missing handler.py, a missing requirements.txt, no top-level def handler(event, context), or a handler with no docstring all fail immediately with a message. Softer issues — a missing -> dict annotation, unannotated parameters, no return in the body — come back as suggestions rather than blockers.
  • Cache and metadata noise is filtered. __pycache__/, .pyc bytecode, .git/, virtual-env directories, and OS metadata files (e.g. .DS_Store) are dropped during extraction so they never reach storage. A single wrapping top-level folder — what right-click → compress produces on macOS and Windows — is stripped automatically.
  • 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 the bundle from the form removes the reference; the underlying objects stay until they are garbage collected.

Authoring Flow

The recommended order when building a Python tool from scratch:

  1. Pick a starter template in the Python Executable Tool card and click Download starter .zip. It contains handler.py, requirements.txt, and a short README.
  2. 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.
  3. Write the handler last. 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.
  4. Declare every dependency in requirements.txt, then zip the folder with handler.py at the archive root — along with any data files the handler opens — and upload it.
  5. If the handler calls an external service, turn on Allow internet access under Runtime access and list the hostnames it needs. Save stays disabled while any entry matches your organization's block list, or while internet access is on with no hostnames listed.
  6. Optionally click Generate from Code to preview the derived schema and description, then edit either freely. If you skip this, saving a new code tool with an empty schema runs the same generator automatically. Review the spec against the handler docstring — they should agree.
  7. Add any Trigger conditions — natural-language cues for when the agent should reach for this tool — and Save. Then attach the tool to an agent from the agent editor's Attachments card to make it callable in conversations.

Ready to build one? Open Tools and click New Tool to start writing your handler.