An agent does not need a large framework. For a first version, you need a model, a small set of tools, and a loop that lets the model ask for a tool result before it answers.
This example uses one local calculator. The model receives a user request, returns a small JSON tool request, the program runs the calculator, and the model gets one more turn with the result. The code uses a manual JSON protocol rather than claiming native function calling support for a particular model.
The route and model come from the live AIWave quickstart checked on 2026-09-23:
- Base URL:
https://aiwave.live/v1 - Model:
deepseek-v4-flash - Client: OpenAI Python package
The complete agent
Create agent.py, set AIWAVE_API_KEY, and run it with a question such as What is 37 * 12?.
import ast
import json
import os
import operator
import sys
from openai import OpenAI
OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
def calculate(expression: str) -> str:
tree = ast.parse(expression, mode="eval").body
if not isinstance(tree, ast.BinOp) or type(tree.op) not in OPS:
raise ValueError("Use one arithmetic expression with +, -, *, or /")
if not all(isinstance(node, ast.Constant) and isinstance(node.value, (int, float))
for node in (tree.left, tree.right)):
raise ValueError("Only numeric operands are allowed")
return str(OPS[type(tree.op)](tree.left.value, tree.right.value))
def ask(client: OpenAI, messages: list[dict[str, str]]) -> str:
response = client.chat.completions.create(
model="deepseek-v4-flash",
messages=messages,
)
return response.choices[0].message.content or ""
def main() -> None:
api_key = os.environ.get("AIWAVE_API_KEY")
if not api_key:
raise SystemExit("Set AIWAVE_API_KEY before running this agent")
question = " ".join(sys.argv[1:]) or "What is 37 * 12?"
client = OpenAI(api_key=api_key, base_url="https://aiwave.live/v1")
messages = [
{"role": "system", "content": (
"You are a small tool-using agent. Return only JSON. "
"For arithmetic, return {\"type\":\"tool_call\",\"tool\":\"calculator\","
"\"input\":{\"expression\":\"37 * 12\"}}. Otherwise return "
"{\"type\":\"final\",\"answer\":\"...\"}."
)},
{"role": "user", "content": question},
]
first = json.loads(ask(client, messages))
if first.get("type") == "tool_call" and first.get("tool") == "calculator":
result = calculate(first["input"]["expression"])
messages.extend([
{"role": "assistant", "content": json.dumps(first)},
{"role": "user", "content": f"Tool result: {result}. Return final JSON."},
])
final = json.loads(ask(client, messages))
print(final.get("answer", "The agent returned no answer."))
else:
print(first.get("answer", "The agent returned no answer."))
if __name__ == "__main__":
main()Validation: run python -m py_compile agent.py, then run the script with a controlled key. The expected output for What is 37 * 12? is 444. The request uses the same base URL and model verified on the live quickstart.
Run the first check without exposing the key:
export AIWAVE_API_KEY="sk-your-api-key"
python -m py_compile agent.py
python agent.py "What is 37 * 12?"Validation: compilation checks the local file; the second command performs one controlled API call and should print 444.
The two message shapes in the loop are intentionally small:
{"type":"tool_call","tool":"calculator","input":{"expression":"37 * 12"}}
{"type":"final","answer":"444"}Validation: these are the only protocol shapes the example consumes. Reject a response that is not valid JSON instead of trying to execute it.
You can test the tool boundary without calling the API:
from agent import calculate
assert calculate("37 * 12") == "444"
assert calculate("8 / 2") == "4.0"
for expression in ("__import__('os')", "open('notes.txt').read()"):
try:
calculate(expression)
except ValueError:
pass
else:
raise AssertionError(expression)Validation: these assertions check allowed arithmetic and reject two unsafe inputs locally. They do not send a request or read a file.
If you add another tool, keep dispatch explicit and bounded:
TOOLS = {
"calculator": calculate,
}
def dispatch_tool(name: str, tool_input: dict[str, str]) -> str:
if name not in TOOLS:
raise ValueError(f"Unknown tool: {name}")
expression = tool_input.get("expression", "")
if not expression:
raise ValueError("The calculator needs an expression")
return TOOLS[name](expression)
MAX_TURNS = 2
turn = 0
while turn < MAX_TURNS:
turn += 1
# Call the model, validate its JSON, and stop on a final answer.
# Never execute a tool name or argument before validating both fields.
break
else:
raise RuntimeError("Agent reached its turn limit")Validation: this is a local design fragment. It demonstrates an allowlist, required input, and a hard turn limit; it does not make an API call.
Keep the tool contract readable in code:
CALCULATOR_CONTRACT = {
"name": "calculator",
"input": {"expression": "string containing one arithmetic expression"},
"output": "string containing the calculated result",
"limits": {"operators": ["+", "-", "*", "/"], "turns": 2},
}Validation: this is documentation data only. It makes the accepted tool shape reviewable before a second tool is added.
What the loop is doing
The first request asks for a decision. The model can either return a final answer or request the calculator. The program owns the tool, so the model cannot execute arbitrary Python or read local files. After the calculator returns a result, the program sends that result back and asks for final JSON.
This ownership boundary matters. A tool should accept a narrow input, validate it, and return a small result. When you add a web search or file tool later, give it its own allowlist, timeout, size limit, and error response. Do not turn the first prototype into a general-purpose shell runner.
The model may include reasoning_content because it is a reasoning model. Read the final answer from message.content and verify provider-specific fields before using them in control flow.
Add one user request at a time
Start with one tool and one loop. Log the validated tool input, result status, and request ID, but not the key or private prompt. Set a turn limit before adding tools.
When it works, add a second tool with a different input contract. Keep the model in configuration and test the same task before changing production traffic.
Use explicit limits as the agent grows:
SAFE_DEFAULTS = {
"max_turns": 2, # bounded loop
"max_expression_chars": 80,
"max_response_chars": 4000,
"request_timeout_seconds": 30,
"allowed_tools": {"calculator"},
"log_secret_values": False,
}
assert SAFE_DEFAULTS["max_turns"] > 0
assert "calculator" in SAFE_DEFAULTS["allowed_tools"]
assert SAFE_DEFAULTS["request_timeout_seconds"] <= 30
assert SAFE_DEFAULTS["log_secret_values"] is FalseValidation: these local assertions document safe defaults; they do not send a request.
Self-check
- [x] Uses the verified AIWave base URL and model ID.
- [x] Demonstrates a model decision, tool execution, and second model turn.
- [x] Uses a restricted calculator instead of arbitrary code execution.
- [x] Does not claim native function calling support.
- [x] Keeps the API key in an environment variable.
- [x] Includes a syntax-check and controlled runtime validation method.
- [x] No banned marketing terms, internal metrics, customer data, or real credentials.
- [ ] Run the code with a controlled test key before publication.
- [ ] Add internal links and run final HEAD checks before publication.