An agent that talks and a graph that writes: the guria.lat intake
How the guria.lat chat works: a LangChain agent gathers the project details and a LangGraph workflow drafts the brief and the proposal.
If you’ve opened the chat on this site, you’ve talked to an agent. You tell it what’s going on in your business, it asks a couple of questions, and once the problem is clear it shows you a summary to confirm. What happens next stays out of sight: in the background it drafts an internal brief for the team and a PDF proposal, and both land in our inbox for review before anyone replies.
I built it with LangChain and LangGraph. The decision that mattered most was splitting it into two parts with different jobs.
Talking is open-ended, drafting isn’t
A conversation with a prospective client has no script. Some people arrive with a well-defined problem; others write “I want an AI app.” The assistant has to decide what to ask, when it has enough, and when to stop. That’s a job for an agent: LangChain’s create_agent with two tools, enviar_intake and cerrar_conversacion, plus a SQLite checkpointer that stores each conversation by thread_id. The browser sends only the new message, and the history lives on the server.
Drafting the brief is a known process: check whether the intake is enough to estimate, look up similar projects, estimate, review, and write the proposal. I don’t want the model choosing the order there. It’s a fixed StateGraph, and the model only works inside some of the nodes.
evaluar → triage ─(vague)─→ pedir_datos
└(clear)─→ buscar ─┬→ redactar ⇄ revisar ─(fit)→ componer ⇄ revisar_propuesta
└→ marketing (in parallel with redactar)
redactar writes the brief the way a product owner with technical judgment would: whether it’s viable, what can be reused, what depends on the client, and which hypothesis the first stage tests. marketing reads the same intake from the buyer’s side: what actually hurts, which objections will come up, and where the proposal should start. Since it only needs the intake, it runs at the same time as redactar. In LangGraph that takes two edges leaving the same node.
The rules live in code
At first everything was in the prompt: no prices, no made-up projects, no pressure. The model followed it almost every time, and almost isn’t good enough for something a client will read. Now whatever can be checked is checked in code.
revisar rejects the brief if the minimum hours exceed the maximum, if it cites a project that doesn’t exist, or if it says “not viable today” while also marking the project as a fit. When it fails, the draft goes back to redactar with the list of problems, at most twice. If the brief concludes the project isn’t something we do, no proposal gets written and the team decides from the brief.
The proposal goes through the same thing, with stricter rules because the client reads it:
PRECIO = re.compile(r"(US\$|R\$|\$|€|\b(usd|brl)\b|\d\s*(mil\s+)?(dólares?|dolares?|reais|reales)\b)", re.I)
PLAZO = re.compile(r"\b\d+\s*(semanas?|meses|mes|días?|dias?|mês)\b", re.I)
If a currency, a timeframe, an em dash, or a pressure line like “only a few spots left” shows up, the proposal goes back to componer. The word “reales” (and “reais” in Portuguese) had a catch: it’s also an adjective meaning “real,” so the pattern only counts it as currency when it sits next to a number.
The model never sets the price
The internal brief carries effort as hours per deliverable. Code turns those hours into cost and weeks using the team’s rate, which lives in an environment variable, so the final number never comes from the model. The proposal uses a separate schema with no price or date fields, so the model has nowhere to write them. Its only call to action is booking a 30-minute online meeting, and money gets discussed there.
The chat assistant doesn’t ask about budget either. A budget mentioned early anchors the estimate, and I’d rather understand the problem first.
Code owns the design
The model fills a schema with the proposal’s content, and deck.py turns it into HTML slides in the site’s visual identity, which headless Chromium exports to PDF. The model never touches typography, colors, or layout, so every proposal looks the same and none of them break.
Everything is written to disk before the email goes out. If sending fails, the intake, the brief, and the proposal are already saved and the lead isn’t lost.
It learns, with a person in the loop
Every conversation is stored, but none of them changes the agent directly. If what a visitor types could change how it behaves, poisoning it would be trivial.
Every night a job classifies the conversations with Jev, from TypeSafe: which category each one falls into, which objections came up, and which question the client asked without getting an answer. That question is picked from the client’s actual messages; the model doesn’t write it. On Mondays a report arrives with conversion, objections, and conversations that stalled halfway. I use it to adjust the prompt or a rule, and the change goes through git like any other.
How I know it still works
A prompt change can fix one thing and break another. To catch that, I run evals on full conversations: a model plays the client, with a personality taken from a personas file, and talks to the agent until the session ends. Then the conversation gets scored. Anything visible in the text is checked in code: no Rioplatense voseo, no questions about budget, no markdown (the widget doesn’t render it). Another model acts as judge only where a regular expression can’t do the job. Each run is saved in LangSmith as an experiment, so I can compare before and after a change.
What’s around it
The API is FastAPI and streams its responses. In production it runs on Coolify next to the site, and Caddy forwards /api/* over the internal network, so the browser talks to the same domain and there’s no CORS to configure. Limits are enforced by the API, not the model: 2,000 characters per message, 30 messages per session, and 60 per IP per hour. A closed session gets a fixed reply and never reaches the model.
If you want to see it in action, open the chat and describe a real project. The team will write back with the proposal after reviewing it.