Hands-on field guide · Python · Groq + Llama 3.3

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.

Goal: 0 → 3 working agents Level: beginner-friendly Copy-ready code throughout
PROJECT 01

Bug Triage

Raw report in → severity, priority, component, owner, dupe-check out. Typed and ready for Jira.

PROJECT 02

RCA Agent

Pulls logs, recent commits and service health like you would, then names the likely root cause.

PROJECT 03

Test Designer

A user story becomes a scoped test plan plus concrete, runnable test cases.

Foundations

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.

Foundations

Why it fits QA

Three reasons this matters specifically for testers — not generic AI hype.

1

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.

2

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.

3

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.

Foundations

The mental model

Hold these five blocks in your head and the whole framework clicks. Everything else is a variation on them.

BUILDING BLOCKS Prompt what you ask, with variables Model the brain (the LLM) Output Parser clean, typed result CHAIN = blocks wired in order ( prompt | model | parser ) AGENT = a chain that can decide & act Reason Call Tool Observe loop until it has the answer Tools Jira · logs CI · Git
The whole framework in one picture. Chains run a fixed pipeline; agents add a reasoning loop with tools.
i
One-line summary to keep: a chain always runs the same steps in order. An agent is a chain that's allowed to think, pick a tool, look at the result, and loop — until it decides it's done.
Foundations

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.

terminal · setup.sh
# 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.

file · .env
GROQ_API_KEY=gsk_your_real_key_goes_here
!
Add .env to your .gitignore before your first commit. A leaked key on GitHub gets scraped within minutes.
LEVEL 00First contact

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.

python · level_0_hello.py
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.
LEVEL 01Composition

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.

ANATOMY OF A CHAIN INPUT {"bug": …} Prompt Template fills variables Model ChatGroq Output Parser str / object DONE | | | chain = prompt | model | parser — the pipe sends each block's output into the next
LCEL — the pipe operator. The same | you know from the shell, wiring blocks left to right.
python · level_1_chain.py
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.
i
This chain is already production-shaped. It's the same pattern the Bug Triage and Test Designer agents use later — they just swap StrOutputParser for a typed parser.
LEVEL 02Reasoning + action

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 AGENT LOOP · REASON → ACT → OBSERVE GOAL "Is TC-101 flaky?" LLM reasons "I need the test history → call a tool" TOOL runs get_test_history("TC-101") Action Observation ↺ Final answer "Yes — failed 3 of last 5 runs." when it has enough → exit loop
Reason → Act → Observe, repeat. The model keeps looping through tools until it can answer — you don't script the steps.

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.

python · level_2_agent.py
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.

i
agent_scratchpad is where the loop lives — each thought, tool call, and observation gets appended there so the model can see what it has already tried. Leave it in; the agent won't run without it.
LEVEL 03Scaling out

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.

SUPERVISOR PATTERN incoming request Supervisor / Router classifies intent → dispatches Bug Triage typed classification RCA Agent tools + evidence Test Designer plan + cases TRIAGE RCA DESIGN
One front door, specialist workers behind it. Each agent stays small and sharp; the router decides who handles what.

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.

python · router.py (the idea)
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."
i
This router is stateless — good enough to start. When you need memory across turns or agents that hand off mid-task, you graduate to LangGraph, which models the whole thing as a stateful graph. We wire the three real agents together at the end.

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 isGeneral 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 ideaComposable runnables wired with the | pipe (LCEL), tools, and graph nodes.Agents (role · goal · backstory) + Tasks + a Crew that runs a Process.
Multi-agentVia LangGraph — an explicit graph where you control state and hand-offs.Native and first-class — sequential or hierarchical crews out of the box.
Structured outputFirst-class: with_structured_output + Pydantic, used all through this guide.Supported via Pydantic on tasks, but less central to the model.
Control vs speedMore control, a little more wiring.Faster to stand up, more opinionated, less low-level control.
Learning curveModerate — lots of surface area, but each piece is simple.Gentle start — the "team of agents" metaphor maps fast.
Best for a QA teamPrecise, testable pipelines and the production API you just shipped.Quickly prototyping a crew of cooperating agents (e.g. planner + coder + reviewer).
Pick it whenYou want fine-grained control, typed output, and a deployable endpoint.You want a role-based multi-agent crew with minimal plumbing.
i
It's not either/or. A common pattern: prototype a crew fast in CrewAI to validate the idea, then rebuild the parts you need to own — structured output, tool calls, the API — in LangChain / LangGraph for control and testability. They interoperate, so CrewAI agents can even call LangChain tools.

