Skip to content
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 403 when missing (like email_analyzer), rather than silently degrading (like reply_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.