The Relay System
How AI agents convert conversations into real orders inside whatever back-end a tenant already runs.
1. The Relay System, in plain language
Overview
What it is
The Relay is the bridge between your AI agents (chat and voice) and however a tenant actually receives an order. The agent speaks to the customer in one common language. The Relay translates that into whatever the store on the other end uses, whether that is a real POS, a text to the owner, an email, a Zap, or a webhook.
One interface (PosRelay in _base.py) with five methods: submit an order, check status, cancel, look up a returning customer, report capabilities. Each tenant config points at one implementation.
The six providers shipping today
- CleanCloud. Full POS integration. Pickups land directly in the store's existing system.
- SMS. Texts the order to a store phone via Twilio. For shops with no POS, or where staff live on their phones.
- Email. Emails the order to the owner via SendGrid. The lowest-tech option.
- Webhook. POSTs JSON to whatever endpoint the store gives you. Good for custom or legacy systems.
- Zapier. Fires a Zap so the tenant routes the order wherever they want (Sheets, Trello, CRM, etc.).
- Manual. Logs the order to the database and lets staff work it from the admin view.
Voice is a sub-package layered on top of any of the six relays, giving an ElevenLabs voice agent the same booking surface as the chat agent.
What benefits this gives tenants
- You meet them where they are. A tenant with no software still gets AI booking via SMS or email. A tenant with CleanCloud gets a real POS integration. Same chat agent, same voice agent, no rebuild.
- They can change back-ends without changing the agent. A laundromat starting on SMS can graduate to CleanCloud later. You flip
pos_relay.typein their config and the customer experience is identical. - Orders don't get lost. The order store, dedupe layer, and idempotency keys mean a retried order doesn't become two orders, and a network hiccup doesn't drop a booking on the floor.
- The voice agent stays graceful on failure. With
pos_relay.use_tool_contract: true, a CleanCloud timeout or Twilio 5xx becomes a typed error with a spoken response ("I'm having trouble reaching the store, can I take your number?") instead of dead air. - Confirmation closes the loop. The customer is told "you're booked" only after the store acknowledges, not just when the agent fires the request.
- Enterprise tier value. POS Relay is gated to Enterprise ($899). That tier sells in large part because of this layer.
"Your AI agent takes the order. The Relay makes sure that order shows up wherever you already run your business, your POS, your phone, your inbox, your Zap. If we ever change the back-end, the customer never notices."
2. SMS Relay
Twilio, two-way text, human in the loop
The big picture
The store has a phone that receives texts. The customer has a chat or voice session with the AI agent. The SMS relay sits in the middle: it converts an in-progress AI conversation into a formatted text to the store, waits for the staff to reply YES or NO, and only then tells the customer their pickup is confirmed.
The agent never tells the customer "you're booked" on its own. That promise is gated on a human at the store actually saying yes.
The pieces
| Piece | What it does |
|---|---|
sms_relay.py | The relay class itself (submit, check, cancel) |
twilio_service.py | The actual Twilio API call |
_order_store.py | Postgres reads/writes for orders and customers |
confirmation_service.py | Background loop that nags the store and expires stale orders |
_constants.py (SMS_* templates) | The exact text the store and customer see |
Per-tenant config (pos_relay.*) | store_phone, relay_number, confirmation_timeout_minutes |
End-to-end flow
1. Agent calls submit_order
Customer says "I'd like a Wash and Fold pickup Tuesday morning." The agent collects name, phone, address, service type, date, time slot, optional notes, then calls SmsRelay.submit_order(...).
2. Write the order to Postgres first
store.create_order(...) inserts a row in relay_orders with status SUBMITTED and a short tracking_code (the thing both sides will quote). It also upserts a row in relay_customers keyed on (client_id, phone) so this customer is recognized next time.
This happens before the SMS goes out. If Twilio fails, you have the order on disk to roll back; you never lose data because of a network blip.
3. Format the store-facing SMS
The SMS_ORDER_TO_STORE template fills in:
๐งบ PICKUP REQUEST #ABC123 โโโโโโโโโโโโโโโโโโ Jane Doe ๐ฑ +1 555-555-1212 ๐ 123 Main St ๐งน Wash and Fold (~2 bags) ๐ Tue Mar 09, 10am-12pm ๐ Leave at side door โ Reply YES to confirm โ Reply NO to decline
The tracking code is the key. Everything downstream pivots on it.
4. Send via Twilio
twilio_service.send_sms(to=store_phone, message=sms_body, from=relay_number). relay_number is the tenant's provisioned Twilio number, so the store always sees the same sender (and can save it as a contact).
5. Handle Twilio's reply right there
If Twilio returns success: false (number invalid, opted out, rate-limited, A2P registration issue, etc.), the relay rolls the order back to CANCELLED immediately and returns success=False to the agent. It does not leave it sitting in SUBMITTED and lie to the customer.
The error message includes Twilio's numeric error code so the contract-wrapper layer (use_tool_contract: true) can classify it and pick the right spoken response.
6. Otherwise, move to PENDING_CONFIRMATION
On a successful send, the relay generates a confirmation_token, computes confirmation_expires_at = now + confirmation_timeout_minutes (per-tenant, typically 10 to 30 min), records relay_sent_at, relay_channel="sms", relay_destination=store_phone, and stashes relay_from_number in metadata so the inbound webhook can match replies to orders. Updates the order to status PENDING_CONFIRMATION.
Returns to the agent: "Pickup request #ABC123 sent to store. You'll receive confirmation shortly." Note the language: "sent," not "confirmed."
7. Store replies
The store texts back YES, YES ABC123, NO, or sometimes free text. An inbound webhook finds the open order and:
- YES: status
CONFIRMED, sendSMS_CUSTOMER_CONFIRMEDto the customer, sendSMS_STORE_CONFIRMEDback to the store as a receipt. - NO: status
REJECTED, sendSMS_CUSTOMER_REJECTED(apology, ask them to call), sendSMS_STORE_REJECTEDto the store. - Multiple pending orders: store sees
SMS_MULTIPLE_PENDINGlisting each tracking code so a single "YES" doesn't accidentally confirm the wrong one. Store specifiesYES ABC123orYES ALL.
8. Background nagging and expiry
confirmation_service.py runs a loop. Every tick it asks Postgres for orders past the reminder threshold (sends SMS_REMINDER) and orders past confirmation_expires_at (marks EXPIRED, sends SMS_EXPIRED to the store, and a "we couldn't reach the store, please call" message to the customer).
That loop is what makes the silent-store case safe.
9. Cancel and lookup
cancel_order(order_id)flips status toCANCELLEDand texts the store "โ Pickup #ABC123 cancelled by customer." Cancel-SMS failure is logged but doesn't fail the cancel; the DB is the source of truth.identify_customer(client_id, phone)is how the agent recognizes a returning caller and skips the name/address questions.
Tenant-visible benefit
A laundromat with no POS, no website, no software, just a phone, gets:
- AI agents that book pickups 24/7.
- Orders that go straight to whoever already answers texts at the store.
- A safety rail that never tells a customer "you're booked" until a human at the store actually agrees.
- A reminder and expiry loop so missed texts become a polite handoff, not a no-show.
- Returning customers recognized by phone.
All of that on Foundation-tier infrastructure cost: one Twilio number per tenant, no integrations.
3. CleanCloud Relay
Real POS API, immediate confirmation, full capabilities
The big picture
CleanCloud is a real laundry POS used by mid- and large-size operations. The integration is the opposite of SMS: instead of texting a human and waiting for "YES," the agent calls CleanCloud's API and the order lands as a real ticket in the store's existing software. The customer is told "confirmed" immediately because there's a machine on the other end, not a person.
The relay is an adapter. The heavy lifting (HTTP, auth, customer and order creation, webhooks) already lives in app/services/cleancloud/. The relay wraps that package behind the same PosRelay interface as SMS/email so the agent code is identical.
The pieces
| Piece | What it does |
|---|---|
cleancloud_relay.py | Thin adapter implementing PosRelay |
cleancloud/client.py | The actual CleanCloud HTTP client (book_pickup, create_order, etc.) |
cleancloud/models.py | BookingRequest, OrderCreate, CustomerCreate, OrderProduct |
cleancloud/webhooks.py | Inbound webhook handler for order-status updates |
cleancloud/customer_lookup.py | Returning-customer recognition |
_order_store.py | Postgres relay_orders / relay_customers (unified tracking) |
Per-tenant config (pos_relay.*) | cleancloud_api_token, cleancloud_store_id |
The dual-write pattern
This is the key architectural choice. Every CleanCloud order is written twice:
relay_ordersrow first. Unified tracking across all relay types. Sametracking_code, same Postgres table, same admin dashboard whether the tenant is on SMS, email, or CleanCloud.- CleanCloud API second. The actual order of record for store operations.
The two are linked by relay_orders.external_order_id = cleancloud.order_id. This lets you query "all orders for tenant X this week" in one SQL statement no matter which back-end took them. And if CleanCloud is down, you still have the customer's submission on disk to retry or roll back.
End-to-end flow
1. Agent calls submit_order
Same call signature as every other relay. The agent has no idea whether this tenant is on CleanCloud or SMS.
2. Write the relay_orders row, status SUBMITTED
Same create_order write as the SMS path. Generates a tracking_code. Also upserts relay_customers keyed on (client_id, phone), this time stamped pos_type="cleancloud".
3. Build a CleanCloud BookingRequest
Service-type mapping happens here: the platform speaks wash_fold, CleanCloud speaks wash_dry_fold, so the relay normalizes.
4. Call cc_client.book_pickup(booking)
This is the meaty step. Inside cleancloud/client.py, book_pickup is a two-call sequence:
- Customer create.
POSTto CleanCloud's customer endpoint with name, phone, email, address. Returns a CleanCloudcustomer_id. CleanCloud dedupes by phone, so a returning customer doesn't create a duplicate. - Order create. Builds
OrderProductlines from the tenant's product config in CleanCloud:- Wash and Fold: quantity computed as
estimated_lbs ร price_per_lbfrom tenant config, defaulting tomin_lbs(typically 10) when the customer hasn't given a weight. - Dry cleaning: quantity 1, price 0, with a note that price is set at dropoff. (Dry cleaning is priced per garment by the store, not the agent.)
- Both: both lines on one order.
POSTto CleanCloud's order endpoint withorder_type="pickup", the pickup date and normalized time slot, special instructions, and the tenant'snotify_method. - Wash and Fold: quantity computed as
Returns a BookingResponse with the CleanCloud order_id. A six-character confirmation number is derived from the last six characters of that ID.
5. Update relay_orders to CONFIRMED
Critically different from SMS: the relay jumps directly to CONFIRMED, not PENDING_CONFIRMATION. There's no human waiting to reply YES; the POS already accepted the booking.
Returns to the agent: "Pickup #ABC123 confirmed! Order ID: 12345678". The agent can promise the customer their booking is real.
6. If CleanCloud fails
Any exception from book_pickup falls into the relay's except block, which logs and returns success=False. The relay_orders row stays at SUBMITTED so you can see exactly which bookings the agent tried but the POS rejected.
When the contract wrapper is on (pos_relay.use_tool_contract: true), the failure is classified into a typed error code (CC_TIMEOUT, CC_AUTH, CC_VALIDATION, etc.) so the voice agent reads a graceful spoken response instead of a stack trace.
7. Status updates flow back via webhooks
CleanCloud sends webhooks whenever the order moves through its lifecycle. webhooks.py receives them and translates CleanCloud's numeric status codes via CC_STATUS_MAP:
| CC code | Meaning | Mapped to |
|---|---|---|
| 0 | New | SUBMITTED |
| 1 | Confirmed / Picked up | CONFIRMED |
| 2 | In progress / Processing | IN_PROGRESS |
| 3 | Ready | IN_PROGRESS |
| 4 | Delivered | COMPLETED |
| 5 | Cancelled | CANCELLED |
That mapping is what lets the chat agent answer "Is my order ready?" with a real answer rather than just "I sent it to the store, I don't know."
8. Returning-customer recognition
identify_customer(client_id, phone) does a two-step lookup:
relay_customersfirst. Fast path for anyone who's used the agent before.- Fallback to
cleancloud_customers. A legacy table that holds customers CleanCloud knew about before the platform did. If a hit, the relay back-fills arelay_customersrow so future lookups skip step 2.
This matters because Enterprise tenants usually arrive with thousands of existing customers in CleanCloud. The agent recognizes them on call one, not call two.
Capabilities
Unlike SMS, CleanCloud reports every capability flag True: real-time status, real-time availability, account info, auto-confirm. This is what unlocks Enterprise-only conversational features: "When's my next pickup?", "What did I spend last month?", "Are you open Saturday at 10?", none of which the SMS or email relay can answer.
Tenant-visible benefit
A laundromat already on CleanCloud gets:
- AI agents that book pickups directly into the POS, with no staff intervention.
- Immediate confirmation to the customer.
- Live order-status questions answered from the source of truth.
- Returning-customer recognition seeded from years of existing CleanCloud customer data.
- A unified
relay_ordersledger so the platform's reports, dashboards, and attribution work the same way they do for SMS-only tenants. - Per-line product pricing pulled from their CleanCloud config, so quoted estimates match what the store actually charges.
This is the integration that justifies the Enterprise ($899) tier. SMS gets a store an AI agent. CleanCloud gets a store an AI employee.
4. Email Relay
SendGrid, magic-link buttons, lowest-friction onboarding
The big picture
Email relay is the gentlest tier of integration. The store has nothing but an inbox, but they get a clean, formatted email with two big buttons, CONFIRM and DECLINE, that close the loop with one click. No reply needed, no app to install, no POS to learn.
The flow mirrors SMS in structure (DB-first, then send, then wait for the store), but the confirmation mechanism is fundamentally different. SMS waits for a text reply. Email waits for a click on a magic link that carries a one-time confirmation token.
The pieces
| Piece | What it does |
|---|---|
email_relay.py | The relay class implementing PosRelay |
app/services/email/relay.py | send_relay_order_email, builds HTML and text, calls SendGrid |
app/api/relay_routes.py | GET /api/relay/confirm/{token}, the magic-link landing endpoint |
_order_store.py | relay_orders and relay_customers writes |
confirmation_service.py | Background reminder and expiry loop (shared with SMS) |
| Per-tenant config | store_email, confirmation_timeout_minutes |
| Platform secret | SENDGRID_API_KEY |
End-to-end flow
1. Agent calls submit_order
Same call as every other relay. Agent collects name, phone, address, service, date, slot, notes.
2. Write the order to Postgres first
store.create_order(...) inserts a row in relay_orders with status SUBMITTED and a tracking_code. Upserts the customer in relay_customers. Same DB-first pattern as SMS, same reason: no lost orders if SendGrid is having a bad day.
3. Mint a confirmation token
token = secrets.token_urlsafe(32) expires_at = now + confirmation_timeout_minutes
The token is the secret that proves "this click came from the email we sent." 32 random URL-safe bytes is roughly 256 bits of entropy, impossible to guess. The expiry is per-tenant, typically 30 to 120 minutes for email (longer than SMS because store staff check inboxes less often).
4. Build the email
send_relay_order_email(order, store_email, token) constructs both HTML and plain-text bodies. The HTML version has the buttons:
Subject: Pickup Request #ABC123 - Jane Doe ๐งบ PICKUP REQUEST #ABC123 โโโโโโโโโโโโโโโโโโ Jane Doe ๐ฑ +1 555-555-1212 ๐ 123 Main St ๐งน Wash and Fold (~2 bags) ๐ Tue Mar 09, 10am-12pm ๐ Leave at side door [ CONFIRM ] [ DECLINE ]
The buttons are anchors pointing at:
https://api.cwadagency.com/api/relay/confirm/{token}?action=confirmhttps://api.cwadagency.com/api/relay/confirm/{token}?action=reject
Plain-text body has the same content with the URLs spelled out (for inbox clients that strip HTML).
5. Send via SendGrid
sg.send(message). Subject, HTML, and plain-text in one multipart message. From-address is the tenant's configured sender (or platform default with the tenant's name).
Important: relay-order emails explicitly bypass notification_preferences. Comment in the code is blunt:
Relay order emails are transactional: stores must see every order to confirm or decline. These intentionally skip notification_preferences checks (unlike digest/transcript/callback emails).
A store can mute the weekly digest. They cannot mute the order pipeline.
6. Handle SendGrid's response right there
If send_relay_order_email returns {"success": False, "status_code": ...} (invalid recipient, suppression list, 5xx from SendGrid, etc.), the relay rolls the order back to CANCELLED immediately and returns success=False to the agent. Same "tool-first truth" rule as SMS: don't promise PENDING_CONFIRMATION for an email that didn't go out.
The send result is also written to email_audit_logs via log_email_send so failures are observable in the dashboard, not just in container logs.
7. Otherwise, move to PENDING_CONFIRMATION
Returns to the agent: "Pickup request #ABC123 emailed to store. You'll receive confirmation shortly." Same careful wording as SMS: sent, not confirmed.
8. Store clicks a button
GET /api/relay/confirm/{token}?action=confirm does:
- Look up the order by
confirmation_token. If no match, render a "link expired or invalid" page. - Check
confirmation_expires_at. If expired, markEXPIREDand render the expiry page. - Check the order isn't already confirmed, rejected, or cancelled (idempotency: a double-click doesn't trigger a second "you're confirmed" email to the customer).
- Update status to
CONFIRMEDorREJECTEDbased on?action=.... - Record
store_response_at,store_response_raw="email_link_clicked". - Call
_notify_customer_confirmed(order)or_notify_customer_rejected(order)to send the customer their own follow-up email. - Render a confirmation page in the browser: "โ Pickup confirmed. The customer has been notified."
The whole interaction is stateless from the store's perspective. They never log in. They click once. Done.
9. Background reminders and expiry
confirmation_service.py is the same loop that handles SMS reminders. For email orders past the reminder threshold, it calls send_relay_order_email(order, store_email, token, is_reminder=True), which prepends REMINDER: to the subject. Past confirmation_expires_at, it marks the order EXPIRED and sends send_relay_expiry_email to the store and a "we couldn't reach the store" message to the customer.
Token security
- One token per order. Issued once at
submit_order, written torelay_orders.confirmation_token. A token confirms exactly one order. - Hard expiry.
confirmation_expires_atis enforced server-side, not client-side. - Idempotent endpoint. Once an order is
CONFIRMEDorREJECTED, clicking again is a no-op. - No PII in the URL. The token is opaque random bytes. The order lookup happens server-side.
Tenant-visible benefit
A laundromat with nothing but an inbox gets:
- AI agents that book pickups 24/7.
- Orders that arrive looking like a real product, not a forwarded chat transcript.
- One-click confirm/decline with no login, no app, no learning curve.
- The same
relay_ordersledger as every other tier. - Reminders and expiry so a missed email doesn't strand the customer.
- Audit trail in
email_audit_logsproving each store-bound order email was delivered (or wasn't, and why).
Email is the lowest-friction onboarding path. A Foundation-tier tenant can be live in minutes with nothing more than store_email set in their config.
5. Webhook Relay
Generic HTTP POST, bring-your-own back-end
The big picture
Webhook relay is the "plug your own back-end in here" option. The store, or the chain operating the store, already has software, a custom dispatch system, an in-house POS, a third-party platform without a published integration, a Make/n8n flow, a Lambda. The platform doesn't care. It POSTs a clean JSON envelope to whatever URL you give it, with whatever headers you specify, and trusts the receiving system to take it from there.
Confirmation flows back over a second HTTP call: the receiving system POSTs to a platform endpoint with the new status and a shared secret. It's a two-way HTTP handshake, decoupled in time.
The pieces
| Piece | What it does |
|---|---|
webhook_relay.py | The relay class, POSTs orders, parses HTTP errors |
relay_routes.py POST /api/relay/webhook/status | The callback endpoint the receiver hits to push status updates |
_order_store.py | relay_orders and relay_customers writes |
Per-tenant config (pos_relay.*) | webhook_url, webhook_headers, webhook_secret |
End-to-end flow
1. Agent calls submit_order
Same uniform call. Agent doesn't know the back-end is custom.
2. Write the order to Postgres first
Standard store.create_order(...) with relay_type=WEBHOOK, status SUBMITTED. Generates a tracking_code. Upserts the customer.
3. Build the JSON payload
A clean, stable envelope. This is the contract the tenant's engineer integrates against:
{
"event": "order_submitted",
"tracking_code": "ABC123",
"order_id": "uuid-...",
"client_id": "tenant-slug",
"customer": {
"name": "Jane Doe",
"phone": "+15555551212",
"email": "jane@example.com",
"address": "123 Main St",
"zip": "10001"
},
"order": {
"service_type": "wash_fold",
"estimated_items": "2 bags",
"special_instructions": "Leave at side door",
"pickup_date": "2026-03-09",
"pickup_time_slot": "10am-12pm",
"estimated_total": 25.00
}
}
Three top-level groupings: event metadata, customer block, order block. Numeric values are real numbers. Dates are ISO 8601.
4. Compose headers
headers = dict(self.config.webhook_headers or {})
headers.setdefault("Content-Type", "application/json")
headers["Idempotency-Key"] = order.tracking_code
Three layers: tenant-supplied headers (auth/bearer/HMAC), default Content-Type, and platform-set Idempotency-Key. The Idempotency-Key is the same across retries because the tracking code is stable. If the receiver caches the response keyed on this header, a network hiccup followed by a re-POST returns the original response instead of creating a duplicate order.
5. POST it
15-second timeout, single attempt at the relay layer. Any 4xx or 5xx raises and falls into the except block. The contract wrapper layer, when enabled, adds the typed retry/backoff policy on top.
6. On success, move to PENDING_CONFIRMATION
Returns to the agent: "Pickup request #ABC123 sent. You'll receive confirmation shortly."
Note no confirmation token is minted here. Unlike SMS or email, there's no human in the loop on the receiving side and no magic-link click to validate. The receiver authenticates via the shared webhook_secret when it calls back.
7. On failure
raise_for_status() raises, the except block logs and returns success=False. The order is left in SUBMITTED. Different from SMS/email, which actively flip to CANCELLED.
This is intentional. Webhook receivers are often retried by external infrastructure (the tenant's queue, a scheduled job, a manual replay). Leaving the order at SUBMITTED keeps the door open for a later retry without confusing state transitions.
8. Receiver pushes status back
Once the receiver has actually processed the order, they POST to your platform:
POST https://api.cwadagency.com/api/relay/webhook/status
Content-Type: application/json
{
"tracking_code": "ABC123",
"status": "confirmed",
"external_order_id": "their-system-id-12345",
"secret": "the-shared-secret-from-config"
}
The handler matches by tracking_code or external_order_id, validates webhook_secret (403 on mismatch), validates the status against the OrderStatus enum (400 on invalid), updates relay_orders, and fires _notify_customer_confirmed(order) or _notify_customer_rejected(order) on the meaningful transitions.
The receiver can call this multiple times as the order moves through their lifecycle. submitted โ confirmed โ in_progress โ completed is a normal sequence.
Security model
- Shared secret in the callback.
pos_relay.webhook_secretis the platform's only authn check for inbound status pushes. Set this for every webhook tenant before they go live. - Outbound auth via tenant-supplied headers. Bearer token, API key, signed HMAC, anything the receiver requires goes in
webhook_headers. - Idempotency-Key. Not security per se, but prevents replay-amplification.
Tenant-visible benefit
A tenant with their own infrastructure gets:
- A clean, stable JSON contract they integrate against once, not a per-tenant custom build.
- Headers and auth fully under their control.
- Built-in idempotency on retries so they can dedupe safely.
- A push-back endpoint they own the timing of.
- Customer-side notifications (email/SMS confirmations) handled by the platform on their behalf.
- The same
relay_ordersledger as every other tier.
Webhook is the "bring your own back-end" door. SMS gives you AI booking when you have nothing. CleanCloud gives it to you when you have CleanCloud. Webhook gives it to you when you have something nobody else has thought of.
6. Zapier Relay
Catch-hook, flat payload, no-code fan-out
The big picture
Zapier relay is webhook relay's friendlier cousin, shaped specifically for Zapier's catch-hook. The mechanics are almost identical to the webhook relay, but the payload and conventions are tuned to make life easy for a non-engineer building a Zap in the Zapier editor. Drop the URL in the config, and a small-business owner with no code can route orders to Google Sheets, Slack, Airtable, Trello, QuickBooks, or any of Zapier's 7,000+ apps.
The pieces
| Piece | What it does |
|---|---|
zapier_relay.py | The relay class, POSTs flat JSON to a Zapier webhook URL |
relay_routes.py POST /api/relay/webhook/status | Shared callback endpoint for status pushes back (same as webhook relay) |
_order_store.py | relay_orders and relay_customers writes |
Per-tenant config (pos_relay.*) | webhook_url (the Zap's catch-hook URL), webhook_secret |
Notice what's not in the config: no webhook_headers. Zapier authenticates by URL alone, the catch-hook URL has a long random secret baked in, and that's the entire authn story for outbound.
The flat payload
This is the meaningful difference from webhook relay. Zapier's editor presents each top-level JSON field as a directly-selectable variable in subsequent Zap steps. Nested objects work, but they create a worse UX. So the payload is intentionally flat:
{
"tracking_code": "ABC123",
"order_id": "uuid-...",
"client_id": "tenant-slug",
"customer_name": "Jane Doe",
"customer_phone": "+15555551212",
"customer_email": "jane@example.com",
"customer_address": "123 Main St",
"customer_zip": "10001",
"service_type": "wash_fold",
"estimated_items": "2 bags",
"special_instructions": "Leave at side door",
"pickup_date": "2026-03-09",
"pickup_time_slot": "10am-12pm",
"estimated_total": 25.00
}
Every field is one level deep. When the tenant builds their Zap and clicks "Insert Field," they see tracking_code, customer_name, pickup_date, etc., as one clean list. They can drop customer_name into a Slack message, customer_phone into a Twilio Send SMS step, pickup_date into a Google Calendar event, all with no field-mapping gymnastics.
The Zap runs
This is the part the platform doesn't see and doesn't care about. Common patterns:
- Order log: Append a row to a Google Sheet with all 13 fields.
- Team notification: Post to a Slack channel.
- Calendar event: Create a Google Calendar event.
- CRM update: Upsert a contact in HubSpot/Pipedrive/Airtable keyed on
customer_phone. - Owner SMS: Trigger Twilio with the order summary.
- Internal dispatch: Create a Trello card, Asana task, or ClickUp item.
Most laundromat tenants build 2 to 3 of these in one Zap. A power user might fan out to 6 to 8 destinations from a single submission.
How it differs from webhook relay
| Webhook | Zapier | |
|---|---|---|
| Payload shape | Nested (customer/order/event groupings) | Flat (every field top-level) |
| Headers config | Tenant-supplied (webhook_headers) | None, URL is the credential |
event field | Yes ("order_submitted") | No (Zap fires on receipt) |
| Auth model | Header-based (Bearer, HMAC, API key) | URL secret only |
| Audience | Tenant engineers integrating custom back-ends | Tenant owners building no-code automations |
Tenant-visible benefit
A tenant with no engineering but a lot of cloud tools gets:
- AI agents that take pickup orders and route them into whatever business stack they already use.
- A no-code editor for routing.
- Fan-out to multiple destinations from one order.
- Optional platform-managed customer confirmations via the status push-back.
- The same
relay_ordersledger for unified reporting.
Zapier relay is the "point at your tools, not your engineer" option. It's the answer to "I don't have a POS or a developer, but I do live in Google Sheets and Slack."
7. Manual Relay
DB-only, dashboard-driven, zero external dependencies
The big picture
Manual relay is the "no integration at all" option. The order is saved to the database and that is it. No SMS, no email, no webhook, no API call goes anywhere. Store staff watch the admin dashboard and confirm orders by hand from a UI.
It's the simplest relay by a wide margin, 122 lines of code, almost all of which is just the standard DB write plus the PosRelay interface boilerplate. There's nothing external to integrate, no credentials to manage, no failure modes outside Postgres.
The pieces
| Piece | What it does |
|---|---|
manual_relay.py | The relay class, DB write only |
relay_routes.py PATCH /api/relay/orders/{order_id}/status | Admin endpoint staff use to confirm/reject |
_order_store.py | relay_orders and relay_customers writes |
| Admin dashboard UI | Where staff see and act on pending orders |
| Per-tenant config | pos_relay.type: "manual", that's the entire config |
No external dependencies. No Twilio number, no SendGrid sender, no webhook URL, no API token. The relay needs nothing to run.
End-to-end flow
1 to 3. DB write, return to agent
The relay writes relay_orders with status SUBMITTED, upserts relay_customers, and returns to the agent. Three details worth pulling out:
- Status is
SUBMITTED, notPENDING_CONFIRMATION. Every other relay flips toPENDING_CONFIRMATIONafter sending the outbound message. Manual never sent anything, so there's nothing to wait on yet. - The customer-facing message is softer. "The store will review and confirm," not "you'll receive confirmation shortly."
- No confirmation token, no expiry. Without an outbound message there's nothing to authenticate against.
4. The order appears in the dashboard
The admin dashboard queries relay_orders filtered to the tenant. Manual orders sit in the SUBMITTED queue alongside any orders from other channels that are still pending. This is the only mechanism by which the order reaches a human. There is no push.
5. Staff confirm via the admin endpoint
When staff click "Confirm" (or "Reject") in the dashboard, the UI calls PATCH /api/relay/orders/{order_id}/status with admin Bearer/JWT auth. The handler validates the status, updates the row, returns success.
6. Customer notification, the gap
This is where manual relay has a meaningful gap. The dashboard PATCH .../status endpoint does not automatically fire _notify_customer_confirmed(order) the way the webhook callback or the email magic-link click do. It just updates the row and returns.
So when staff manually confirm a manual-relay order, the customer is not automatically emailed/texted "you're confirmed." Whoever confirmed the order has to follow up by hand, or the tenant has to build a separate workflow.
7. No reminder, no expiry
confirmation_service.py only operates on orders in PENDING_CONFIRMATION. Manual orders stay in SUBMITTED, which the loop doesn't touch. There's no nudge if staff forget to check the dashboard, no automatic expiry of a stale pending order.
Excluded from the contract wrapper
From ROLLOUT.md:
Manual relay (type: "manual") has no wrapper. The flag is ignored for those tenants and behavior is unchanged.
When manual relay is the right fit
- Onboarding and staging. A new tenant gets provisioned, you flip them on without a Twilio number or webhook URL yet, the platform works end-to-end immediately and you wire up a real channel later. Orders captured during this window aren't lost.
- Internal tenants. CWAD's own tenants, test tenants, demo tenants.
- Tenants where a human is always on the dashboard.
- High-touch tenants who want the order to pause for review.
- Diagnostic mode when something is broken with a tenant's primary channel.
Tenant-visible benefit
A tenant on manual relay gets:
- Zero setup. No accounts to create with third-party providers, no API keys to manage. Flip a config field, you're live.
- Zero external failure modes. Twilio outages, SendGrid blocks, webhook 5xx, none can break manual relay.
- A graceful runway. New tenants can start on manual the day they sign and move to SMS/email/CleanCloud/webhook/Zapier whenever they're ready.
- The same
relay_ordersledger as every other tier. - A safe diagnostic fallback if a real channel breaks.
Manual relay is the floor of the platform: the cheapest, simplest, lowest-friction way to capture AI-generated orders.
8. Voice Relay
ElevenLabs bridge, not a sixth relay type
The big picture
Voice relay is not a sixth relay type alongside SMS, email, CleanCloud, webhook, Zapier, and manual. It is the bridge between the platform's ElevenLabs voice agent and whichever of those six relays the tenant has configured. A customer says "I'd like a Wash and Fold pickup tomorrow morning" on a phone call. The voice agent calls a tool. The tool dispatches an order through the tenant's normal relay path. The answer comes back as a spoken response.
What lives in app/services/relay/voice/ is the contract between ElevenLabs and the relay system: six tool schemas, a universal dispatcher, and the date/phone normalization a voice channel needs that a chat channel doesn't.
The six tools registered with ElevenLabs
| Tool | What it does | Where the data lives |
|---|---|---|
book_pickup | Submit an order | Tenant's relay (SMS/email/CleanCloud/etc.) |
check_service_area | "Do you serve ZIP 10001?" | Tenant config (services.pickup_delivery.service_area_zips) |
get_pricing | "How much is Wash and Fold?" | Tenant config (services.wash_fold, services.dry_cleaning) |
get_available_times | "What times can you pick up?" | Tenant config (time_slots) or hardcoded defaults |
identify_customer | "I've ordered before, my number is 555..." | relay_customers |
check_order_status | "Where's my order ABC123?" | relay_orders |
Plus one API-only tool, gated by relay type: get_account_info, only exposed when pos_relay.type == "cleancloud".
The get_tools_for_relay(relay_type) function is what enforces that gating. CleanCloud tenants get all seven tools because their POS can answer account-level questions in real time. SMS/email/webhook/Zapier/manual tenants get six because their relays can't.
End-to-end flow of a voice booking
1. Customer calls
ElevenLabs picks up. The agent's system prompt is built per-tenant by voice_agent_service.py and includes the tool list returned by get_tools_for_relay().
2. Agent calls book_pickup
{
"tool_name": "book_pickup",
"arguments": {
"customer_name": "Jane Doe",
"customer_phone": "555-555-1212",
"customer_address": "123 Main St, Apt 4",
"pickup_date": "tomorrow",
"pickup_time": "morning",
"service_type": "wash_fold",
"special_instructions": "Leave at side door"
}
}
Two things to notice. pickup_date is "tomorrow", not "2026-03-09". Customers speak relatively. customer_phone is unformatted human-speech digits.
3. RelayVoiceTools._handle_book_pickup cleans the input
_parse_datehandles"today","tomorrow","next monday","next tuesday", and ISO dates. If the agent passed something unparseable, the handler returns a structured error and the agent re-asks the customer._normalize_phonestrips non-digits and adds+1for 10-digit US numbers, needed because every relay downstream expects E.164.
4. Build a SubmitOrderRequest, source_channel="voice"
source_channel="voice" is the only thing distinguishing this from a chat-originated order. It flows into relay_orders so reporting can split voice vs chat bookings. source_session_id lets you join back to the ElevenLabs conversation log for auditing.
5. Dispatch through the contract-aware path
This is the meaningful difference from a chat-originated submission. dispatch_submit_order consults pos_relay.use_tool_contract:
- Flag off (default): calls
relay.submit_order(request)directly. Same as the chat path. - Flag on (pilot tenants): wraps the call in the tool-contract layer, which gives typed errors (
CC_TIMEOUT,EMAIL_INVALID_ADDRESS,TWILIO_OPTED_OUT, etc.), per-error retry policy, and idempotency-keyed deduping. Every failure carries a spoken response designed to be read by the agent.
The contract wrapper is voice-first by design. A failed booking on a chat session can show a UI message. A failed booking on a phone call needs the agent to say something that doesn't sound like a robot crashing.
6. The relay does its job
Whatever the tenant's relay type is, SMS to a Twilio number, email with Confirm/Decline buttons, CleanCloud API booking, webhook POST, Zapier catch-hook, manual DB write, runs exactly as described in the earlier sections. The voice agent doesn't know which.
7. The tool returns a structured response
The agent reads the result. On success, the customer hears something like "Pickup ABC123 confirmed, our team will be there tomorrow morning between 9 and noon." On failure with the contract wrapper, they hear something appropriate to the specific failure mode.
The five non-booking tools
check_service_area: readsservices.pickup_delivery.service_area_zips. Empty list means "we service all areas." Populated list means membership test, and on a miss the agent gets the full list of supported ZIPs.get_pricing: readsservices.wash_fold.price_per_pound,services.wash_fold.minimum_pounds, and per-item dry-cleaning prices from config. Only returns service blocks whereenabled: true.get_available_times: readstime_slotsif present. If empty, falls back to a built-in morning/afternoon/evening triplet.identify_customer: calls into the sameidentify_customermethod every relay implements. Returns name, address, ZIP, preferred service, preferred time slot, default special instructions, total order count. The agent uses these to skip questions.check_order_status: looks up the order by tracking code inrelay_orders. Returns the raw status plus a pre-written spoken sentence for each status value, short, intelligible at TTS speed.
Capabilities, or which tools light up per relay type
| Relay type | book_pickup | service_area | pricing | times | identify | status | account_info |
|---|---|---|---|---|---|---|---|
| SMS | โ | โ | โ | โ | โ | โ | โ |
| โ | โ | โ | โ | โ | โ | โ | |
| Webhook | โ | โ | โ | โ | โ | โ | โ |
| Zapier | โ | โ | โ | โ | โ | โ | โ |
| Manual | โ | โ | โ | โ | โ | โ | โ |
| CleanCloud | โ | โ | โ | โ | โ | โ | โ |
Where it sits in the platform
- Tier-gated. Voice agents are Growth+ ($399/mo, 300 min included) and Enterprise ($899/mo, unlimited). Foundation tenants don't get voice.
- Per-tenant. Every voice-eligible tenant runs their own ElevenLabs agent, not a shared one.
- Metered.
voice_metering_service.pyrecords minute totals per billing period and enforces the limit. Overage on Growth is $0.15/min. - Logged. Every call writes a
voice_call_logrow (with a hashed caller phone for retention safety) and every tool invocation writes avoice_tool_call_logrow. - Synthetically tested.
voice_synthetic_check.pyruns 12 config-integrity assertions against the live ElevenLabs agent on a 13/17/21-UTC schedule.
Tenant-visible benefit
A voice-tier tenant gets:
- A phone number that books pickups 24/7, with the same fidelity as their best human staffer.
- No matter what their back-end is. SMS to a store phone, email to the owner, CleanCloud POS, webhook to custom infrastructure, Zapier to Sheets, manual to dashboard, all six work.
- Pricing and service-area accuracy from their own config. The agent doesn't guess.
- Returning-customer recognition across channels. A chat customer is known on call one.
- Graceful failure on the phone, not robotic crashes.
- Order status questions answered live.
- Full audit trail in
voice_call_logandvoice_tool_call_log.
Voice relay is the mouth and ears on top of the platform. The six relay types are the hands and feet. They share the same circulatory system: relay_orders, relay_customers, the PosRelay interface, the dispatch path. Adding a voice channel to any tenant on any relay is a tier upgrade and a per-tenant ElevenLabs provisioning step, not a redesign.