Customer support automation
A durable customer-support pipeline — email ingest, agent triage, draft response, human approval, send. Built around Flux's pause/resume primitive for the human step.
We will build a customer-support pipeline where an inbound email is triaged by an LLM, a draft response is generated, a human operator approves or edits the draft, and the workflow sends the final reply.
The interesting bit is the human step. In a queue-based system, you would have to invent your own state machine, schedule a callback, store the draft somewhere, and reconcile what came back. In Flux, you call pause() and let the workflow sit. When the operator resumes it with their decision, execution picks up on the next line.
What you are building
A workflow per inbound email. The workflow runs in five stages: ingest, triage, draft, pause for human approval, send. The pause is durable — it can sit for hours or days while the operator is on vacation, and nothing leaks. The triage and draft stages use the Flux agent() primitive against your provider of choice.
email -> triage -> draft -> [PAUSE: operator] -> send
-
Ingest
The ingest task pulls a single message off IMAP and returns it as a structured dict. We retry on transient IMAP failures and time out aggressively so that one stuck connection does not block the worker.
import imaplib import email from email.message import EmailMessage from flux.task import task @task.with_options(retry_max_attempts=3, retry_delay=2, timeout=30) async def fetch_email(uid: str) -> dict: with imaplib.IMAP4_SSL("imap.example.com") as conn: conn.login("support@example.com", "{password}") conn.select("INBOX") _, data = conn.fetch(uid, "(RFC822)") raw = data[0][1] msg = email.message_from_bytes(raw, _class=EmailMessage) body = msg.get_body(preferencelist=("plain",)) return { "uid": uid, "from": msg["From"], "subject": msg["Subject"], "body": body.get_content() if body else "", }In production you would have a separate worker process polling the inbox, posting a new workflow execution per unread UID. The polling loop is not part of the workflow — it is a service that creates work for the workflow to do. We will come back to that in the operational notes at the end.
Treating fetch as an explicit task (instead of pulling the email body inside the workflow function directly) means the IMAP call is replayed from the event log on a retry, not re-issued against the server. That matters when the email already moved out of the INBOX between executions, or when a transient network blip would otherwise trigger a re-fetch of a message the workflow has already processed.
-
Triage
Triage classifies the email into one of five categories. Each category gets a different downstream tone, urgency, and escalation policy.
from pydantic import BaseModel from flux.tasks.ai import agent class Triage(BaseModel): category: str # "complaint" | "refund" | "technical" | "sales" | "spam" urgency: str # "low" | "medium" | "high" summary: str async def make_triage_agent(): return await agent( system_prompt=( "You triage inbound customer emails. " "Choose the single best category and assign an urgency. " "Write a one-sentence summary of what the customer wants." ), model="anthropic/claude-sonnet-4-20250514", response_format=Triage, max_tool_calls=1, max_tokens=512, stream=False, )max_tool_calls=1caps the agent loop. Triage does not have tools and does not need a multi-turn conversation; capping it keeps a triage call from turning into a multi-turn tool-use exchange.The Anthropic provider enforces
response_formatat the API by issuing a forced tool call, so the model returns JSON matching the schema.max_tokens=512is honored as well. For triage that is the right shape — a structuredTriageobject comes back ready to use. -
Draft response
A second agent call generates the draft, with the triage result as context so the tone matches.
async def make_draft_agent(): return await agent( system_prompt=( "You draft customer-support replies. " "Match the tone implied by the triage category — empathetic for " "complaints, factual for technical, concise for sales. " "Never promise refunds, discounts, or timelines without operator confirmation." ), model="anthropic/claude-sonnet-4-20250514", max_tokens=1024, stream=False, )The “never promise” guardrail is non-negotiable. The draft is going to a human reviewer anyway, but you want the LLM to produce something they can rubber-stamp in the easy 70% of cases rather than something they have to rewrite from scratch. Push the policy into the system prompt and the draft quality goes up sharply.
-
Human approval gate
This is the load-bearing step. We pause the workflow and wait for an operator to resume it.
from flux.tasks import pause # inside the workflow: decision = await pause( name="operator_approval", output={ "triage": triage.model_dump(), "draft": draft, "customer_email": email_data, }, )pause(name, output)does two things. It raisesPauseRequestedto suspend the workflow, and it attachesoutputto the pause event so the operator UI (or anything reading the event log) has full context to display. The workflow goes intoPAUSEDstate and stays there indefinitely.Resuming is a separate API call. The CLI takes three positional arguments — the workflow name, the execution ID, and the resume input:
flux workflow resume handle_support_email <execution_id> '{...}'. Whatever JSON you pass as the resume input becomes the return value of thepause()call. So if the operator UI sends back{"approved": true, "modifications": null}, thedecisionvariable in the workflow gets exactly that dict.decision = { "approved": True, "modifications": None, # or a rewritten draft string }There is no timeout on the pause itself. The workflow can sit paused for an hour, a day, a week. The worker is not holding any in-memory state — the workflow context is fully on disk. When the resume arrives, a worker claims it and replays from the pause.
-
Send
The send step actually transmits the reply. The draft might have been modified by the operator, so the workflow reads from the resume input before sending.
import smtplib from email.message import EmailMessage @task.with_options(retry_max_attempts=3, retry_delay=5, timeout=30) async def send_reply(to: str, subject: str, body: str) -> dict: msg = EmailMessage() msg["To"] = to msg["From"] = "support@example.com" msg["Subject"] = f"Re: {subject}" msg.set_content(body) with smtplib.SMTP_SSL("smtp.example.com") as server: server.login("support@example.com", "{password}") server.send_message(msg) return {"sent": True, "to": to, "subject": msg["Subject"]}Sending is the one truly irreversible step. We retry on transient SMTP failures, but we do not roll back — once an email has gone out, there is no
delete_email_rowto call. The retry policy and the explicit human approval upstream do the work that rollback does for reversible side effects.If you wanted the LLM itself to be able to invoke
send_reply(in a more agentic variant of this pipeline), you would declare the task withrequires_approval=True:@task.with_options(requires_approval=True) async def send_reply(...) -> dict: ...The engine emits a
TASK_AWAITING_APPROVALevent before the body runs and pauses the workflow until an operator decides viaflux execution approve|rejector the equivalent HTTP endpoint. For the workflow shown here we do not need it — the workflow-levelpause()already gates the send.The two patterns — workflow-level pause and tool-level approval — solve different problems. Workflow-level pause is right when the human is reviewing a finished artifact (the draft) and approving the whole action. Tool-level approval is right when an autonomous agent might choose to invoke a sensitive tool mid-loop, and you want a human gate at the tool invocation rather than at the end of the run. Mix them when the workflow gives the agent freedom inside a bounded budget and the human still wants final say on irreversible actions.
-
The workflow
Everything wired together:
from flux import ExecutionContext from flux.workflow import workflow @workflow async def handle_support_email(ctx: ExecutionContext[dict]): params = ctx.input or {} uid = params["uid"] email_data = await fetch_email(uid) triage_agent = await make_triage_agent() triage = await triage_agent( instruction="Triage this email.", context=email_data["body"], ) draft_agent = await make_draft_agent() draft = await draft_agent( instruction=( f"Draft a reply to this {triage.category} email. " f"Urgency: {triage.urgency}. Summary: {triage.summary}." ), context=email_data["body"], ) decision = await pause( name="operator_approval", output={ "triage": triage.model_dump(), "draft": draft, "customer_email": email_data, }, ) if not decision.get("approved"): return { "status": "rejected", "reason": decision.get("reason"), "uid": uid, } final_body = decision.get("modifications") or draft send_result = await send_reply( to=email_data["from"], subject=email_data["subject"], body=final_body, ) return { "status": "sent", "uid": uid, "category": triage.category, **send_result, }The workflow is fifty lines. Most of the value is in what is not there: no state machine, no scheduling glue, no manual checkpointing, no reconciliation between the paused state and what the operator saw. Flux’s event log carries all of that.
-
Operational notes
A few things worth knowing before you run this in anger.
The IMAP poll is not part of the workflow. You want a separate small worker process (or a
cron-driven script) that polls the inbox every few minutes and starts a newhandle_support_emailexecution per unread UID. Keep it stateless: the workflow’s own idempotency onuidhandles dedup, and the inbox itself tracks read state.Use label-based worker affinity for the polling worker. Tag the poll worker with a label like
role=pollerand tag the main workers withrole=processor. Then setaffinity={"role": "processor"}onhandle_support_emailso polling and processing do not compete for the same worker slot. See worker affinity for the full pattern.Long pauses are fine. A worker is not holding the paused workflow in memory. The pause sits in the event log. A separate worker (potentially in a future deployment) picks up the resume when it arrives.
Auto-triage some categories if volume demands it. For a high-volume inbox, you may want spam and certain low-risk categories to auto-resolve without the human gate. Branch on
triage.categorybefore the pause and route the auto-paths around it. The shape of the workflow stays the same; you are just choosing where thepause()call lives.
What to remember
pause() is a first-class workflow primitive, not a hack. The workflow goes into PAUSED state, releases the worker, and waits indefinitely. There is no timeout, no in-memory state, and no need to invent your own scheduling glue.
Agents are tasks. The result of agent(...) is a @task-decorated function. It records events, supports retries, fits inside parallel(...) and pipeline(...), and replays from cache like any other task.
Human-in-the-loop is a workflow design decision, not a feature. You decide where the human gate goes by where you put pause(). The runtime treats it as just another suspension point.
Where this shows up next
- Pause and resume — the canonical reference for the pause primitive
- Agent with human approval — the agent-flavored variant of this pipeline
- Composing workflows — splitting the poll and process sides into separate workflows
- Worker affinity — using labels to route workflows to the right workers
- Anthropic integration — provider notes for the triage and draft agent calls