Recipes
Complete scripts for the things people actually build. Every one here was run
against a local qwen2.5-coder:7b on Ollama — no keys, no cloud. The outputs are
real, but a model's exact wording and turn count vary run to run, so treat them
as representative rather than literal. Swap the model and base_url for a hosted
provider and nothing else changes.
A coding agent that edits your files
The built-in tools ship as one bundle. Give it a directory and a job.
import asyncio
import pathlib
import tempfile
from mantis_agent import MantisAgentOptions, query
from mantis_agent.builtin_tools import bash, read_file, write_file
WORK = pathlib.Path(tempfile.mkdtemp())
async def main() -> None:
async for msg in query(
prompt=(
"Use write_file to create fizzbuzz.py with a function fizzbuzz(n). "
"Then use write_file to create test_fizzbuzz.py with asserts for "
"3, 5, 15 and 7. Then use bash to run 'python test_fizzbuzz.py'."
),
options=MantisAgentOptions(
model="qwen2.5-coder:7b",
tools=[write_file, read_file, bash],
cwd=str(WORK), # every relative path lands in here
max_turns=14,
permission_mode="bypass", # unattended; see the guardrail recipe
skills=[], # don't inherit ~/.mantis-agent/skills
),
):
if msg.type == "assistant":
for block in msg.content:
if getattr(block, "name", None):
print("[tool]", block.name)
elif msg.type == "result":
print(f"[{msg.subtype}] {msg.num_turns} turns {msg.errors}")
print(sorted(p.name for p in WORK.iterdir()))
asyncio.run(main())[tool] write_file
[tool] write_file
[tool] bash
[success] 5 turns []
['fizzbuzz.py', 'test_fizzbuzz.py']Three tools on purpose. The full bundle is one import —
from mantis_agent.builtin_tools import CODING_TOOLS, which is bash,
bash_output, bash_kill, monitor, read_file, write_file, edit_file,
multi_edit, notebook_edit, ls, glob, grep, sleep — and it's the right
call for a frontier model. A 7B given thirteen tools wanders: in testing it
opened with sleep before doing any work. Narrow the belt to the job and small
models get sharply more reliable.
!!! tip "cwd is load-bearing"
It scopes the file tools *and* is what the model is told its working
directory is. Leave it unset and relative paths resolve against your
process's directory, which is rarely what you want for a server.!!! warning "Skills load themselves unless you say otherwise"
With `skills` unset, any skill in `~/.mantis-agent/skills/` (or the
project's) whose description matches the prompt is injected into the
conversation, and the model will happily call it. That's the terminal's
behavior inherited by the library — so an agent's behavior can depend on
files in whoever's home directory ran it. `skills=[]` turns it off;
`skills=["name"]` preloads exactly what you choose. Worth being explicit
about in anything you ship.Extract structured JSON from prose
Hand over the type you already have; get an instance back.
import asyncio
from dataclasses import dataclass
from mantis_agent import MantisAgentOptions, query
@dataclass
class Invoice:
vendor: str
total_usd: float
due_date: str
TEXT = (
"Invoice from Northwind Traders, amount due $1,240.50, "
"payable by 2026-09-01. Reference NW-7781."
)
async def main() -> None:
async for msg in query(
prompt=f"Extract the invoice fields:\n\n{TEXT}",
options=MantisAgentOptions(
model="qwen2.5-coder:7b",
response_model=Invoice,
max_turns=2,
),
):
if msg.type == "result":
invoice = msg.parsed
print(invoice, "| plus tax:", invoice.total_usd * 1.2)
asyncio.run(main())Invoice(vendor='Northwind Traders', total_usd=1240.5, due_date='2026-09-01') | plus tax: 1488.6response_model takes a dataclass, a msgspec.Struct, a TypedDict, or a
pydantic model. It derives the JSON schema, sets the provider's
response_format, and decodes the reply onto result.parsed — so field names
live in one place instead of two.
A few behaviors worth knowing:
- Fenced replies are handled. Small models answer JSON in a
```jsonblock constantly; the fence is stripped before parsing. - A parse failure is a run failure. You asked for an
Invoice; gettingparsed=Nonewith a success flag would be a silent failure. The reason lands inresult.errors(with the head of what the model actually said), andraise_on_error=Trueturns it into an exception. - An explicit
response_formatwins. If you hand-wrote the envelope, it's kept as-is.
Prefer the raw envelope? response_format still works exactly as before — see
Models and backends.
Stop the model from doing something dangerous
import asyncio
from mantis_agent import (
MantisAgentOptions,
PermissionResultAllow,
PermissionResultDeny,
query,
tool,
)
@tool
async def delete_account(user_id: str) -> str:
"""Permanently delete a user account."""
return f"deleted {user_id}"
@tool
async def get_user(user_id: str) -> str:
"""Look up a user's status."""
return f"{user_id}: active since 2024"
async def can_use_tool(tool_name, tool_input, ctx):
# tool_name is a string; tool_input is the arguments the model produced.
if tool_name == "delete_account":
return PermissionResultDeny(message="Deleting accounts requires a human.")
return PermissionResultAllow()
async def main() -> None:
async for msg in query(
prompt="Delete account u-42, then tell me its status.",
options=MantisAgentOptions(
model="qwen2.5-coder:7b",
tools=[delete_account, get_user],
can_use_tool=can_use_tool,
max_turns=6,
),
):
if msg.type == "result":
print(f"[{msg.subtype}] blocked {len(msg.permission_denials)} call(s)")
asyncio.run(main())[success] blocked 2 call(s)The denial goes back to the model as a tool error, so it adapts instead of
dying — here it gave up on deleting and reported the status instead. Every
blocked call is listed in result.permission_denials for auditing.
For rule-based gating instead of a callback, see
Permissions; to confine shell commands at the OS level, see
MANTIS_SANDBOX in Configuration.
Fail loudly instead of quietly
query() reports provider failures on the final message and never raises. That
is right for a streaming API — you can render the partial transcript, then the
error — but it makes the silent version the easy one to write:
import asyncio
from mantis_agent import AgentError, MantisAgentOptions, query
async def main() -> None:
options = MantisAgentOptions(
model="qwen2.5-coder:7b",
max_turns=2,
raise_on_error=True, # the result is still yielded first
)
try:
async for msg in query(prompt="hello", options=options):
if msg.type == "result" and msg.is_error:
print("errors:", msg.errors)
except AgentError as e:
print("raised:", e)
asyncio.run(main())Without raise_on_error, check msg.is_error and read msg.errors yourself —
["ProviderError: Not Found (404 from http://localhost:8000/v1/chat/completions) — port 8000 is the vLLM default …"]. Errors name the URL they tried, so a
wrong port or an unpulled model diagnoses itself.
Hold a conversation
ClaudeSDKClient keeps history across calls.
import asyncio
from mantis_agent import ClaudeSDKClient, MantisAgentOptions, tool
@tool
async def get_stock(symbol: str) -> str:
"""Current share price for a ticker symbol."""
return {"ACME": "$41.20", "GLOB": "$7.05"}.get(symbol.upper(), "unknown")
async def main() -> None:
options = MantisAgentOptions(
model="qwen2.5-coder:7b", tools=[get_stock], max_turns=4
)
async with ClaudeSDKClient(options=options) as client:
for prompt in ("What is ACME trading at?", "And what did I just ask about?"):
await client.query(prompt)
async for msg in client.receive_response():
if msg.type == "assistant":
for block in msg.content:
if getattr(block, "text", None):
print(">", block.text.strip())
asyncio.run(main())> ACME is trading at $41.20.
> You just asked about the current share price of ACME.Delegate to a sub-agent
Wrap a whole agent as a tool the parent can call. Useful for giving a narrow job a narrow tool belt, a cheaper model, or its own turn cap.
import asyncio
from mantis_agent import MantisAgentOptions, SubAgentSpec, as_subagent_tool, query, tool
@tool
async def get_stock(symbol: str) -> str:
"""Current share price for a ticker symbol."""
return {"ACME": "$41.20", "GLOB": "$7.05"}.get(symbol.upper(), "unknown")
analyst = as_subagent_tool(
SubAgentSpec(
name="stock_analyst",
description="Looks up share prices and reports them.",
system_prompt="Look up the price with get_stock and state it plainly.",
model="qwen2.5-coder:7b",
backend="http://localhost:11434", # or share the parent's provider
tools=[get_stock],
max_turns=4,
)
)
async def main() -> None:
async for msg in query(
prompt="What is GLOB trading at? Use your delegate.",
options=MantisAgentOptions(
model="qwen2.5-coder:7b",
tools=[analyst],
system_prompt="Delegate price questions to the stock_analyst tool.",
max_turns=4,
),
):
if msg.type == "assistant":
for block in msg.content:
if getattr(block, "name", None):
print("[delegates to]", block.name)
elif getattr(block, "text", None):
print(">", block.text.strip())
asyncio.run(main())[delegates to] stock_analyst
> GLOB is trading at $7.05.A spec needs a destination, not just a model name: pass backend= as above,
or as_subagent_tool(spec, parent_provider=parent.provider) to reuse the
parent's HTTP pool (cheaper, and the recommended form when both run on the same
backend).
Cap what a run can cost
from mantis_agent import BudgetExceededError, MantisAgentOptions, query
async def main() -> None:
options = MantisAgentOptions(
model="deepseek-chat",
base_url="https://api.deepseek.com/v1",
max_budget_usd=0.50,
fallback_model="deepseek-chat", # retried once if a turn dies pre-output
max_turns=20,
)
try:
async for msg in query(prompt="big job", options=options):
pass
except BudgetExceededError as e:
print(f"stopped at ${e.used:.4f} of ${e.limit:.2f}")A cap only bites where the model has a price — local models cost $0.0000, so
max_turns is your limit there. See Budget and limits.
Test without a model
backend="mock" swaps in a scripted provider. Same agent loop, no network, so
tool dispatch and message shapes are exercised in CI with no keys.
from mantis_agent import Agent
agent = Agent(model="mock", backend="mock")
print(type(agent.provider).__name__)MockProviderFor a scripted conversation, hand it a provider that yields your own stream
events — tests/test_can_use_tool_contract.py in the repo is a compact example.
See what the agent is doing
agent.stream() gives you the raw event stream for one turn — tokens as they
arrive, tool calls as they form.
import asyncio
import sys
from mantis_agent import Agent, UserMessage
from mantis_agent.events import ContentBlockDelta, TextDelta
async def main() -> None:
agent = Agent(model="qwen2.5-coder:7b", backend="http://localhost:11434")
try:
async for event in agent.stream([UserMessage(content="Name one moon of Mars.")]):
if isinstance(event, ContentBlockDelta) and isinstance(event.delta, TextDelta):
sys.stdout.write(event.delta.text)
sys.stdout.flush()
finally:
await agent.aclose()
asyncio.run(main())stream() covers one assistant turn and does not run the loop — append the
result and call it again to continue. Full event taxonomy in
Streaming.