12 · Use Case · Sales Pipeline

One sales email, end-to-end on AWS

Follow a single outreach email from idea to inbox on the live pipeline. Gabrielle (the sales draft bot) generates the draft through Maestro — the AWS engine that replaced the old ClawBot VPS — landing it in DynamoDB as pending_review. A rep reviews it in the Hub v2 queue, then Approve & Send enqueues it onto SQS for the message-dispatcher Lambda (the Unified Sender). That send reaches the real client today: the send gates are live Governor switches in /admin/governor (no deploy), and as last verified 2026-08-07 globalSendEnabled=true and sendDevelopmentMode=false — email goes to the real recipient. (Flipping sendDevelopmentMode=true would redirect every email to the developmentTestEmails inboxes, but it is currently OFF.)

The bot

Gabrielle Sales

Gabrielle (= ClawBot = Sales_Assistant) is the sales draft bot. Its config — system prompt, voice, guardrails, kill-switch — stays in Airtable; only the engine moved. It drafts, it never sends.

The engine

Maestro on AWS

The cutover is done: generation moved off the VPS/n8n onto AWS. n8n crons POST /generate → SQS → the task_consumer Lambda drafts via Bedrock into DynamoDB …-tasks (us-east-2).

The human gate

Hub v2 review

Every draft is pending_review until a rep acts in the Hub v2 queue: Approve & Send, Edit (incl. Edit-with-AI), Reject, Snooze, Comment, or Escalate. Nothing reaches a customer without a human.

Step 1 · Generation

Maestro drafts the email

An n8n "Auto Tasks" cron ticks for a playbook (e.g. Account Growth follow-up) and POSTs /generate to the producer Lambda — a thin sub-second ACK that enqueues onto SQS …-tasks-ingest. The task_consumer Lambda pulls the message, loads Gabrielle's prompt + voice + guardrails, pulls the contacts (HubSpot list or Airtable Orders view), drafts with Bedrock Claude, and writes one row to DynamoDB as pending_review. It never sends.

⏰
01
n8n cron
"Auto Tasks" → POST /generate
📨
02
Producer → SQS
202 ACK <1s · …-tasks-ingest + DLQ
🔎
03
Pull contacts
HubSpot list / Airtable Orders view
🧠
04
Draft
consumer Lambda · Bedrock Claude
📥
05
Emit task
DynamoDB · pending_review

Why SQS in the middle

Durable, resume-where-left-off

The thin producer ACKs in under a second so the n8n cron never times out (it gives up at ~60s). A message is deleted only after the consumer succeeds — a crash mid-draft just replays after the visibility timeout, so zero loss, with a DLQ for poison messages.

What stayed in Airtable

The brain, not the store

Only the task store moved to DynamoDB. Gabrielle's config — Agent Settings (system prompt, voice, signature), the Workflows & Automations SSOT (cadence, thresholds), guardrails, suppression and templates — stays in Airtable, including the Status kill-switch the consumer respects.

The drafting pipeline
Animated · interactive — the task_consumer path from an enqueued job to a pending_review draft. Open full ↗
Step 2 · Human review

A rep reviews it in Hub v2

The draft surfaces in the Hub v2 /queue — a NestJS API + Next.js 15 app on ECS Fargate at hubv2.apphappenventures.com. The rep sees the draft, its confidence, the sources it was grounded on, and the deal/contact links, then takes one of six actions. The Contact Governor (GOV-01) has already gated the task — ALLOW / DEFER / BLOCK / DROP against frequency caps and a per-contact touch log — before it ever reaches the queue (only ALLOW gets drafted; the rest are skipped).

✅
A
Approve & Send
two-click confirm · sign-off
✏️
B
Edit
manual or Edit-with-AI (Bedrock)
🚫
C
Reject
status → Rejected · reason optional
💤
D
Snooze
resurfaces on date · ET-pinned
💬
E
Comment / Escalate
state-only · notify fan-out on roadmap

The six reviewer actions

Deliberate, one at a time