PROJECT 01Typed classification

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.

BUG TRIAGE FLOW RAW REPORT free text from a user / tester Triage chain · map impact → severity · map urgency → priority · guess component + team · flag likely duplicate Triage object typed fields → Jira ticket
Free text in, typed verdict out. The duplicate flag and team guess are what save real triage time.

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.

python · bug_triage_agent.py
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.


PROJECT 02Tools + evidence

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.

RCA — GATHER EVIDENCE, THEN CONCLUDE FAILURE "checkout test just failed" RCA agent reasons over evidence fetch_test_logs() recent_commits() service_health() observations ↺ Root cause + fix ranked, with evidence
It does the 2am legwork. Logs show a 503, commits show a gateway swap + timeout cut, health shows the service degraded — the agent connects them.

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.

python · rca_agent.py
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.


PROJECT 03Generation, structured

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.

TEST DESIGN FLOW USER STORY "reset password via email link" Test Designer 1 · scope + risks 2 · generate cases happy · negative · edge · security TC-001 · valid reset · P0 TC-002 · expired link · P1 TC-003 · unknown email · P1 → export to TestRail / Xray
Story in, a real test suite out. Each case is a typed object with steps and expected results — not a paragraph you have to retype.

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.

python · test_designer_agent.py
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.


CAPSTONEEverything, wired

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.

QA COPILOT — FULL ARCHITECTURE request Supervisor (router) classify → dispatch Bug Triage structured output RCA Agent tool-using loop Test Designer nested output shared model · ChatGroq (Llama 3.3) one line to swap for OpenAI / Anthropic Tools: Jira · Git · logs · CI used by the RCA agent → Jira / TestRail where results land
The full picture. Specialist agents, a shared brain, your real tools, and outputs that land in the systems you already use.

Assembling it

No new concepts — just imports. Each agent is a chain or executor you already wrote; the copilot stitches them behind the router.

python · qa_copilot.py
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?"))
i
You now have the complete loop: a request comes in, the router picks the right specialist, the specialist does its job with a shared model and your tools, and the result is typed data ready for Jira or TestRail. That's a working QA Copilot — built from blocks you understand.

DEPLOYShip to prod

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.

REQUEST LIFECYCLE · CLIENT → API → AGENT → REPLY curl / app your client nginx TLS · :443 reverse proxy uvicorn · FastAPI 127.0.0.1:8000 /health /triage /rca /design /ask QA agent chain / loop Groq model + tools HTTPS proxy JSON reply ◀
One request in, one JSON answer out. Every agent reachable over the wire — nginx is the only public door.

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.

python · main.py
# 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}
!
The project files from earlier end with demo .invoke(...) / print(...) lines. Guard them so importing the file here doesn't fire a live agent on startup:
python · bottom of each agent file
# 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

file · requirements.txt
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.

terminal · local test
# 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."}'
i
Open http://127.0.0.1:8000/docs in a browser — FastAPI gives you a free, clickable Swagger UI for every endpoint. If the curls return JSON, you're ready to deploy.

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.

terminal · on the VPS
# --- 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.

file · /etc/systemd/system/qa-agents.service
[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
terminal · start the service
# 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
STAYS UP 24×7 · SUPERVISED BY systemd API running uvicorn workers systemd unit Restart=always · enabled crash · or VPS reboot respawns in ≈3s enable on boot → survives reboot Restart=always → survives crash journalctl -u qa-agents -f → live logs
systemd is the supervisor. If the process dies or the box reboots, the API comes straight back — no human needed.
i
enable --now does both jobs at once: starts it now and wires it to launch on every boot. Restart=always handles crashes. That combination is your 24×7.

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.

file · /etc/nginx/sites-available/qa-agents
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;
    }
}
terminal · enable site + firewall
# 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:

terminal · HTTPS with certbot
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.

terminal · all endpoints
# 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:

response · POST /rca
{
  "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

MethodPathBody (JSON)Returns
GET/healthLiveness 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.
That's the full loop, in production. One API, every agent, reachable over HTTPS and supervised by systemd — it answers curl at 3am whether or not your laptop is open.

Ship it further

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.

Recap. You learned the five blocks, made a call, built a chain, built an agent, routed many agents, and shipped Triage + RCA + Test Designer behind one copilot. Everything ran on free infrastructure and ports to any model with one line.