Everyone says "agents" are the next big thing and we are in the agentic era, and almost nobody says what they mean actually. Sometimes it's a single model call with a system prompt. Sometimes it's a loop that keeps calling a model until a task is done. Sometimes it's five of those loops talking to each other. These are different things with different failure modes, and if you're going to build one it's worth being precise about which one you're building.
The best way to understand a multi-agent system is to build a small one and then take it apart. That's the plan here. We start at the bottom with what a language model actually is on its own, add a loop, add tools, find the wall a single agent runs into, and end up at LangGraph, which turns the whole arrangement into a graph you can draw on a whiteboard and test.
Meet F.R.I.D.A.Y.
F.R.I.D.A.Y. stands for Feasibility & Refinement for Idea Design & Architecture Yardstick. Yes, I spent way too much time forcing that acronym just to feel like Tony Stark :)
But unlike the Marvel version, my F.R.I.D.A.Y. won't help you build a nanotech flying suit. It does exactly one thing instead: you hand it a product idea for a hackathon in a single sentence, and it hands you back a complete 24 hour build plan.
In between, three small agents do the work:
- Forge reads the idea and proposes exactly three features.
- Sentinel is the skeptic. It checks whether a small team could actually ship those three features in a day, then either approves them or sends back a numbered list of objections.
- Blueprint runs once at the end and writes the tech stack, database schema, and folder structure.
Forge and Sentinel go back and forth in a loop, propose, object, revise, for at most two rounds (so that they do not eat up my credits). Then Blueprint writes the plan whether they fully agreed or not. That's all of it.
I'll call it Friday from here on, because typing seven periods every time is its own kind of feasibility problem. The rest of this post pulls it apart one idea at a time. If you'd rather see the finished machine first, jump to The Swarm.
A model is one function
A language model is one function. Text goes in, text comes out, and that's one round trip through the network with no going back for a second look. Given everything it has seen so far, it predicts the most plausible continuation, and then it stops. That's the whole primitive. Prompt engineering doesn't change its shape, only the text you feed it.
What it doesn't have is the interesting part, because every piece of an agent is scaffolding bolted onto one of these gaps:
- No memory. Every call starts cold. Anything the model should "remember", the conversation so far or an earlier tool result, you paste back in on the next call. It isn't recalling anything. You're re-supplying it.
- No hands. It can write the words "I'll check the database," but it can't check the database. Nothing it produces reaches outside its own text.
- No second opinion. It produces one answer. Nothing in the call reviews that answer, and a model asked to grade its own work marks itself an A with the confidence of a student who never opened the textbook.
- No way to stop and reconsider. One pass, front to back. A bad assumption in the first sentence rides all the way to the last one. There's no point where the model pauses and re-reads what it already wrote.
Keep that list in mind. "Agent," "tool use," and "multi-agent" are roughly names for filling one of those gaps each.
What an agent actually is
Underneath all the marketing bluff, an agent is four parts:
- A model. An LLM that, given some text, produces some text.
- A loop. Something that calls the model more than once, feeding each result back in.
- Tools. Functions the model can ask to run, like a search or a database query, whose output goes back into the loop as new context.
- A stopping condition. A rule that decides the loop is done.
A plain LLM call has only the first part. A fixed pipeline (call the model, parse the output, call it again in a set order) has the first two. Something is agentic when the model influences what happens next: which tool runs, whether to retry, whether to stop. Part of the control flow is decided at runtime by the model's output instead of entirely by you when you write the code.
That's the core of it. Planning, memory, reflection, multi-agent setups: all of it builds on the same move, letting the model's output steer the next step inside a loop that can end.
In Friday, the first agent is forge, the "System Architect." It reads an idea and proposes exactly three core features. On its own it's an agent only in the thin sense: a model, a role set by a system prompt, and a fixed output shape. The loop it runs in isn't inside the model. It's the graph, which comes later.
Agent versus Chatbot
A chatbot and an agent can be the exact same model with the exact same prompt. The difference is who drives the loop.
With a chatbot, you are the loop. You send a message, it answers once, and nothing else happens until you send another. Every turn is a person deciding to type again. The model doesn't act between your messages and doesn't decide when it's finished. The conversation stops because you stopped.
An agent gets a goal and is then allowed to take many turns on its own: call a tool, read the result, call another, revise, and keep going until its own stopping condition fires. "Book me a table" doesn't come back with a clarifying question. It comes back once the agent has checked availability, picked a slot, and confirmed the booking, across several model calls you never see.
That autonomy is the whole distinction, and the whole risk. A chatbot that gets something wrong produces one bad message. An agent that starts from a wrong assumption will act on it, several times, in the same confident tone it uses when it's right, before anything stops it.
The reason-act loop
A single agent, run to completion, cycles through the same four steps:
- Observe. Take in the current context: the task, plus whatever has been gathered so far.
- Reason. The model decides what to do next: answer now, or call a tool.
- Act. Run the tool, or emit the final answer.
- Feed back. Append the result to the context and loop again, unless the stopping condition is met.
This is the ReAct pattern, reason and act interleaved, and it's the backbone of almost every agent framework. The hard engineering questions are all at the edges. How do you represent "the model wants to call a tool" as data? How do you stop a model that never decides it's done? What happens when a tool throws?
Friday's second agent, sentinel, the "Feasibility Judge," shows the cleanest version of "act as data." It reviews Forge's proposal and, if it approves, is required to begin its reply with an exact line:
STATUS: APPROVEDThe rest of the system never asks the model "did you approve?" It checks whether that string is at the front of the response:
approved = critique.upper().startswith("STATUS: APPROVED")The model's plain-text output is the control signal. Parsing it directly, instead of making a second model call to interpret the first, is a pattern that keeps coming up once you have more than one agent.
Tools are the other half of "act," and worth a section on their own.
Why an agent needs tools
A tool is a function the model is allowed to ask for: a web search, a SQL query, an email send, a call into your own API. Any function will do, as long as it has a name and a one-line description of when to use it:
@tool
def check_rate_limit(model: str) -> str:
"""Return the current requests-per-minute budget for a model."""
return requests.get(f"{API_BASE}/limits/{model}").textThe docstring is not a comment for your future self. It is the only thing the model reads when it decides whether this function is the right one to reach for, so write it like a hint, not like documentation.
Two things about how this runs matter more than they look at first.
The model never runs anything. It emits a request, this tool with these arguments, as structured data in its output. Your program reads the request, runs the actual function, and passes the return value back as the next message. "The model called a tool" is shorthand for "the model asked politely and your code did the work." It has no shell, no network, no filesystem. It writes a sticky note and hopes somebody reads it. Same idea as Sentinel's STATUS: APPROVED line above, with arguments attached.
Tools trade recall for lookup. Without tools, a model answers from what it picked up in training, which is a blurry, frozen, uncited memory that was never meant to work like a database. Give it a search tool and "what's our current rate limit?" goes from a guess to a lookup against something real. This is the main reason tools matter. They give the agent something outside itself that can push back. With no such thing in the loop, a confident wrong answer has nothing to hit, so it just becomes the output.
Friday's agents have no tools on purpose. Their only action is writing text for the next node. That keeps this example focused on orchestration and not on tool plumbing. I built the tool-calling side separately in Relay, where one agent can call other workflow nodes as tools mid-conversation before it decides it's done.
Why one agent hits a ceiling
Single agents are enough for a lot. They start to strain when a task has any of these shapes:
- Conflicting objectives. "Invent an ambitious feature set" and "tear that feature set apart for risk" are different jobs with different temperaments. Ask one agent, in one context, to do both and it splits the difference. The ideas come out timid and the critique comes out soft, because one model in one call is trying to satisfy both instructions at once.
- Context dilution. As the transcript grows (task, plan, tool results, self-corrections) the signal-to-noise ratio drops. Later steps are reasoning over a haystack.
- No separation of concerns. When everything happens in one loop, you can't swap the "critic" for a cheaper model, or run the "researcher" three times in parallel, or unit-test the routing logic, because there's no seam to grab.
- Self-grading. An agent asked to check its own work tends to pass itself. There's no independent adversary.
All four come down to one thing: a single agent runs in a straight line, and its first attempt is its only one. Nothing gives the work a second pair of eyes before it becomes the answer. And since an LLM's wrong answer reads exactly like its right one, the output itself never signals that another pass is needed.
Friday exists specifically because idea validation has the first shape. You want a proposer that reaches, and a critic that's cold about a 24-hour build budget, and you do not want them to be the same context. So it uses two models with two temperaments, set explicitly:
forge_model = ChatGroq(model_name=FORGE_MODEL, temperature=0.4)
sentinel_model = ChatGroq(model_name=SENTINEL_MODEL, temperature=0.2)temperature is the dial for how much randomness the model allows itself when picking the next word. Near zero it takes the safest option every time and gives you almost the same answer on every run. Turn it up and it starts taking chances, which is what you want from someone brainstorming and very much not what you want from someone signing off on a deadline.
So a larger, warmer model proposes. A smaller, colder one judges. They never share a context window, which is the running transcript a model can see in one call. Sentinel gets Forge's finished proposal handed to it as a fresh document, not Forge's train of thought. It never sees the reasoning, only the result, which is exactly the position you want a reviewer in.
Prompt, agent, swarm: three levels
Put the three shapes side by side. They're one idea, a language model doing work, with more and more structure wrapped around it.
| Level | Shape | Tools? | Can it disagree? | Good for |
|---|---|---|---|---|
| Prompt | One call, one reply | No | No | Drafting, summarising, rewriting |
| Agent | A loop around one model | Yes | Only with itself | Research, data lookup, multi-step tasks |
| Multi-agent | A graph of several models | Yes | Yes, by design | Judgement calls, review, planning |
The jump that changes the kind of system you have is the last one. A loop lets one model try again and reach for tools, but every turn is still that model, in that context, building on its own earlier reasoning. Add a second agent with a different job and a different model, and for the first time something in the system can say no to the first one. That "no" is the reason Friday is built the way it is.
It isn't a free upgrade. A debate between two models over several passes costs several times the tokens of one call, plus latency, plus loops you now have to cap. Go multi-agent when you actually need independent review or real specialisation. Two agents doing the work of one is not an architecture, it's just a slower bill.
What "multi-agent" means
A multi-agent system is four things:
- Several specialised agents. Each has its own role, prompt, and often its own model.
- A shared state. The common ground they read from and write to.
- A communication structure. Who can hand work to whom.
- A control policy. The rule that decides who runs next, and when the whole thing stops.
Point four is where most of the design effort goes. It's an orchestration problem, and a few shapes come up again and again:
Pipeline. Agents run in a fixed order, each taking the last one's output. Simple, predictable, no branching. It's barely "multi-agent," closer to function composition where the functions happen to be LLMs.
Supervisor (router). One agent sits in the middle and delegates. It reads the task, picks a worker, reads the result, and decides whether to delegate again or finish. Flexible, but the supervisor is an LLM call on every hop, which means more latency, more cost, and a middle manager who can hallucinate.
Swarm (peer-to-peer). No central authority. Each agent can hand off directly to another based on its own output, and control moves around the group until someone produces a terminal result. The "who runs next" decision is distributed.
Debate / critic. A proposer and a critic in an adversarial loop: the proposer drafts, the critic attacks, the proposer revises, repeat until the critic is satisfied or a turn limit is hit. Then a final pass synthesizes the agreed result. This is Friday.
These aren't exclusive. A real system might have a supervisor whose workers are each small debate loops. But debate is the one worth building by hand first, because its control policy is simple enough to be deterministic, and that turns out to matter.
Why My AI Backend Is Banned From Touching the DatabaseHow Kortex's FastAPI agents get their output back into Postgres without ever touching the database themselves, routed through Inngest events and a shared-secret internal API on the Next.js side.That post walks through a three-agent system wired closer to a pipeline, coordinating through Postgres and an event bus instead of a shared in-memory state. Same question as here, who runs next and what do they share, answered differently because the agents there run as separate services.
Where LangGraph sits
You can build a debate loop with nothing but a while loop and a provider SDK. The reason to reach for a framework is that the loop, its exit conditions, and its cap stop being code you have to remember to write, and become structure the tool holds for you.
LangGraph is one option among several. It's part of the LangChain family, where each piece has a separate job:
| Piece | What it gives you | Reach for it when |
|---|---|---|
| LangChain | One interface over many model providers, plus tools, retrievers, output parsers | You want to swap models without rewriting calls |
| LangGraph | A state machine: nodes, edges, cycles, checkpoints, human interrupts | Control flow stops being a straight line |
| LangSmith | Tracing of every call: token counts, latency, prompt versions | An agent misbehaves and you need to see why |
| LangServe | Your graph exposed as an HTTP endpoint | The notebook has to become a service |
LangChain is the parts bin. LangGraph is the wiring diagram, and it runs on its own, so you don't need the rest of the family to use it.
The alternatives make different trades:
| Framework | Mental model | Trade-off |
|---|---|---|
| LangGraph | Graph of nodes over shared state | Most explicit control; you draw the flow yourself |
| CrewAI | Roles, tasks, and a crew | Fast to a first demo; less control over cycles |
| AutoGen | Agents in a group conversation | Strong at free-form chat; flow is harder to pin down |
| OpenAI Agents SDK | Agents with hand-offs | Lean and tidy; closely tied to one provider |
| Plain Python | Your own while loop | Zero abstraction; you rebuild state, retries, and caps yourself |
All of them can express a debate. LangGraph's pitch is that the loop, the exit, and the turn cap are written down as structure you can see, instead of behaviour buried in a prompt. Rough rule: if you can draw the workflow as boxes and arrows on a whiteboard, you can run it as a graph.
The Swarm
Here are the three agents again, with the exact output each one produces:
| Node | Role | What it does |
|---|---|---|
| Forge | System Architect | Proposes exactly three core features. On a revision pass, rewrites the proposal to answer every objection the critic raised. |
| Sentinel | Feasibility Judge | Reviews the proposal for 24-hour build feasibility, edge cases, and technical flaws. Approves with STATUS: APPROVED, or returns a numbered objection list. |
| Blueprint | Final Execution | Runs once, at the end. Produces the tech stack, database schema, third-party APIs, and folder structure. |
The user gets one screen: a box for the idea, and a picture of the graph it's about to run through.
Here's a full run end to end, idea in, debate, blueprint out:
Now the mechanics.
Modelling it in LangGraph: state, nodes, edges
LangGraph's premise is that an agentic application is a graph. You define:
- a state, one typed object that every step reads and writes,
- nodes, functions that take the state and return an update to it,
- edges, the connections that decide which node follows which.
You build the graph, compile it, and get back a normal callable. That's the whole mental model. Everything else is detail.
State: the shared channel
Friday's state is a TypedDict, which is a plain Python dictionary with the allowed keys and their types written down so your editor can catch a typo before the graph runs:
class AgentState(TypedDict):
idea: str
proposal: str
critique: str
consensus: bool
iterations: int
blueprint: str
history: listEach key is a channel, a named slot in the shared object. A node doesn't mutate the state in place, and it doesn't return the whole thing. It returns a partial dict naming only the channels it wants to change, and LangGraph merges that into the running state:
return {"proposal": proposal, "iterations": iterations}By default, merging means overwrite: the new value for a channel replaces the old one. That's fine for proposal (each Forge turn supersedes the last) and consensus (the latest verdict wins). It's wrong for history, which has to accumulate every turn so the UI can replay it.
LangGraph's idiomatic fix is a reducer: annotate the channel with a function that says how to combine the old value and the update.
from typing import Annotated
import operator
history: Annotated[list, operator.add]With that annotation, a node returning {"history": [entry]} appends rather than clobbers. Friday instead does the concatenation by hand in every node:
"history": state.get("history", []) + [entry]Both work. The manual version is more explicit and keeps the state definition free of imports. The reducer version means a node can't forget to carry the list forward. For a graph this small the hand-rolled way is fine. Reach for a reducer as soon as more than one node writes the same accumulating channel.
Nodes: specialized agents as functions
A node is a plain function: state in, partial-state-update out. No class hierarchy, no base agent to subclass. Friday's forge node, in outline:
- read
state["idea"]andstate.get("critique", ""), - pick a prompt: a fresh proposal if there's no critique yet, a revision prompt if there is,
- call
forge_model.invoke([...]), - return
{"proposal": ..., "iterations": ..., "history": ...}.
Because it's just a function, each node makes its own choices. forge and blueprint call the larger model, sentinel calls the smaller one. Nothing forces them to share anything except the state schema.
One detail that bites everyone eventually: reasoning models. Some models think out loud before answering and wrap that thinking in <think>...</think> tags. Useful for debugging, deeply weird to show a user, and fatal to the STATUS: APPROVED check, because a verdict that starts with four hundred words of "hmm, let me consider the payment flow" no longer starts with STATUS. So sentinel and blueprint run their output through a stripper first:
cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)Three lines of regex, and it's the difference between a node that survives a model swap and one that silently stops approving anything the day you point SENTINEL_MODEL at something that reasons.
Edges: fixed flow and branching
Edges wire the nodes together. The simple ones are unconditional: after node A, always go to node B:
builder.add_edge(START, "forge")
builder.add_edge("forge", "sentinel")
builder.add_edge("blueprint", END)START and END are LangGraph's sentinels for "where execution enters" and "where it's finished."
The interesting edge is the branch after sentinel. Whether the graph loops back to forge or moves on to blueprint depends on the state, so it's a conditional edge: a routing function plus a mapping from that function's return values to node names.
builder.add_conditional_edges(
"sentinel", consensus_router, {"forge": "forge", "blueprint": "blueprint"}
)Here's the whole router:
def consensus_router(state: AgentState) -> str:
if state.get("consensus") or state.get("iterations", 0) >= MAX_ITERATIONS:
return "blueprint"
return "forge"It's a plain function. No LLM call. It reads two fields off the state and returns a string. This is the deliberate choice from earlier: in a debate topology, "should we keep arguing?" is a decision you can write in three lines of Python, so you should. It's free, it's instant, and you can unit-test it with a dict. A supervisor topology would put a model call here and pay for it on every turn.
The loop, and how it terminates
Those edges create a cycle: forge → sentinel → consensus_router → forge → .... Most workflow tools refuse to let you draw an arrow back to a node you already visited, because a pipeline that can revisit a step can also run forever. LangGraph allows it, which is the entire reason a critic can send work back. It's also how you get an agent that never stops. Friday has two independent exits from the loop:
- Consensus.
sentinelsetconsensus: Truebecause the proposal earnedSTATUS: APPROVED. - Turn limit.
iterationshas reachedMAX_ITERATIONS(2). Forge and Sentinel get at most two rounds before Blueprint is forced to run whether they agree or not.
The second condition is not optional. Write the cap before you write the loop, not after. A critic with no turn limit and a proposer with infinite patience will argue until your rate limit steps in and ends the discussion for both of them, and you will find out from your billing page rather than your terminal. The loopback the UI labels NO, FORGE REVISES is this branch: the critic isn't satisfied and there are turns left.
Compiling and running
builder.compile() freezes the graph into a runnable. Then it's one call:
graph.invoke(initial_state)invoke runs the whole graph to completion and returns the final state: every channel, including the accumulated history and the final blueprint. For a UI that wants to show progress as it happens, LangGraph also has graph.stream(...), which yields an event after each node instead of one result at the end.
One honest note before you watch that demo again and feel impressed by the wrong thing. Friday's backend uses invoke and runs the entire graph server-side before the browser hears anything back. The "live" debate you see is the frontend replaying the finished history list with a timer between cards. It looks like streaming. It is a slideshow. Real streaming means pushing graph.stream() events to the browser as they happen, over a connection that stays open (Server-Sent Events or a WebSocket) instead of one request that returns once. For a run that takes about a minute, the replay was the shortcut, and the graph would support the real thing unchanged.
Now the whole file. I'll take it in four pieces rather than dropping two hundred lines on you at once.
Piece one: setup. Imports, the key check, the two models, and the turn cap. Note that both model IDs come from environment variables, so swapping either agent's brain is a one-line experiment and never touches the graph.
"""F.R.I.D.A.Y. multi-agent idea validation swarm.
Forge (System Architect) proposes features, Sentinel (Feasibility Judge)
critiques them, and once they reach consensus (or hit the turn limit) the
Blueprint node writes the final execution plan.
"""
import os
import re
from typing import TypedDict
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langgraph.graph import END, START, StateGraph
load_dotenv()
if not os.getenv("GROQ_API_KEY"):
raise RuntimeError(
"GROQ_API_KEY is not set. Copy .env.example to .env and add your key."
)
# Model IDs come from .env so each machine can use whatever its Groq org
# allows. Fallbacks here just keep the app runnable without an .env entry.
FORGE_MODEL = os.getenv("FORGE_MODEL", "openai/gpt-oss-120b")
SENTINEL_MODEL = os.getenv("SENTINEL_MODEL", "openai/gpt-oss-20b")
forge_model = ChatGroq(model_name=FORGE_MODEL, temperature=0.4)
sentinel_model = ChatGroq(model_name=SENTINEL_MODEL, temperature=0.2)
MAX_ITERATIONS = 2
class AgentState(TypedDict):
idea: str
proposal: str
critique: str
consensus: bool
iterations: int
blueprint: str
history: list
def _strip_reasoning(text: str) -> str:
"""Remove reasoning model <think> blocks so only the verdict reaches the UI."""
cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
cleaned = re.sub(r"<think>.*$", "", cleaned, flags=re.DOTALL)
return cleaned.strip()Piece two: Forge. The whole node is one if. No critique in state means this is turn one, so ask for a fresh proposal. A critique means we've been sent back, so hand the model its own previous answer plus the objections and tell it to resolve them. Same function, same model, two different jobs depending on what's in the state.
def forge(state: AgentState) -> dict:
"""System Architect: define exactly three core features, revise on critique."""
iterations = state.get("iterations", 0) + 1
critique = state.get("critique", "")
system = (
"You are Forge, a System Architect on the F.R.I.D.A.Y. validation swarm. "
"Given a hackathon idea, define exactly three core features that a small "
"team could build. Be concrete about what each feature does. Respond in "
"under 150 words. Do not use emojis."
)
if critique:
user = (
f"Original idea:\n{state['idea']}\n\n"
f"Your previous proposal:\n{state.get('proposal', '')}\n\n"
f"Sentinel's objections:\n{critique}\n\n"
"Revise the proposal so it directly resolves every objection above. "
"Keep exactly three core features."
)
else:
user = f"Hackathon idea:\n{state['idea']}\n\nDefine the three core features."
response = forge_model.invoke(
[("system", system), ("human", user)]
)
proposal = response.content.strip()
entry = {
"agent": "Forge",
"model": FORGE_MODEL,
"role": "System Architect",
"iteration": iterations,
"content": proposal,
"revised": bool(critique),
}
return {
"proposal": proposal,
"iterations": iterations,
"history": state.get("history", []) + [entry],
}Piece three: Sentinel and Blueprint. Sentinel is where the whole design lives or dies. Its prompt does two jobs at once: it tells the model to be harsh, and it pins the approval to one exact string so the router can read the verdict without a second model call. Blueprint is the opposite temperament, one long generous call that only ever runs after the arguing is over.
def sentinel(state: AgentState) -> dict:
"""Feasibility Judge: evaluate the proposal for a 24-hour hackathon."""
system = (
"You are Sentinel, a Feasibility Judge on the F.R.I.D.A.Y. validation "
"swarm. Evaluate the proposal for a 24-hour hackathon build: scope "
"feasibility, edge cases, and technical flaws. Respond in under 150 "
"words. Do not use emojis. If the proposal is feasible and sound, begin "
'your response with the exact line "STATUS: APPROVED" and then justify '
"it briefly. Otherwise, list the specific objections the architect must "
"fix."
)
user = (
f"Original idea:\n{state['idea']}\n\n"
f"Proposal under review:\n{state.get('proposal', '')}"
)
response = sentinel_model.invoke(
[("system", system), ("human", user)]
)
critique = _strip_reasoning(response.content)
approved = critique.upper().startswith("STATUS: APPROVED")
entry = {
"agent": "Sentinel",
"model": SENTINEL_MODEL,
"role": "Feasibility Judge",
"iteration": state.get("iterations", 0),
"content": critique,
"approved": approved,
}
return {
"critique": critique,
"consensus": approved,
"history": state.get("history", []) + [entry],
}
def blueprint(state: AgentState) -> dict:
"""Final Execution: write the full technical blueprint once."""
system = (
"You are the Blueprint node on the F.R.I.D.A.Y. validation swarm. Write "
"the final execution plan as Markdown with these exact section headings: "
"## Tech Stack, ## Database Schema, ## Third-Party APIs, ## Folder "
"Structure. Be specific and practical for a 24-hour build. Do not use "
"emojis."
)
user = (
f"Original idea:\n{state['idea']}\n\n"
f"Agreed proposal:\n{state.get('proposal', '')}\n\n"
f"Latest feasibility review:\n{state.get('critique', '')}\n\n"
"Produce the execution blueprint."
)
response = forge_model.invoke(
[("system", system), ("human", user)]
)
plan = _strip_reasoning(response.content)
entry = {
"agent": "Blueprint",
"model": FORGE_MODEL,
"role": "Final Execution",
"iteration": state.get("iterations", 0),
"content": plan,
}
return {
"blueprint": plan,
"history": state.get("history", []) + [entry],
}Piece four: the wiring. Everything above was agents and prompts. This last part is the actual graph, and it's nine lines. Three nodes registered, three fixed edges, one conditional edge, compile.
def consensus_router(state: AgentState) -> str:
"""Deterministic routing. No LLM call."""
if state.get("consensus") or state.get("iterations", 0) >= MAX_ITERATIONS:
return "blueprint"
return "forge"
def build_graph():
builder = StateGraph(AgentState)
builder.add_node("forge", forge)
builder.add_node("sentinel", sentinel)
builder.add_node("blueprint", blueprint)
builder.add_edge(START, "forge")
builder.add_edge("forge", "sentinel")
builder.add_conditional_edges(
"sentinel",
consensus_router,
{"forge": "forge", "blueprint": "blueprint"},
)
builder.add_edge("blueprint", END)
return builder.compile()
graph = build_graph()
def run_validation(idea: str) -> dict:
"""Run the full swarm for one idea and return the final state."""
initial_state: AgentState = {
"idea": idea,
"proposal": "",
"critique": "",
"consensus": False,
"iterations": 0,
"blueprint": "",
"history": [],
}
return graph.invoke(initial_state)That's the ratio worth noticing. Nine lines of graph, roughly two hundred lines of prompts and output handling. The orchestration is almost never the hard part. Deciding what each agent is allowed to say, and how you read its answer, is the whole job.
Putting a web layer on it
A compiled graph is just a function from dict to dict, so wrapping it in an API is nothing special. Friday uses Flask with one route that matters:
"""Flask backend for F.R.I.D.A.Y."""
import markdown
from flask import Flask, jsonify, render_template, request
app = Flask(__name__)
_MD_EXTENSIONS = ["fenced_code", "tables", "sane_lists"]
def _to_html(text: str) -> str:
if not text:
return ""
return markdown.markdown(text, extensions=_MD_EXTENSIONS)
@app.get("/")
def index():
return render_template("index.html")
@app.post("/api/evaluate")
def evaluate():
payload = request.get_json(silent=True) or {}
idea = (payload.get("idea") or "").strip()
if not idea:
return jsonify({"error": "Provide an idea to validate."}), 400
# Import here so a missing GROQ_API_KEY surfaces as a clean JSON error
# instead of crashing on startup.
try:
from agent import run_validation
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 500
try:
final_state = run_validation(idea)
except Exception as exc: # noqa: BLE001 - surface any Groq/LangGraph failure
return jsonify({"error": f"Validation failed: {exc}"}), 502
steps = []
for turn in final_state.get("history", []):
steps.append(
{
"agent": turn["agent"],
"model": turn["model"],
"role": turn["role"],
"iteration": turn.get("iteration", 0),
"approved": turn.get("approved", False),
"revised": turn.get("revised", False),
"content": turn["content"],
"content_html": _to_html(turn["content"]),
}
)
return jsonify(
{
"idea": idea,
"consensus": final_state.get("consensus", False),
"iterations": final_state.get("iterations", 0),
"steps": steps,
"blueprint_md": final_state.get("blueprint", ""),
"blueprint_html": _to_html(final_state.get("blueprint", "")),
}
)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)Three things in there are deliberate and worth stealing:
- The import is inside the handler.
agent.pyraises at import time ifGROQ_API_KEYis missing. Importing it lazily, inside atry, turns that into a500with a readable JSON body instead of a stack trace on boot. - The graph call has its own
except. Anything Groq or LangGraph throws mid-run becomes a502. The swarm is a flaky upstream dependency, and the route treats it like one. - The response is the
historychannel, reshaped. The endpoint doesn't invent its own event format. It walksfinal_state["history"], the same list the nodes appended to, and serialises each turn. The graph's state is the API contract.
What the swarm produces
When the loop exits, blueprint runs exactly once. It's the synthesizer. It sees the original idea, the agreed proposal, and the last critique, and it's pinned to four fixed Markdown sections: tech stack, schema, third-party APIs, folder structure.
The objection list above the blueprint is the debate doing its job. Sentinel flagged relay safety, audit-trail integrity, concurrency, and privacy compliance. Forge's second proposal had to address all of them before Sentinel would return STATUS: APPROVED.
Run it yourself
Friday is on GitHub at github.com/yash27007/friday (a star helps if this was useful). Four commands and you have a running multi-agent debate on your own machine:
git clone https://github.com/yash27007/friday
cd friday
cp .env.example .env # then paste your Groq key into GROQ_API_KEY
uv run python app.pyGrab a free key from the Groq console, no card needed. Open localhost:5000, throw your worst hackathon idea at it, and let Sentinel tell you exactly why it won't ship.
Then break it on purpose, because that's where the learning actually happens:
- Set
MAX_ITERATIONS = 6and watch the bill. The fastest way to understand why the cap matters is to remove it for one run. - Swap
SENTINEL_MODELfor the bigger model. A smarter critic finds better objections and approves less often. Sometimes it never approves, which is when you discover your cap was doing more work than you thought. - Make Sentinel nicer. Delete "list the specific objections" from its prompt and watch consensus happen on turn one, every time, on garbage proposals. That's the self-grading problem from the top of this post, reproduced in one prompt edit.
- Add a fourth node. A cost estimator or a security reviewer between Sentinel and Blueprint is about twenty lines: one function, one
add_node, oneadd_edge.
If you take one thing from all of this, take the router. The temptation with multi-agent systems is to let a model decide everything, including who goes next. Resist it for as long as the routing question stays simple. A model call you replaced with an if statement is a model call that can't hallucinate, can't rate-limit you, and can't bill you.