Approve & Send, Edit (incl. Edit-with-AI — an in-place Bedrock rewrite the rep reviews before sending), Reject, Snooze, Comment, Escalate. By design there is no bulk-approve: these are real customer conversations. Reject and Escalate accept an optional reason (free text or a structured reason code); Comment appends to the thread without auto-advancing the status.

Every edit is a signal

Originals kept for learning

Edits are captured as edited_content alongside the original draft, and every action writes before/after to Audit_Log. That diff is the learning signal for improving future drafts — nothing is discarded.

Step 3 · Send (live)

Approve & Send → the inbox

On Approve & Send the task flips to resolved and the API enqueues a typed message onto SQS …-outbound-messages. The message-dispatcher Lambda — SQS-triggered, not a poll — claims each message idempotently (OUTBOX# in DynamoDB; claim refusal = already sent → skip, no double-send), routes by channel, and for email builds the RFC822 message and sends through the Gmail API impersonating the rep's inbox via domain-wide delegation. It sends for real: as last verified 2026-08-07 the Governor has sendDevelopmentMode=false, so email reaches the real client. The sandbox exists but is OFF — setting sendDevelopmentMode=true in /admin/governor would redirect every message to developmentTestEmails instead. (A separate SEND_DRY_RUN env kill-switch on the dispatcher, currently false, can force log-only.)

✅
01
Approve & Send
API enqueues a typed message
📨
02
SQS enqueue
…-outbound-messages · batch 1 + DLQ
🔒
03
Claim
idempotent OUTBOX# PutItem
📧
04
Gmail Send
send AS the rep · DWD · live to client
📝
05
Record
status → sent / failed · Audit_Log

One Sender, every channel

Identity · throttle · audit

The message-dispatcher is the unified Sender: it owns the cross-cutting concerns once — send as the right rep (per-rep Gmail via service account + domain-wide delegation), claim-before-send, dry-run safety, and uniform audit. Its email adapter was ported verbatim from the old maestro_sender; real Slack and WhatsApp adapters already ride behind the same dispatcher (SMS is still a stub).

Open risk · dual send-worker

Retire the legacy n8n sender

A legacy n8n "AWS Send Worker (Maestro)" still polls the /approved queue alongside the inert maestro_sender Lambda (both in dry-run, EventBridge disabled) — while the live sender is the message-dispatcher. Today the n8n worker just leaves a misleading failed status, but now that email is live to real clients it must be retired: an approved task it also delivered would reach the client twice.

AWS Architecture · As built

The components behind the three steps

Per ADR 0001 the store is DynamoDB (one single-table, not Postgres) and ingestion is Path B (SQS producer/consumer). Generation and send are two independent serverless paths that meet only at the DynamoDB task and the Hub's /queue read/action API — no shared god-table, no chatty calls.

Path 1 · Generation

producer + consumer

Trigger: n8n "Auto Tasks" cron → POST /generate (API Gateway)

Producer: …-task-producer Lambda (Node 22) · 202 ACK <1s → SQS …-tasks-ingest + DLQ (maxReceive 5)

Consumer: …-task-consumer Lambda (Python 3.12) · SQS event-source · drafts with Bedrock claude-sonnet-4-6

Store: DynamoDB hv-hub-v2-development-tasks · single-table (PK/SK + GSI1 + Streams) · idempotent PutItem on task_guid · status pending_review

Brain config: Airtable (Agent Settings, SSOT, guardrails, templates) — read, not moved

Path 2 · Review + send

Hub v2 + message-dispatcher

Review UI/API: Hub v2 — NestJS API + Next.js 15 on ECS Fargate (hubv2.apphappenventures.com) reading DynamoDB via GET /api/queue

Gate: Contact Governor (GOV-01) in the consumer · ALLOW / DEFER / BLOCK (HOLD reserved, not yet emitted) · touch log + frequency caps + Status kill-switch

Sender: message-dispatcher Lambda · SQS …-outbound-messages event-source → idempotent claim → channel adapter (email Gmail DWD / Slack / WhatsApp)

Send mode: SEND_DRY_RUN=false and the Governor sendDevelopmentMode=false (as last verified 2026-08-07) → email reaches the real client; flip sendDevelopmentMode=true to redirect to developmentTestEmails · idempotent OUTBOX# claim-before-send · DLQ for poison messages · status → sent/failed + Audit_Log

Credentials: Secrets Manager · per-rep Gmail SA + HubSpot/Airtable tokens, never in code

Why this shape

DynamoDB's schemaless single-table fits the still-evolving HITL schema; PutItem ConditionExpression gives idempotency; Streams feed the audit/learning loop; idle cost is ~$0. The durable SQS bus + DLQ mean a consumer crash replays instead of losing work. And generation and send stay decoupled — the consumer only ever writes pending_review, so nothing reaches a customer until a human approves and a single sender dispatches it.

Cutover status

What's live · what's left

✅ Done

Generation on AWS

The "Auto Tasks" generation crons were re-pointed off the VPS to the producer /generate (verified by ID). The consumer drafts live into DynamoDB; the legacy backfill rows were imported at parity.

✅ Done

Hub review + Approve & Send

The Hub v2 sales queue reads + acts on DynamoDB; Approve & Send is un-gated (two-click confirm with sign-off) and a real end-to-end send was verified. The Contact Governor gate is live in dev.

⚠️ Remaining

Retire the dual sender

Email and Slack are already live to real recipients (Governor globalSendEnabled=true, sendDevelopmentMode=false, sendSlackEnabled=true; WhatsApp still off via sendWhatsappEnabled=false). What's left is retiring the legacy n8n "AWS Send Worker (Maestro)" (the inert maestro_sender Lambda can go with it) so the message-dispatcher is the sole sender — otherwise an approved task both pick up double-sends to the client. Turning on WhatsApp is a later per-channel flag.

✅ Done

VPS decommissioned

Generation has been 100% on AWS (DynamoDB-verified) since the 2026-06 cutover — no live generator hits the VPS. The leftover (disabled) VPS POST nodes were dropped, the historical task_queue_sales table left as an archive, the ClawBot VPS (187.77.29.73) shut down, and the leaked Basic credential rotated (#152). The Airtable robin_tasks table is still being retired (DRV/MCP writers pending re-point — #259).

AWS Service Map

Every old concern → the AWS primitive in use

Old VPS / n8n concernAWS primitive (live)Why
Generation cron entrypointAPI Gateway → producer LambdaSub-second 202 ACK so the n8n cron never times out.
Ingestion bufferSQS …-tasks-ingest + DLQDurable, resume-where-left-off, poison-message isolation.
Drafting brain (VPS FastAPI)task_consumer Lambda + BedrockPackaged code, version-controlled, $0 idle, auto-scales.
Draft modelBedrock claude-sonnet-4-6In-account inference profile, us-east-2.
task_queue_sales storeDynamoDB single-tableSchemaless HITL fit, idempotent PutItem, Streams for audit.
Approval UI (Airtable Interfaces)Hub v2 (NestJS + Next.js, ECS)Reads/acts on DynamoDB via /api/queue.
Frequency / suppression rulesContact Governor (GOV-01)Central ALLOW/DEFER/BLOCK/DROP gate + touch log + caps.
Claim / no double-sendclaim-before-send (status)Refusal = another sender took it → skip.
5-credential Gmail switchSecrets Manager + Gmail DWDSend as the rep via service account, IAM-scoped, no key in code.
Send triggerSQS outbound-messages → message-dispatcherChannel-agnostic dispatch, idempotent claim, dry-run-safe.
Execution historyCloudWatch + #hv-alertsStructured logs; SNS → Slack relay + Sentinel poller.
Replay / audit / learningDynamoDB Streams (→ S3/Athena later)Edit-diffs + Audit_Log feed the learning loop.

The config still lives in Airtable — by design

Only the task store moved to AWS. Gabrielle's brain — the SSOT kill-switch, Agent Settings, guardrails, suppression and templates — stays in Airtable per ADR 0001. The robin (R3 ops) and finance (Frank) task queues migrated to the same DynamoDB table (source distinguishes the bot); see Robin and Current Flow.