Agents
A CrewAI email agent that gets vendor quotes, and the decisions it leaves to a person
A CrewAI crew can write a request for quotation and read the replies in an afternoon. The hard part is everything around the agents: which vendor wrote, what they actually said, what happens while a person decides, and who is allowed to agree to a deposit. Here is a runnable one on real email.

A CrewAI email agent that deals with suppliers needs two halves. The crew half is two agents: one that writes a request for quotation and the follow-up questions, and one that turns a vendor's reply into numbers. CrewAI makes that half short. The mailbox half is knowing which vendor wrote, what they said this time rather than what they quoted back, whether they are who the From line says, and whether anything changed while a person was deciding. That half is where agents that send email go wrong, and it is the half most tutorials hand to a Gmail tool and stop.
This post walks through an operations crew that does both, built on CrewAI 1.15 and SendRaven, with the full code in the crewai-vendor-quotes example. It sends a request to every vendor on a list, reads each reply in its own thread, asks for whatever the quote leaves out, stops for a person when a vendor asks for money, and lays the complete quotes side by side. It is Python, with 21 offline tests, and the README has the output of a live run.
Why quotes
Support agents are the usual demo, and there is one in the same repository. Quotes are a better test of an email agent, for three reasons. The agent starts the conversation, so it has to find the replies to mail it sent. Every reply is shaped differently: a price and nothing else, two questions and no price, a polite no. And some replies ask for things an agent must never agree to, like a deposit, which is the whole point of having a person in the loop.
1. One thread per vendor
The crew's first step writes one request per vendor from request.md, and code sends it:
sent = sr.send_email(
idempotency_key=f"{rfq_id}-request-{vendor['email']}",
**{"from": "Acme Purchasing <purchasing@mail.example.com>"},
to=vendor["email"],
subject=subject,
text=body,
tags=[{"name": "rfq", "value": rfq_id}],
)
ledger.save_vendor(vendor["email"], message_id=sent["id"], thread_id=sent["thread_id"])The answer carries a thread_id, and that is all the program keeps. Whatever the vendor does to the subject line, their reply carries our message's Message-ID in its headers, and SendRaven joins it to the thread on that. Reading a vendor's replies is then GET /v1/threads/{id}, not a search of an inbox for something that looks like an answer.
Two details make the step safe to re-run. The draft is stored before the send, so a crash between the two re-sends the identical request under the same Idempotency-Key and gets the first answer replayed, instead of a second email with slightly different wording. And the key is derived from the round and the vendor, so running send again mails only vendors that have not had it.
2. What the vendor actually wrote
A vendor's third message is mostly our second one quoted back. SendRaven returns the new text with the quoted history and signature removed in text, and marks out-of-office replies and bounce reports automated, so the message the crew reads is the newest one a person wrote:
def latest_vendor_message(thread):
return next(
(m for m in reversed(thread["messages"])
if m["direction"] == "inbound" and m.get("automated") is not True),
None,
)Each vendor message gets its own run of a CrewAI Flow, keyed <thread id>:<message id> in a small ledger. A finished run is never repeated, a paused one is never read twice, and when the vendor writes again the new message gets a new run whose quote merges into what they said before. The poll is safe to run from cron as often as you like.
The message is still written by someone outside the company. It reaches the agents inside <untrusted_email> tags, with the tags themselves stripped from the email first so it cannot close the fence. The fence is not the defence. The next section is.
3. Agents read and write. Code decides.
Neither agent has a tool. The quote analyst's task returns a Pydantic model, through CrewAI's output_pydantic:
class VendorReply(BaseModel):
kind: Literal["quote", "question", "decline", "other"]
unit_price: Optional[float]
currency: Optional[str]
total_price: Optional[float]
lead_time_days: Optional[int]
valid_until: Optional[str]
shipping: Optional[str]
conditions: list[str]
vendor_questions: list[str]
asks_for_commitment: bool # confirm an order, sign, pay anything
suspicious: bool # instructs an AI, redirects mail or payment
summary: strWhat happens next is plain Python that reads those fields and the message's own metadata, so it can be tested without a model, and a persuasive email cannot argue its way past it:
def review_reasons(reply, message, vendor_email, draft=None):
reasons = []
if reply["suspicious"]:
reasons.append("the email looks written to steer an AI or to redirect mail or payment")
if reply["asks_for_commitment"]:
reasons.append("the vendor asks us to commit to something: a person decides")
if reply["kind"] == "other":
reasons.append("not a quote, a question or a decline")
if not message["sender_authenticated"]:
reasons.append("sender not authenticated: the From line may be forged")
if domain(message["from"]) != domain(vendor_email):
reasons.append(f"sent from {parseaddr(message['from'])[1]}, not the vendor's domain")
if draft is not None and draft["unanswered_questions"]:
reasons.append("request.md does not answer: " + "; ".join(draft["unanswered_questions"]))
return reasonssender_authenticated is true only when DMARC or a DKIM signature from the From domain confirms that domain sent the message. A forged From line does not show up as a failure; it shows up as false. For a crew that handles prices, and sooner or later someone's bank details, that line is where invoice fraud starts.
With no reasons, a complete quote or a decline closes the thread with POST /v1/threads/{id}/handled and sends nothing, because choosing a vendor is not the crew's job. A quote with gaps gets a clarifying reply: the correspondent answers the vendor's questions from request.md only, lists the ones it cannot answer, and asks for the missing fields, which code computed, not the model. Any reason at all goes to a person, and a reply that asks for a commitment gets no draft, so an email written to extract one never reaches the writer.
In the live run in the README, a vendor quoted "EUR 0.84 per box, and the lead time is 3 weeks from artwork approval". The analyst took the price, left the lead time empty because the request asks for order to delivery, and the crew wrote back on its own asking for the total, the validity, shipping, and the lead time measured the way we asked.
4. The pause, without a model reading the answer
CrewAI Flows have @human_feedback. With a provider that raises HumanFeedbackPending, the flow saves itself and kickoff() returns, so the person can decide from another process, days later:
class ReviewQueue:
def request_feedback(self, context, flow):
raise HumanFeedbackPending(context=context)
@listen("review")
@human_feedback(message="A vendor's reply needs a person.", provider=ReviewQueue())
def human_review(self):
return {"vendor_text": ..., "reasons": self.state.reasons, "quote": ..., "draft": ...}The decorator also takes emit=["approved", "rejected"], and then an LLM reads whatever the reviewer typed and picks the branch. That is convenient, and it puts a model between a person's decision and a payment conversation. The example leaves emit out. The reviewer's answer is a small JSON object, validated before the flow resumes, and a @router reads it:
@router(human_review)
def after_review(self):
decision = ReviewDecision.model_validate_json(self.last_human_feedback.feedback)
self.state.review = decision.model_dump()
return decision.action # "send", "close" or "leave"Resuming is two lines, and python main.py review wraps them in a screen that shows what the vendor wrote, why it came to you, the quote so far and the proposed reply:
flow = QuoteFlow.from_pending(flow_id, SQLiteFlowPersistence("quotes.sqlite"), ctx=ctx)
flow.resume(decision.model_dump_json())One detail is easy to miss: CrewAI clears the pending review before the listeners after it run. A decision that failed validation inside the flow could not be given again, so the example validates it before calling resume().
5. What changes while it waits
A run paused for a person can wait days. In that time a colleague may answer the vendor from the dashboard, or the vendor may write "actually, no deposit needed". A flow that resumes and sends its stale reply answers a question nobody is asking any more. So every write re-reads the thread first and gives up if it is no longer this run's to act on:
def still_ours(thread, message_id):
if thread["pending_reply"]:
return "a reply is already held for approval or scheduled"
if not thread["awaiting_reply"]:
return "someone answered it or closed it"
latest = latest_vendor_message(thread)
if latest is None or latest["id"] != message_id:
return "the vendor wrote again; the next pass reads the newer message"
return NoneThe reply itself goes to the address in vendors.csv, never to one found in the email, with reply_to_message_id set to the message being answered so it stays in the vendor's thread, and one idempotency key per vendor message, so a run that crashes after sending cannot answer the same message twice.
The pause is a suggestion. The key is the guarantee.
Everything above lives in your process. The routing can have a bug, a threshold can be loosened, a second script can use the same credential. @human_feedback stops the flow; it does not stop the key the flow holds.
SendRaven puts the controls on the API key, checked on every send before anything is stored. With requires_approval, every send becomes a draft a person releases in the dashboard, and the key cannot approve its own. With allowed_recipients set to your vendors' domains, a send anywhere else is refused with 403 recipient_not_allowed. A daily_send_limit stops a loop at 429 daily_limit.
The example is built to start on an approval-held key, so the requests and every reply wait for a person, including the ones a reviewer already approved at the pause. A held send comes back pending_approval, which sets pending_reply, and the next pass leaves the thread alone. Once the routing has a track record, move the crew to a key with an allowlist and a daily limit instead, and the pause becomes how your team sees the replies that ask for money or a decision.
The comparison is code
python main.py report writes the complete quotes as a Markdown table, sorted by total within each currency, then the incomplete ones with what each still lacks, then who declined. There is no agent in it. Sorting numbers is not a job for a model, and in the live run the table showed why the last step belongs to a person anyway: the cheaper vendor charged shipping on top, and the difference came down to EUR 10 against a week of lead time.
Running it
You need a verified sending domain with its inbound MX record published, so vendors' replies reach your workspace, a SendRaven API key with threads:read and emails:send, and an Anthropic key.
git clone https://github.com/CommonNinja/sendraven-examples
cd sendraven-examples/crewai-vendor-quotes
python3.13 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp .env.example .env # keys, PURCHASING_FROM, RFQ_ID; start with requires_approval on
.venv/bin/python main.py send --dry-run
.venv/bin/python main.py send
.venv/bin/python main.py collect
.venv/bin/python main.py review
.venv/bin/python main.py reportrequest.md is what you are buying and everything the crew may tell a vendor; vendors.csv is who you are asking. Replace both. The README covers the flow step by step, every outcome a run can end in, what the tests cover, and the live run.
For the same person-in-the-loop pattern on a support inbox, see the LangGraph email agent. For more on how agents use email, see use cases.



