LangChain for QA Engineers
Calling an LLM is one line. Building something a QA team can actually rely on — typed output, tool use, multi-step reasoning, swappable models — is the hard part. That hard part is what LangChain handles. We start from zero and finish with three agents you can wire into your own pipeline.
Bug Triage
Raw report in → severity, priority, component, owner, dupe-check out. Typed and ready for Jira.
RCA Agent
Pulls logs, recent commits and service health like you would, then names the likely root cause.
Test Designer
A user story becomes a scoped test plan plus concrete, runnable test cases.
❯ What is LangChain?
LangChain is an open-source framework for building applications on top of large language models. It hands you a small set of composable building blocks — and a clean way to wire them together — so you stop writing throwaway glue code around raw API calls.
The objective of this guide
Take you from "I've never touched LangChain" to three running QA agents. Along the way every building block is introduced once, in plain terms, with code you can paste and run. No abstract theory you can't use the same day.
Why the framework exists
Hitting an LLM endpoint directly is trivial. The trouble starts the moment you want it to be dependable:
- You need the model to return structured data, not a paragraph you have to parse by hand.
- You want it to call your tools — Jira, logs, CI, Git — and act on what it finds.
- You want to swap the model (Groq today, OpenAI tomorrow) without rewriting everything.
- You want multi-step reasoning: look something up, decide, look up more, then answer.
LangChain is the plumbing for exactly that. You think in blocks — Prompt, Model, Parser, Tool, Agent — and snap them together.
❯ Why it fits QA
Three reasons this matters specifically for testers — not generic AI hype.
Glue without the glue code
LangChain standardises prompts, models, tools and parsers behind one interface. Your triage or RCA logic stops being tied to a single vendor's SDK, so it outlives any one model. Change ChatGroq to ChatOpenAI and the rest of your pipeline doesn't flinch.
Structured output you can trust
with_structured_output() turns a messy model reply into a typed object — severity, priority, test steps — that you push straight into Jira, TestRail or Xray. No regex-scraping the model's prose and praying the format held.
Your test infra, callable by an LLM
Wrap Jira, Playwright reports, CI logs and Git as tools, and an agent can investigate a failure the way you would — pull the stack trace, check what shipped, look at error rates — instead of you doing it by hand at 2am. The agent does the legwork; you make the call.
❯ The mental model
Hold these five blocks in your head and the whole framework clicks. Everything else is a variation on them.
❯ Install & setup
Two minutes of setup. A clean virtual environment, a handful of packages, and one free API key.
Packages
We use Groq as the model provider because it's fast and free to start. Everything here ports to OpenAI or Anthropic by changing one import.
# 1. Clean, isolated environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 2. Core LangChain
pip install langchain langchain-core langchain-community
# 3. Our model provider (swap this later for openai / anthropic)
pip install langchain-groq
# 4. Helpers: typed output + reading the .env file + multi-agent graphs
pip install pydantic python-dotenv langgraph
The API key
Grab a free key from console.groq.com. Never hard-code it. Put it in a .env file next to your scripts and load it at runtime.
GROQ_API_KEY=gsk_your_real_key_goes_here
❯ Your first LLM call
Before chains, agents, or any QA logic — just talk to the model and get text back. If this runs, your setup is correct.
from dotenv import load_dotenv
from langchain_groq import ChatGroq
load_dotenv() # reads GROQ_API_KEY from .env
# The model object. temperature=0 -> consistent, deterministic answers.
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
# .invoke() runs it once and returns a message object.
response = llm.invoke("In one line: what makes an automated test 'flaky'?")
print(response.content)
Run it: python level_0_hello.py. You get a one-line answer about non-determinism in tests. Two things to notice:
- ChatGroq(...) is the Model block from the mental model — interchangeable with any other provider.
- .invoke() is how you run any LangChain block. Chains and agents use the exact same method. Learn it once.
❯ Your first chain
A raw call is fine for a demo. Real work needs a reusable template, the model, and clean output — wired together. That wiring is a chain.
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
load_dotenv()
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
# A reusable template. {bug} is filled in at run time.
prompt = ChatPromptTemplate.from_messages([
("system", "You are a senior SDET. Be concrete and terse."),
("human", "Summarise this bug in one line, then give a likely "
"root-cause guess:\n\n{bug}"),
])
# Wire it together with the pipe. This is LCEL.
chain = prompt | llm | StrOutputParser()
print(chain.invoke({
"bug": "Checkout 'Pay' button does nothing on Safari 17. "
"Works on Chrome. No console error."
}))
What each piece buys you:
- ChatPromptTemplate — your prompt becomes reusable. Same template, different {bug} every call. No string-concatenation mess.
- The pipe | — this is LCEL (LangChain Expression Language). It reads left to right: prompt's output feeds the model, the model's output feeds the parser.
- StrOutputParser — pulls the plain string out of the model's message object so you get text, not response.content boilerplate.
❯ Your first agent
A chain runs a fixed pipeline. An agent can decide it needs data, fetch it with a tool, look at the result, and only then answer. This is the leap that makes LLMs useful for real QA work.
The agent loop, in one picture
The code
A tool is just a Python function with the @tool decorator and a clear docstring. The docstring is how the model knows what the tool does and when to use it — write it like a teammate will read it.
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate
from langchain.agents import create_tool_calling_agent, AgentExecutor
load_dotenv()
# 1. A tool = a function the LLM is allowed to call.
# The docstring tells the model what it does.
@tool
def get_test_history(test_id: str) -> str:
"""Return the recent pass/fail history for a given test ID."""
fake_db = {
"TC-101": "FAIL, PASS, FAIL, FAIL, PASS",
"TC-102": "PASS, PASS, PASS, PASS, PASS",
}
return fake_db.get(test_id, "No history found.")
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
tools = [get_test_history]
# 2. The prompt needs a slot for the agent's running thoughts.
prompt = ChatPromptTemplate.from_messages([
("system", "You are a QA assistant. Use tools when you need data."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"), # the agent's working memory
])
# 3. Build the agent and an executor to actually run the loop.
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
executor.invoke({"input": "Is test TC-101 flaky? Explain why."})
With verbose=True you'll watch it work in the console: the model decides to call get_test_history, reads back the failure pattern, then concludes the test is flaky. You never wrote "if X then look up history" — the agent figured out the step itself.
❯ Many agents, one front door
One agent that tries to do everything gets vague and unreliable. The fix is the same as in a real QA org: small, specialised agents, and a supervisor that routes each request to the right one.
The simplest supervisor is a tiny classification chain plus a dispatch table. No new concepts — just the chain you already built, used to pick a destination.
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# A classifier chain: request in, one label out.
route_prompt = ChatPromptTemplate.from_messages([
("system", "Classify the request into exactly one label: "
"TRIAGE, RCA, or DESIGN. Reply with the label only."),
("human", "{input}"),
])
router = route_prompt | llm | StrOutputParser()
def dispatch(request: str):
label = router.invoke({"input": request}).strip().upper()
if "TRIAGE" in label: return triage_chain.invoke({"report": request})
if "DESIGN" in label: return design_chain.invoke({"story": request})
if "RCA" in label: return rca_executor.invoke({"input": request})
return "Could not route the request."
LangChain vs CrewAI — which to reach for?
Both are popular, and they solve overlapping problems from different angles. LangChain (with LangGraph) gives you composable, low-level control; CrewAI hands you an opinionated team of role-based agents. This guide leans on LangChain because QA work needs typed output and precise, testable pipelines — but here's the honest comparison.
| Dimension | LangChain + LangGraph | CrewAI |
|---|---|---|
| What it is | General framework for LLM apps — chains, tools, structured output, agents. LangGraph adds stateful multi-agent graphs. | Higher-level, opinionated framework for multi-agent "crews" of role-playing agents. |
| Core idea | Composable runnables wired with the | pipe (LCEL), tools, and graph nodes. | Agents (role · goal · backstory) + Tasks + a Crew that runs a Process. |
| Multi-agent | Via LangGraph — an explicit graph where you control state and hand-offs. | Native and first-class — sequential or hierarchical crews out of the box. |
| Structured output | First-class: with_structured_output + Pydantic, used all through this guide. | Supported via Pydantic on tasks, but less central to the model. |
| Control vs speed | More control, a little more wiring. | Faster to stand up, more opinionated, less low-level control. |
| Learning curve | Moderate — lots of surface area, but each piece is simple. | Gentle start — the "team of agents" metaphor maps fast. |
| Best for a QA team | Precise, testable pipelines and the production API you just shipped. | Quickly prototyping a crew of cooperating agents (e.g. planner + coder + reviewer). |
| Pick it when | You want fine-grained control, typed output, and a deployable endpoint. | You want a role-based multi-agent crew with minimal plumbing. |
❯ The Bug Triage agent
Feed it a raw bug report; get back a structured verdict — severity, priority, likely component, owning team, and a duplicate flag — as a typed object you can drop straight into Jira. This is the single highest-leverage agent for most teams.
The code
The trick is with_structured_output(). You define the exact shape you want with a Pydantic model, bind it to the LLM, and the model is now forced to return that object — no free-form prose to parse.
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate
load_dotenv()
# 1. Define the EXACT output shape. No loose text — real fields.
class Triage(BaseModel):
title: str = Field(description="One-line normalized title")
severity: str = Field(description="Critical | High | Medium | Low")
priority: str = Field(description="P0 | P1 | P2 | P3")
component: str = Field(description="Most likely affected area")
suggested_team: str = Field(description="Team that should own this")
likely_duplicate: bool = Field(description="True if it looks known")
reasoning: str = Field(description="2-3 line justification")
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
# 2. Bind the schema. The model now RETURNS a Triage object.
triager = llm.with_structured_output(Triage)
prompt = ChatPromptTemplate.from_messages([
("system", "You triage bugs for a web product. Map user impact to "
"severity and business urgency to priority. Be decisive."),
("human", "Bug report:\n\n{report}"),
])
chain = prompt | triager
# 3. Run it on a real report.
result = chain.invoke({"report": """
Users on the mobile app cannot complete payment. After tapping 'Pay'
the spinner runs forever. Started after the 4.2.0 release. About 30%
of checkout attempts affected. No crash, no error toast.
"""})
# result is a real object — use the fields directly.
print(f"{result.severity} / {result.priority} -> {result.component}")
print(f"Owner: {result.suggested_team} | dupe? {result.likely_duplicate}")
print(result.reasoning)
Because result is a typed object, the next step is trivial: result.priority, result.component — feed them into the Jira REST API and the ticket files itself with the right fields set. That's the whole point of structured output: the model's answer is data, not a wall of text.
❯ The RCA agent
Triage classifies. Root-cause analysis investigates. This agent gathers evidence with tools — the failure log, what shipped recently, the service's health — before it commits to a cause. It's Level 2's agent loop, pointed at a real debugging task.
The code
Same agent machinery as Level 2 — only the tools and the system prompt change. In production these tools call your real Jira, Git, log store and CI; here they return canned data so you can run it immediately.
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate
from langchain.agents import create_tool_calling_agent, AgentExecutor
load_dotenv()
# Tools = the agent's senses. Real versions hit Jira / Git / logs / CI.
@tool
def fetch_test_logs(test_name: str) -> str:
"""Return the failure log / stack trace for a failing test."""
return ("AssertionError: expected status 200 but got 503\n"
" at checkout.spec.ts:42\n"
" POST /api/v2/payment -> 503 Service Unavailable")
@tool
def recent_commits(area: str) -> str:
"""Return recent commits that touched a given area/module."""
return ("- a1b2c3 'switch payment gateway to v2 endpoint' (2h ago)\n"
"- d4e5f6 'cut payment client timeout 3s -> 1s' (3h ago)")
@tool
def service_health(service: str) -> str:
"""Return current health / error rate for a backend service."""
return "payment-svc: error rate 18%, p99 latency 9.4s (degraded)"
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
tools = [fetch_test_logs, recent_commits, service_health]
prompt = ChatPromptTemplate.from_messages([
("system", "You are a root-cause analysis agent. Gather evidence "
"with tools BEFORE concluding. Then give: the single most "
"likely root cause, the evidence for it, and one fix."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
executor.invoke({"input":
"The test 'checkout payment flow' just started failing. "
"Find the root cause."
})
Watch the loop in verbose mode: it reads the log (a 503 on the payment endpoint), checks recent commits (a gateway swap and a timeout cut to 1s), checks service health (degraded, 9.4s p99) — then concludes the slashed timeout is almost certainly the trigger, and suggests reverting it. Three tools, one coherent story, zero manual digging.
❯ The Test Designer agent
Hand it a requirement or user story; get back a scoped test plan plus a list of concrete, runnable test cases — happy path, negative, edge, and security. Structured output again, but this time the shape is a list of objects, so it slots straight into TestRail or Xray.
The code
Nesting Pydantic models is the key move: a TestPlan contains a List[TestCase], and each TestCase has its own typed steps. The model fills the whole tree in one call.
from dotenv import load_dotenv
from typing import List
from pydantic import BaseModel, Field
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate
load_dotenv()
# Nested shape: a plan holds many fully-specified test cases.
class TestCase(BaseModel):
id: str = Field(description="e.g. TC-001")
title: str
type: str = Field(description="Functional | Negative | Edge | Security")
priority: str = Field(description="P0 | P1 | P2")
preconditions: str
steps: List[str]
expected: str
class TestPlan(BaseModel):
feature: str
scope: str = Field(description="What is and isn't covered")
risks: List[str]
test_cases: List[TestCase]
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
designer = llm.with_structured_output(TestPlan)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a test design expert. Turn a requirement into a "
"crisp plan and concrete, runnable cases. Cover happy path, "
"negative, edge and security. No fluff."),
("human", "Requirement / user story:\n\n{story}"),
])
chain = prompt | designer
plan = chain.invoke({"story": """
As a user, I can reset my password via a 'Forgot password' link.
A reset email is sent if the account exists; the link expires in
30 minutes; the new password must meet the strength policy.
"""})
print(f"Feature: {plan.feature} ({len(plan.test_cases)} cases)")
for tc in plan.test_cases:
print(f" [{tc.priority}] {tc.id} {tc.title} ({tc.type})")
Loop over plan.test_cases and each one already has steps and expected populated — ready to POST into your test-management tool, or to render as Gherkin, or to seed Playwright stubs. The requirement-to-coverage gap, closed in one call.
❯ The QA Copilot
Three agents, one entry point. The supervisor from Level 3 now routes to the real Triage, RCA, and Test Designer agents you just built — all sharing one model, all feeding your existing tools. This is the whole system on one page.
Assembling it
No new concepts — just imports. Each agent is a chain or executor you already wrote; the copilot stitches them behind the router.
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# the pieces you built in the projects above
from bug_triage_agent import chain as triage_chain
from test_designer_agent import chain as design_chain
from rca_agent import executor as rca_executor
load_dotenv()
llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
route = ChatPromptTemplate.from_messages([
("system", "Classify into exactly one label: TRIAGE, RCA, or DESIGN. "
"Reply with the label only."),
("human", "{input}"),
])
router = route | llm | StrOutputParser()
def qa_copilot(request: str):
label = router.invoke({"input": request}).strip().upper()
if "TRIAGE" in label: return triage_chain.invoke({"report": request})
if "DESIGN" in label: return design_chain.invoke({"story": request})
if "RCA" in label: return rca_executor.invoke({"input": request})
return "Could not route the request."
# One front door for the whole QA team:
print(qa_copilot("Bug: app freezes when uploading a 0-byte file on iOS."))
print(qa_copilot("Write test cases for the password reset flow."))
print(qa_copilot("The login smoke test just started failing — why?"))
❯ Ship it — deploy & serve 24×7
Everything so far runs on your laptop. Now we put one API in front of all the agents, push it to an Ubuntu 24.04 LTS VPS, and keep it alive around the clock — so any teammate, CI job, or script can hit it with a single curl.
What we're building
A small FastAPI app wraps each agent as an HTTP endpoint. A request comes in over HTTPS, nginx hands it to uvicorn, uvicorn runs the agent, and the JSON answer travels back. systemd keeps the whole thing running — restarting on crash and on reboot.
Step 1 · Wrap every agent in one API
This is the only new Python. It imports the chains and executors you already built and exposes them as routes — JSON in, JSON out. A simple API-key header keeps the world from spending your Groq credits.
# main.py — one HTTP API in front of every QA agent
import os
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from dotenv import load_dotenv
# the agents you built earlier
from bug_triage_agent import chain as triage_chain
from rca_agent import executor as rca_executor
from test_designer_agent import chain as design_chain
from qa_copilot import qa_copilot
load_dotenv()
API_KEY = os.environ.get("QA_API_KEY", "") # set on the server, in .env
app = FastAPI(title="QA Agents API", version="1.0.0")
def require_key(x_api_key: str | None):
"""Reject calls without the right header (only if a key is configured)."""
if API_KEY and x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
# ---- request bodies ----
class BugIn(BaseModel): report: str
class RcaIn(BaseModel): input: str
class StoryIn(BaseModel): story: str
class AskIn(BaseModel): request: str
# ---- endpoints ----
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/triage")
def triage(body: BugIn, x_api_key: str | None = Header(default=None)):
require_key(x_api_key)
result = triage_chain.invoke({"report": body.report})
return result.model_dump() # typed object -> JSON
@app.post("/rca")
def rca(body: RcaIn, x_api_key: str | None = Header(default=None)):
require_key(x_api_key)
out = rca_executor.invoke({"input": body.input})
return {"answer": out["output"]} # AgentExecutor -> final text
@app.post("/design")
def design(body: StoryIn, x_api_key: str | None = Header(default=None)):
require_key(x_api_key)
plan = design_chain.invoke({"story": body.story})
return plan.model_dump()
@app.post("/ask")
def ask(body: AskIn, x_api_key: str | None = Header(default=None)):
require_key(x_api_key)
answer = qa_copilot(body.request) # router picks the agent
if hasattr(answer, "model_dump"): # normalise to JSON
return answer.model_dump()
if isinstance(answer, dict):
return {"answer": answer.get("output", answer)}
return {"answer": answer}
# wrap the demo run so it ONLY fires when you run this file directly,
# not when main.py imports it.
if __name__ == "__main__":
print(chain.invoke({"report": "..."}))
Step 2 · Pin the dependencies
langchain
langchain-core
langchain-community
langchain-groq
langgraph
pydantic
python-dotenv
fastapi
uvicorn[standard]
Step 3 · Run it locally first
Always prove it boots on your machine before touching the server.
# boot the API on your laptop (auto-reload while you edit)
uvicorn main:app --reload --port 8000
# in a second terminal — is it alive?
curl http://127.0.0.1:8000/health
# {"status":"ok"}
# fire a real triage
curl -X POST http://127.0.0.1:8000/triage \
-H "Content-Type: application/json" \
-d '{"report":"Pay button spins forever on iOS after 4.2.0; ~30% of checkouts."}'
Step 4 · Provision the VPS (Ubuntu 24.04 LTS)
A small box is plenty — 1 vCPU / 1 GB RAM works fine, since the heavy lifting happens on Groq's side. SSH in, get the code onto the box, and install into a virtualenv. Ubuntu 24.04 already ships Python 3.12.
# --- on your laptop: copy the project up (or git clone on the server) ---
scp -r ./qa-agents ubuntu@SERVER_IP:/home/ubuntu/
# --- ssh into the box ---
ssh ubuntu@SERVER_IP
# --- system packages (Ubuntu 24.04 already ships Python 3.12) ---
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3-venv python3-pip nginx
# --- virtualenv + dependencies ---
cd ~/qa-agents
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# --- secrets: create .env on the server (never commit it) ---
nano .env
# GROQ_API_KEY=gsk_xxx
# QA_API_KEY=choose-a-long-random-string
chmod 600 .env
# --- smoke test it boots, then press Ctrl-C ---
uvicorn main:app --host 127.0.0.1 --port 8000
Step 5 · Keep it alive 24×7 with systemd
A bare uvicorn command dies the moment you close SSH. systemd turns it into a managed service that starts on boot and respawns if it ever crashes — that's what makes it 24×7.
[Unit]
Description=QA Agents API (FastAPI + uvicorn)
After=network.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/qa-agents
ExecStart=/home/ubuntu/qa-agents/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 2
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
# load the unit, enable on boot AND start it now
sudo systemctl daemon-reload
sudo systemctl enable --now qa-agents
# is it healthy?
sudo systemctl status qa-agents
# live logs (Ctrl-C to stop watching)
journalctl -u qa-agents -f
Step 6 · Put nginx in front + lock the firewall
uvicorn is bound to 127.0.0.1, so it isn't reachable from the internet directly. nginx is the only public door — it forwards traffic to uvicorn and is where TLS lives.
server {
listen 80;
server_name api.yourdomain.com; # or your server's IP
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# enable the site
sudo ln -s /etc/nginx/sites-available/qa-agents /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# firewall: allow SSH + web only — port 8000 stays private
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
The firewall opens only SSH and the web ports; port 8000 stays private. Add a real, auto-renewing certificate in one command:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.yourdomain.com
Step 7 · Call it from anywhere — every endpoint
With the service live behind your domain (or IP), here's the full surface. Swap api.yourdomain.com and the key for yours.
# set these once for convenience
BASE="https://api.yourdomain.com"
KEY="your-qa-api-key"
# 1. liveness check (no key needed)
curl $BASE/health
# 2. bug triage -> typed verdict
curl -X POST $BASE/triage -H "Content-Type: application/json" -H "X-API-Key: $KEY" \
-d '{"report":"Users cannot pay on iOS after 4.2.0; spinner never stops; ~30% of checkouts."}'
# 3. root-cause analysis -> cause + evidence + fix
curl -X POST $BASE/rca -H "Content-Type: application/json" -H "X-API-Key: $KEY" \
-d '{"input":"The checkout payment flow test just started failing. Find the root cause."}'
# 4. test design -> plan + cases
curl -X POST $BASE/design -H "Content-Type: application/json" -H "X-API-Key: $KEY" \
-d '{"story":"As a user I can reset my password via an emailed link that expires in 30 min."}'
# 5. ask -> the router picks the right agent
curl -X POST $BASE/ask -H "Content-Type: application/json" -H "X-API-Key: $KEY" \
-d '{"request":"Write test cases for the password reset flow."}'
A call to /rca comes back like this — the agent did the log, commit and health legwork on the server and handed you the verdict:
{
"answer": "Most likely root cause: the payment client timeout was cut
from 3s to 1s (commit d4e5f6), so calls to the degraded payment-svc
(p99 9.4s) now abort with 503 before completing. Evidence: expected 200
got 503 on POST /api/v2/payment; payment-svc error rate 18%. Suggested
fix: revert the timeout to 3s and add one retry, then re-run the spec."
}
The endpoint map
| Method | Path | Body (JSON) | Returns |
|---|---|---|---|
| GET | /health | — | Liveness check, {"status":"ok"}. Point your uptime monitor here. |
| POST | /triage | {report} | Typed triage: severity, priority, component, owning team, duplicate flag, reasoning. |
| POST | /rca | {input} | {answer} — the root cause, the evidence behind it, and a suggested fix. |
| POST | /design | {story} | A scoped test plan plus a list of typed, runnable test cases. |
| POST | /ask | {request} | Router reads intent and dispatches to triage / rca / design, then returns that result. |
Before you expose it widely
- Keep the API key on. It's the line between a private tool and a public bill — anyone who finds the URL can otherwise spend your Groq credits.
- Only nginx is public. uvicorn stays bound to 127.0.0.1:8000 and the firewall blocks that port. Never expose uvicorn directly.
- Secrets live in .env, chmod 600, never in git. Rotate the key the moment it leaks.
- Add rate limiting if it's internet-facing — a small library like slowapi caps requests per IP so one caller can't drain your budget.
- Scale by workers, not rewrites. Agent calls are I/O-bound (waiting on Groq), so raise --workers, or move to gunicorn -k uvicorn.workers.UvicornWorker when traffic grows.
❯ Where to go next
You've got the foundations and three real agents. Four directions turn this from a demo into something your team leans on daily.
Make it stateful with LangGraph
The router here is stateless. LangGraph models the system as a graph with memory and hand-offs, so an agent can pause, ask a follow-up, or pass work to another agent mid-task. The natural next step once one-shot routing isn't enough.
Give agents your knowledge (RAG)
Index your test docs, runbooks and past bug history in a vector store like ChromaDB, and let agents retrieve from it. Now triage knows your real components and RCA knows your past incidents — answers stop being generic.
Wire the real tools
Swap the canned tool returns for live calls to the Jira REST API, your Git host, log store and CI. The agent code doesn't change — only what the tools return. That's the payoff of the tool abstraction.
Evaluate before you trust it
An agent that's right 70% of the time is dangerous if you can't tell which 70%. Build an eval set with tools like DeepEval or PromptFoo and score triage accuracy and RCA correctness on known cases before you put it in front of the team.