TL;DR
This tutorial walks through building round robin lead routing on OpenClaw using workflow automation to assign a CRM owner deterministically, even under concurrent intake. You will define a simple roster store, create a playbook that picks the next owner, update the contact in your CRM, and emit metrics for auditing. The pattern uses a single atomic counter with retries to avoid double assignment. By the end, you will have a production ready router that integrates with Slack and your CRM API.
What you will build
You will implement a deterministic lead router that receives a POST request, validates and deduplicates the payload, picks the next available owner from a roster, updates the owner on the CRM record, posts a notification to Slack, and returns a 202 to the caller. The router uses a single shared counter per team and applies modulo arithmetic to rotate through the roster. If two requests try to update at the same time, the playbook performs a compare and swap and retries quickly on contention.
Along the way you will link the router to metrics and logs so you can trace every assignment. This pattern scales from a single small team to multiple territories by using a namespace per rotation key such as product line or geographic region.
For product context and capabilities, review the AI marketing automation features in the overview of what ButterGrow does. If you are new to our hosted OpenClaw assistant, the ButterGrow page provides a quick introduction to the platform.
Prerequisites
- An OpenClaw workspace with permission to create playbooks.
- A CRM integration. The examples show HubSpot and Salesforce, but any CRM with an owner field will work.
- A Slack incoming webhook URL for notifications.
- A place to store a small counter and roster. You can use OpenClaw's key value store or a relational database.
Set these environment variables locally so the snippets are easy to run during testing:
export ROUTER_TEAM_ID="sales-team-a"
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
export HUBSPOT_API_KEY="hs_live_xxx"
export SFDC_INSTANCE_URL="https://your-instance.salesforce.com"
export SFDC_ACCESS_TOKEN="Bearer 00D..."
If you prefer a quick start before you customize, the onboarding flow helps you get started in minutes.
Architecture at a glance
The router is a short path designed for reliability:
- HTTP trigger receives a JSON payload with a stable lead identifier such as email and an optional CRM record ID.
- The playbook validates the payload and uses an idempotency key to dedupe retries.
- It loads the roster for the rotation key, selects the next owner index using a compare and swap increment, and records the selection.
- It updates the CRM record owner using the platform API.
- It emits logs and metrics and posts a Slack message with the assignment.
Data model
You can keep round robin state in a tiny table. If you are comfortable with SQL, create a table and a roster table. This is one minimal approach:
-- Table to store the current cursor per team or rotation key
create table if not exists router_cursors (
team_id text primary key,
cursor integer not null default 0,
updated_at timestamp not null default now()
);
-- Roster table mapping rotation key to a list of owner IDs
create table if not exists router_rosters (
team_id text not null,
owner_id text not null,
position integer not null,
primary key (team_id, position)
);
If you use OpenClaw's key value store instead of SQL, you only need two keys per team: router:${team_id}:cursor and router:${team_id}:roster. The roster value can be a JSON array of owner IDs, and the cursor an integer. The playbook will read both and perform an atomic increment.
Playbook definition
Below is an OpenClaw playbook written in YAML to implement a simple router. The steps are named so runs are easy to trace. Replace secrets with your environment values.
# openclaw/playbooks/lead-router.yml
name: lead-router
trigger:
type: http
method: POST
path: /ingest/lead
auth: bearer
vars:
rotation_key: ${env.ROUTER_TEAM_ID}
steps:
- id: validate
run: jsonschema.validate
with:
schema:
type: object
required: [lead_id, email]
properties:
lead_id: { type: string }
email: { type: string }
- id: dedupe
run: kv.put_if_absent
with:
key: idempotency:${input.lead_id}
value: true
ttl_seconds: 86400
- id: get_roster
run: kv.get
with:
key: router:${vars.rotation_key}:roster
- id: get_cursor
run: kv.get
with:
key: router:${vars.rotation_key}:cursor
default: 0
- id: pick_owner
run: js.eval
with:
code: |
const roster = JSON.parse(inputs.get_roster.value || '[]');
if (roster.length === 0) throw new Error('Empty roster');
const cursor = parseInt(inputs.get_cursor.value || '0', 10);
const nextIndex = cursor % roster.length;
const ownerId = roster[nextIndex];
outputs.ownerId = ownerId;
outputs.nextCursor = cursor + 1;
- id: cas_cursor
run: kv.compare_and_set
with:
key: router:${vars.rotation_key}:cursor
expected: ${steps.get_cursor.value}
value: ${steps.pick_owner.outputs.nextCursor}
retries: 3
- id: update_crm_hubspot
when: ${env.HUBSPOT_API_KEY}
run: http.request
with:
method: PATCH
url: https://api.hubapi.com/crm/v3/objects/contacts/${input.lead_id}
headers:
Authorization: Bearer ${env.HUBSPOT_API_KEY}
Content-Type: application/json
body:
properties:
hubspot_owner_id: ${steps.pick_owner.outputs.ownerId}
- id: update_crm_sfdc
when: ${env.SFDC_ACCESS_TOKEN}
run: http.request
with:
method: PATCH
url: ${env.SFDC_INSTANCE_URL}/services/data/v59.0/sobjects/Lead/${input.lead_id}
headers:
Authorization: ${env.SFDC_ACCESS_TOKEN}
Content-Type: application/json
body:
OwnerId: ${steps.pick_owner.outputs.ownerId}
- id: notify_slack
run: http.request
with:
method: POST
url: ${env.SLACK_WEBHOOK_URL}
body:
text: "Lead ${input.lead_id} assigned to ${steps.pick_owner.outputs.ownerId}"
- id: result
run: respond.json
with:
status: 202
body:
assigned_to: ${steps.pick_owner.outputs.ownerId}
rotation_key: ${vars.rotation_key}
Step 1Seed a roster
Populate a roster for your team. If you use the key value store approach, write the owner list as a JSON array and set the cursor to zero.
openclaw kv set router:${ROUTER_TEAM_ID}:roster '["123","456","789"]'
openclaw kv set router:${ROUTER_TEAM_ID}:cursor 0
If you maintain rosters in SQL, write them in positional order so you can add or remove an owner without rewriting the entire list.
Step 2Define the HTTP contract
Design the payload shape so it contains a stable identifier and optional fields to assist logging. The schema below uses lead_id and email as the minimal input.
{
"lead_id": "123456789",
"email": "prospect@example.com",
"source": "webform",
"utm": {"campaign": "spring-promo"}
}
This schema is validated in the playbook by the validate step, and rejected with a 400 response if required fields are missing. The idempotency step keys on lead_id to avoid double assignments during retries.
Step 3Implement deterministic selection
The JavaScript step performs the modulo selection and prepares a new cursor. The update is committed via a compare and set. On contention the step retries up to three times. If the roster is empty, the playbook throws and the caller receives an error.
For a more advanced approach, you can model selection in a database with a single update statement that increments the cursor and returns the previous value.
-- Atomic increment in SQL with returning
update router_cursors
set cursor = cursor + 1, updated_at = now()
where team_id = :team_id
returning cursor as previous_cursor;
Then compute previous_cursor % roster_length to pick the owner index.
Step 4Connect to your CRM
Two examples are included in the playbook. For HubSpot, the update sets hubspot_owner_id on the contact. For Salesforce, it writes OwnerId on the Lead SObject. Ensure the integration user can own or transfer records.
You can verify the owner change by reading the record after the patch.
# HubSpot read back
curl -sS -H "Authorization: Bearer $HUBSPOT_API_KEY" \
https://api.hubapi.com/crm/v3/objects/contacts/$LEAD_ID | jq '.properties.hubspot_owner_id'
# Salesforce read back
curl -sS -H "Authorization: $SFDC_ACCESS_TOKEN" \
"$SFDC_INSTANCE_URL/services/data/v59.0/sobjects/Lead/$LEAD_ID" | jq '.OwnerId'
Step 5Add notifications and metrics
The Slack step provides a human friendly confirmation per assignment. For production, add a metrics step that emits counters and histograms such as assignments_total and assignment_latency_ms. OpenClaw surfaces these in run views and you can export them to your observability stack.
Consider adding a per owner counter to evaluate load distribution. An example metric schema:
{
"metric": "assignments_total",
"labels": {"team_id": "sales-team-a", "owner_id": "123"},
"value": 1
}
Step 6Handle retries and idempotency
Inbound webhooks often retry on timeouts or transient failures. The dedupe step stores an idempotency key with a short time to live and short circuits the run if the same lead arrives again. For a longer window, store the key without a TTL and expire it with a scheduled job.
To go deeper on durable playbooks, see how to add idempotency, retries, and DLQs in related guidance.
Step 7Guard against unavailable owners
Round robin works best when everyone is available. In practice, people take time off or pause intake. Extend the roster with an active flag fed by your calendar or Slack presence and filter inactive owners before applying selection. If the filtered list is empty, route the lead to a fallback owner or a shared queue and raise an alert.
You can also add a per owner capacity limit such as max_daily_assignments. When the limit is reached, temporarily skip that owner until the next day. Store the counters in the same key value namespace.
Step 8Test end to end
Start by seeding a roster and setting the cursor to zero. Send three leads and confirm they route to the first three owners in order.
curl -X POST http://localhost:8787/ingest/lead \
-H 'Authorization: Bearer test' -H 'Content-Type: application/json' \
-d '{"lead_id":"L-1","email":"a@example.com"}'
curl -X POST http://localhost:8787/ingest/lead \
-H 'Authorization: Bearer test' -H 'Content-Type: application/json' \
-d '{"lead_id":"L-2","email":"b@example.com"}'
curl -X POST http://localhost:8787/ingest/lead \
-H 'Authorization: Bearer test' -H 'Content-Type: application/json' \
-d '{"lead_id":"L-3","email":"c@example.com"}'
Inspect Slack notifications or run logs to confirm rotation order and cursor values. Then clear the idempotency key for L-2 and resend it to verify the dedupe gate.
Step 9Ship safely
Ship behind a low traffic path first and gradually cut over traffic. Schedule changes during a maintenance window and monitor error rates and contention retries. If you are using ButterGrow's hosted assistant, you can manage releases with change windows and progressive rollout, and you can revert quickly if anything degrades.
For a broader view of risk management in automations, the change gates article in our library shows how approval steps reduce blast radius in production.
Step 10Extend to territories and queues
Add a rotation_key that combines attributes such as region and product tier. This lets you run separate counters per territory without cross talk. Maintain one roster per key and reuse the same playbook by routing based on attributes in the payload. For high volume queues, consider sharding by range or hash to reduce contention.
Common pitfalls and fixes
- Empty roster or missing owners. Fail fast with a clear error and alert the team.
- Mismatched owner identifiers. Normalize owner IDs to the CRM format and verify by reading back the record.
- High contention during spikes. Add a small jittered retry with exponential backoff and consider sharding by rotation key.
- Slow CRM responses. Use a short timeout and a retry policy, and raise a metric when the CRM call exceeds your latency budget.
For more pipeline ideas, see how to build lead scoring that feeds your CRM and combine that score with routing to prioritize high intent prospects.
The ButterGrow blog includes many related guides. Browse more from the ButterGrow blog for additional patterns you can adapt to your stack.
Your teams can also explore the feature set to decide what to activate. The AI marketing automation features page shows modules that relate to routing, notifications, and observability, and the answers to common questions page clarifies permissions and setup.
The router pattern described here fits well with automated workflows in cultivation campaigns and intake. When you are ready to operate this in production, the onboarding section explains how to get started in minutes with a prebuilt template.
ButterGrow's platform reduces boilerplate and brings the hosted OpenClaw experience together with audit logs and cost guardrails, so you can focus on the logic instead of wiring.
Our team maintains templates and provides guidance if you are connecting multiple CRMs or hybrid owner models.
This paragraph is a natural call to action. If you want to run this router without managing infrastructure, use the hosted assistant at ButterGrow and import the template, then follow the onboarding flow to get started in minutes. The UI guides you through credentials, roster seeding, and a dry run with sample payloads.
References
- Round robin scheduling on Wikipedia: grounding for the rotation algorithm.
- Slack incoming webhooks: official guide for sending notifications via webhook.
- HubSpot CRM contacts API: reference for updating contact properties including owner.
Frequently Asked Questions
How does the round robin counter avoid race conditions during concurrent lead intake?+
Use a single atomic counter with compare and swap semantics in the OpenClaw key value store, or store the counter in a transactional database and update it in one statement. The playbook acquires a short lease per request, retries on contention, and logs contention metrics for visibility.
What property should I update to assign an owner in HubSpot or Salesforce?+
For HubSpot, set the hubspot_owner_id on the contact or deal using the CRM API update endpoint. For Salesforce, update the OwnerId field on the target SObject via REST, and ensure the integration user has permission to transfer ownership.
How do I handle idempotency if the webhook retries the same lead?+
Generate a deterministic idempotency key from a stable identifier such as email hash, CRM record ID, or a vendor event ID. Store the key in OpenClaw and short circuit the playbook if it already exists, then return 200 to the caller to stop further retries.
Can I include out of office or capacity rules in the rotation?+
Yes. Extend the owner roster with availability flags pulled from your calendar system or CRM, then filter the list before applying the round robin. If all owners are filtered out, route to a fallback queue and alert a Slack channel.
How do I monitor success and latency for service level agreements?+
Emit structured metrics from the playbook such as assignments_total, assignment_latency_ms, and contention_retries. Set alerts on threshold breaches and include trace links so you can replay a failed assignment run using OpenClaw's observability tools.
What is the safest way to roll out routing changes without breaking production?+
Use progressive rollout by duplicating the playbook and routing a small percentage of traffic to the new version. If errors increase, shift traffic back, and only promote the new version to 100 percent after stability.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo