Recipes
Copy-paste solutions for common toolkit configuration patterns. Start with the simplest pattern that fits your needs.
Which recipe do I need?
- Wrapping an external API that needs a key? → Recipe 1
- Just need paths or numbers? → Recipe 2
- Need to validate that a file exists? → Recipe 3
- Need to download something? → Recipe 4
- Multiple setup options (download or provide path)? → Recipe 5
- Custom system detection (CUDA, libraries)? → Recipe 6
- Tools that need injected configuration? → Recipe 7
Recipe 1 — A toolkit that needs an API key
Many tools wrap an external API. The user has a key; your tool needs to use it without the LLM ever seeing it.
Declare the key in toolkit.yaml:
config:
- name: api_key
description: "Your NASA API key. Get one at https://api.nasa.gov."
type: secret
required: trueyamlUse it in your tool by declaring state=["api_key"] on the decorator:
from orchestral import define_tool
import requests, json
@define_tool(state=["api_key"])
def search_neos(query: str, api_key: str) -> str:
"""Search for near-Earth objects."""
response = requests.get(
"https://api.nasa.gov/neo/rest/v1/feed",
params={"api_key": api_key, "q": query},
)
return json.dumps(response.json())pythonThe user runs toolbase install your-toolkit, gets prompted once for the key (or skips with --no-input and edits ~/.toolbase/config/your-toolkit.yaml afterward). The agent calls search_neos(query="2024 NA1"); your function receives both query (from the agent) and api_key (from the platform). The agent never sees the key.
For CI environments, where the secret comes from the platform's secret manager rather than a hand-edited file, see the "Bridging environment variables" pattern in Configuration.
Recipe 2: Simple paths and values
You need a path, a number, or both — but no validation logic.
toolkit.yaml
config:
- name: data_dir
type: path
description: "Where to store outputs"
default: "~/.my-toolkit/data"
- name: max_workers
type: integer
description: "Number of parallel workers"
default: 4yamlNo setup.py needed. Toolbase prompts for these on install. The user can press Enter to accept defaults.
Tool code
from pathlib import Path
from orchestral import define_tool
import json
@define_tool(state=["data_dir", "max_workers"])
def process_data(input_file: str, data_dir: Path, max_workers: int) -> str:
"""Process input file."""
output = process(input_file, output_dir=data_dir, n_workers=max_workers)
return json.dumps({"output": str(output)})pythonSee Stateful Tools for the state=[...] mechanism.
Recipe 3: Validate a file exists
You collect a path declaratively, but need to check that the path actually contains usable files.
toolkit.yaml
config:
- name: opacity_path
type: path
description: "Path to opacity data files"
required: true
setup_script: trueyamlsetup.py
from pathlib import Path
def setup(ctx) -> bool:
return validate(ctx)
def validate(ctx) -> bool:
path_str = ctx.get_config('opacity_path')
if not path_str:
ctx.error("opacity_path not configured")
ctx.hint("Run: toolbase setup my-toolkit")
return False
path = Path(path_str).expanduser()
if not path.exists():
ctx.error(f"Path does not exist: {path}")
return False
h5_files = list(path.glob("*.h5"))
if len(h5_files) < 10:
ctx.error(f"Expected 10+ .h5 files, found {len(h5_files)}")
return False
return TruepythonRecipe 4: Download a data file
Your toolkit needs data that's too big to bundle. Ship it on a server and have setup.py download it.
toolkit.yaml
name: my-toolkit
version: 1.0.0
setup_script: trueyamlsetup.py
from pathlib import Path
DATA_URL = "https://data.example.com/my-toolkit/dataset_v1.tar.gz"
DATA_SHA256 = "abc123def456..." # Optional integrity check
def setup(ctx) -> bool:
dest = ctx.data_dir / 'dataset'
if dest.exists() and any(dest.iterdir()):
if not ctx.confirm("Dataset already exists. Re-download?", default=False):
ctx.set_config('dataset_path', str(dest))
return True
ctx.info("Downloading dataset (~500MB)...")
ctx.download(
url=DATA_URL,
destination=dest,
description="Dataset",
size_hint="500MB",
extract=True,
sha256=DATA_SHA256,
)
ctx.set_config('dataset_path', str(dest))
ctx.success(f"Dataset installed at {dest}")
return True
def validate(ctx) -> bool:
path = ctx.get_config('dataset_path')
if not path or not Path(path).exists():
ctx.error("Dataset not found")
ctx.hint("Run: toolbase setup my-toolkit")
return False
return TruepythonRecipe 5: Download or provide path (multiple options)
The user might already have the data on disk. Don't force them to re-download.
setup.py
from pathlib import Path
OPACITY_URL = "https://data.example.com/aster/opacity_v2.tar.gz"
def setup(ctx) -> bool:
existing = ctx.get_config('opacity_path')
if existing and Path(existing).expanduser().exists():
ctx.info(f"Opacity data already configured: {existing}")
if not ctx.confirm("Reconfigure?", default=False):
return validate(ctx)
choice = ctx.choice(
"How would you like to set up opacity data?",
[
("download", "Download automatically (~2.3GB)"),
("path", "I have the data — let me provide the path"),
("skip", "Skip for now"),
]
)
if choice == "download":
return _download_opacity(ctx)
elif choice == "path":
return _prompt_opacity_path(ctx)
else:
ctx.warn("Setup skipped. Toolkit unavailable until configured.")
return False
def _download_opacity(ctx) -> bool:
dest = ctx.data_dir / 'opacity'
ctx.download(
url=OPACITY_URL, destination=dest,
description="Opacity data", size_hint="2.3GB", extract=True,
)
ctx.set_config('opacity_path', str(dest))
return validate(ctx)
def _prompt_opacity_path(ctx) -> bool:
path = ctx.prompt_path("Path to opacity data:", must_exist=True)
ctx.set_config('opacity_path', str(path))
return validate(ctx)
def validate(ctx) -> bool:
path_str = ctx.get_config('opacity_path')
if not path_str:
ctx.error("opacity_path not configured")
return False
path = Path(path_str).expanduser()
if not path.exists():
ctx.error(f"Opacity path not found: {path}")
return False
h5_files = list(path.glob("*.h5"))
if len(h5_files) < 10:
ctx.error(f"Expected 10+ .h5 files, found {len(h5_files)}")
return False
return TruepythonRecipe 6: Custom detection (CUDA, system libraries)
You need to detect what's available on the system and adapt. Drop to plain Python.
setup.py
import subprocess
import shutil
def setup(ctx) -> bool:
# Detect CUDA
if shutil.which('nvcc'):
try:
result = subprocess.run(
['nvcc', '--version'],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
version = _parse_cuda_version(result.stdout)
ctx.success(f"CUDA detected: {version}")
ctx.set_config('compute_mode', 'gpu')
ctx.set_config('cuda_version', version)
return True
except subprocess.TimeoutExpired:
pass
# CUDA not available
ctx.warn("CUDA not detected")
if ctx.confirm("Continue in CPU-only mode?", default=True):
ctx.set_config('compute_mode', 'cpu')
return True
ctx.error("GPU is required for this toolkit")
ctx.hint("Install CUDA: https://developer.nvidia.com/cuda-downloads")
return False
def validate(ctx) -> bool:
mode = ctx.get_config('compute_mode')
if mode not in ('gpu', 'cpu'):
ctx.error("compute_mode not configured")
return False
return True
def _parse_cuda_version(nvcc_output: str) -> str:
for line in nvcc_output.split('\n'):
if 'release' in line:
return line.split('release')[1].split(',')[0].strip()
return "unknown"pythonRecipe 7: Tools that need injected configuration
Your tools need access to configuration values that the AI agent shouldn't see — workspace directories, data paths, API keys, worker counts. This is the stateful tools pattern.
toolkit.yaml
config:
- name: base_directory
type: path
description: "Workspace for file operations"
default: "~/my-toolkit-workspace"yamlTool code
from pathlib import Path
from orchestral import define_tool
import json
@define_tool(state=["base_directory"])
def write_file(
relative_path: str,
content: str,
base_directory: Path,
) -> str:
"""
Write content to a file in the workspace.
Args:
relative_path: Path relative to the workspace.
content: Text content to write.
"""
full_path = (base_directory / relative_path).resolve()
base_resolved = base_directory.resolve()
# Safety: ensure the path stays inside base_directory
if not str(full_path).startswith(str(base_resolved)):
return json.dumps({"error": "Path escapes workspace"})
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
return json.dumps({"written": str(full_path.relative_to(base_resolved))})
@define_tool(state=["base_directory"])
def read_file(relative_path: str, base_directory: Path) -> str:
"""Read a file from the workspace."""
full_path = (base_directory / relative_path).resolve()
if not str(full_path).startswith(str(base_directory.resolve())):
return json.dumps({"error": "Path escapes workspace"})
return json.dumps({"content": full_path.read_text()})pythonFrom the agent's perspective, the schema is just write_file(relative_path, content). The base_directory is invisible to it. Clean for the agent, configurable for the user.
Starter template
When you run toolbase init my-toolkit --with-setup, you get a working setup.py scaffold with commented-out examples for prompts, downloads, and config writes. The template is at toolbase/templates/setup.py.template in the package; pull the latest version with a fresh init.
Full example: ASTER
Here's ASTER's complete setup as a reference. It exercises most of the patterns in this guide.
toolkit.yaml
name: aster
version: 1.0.0
category: astro
description: "Agentic Science Toolkit for Exoplanet Research"
author: "Alex Roman"
license: "MIT"
python_version: "3.12"
config:
- name: max_workers
type: integer
description: "Parallel workers for computation"
default: 4
- name: base_directory
type: path
description: "Workspace for outputs"
default: "~/.aster/workspace"
setup_script: trueyamlsetup.py
"""ASTER setup — handles opacity data download and workspace config."""
from pathlib import Path
OPACITY_URL = "https://data.example.com/aster/opacity_v2.tar.gz"
OPACITY_SHA256 = "..." # Fill in real hash
def setup(ctx) -> bool:
# Workspace
workspace = Path(ctx.get_config('base_directory')).expanduser()
workspace.mkdir(parents=True, exist_ok=True)
ctx.info(f"Workspace: {workspace}")
# Opacity data
existing = ctx.get_config('opacity_path')
if existing and _opacity_valid(Path(existing).expanduser()):
ctx.info(f"Opacity data already configured: {existing}")
return True
choice = ctx.choice(
"ASTER needs opacity data (~2.3GB). How would you like to set it up?",
[
("download", "Download automatically"),
("path", "I have the data — let me provide the path"),
("skip", "Skip for now (ASTER won't work until configured)"),
]
)
if choice == "download":
dest = ctx.data_dir / 'opacity'
ctx.download(
url=OPACITY_URL,
destination=dest,
description="Opacity data",
size_hint="2.3GB",
extract=True,
sha256=OPACITY_SHA256,
)
ctx.set_config('opacity_path', str(dest))
elif choice == "path":
path = ctx.prompt_path("Path to opacity data:", must_exist=True)
ctx.set_config('opacity_path', str(path))
else:
return False
return validate(ctx)
def validate(ctx) -> bool:
opacity_str = ctx.get_config('opacity_path')
if not opacity_str:
ctx.error("opacity_path not configured")
ctx.hint("Run: toolbase setup aster")
return False
if not _opacity_valid(Path(opacity_str).expanduser()):
ctx.error(f"Opacity data at {opacity_str} is invalid or incomplete")
ctx.hint("Re-run: toolbase setup aster")
return False
workspace = Path(ctx.get_config('base_directory')).expanduser()
if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True)
return True
def _opacity_valid(path: Path) -> bool:
if not path.exists():
return False
return len(list(path.glob("*.h5"))) >= 10pythonTools then declare opacity_path, base_directory, and max_workers as state fields, and they're injected automatically at serve time. See Stateful Tools for what those tool functions look like.