3 min read · 588 words
Guide: Add a Tool
Worked example: a sentiment_analyzer tool that scores an email's sentiment. Five steps: ID, service, gated endpoint, UI checkbox, docs.
Background reading: The Tool System — especially gate vs. enhancement semantics.
Step 1 — Choose the ID and semantics
- ID:
sentiment_analyzer(snake_case, exact-match string — this ID is the permission). - Semantics: gate tool — the endpoint refuses with
403when missing (likeemail_analyzer), rather than silently degrading (likereply_generator).
Step 2 — Add the service function (LLM logic lives here)
python
def analyze_sentiment(text: str) -> dict:
if not os.getenv("OPENAI_API_KEY"):
raise ValueError("OPENAI_API_KEY is missing. Please add it to your .env file.")
system_prompt = """
You are a sentiment analyzer.
Return ONLY valid JSON with this exact structure:
{"sentiment": "positive | neutral | negative", "score": 0.0, "reason": "short explanation"}
Rules:
- score must be between 0 and 1 (intensity of the sentiment).
- Return only JSON. Do not include markdown.
"""
response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=[{"role": "system", "content": system_prompt},
{"role": "user", "content": text}],
temperature=0, # a judge → temp 0
)
raw = response.choices[0].message.content
try:
result = json.loads(raw)
except json.JSONDecodeError:
raise ValueError(f"Sentiment analyzer did not return valid JSON: {raw}")
return {
"sentiment": result.get("sentiment", "neutral"),
"score": float(result.get("score", 0)),
"reason": result.get("reason", ""),
}This follows every LLM-call convention: key guard, ladder temperature, strict-JSON prompt, parse-and-normalize.
Step 3 — Add the gated, logged endpoint
python
@router.post("/{agent_id}/email/sentiment")
def analyze_email_sentiment(agent_id: int, email_data: schemas.EmailAnalyzeRequest,
db: Session = Depends(get_db)):
agent = db.query(models.Agent).filter(models.Agent.id == agent_id).first()
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
if not agent_has_tool(agent, "sentiment_analyzer"): # the gate
raise HTTPException(status_code=403,
detail="This agent does not have sentiment_analyzer tool enabled")
try:
start = time.time()
result = analyze_sentiment(email_data.email_text)
latency_ms = int((time.time() - start) * 1000)
monitoring = estimate_openai_cost( # costed: it's an LLM step
input_text=email_data.email_text,
output_text=json.dumps(result),
model_name=os.getenv("OPENAI_MODEL", "gpt-4o-mini"))
log_tool_call(db, agent.id, "sentiment_analyzer",
tool_input=email_data.email_text,
tool_output=json.dumps(result),
success=True, latency_ms=latency_ms,
model_name=monitoring["model_name"],
estimated_tokens=monitoring["estimated_tokens"],
estimated_cost=monitoring["estimated_cost"])
db.commit()
return {"sentiment": result}
except Exception as e:
log_tool_call(db, agent.id, "sentiment_analyzer",
tool_input=email_data.email_text,
success=False, error_message=str(e))
db.commit()
raise HTTPException(status_code=500, detail=str(e))Note: costed logging on success — this immediately does better than the legacy analyze path (coverage gaps).
Step 4 — Make it grantable in the UI
ts
{
id: "sentiment_analyzer",
label: "sentiment_analyzer",
description: "Score the sentiment of inbound emails."
},The checkbox UI, CSV assembly, and live preview pick it up automatically.
Step 5 — Verify and document
bash
# without the tool → 403
curl -X POST http://localhost:8001/agents/1/email/sentiment \
-H "Content-Type: application/json" -d '{"email_text": "This is fantastic news!"}'
# create an agent granting it, then → 200 + a costed ToolCall
curl http://localhost:8001/agents/2/tool-calls | jq '.[0]'Document the tool in Tools (making it five), the endpoint in the API Reference, and the prompt in the Prompt Catalog.
Related pages
- The Tool System — the machinery you just extended
- Add an Endpoint — the generic endpoint recipe