TL;DR
We shipped a conversation router that turns messy inbound leads into crisp sales handoffs. The core is a typed state machine on OpenClaw with idempotent writes, adaptive backpressure, and human approval gates where needed for compliance. By framing the problem as workflow automation instead of a brittle rules spreadsheet, we raised qualification precision and cut response time. The single biggest lesson was to treat routing like a product with telemetry, tests, and rollback, not a one off script. It runs in production today.
The moment we knew rules were not enough
Our sales team had three complaints in one week. Leads went silent after a first reply, hot prospects waited hours for a human, and duplicate contacts kept bumping older threads. All three problems shared one root. We were moving faster than the glue code that stitched our channels together.
Early on, we used a spreadsheet of if this then that rules sprinkled across landing pages, a chat bot, and a few ad platforms. It worked until traffic spiked after a launch stream. Volume revealed timing bugs, missing context, and a surprising number of near duplicates. We decided to rebuild the routing layer as a real system.
System goals and guardrails
- Cut time to first qualified reply under 5 minutes for high intent leads.
- Keep duplicate handoffs under 1 percent of total contacts.
- Make every decision explainable with human readable reason codes.
- Ship changes safely with typed contracts, preview runs, and instant rollback.
We also drew the lines we would not cross. No outreach without consent. No messages during local quiet hours. No enrichment that pulls data we could not justify in an audit.
To align engineering and go to market, we wrote the goals into the playbook as assertions. Fail an assertion and the run halts with a clear message rather than producing a half baked handoff.
For readers new to our stack, you can skim the AI marketing automation features to see what ButterGrow does at a glance in one place: AI marketing automation features.
Architecture at a glance
The router ingests events from forms, chat, ads, and webinars. Each event lands in a normalized envelope with a typed schema. From there, a state machine decides whether we enrich, ask a question, hand off to sales, or wait for another signal.
Key building blocks:
- Typed envelopes per source with a common core: person, account, consent, intent, and context.
- A small facts table persisted in our warehouse and cached in memory for hot reads.
- A state machine that evaluates signals and returns next action plus reason codes.
- A dispatcher that respects per channel budgets and pauses emitters if API responses show backoff.
- Idempotent writes and dedupe checks around any external side effect.
State machine in practice
We modeled the router as simple states with typed inputs and outputs. Below is a simplified view.
states:
ingest:
on:
lead_event:
actions: [normalize, dedupe_check]
target: qualify
qualify:
on:
enriched:
actions: [score_cql]
target: decide
decide:
on:
cql_high:
actions: [handoff_sales]
target: confirm
cql_mid:
actions: [ask_discovery_question]
target: wait_reply
cql_low:
actions: [nurture]
target: confirm
wait_reply:
timeout: 15m
on:
reply_received:
actions: [recalculate_score]
target: decide
timeout:
actions: [nurture]
target: confirm
confirm:
on:
side_effects_committed: { target: done }
Two details saved us from flaky behavior. First, every transition includes a reason code. A reason looks like cql_high.intents:demo + persona:ops + account:in_segment. Second, we never fire side effects from within the state logic. We return a command list and let a separate executor perform effects with retries and idempotency.
Dealing with dedupe and idempotency
Duplicates showed up as the same person filling two forms or a chat follow up that mirrored a form submission. We fixed this with a composite key that included source, campaign, and a stable user identifier. Before any write, the router checks a short lived cache and the latest warehouse snapshot.
For inspiration and language, the Apache Kafka docs on idempotent producers are clear about why dedupe must live near the edge of side effects. See the section on idempotence for producers in the official docs: Kafka producer idempotence. It helped us shape our guarantees.
Adaptive backpressure when APIs push back
API limits are a fact. Rather than retry blindly, we implemented adaptive tokens per channel and sender. Tokens refill on a schedule, drop when latencies rise, and reset quickly after errors. When an emitter runs dry, the dispatcher parks that channel and lets others proceed.
The idea mirrors stream processors that slow producers when consumers fall behind. Google Cloud shows a clean approach with flow control that tunes outstanding messages by memory and count: Pub Sub flow control. We carried the same intent into our router.
Policy guardrails before any outreach
Before sending a message, we check policy. Consent, geography, quiet hours, and audience rules run as a deterministic gate that returns allow, block, or require approval. If approval is required, the router posts a compact summary to a human channel for a quick thumbs up.
The guardrails let us combine automation speed with human judgment where stakes are high. We saw this matter in markets with strict local rules and in edge cases where a name matched an allowlist pattern but the context did not.
What we shipped and what broke anyway
We cut to production with three features and a safety net.
- A single CQL score per person with reason codes written to the CRM.
- A dispatcher that respects per channel budgets and pushes late night outreach to the morning.
- A human approval step for high risk outreach with an audit trail.
- A kill switch that routes all next actions to nurture if an assertion fails.
The first day was quiet. The second day, a partner event doubled traffic and revealed a subtle bug in our chat ingestion. Our chat adapter tagged some repeat replies as new lead events. The composite key did not include the message thread id, so duplicates slipped past the cache. We patched the key and replayed the affected events.
Metrics after 30 days
We compared a 30 day window before and after launch. The numbers below are real enough to be useful and abstracted enough to be safe.
| Metric | Before | After |
|---|---|---|
| Time to first qualified reply (P90) | 47 minutes | 6 minutes |
| Duplicate handoffs | 3.4 percent | 0.7 percent |
| Sales accepted opportunities per 100 leads | 9.2 | 13.5 |
| Blocked outreach due to missing consent | 0.8 percent | 1.1 percent |
Two notes stand out. First, blocked outreach went up slightly. This is expected since we stopped sending to ambiguous consent. Second, a faster first reply did not mean more messages, it meant sharper timing and fewer dead ends.
Human in the loop that actually works
We wanted approvals that felt like help, not friction. We limited approvals to offers with legal risk, large discounts, or time zones with strict quiet hours. The summary the approver sees includes a link to reason codes, last touch, and the draft copy. Approvers see one message, decide in seconds, and the router continues.
If you want to compare stack choices while building a similar system, see how it stacks up on the comparison page: see the side by side comparison.
How we split the build
We wrote the router in three thin slices so we could demo value early.
Step 1Normalize events into typed envelopes
We wrote mappers per source that emit a normalized envelope. Each mapper is small and lives next to a contract test. The goal was to avoid a central god mapper. The envelope format included core fields like person.id, consent.source, and context.campaign.
Step 2Score with a simple model and clear reason codes
We resisted building a complex model early. The first pass was a linear score with capped weights and two boosts for intent events. We wrote reason codes alongside the score so a human could read why the number moved. As a long tail, we kept a backlog item for adding a learned model once telemetry proved stability.
Step 3Add the dispatcher and channel budgets
The dispatcher turned score into action. It respects tokens per channel and sleeps a source when latencies rise or responses show backoff. This preserved response times on priority channels during spikes. We also added soft quotas per campaign to protect brand new tests from starving.
Step 4Wire policy checks before any side effect
We added a deterministic gate that checks consent, geography, and quiet hours. The gate returns allow, block, or require approval. When approval is required, the router posts a summary in our human channel and waits.
Step 5Add idempotency and dedupe before writes
We derived an idempotency key from the source, campaign, and person. On write, we check a tiny cache and last warehouse snapshot for a matching key. If present, we update the prior record and stop. For a longer read, Meta explains dedup patterns with event_id and timestamps: Conversions API dedup patterns. The idea travels well to other systems.
Step 6Bake in observability and replay
We emitted logs with reason codes, token levels, and policy outcomes. Traces carry a run id and a link to the envelope. For replay, we store envelopes and commands so we can rerun decisions without touching the outside world. This made incident response much simpler when a partner changed a payload shape.
A small executor that does the dirty work
The command executor handles side effects. It receives a list of commands from the state machine and runs them with retries, timeouts, and idempotency. Here is a simplified shape.
from typing import List, Dict
class Command:
def __init__(self, kind: str, payload: Dict, idempotency_key: str):
self.kind = kind
self.payload = payload
self.key = idempotency_key
def execute(commands: List[Command], store):
for cmd in commands:
if store.seen(cmd.key):
continue
try:
if cmd.kind == "handoff_sales":
send_to_crm(cmd.payload)
elif cmd.kind == "ask_discovery_question":
post_chat(cmd.payload)
elif cmd.kind == "nurture":
enqueue_email(cmd.payload)
store.mark_seen(cmd.key)
except TransientError:
retry_with_backoff(cmd)
It looks boring by design. The boring parts carry the most risk when they are hidden inside business logic. Pulling them out made tests short and failures clean.
What surprised us
Two surprises changed our plan. First, reason codes were more valuable to sales than we expected. Reps pasted codes into follow ups because they read like a summary of the last touches. Second, the small facts table fixed more latency than any caching trick we tried earlier. It put 90 percent of the data we needed within a single low latency read.
If you want background on why CQLs beat MQLs in the first place, our colleagues wrote a forward looking view at conversation first teams: why CQLs beat MQLs.
Where we would start if we did it again
We would start with envelopes and reason codes on day one. The temptation is to jump straight to an advanced model. Instead, invest in clarity. Clarity makes every metric and decision easier to debug. We would also budget time for a tiny facts table and replay tooling. Both paid off in the first week.
For readers exploring options beyond homegrown scripts, you can see the hosted assistant and the broader platform at ButterGrow. You can also browse more from the ButterGrow blog to compare build stories and patterns: more from the ButterGrow blog.
Finally, if you want to try a similar build without custom plumbing, you can get started in minutes with the onboarding flow in our product. If you still have trust or policy questions, the FAQ covers common setup questions with short, direct answers: answers to common questions.
Ready to route conversations with reason codes, approvals, and safeguards that your team will trust? ButterGrow runs the hosted OpenClaw assistant with typed playbooks, policy checks, and replay. You can get started in minutes via the onboarding flow and wire your first envelope in under an hour.
References
- Kafka producer idempotence: Official docs that describe idempotent producers and why dedupe must sit near side effects.
- Pub Sub flow control: Google Cloud guide on applying flow control and backpressure to streaming workloads.
- Conversions API dedup patterns: Meta documentation on event deduplication using
event_idand timestamps.
Frequently Asked Questions
How does the conversation router convert MQL intake into a single CQL score?+
We normalize signals from forms, ads, chat, and events into a shared schema, then score each lead with a state machine that weighs intent, recency, and source trust. The router writes a CQL score and reason codes back to the CRM for sales routing.
What playbook patterns prevented duplicate messages and double handoffs?+
We used idempotent keys derived from contact identifiers and campaign metadata, plus a dedupe check on a short lived cache and the warehouse. If a key repeats, the playbook updates the existing record instead of emitting a new handoff.
How did you handle channel rate limits when traffic spiked?+
We added adaptive backpressure with per channel tokens and a queue. The router slows specific emitters based on API responses and latency budgets so high volume sources do not starve low volume but high intent ones.
How do policy guardrails block risky outreach?+
Before any message is sent, a policy check validates consent, quiet hours, and geography. The check returns allow, block, or require approval, and the router logs the decision and evidence for audit.
What data model powers fast decisions without joining the whole warehouse?+
We materialized a small facts table keyed by person and account that stores last touch, last conversation, consent status, and offer eligibility. The router queries this table synchronously and falls back to a cached snapshot if the warehouse is unavailable.
How do you test the router without risking live prospects?+
We run synthetic traffic through a staging workspace with a replay of anonymized production events. We also use a canary mode in production that samples 5 percent of traffic into shadow routing to compare outcomes before a full rollout.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo