Part 7 of 7 · Proposal generator series ~8 min read

Engineering reference: the proposal generator architecture

Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the S3 Vectors retrieval setup, EventBridge config, the DynamoDB schemas, and the Slack interactive flow. Read alongside the previous six posts; this one’s the build sheet.

Region and account shape

Default region: ap-southeast-1 (Singapore). SES inbound, Bedrock cross-Region inference (Global profile), S3 Vectors, and EventBridge are all in good shape there. A second region for resilience isn’t worth the setup at SMB volume — the failure mode for an SMB is a proposal that takes an extra hour, not a regional outage. One AWS account dedicated to the generator keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.

Topology

AWS topology of the proposal generator A topology diagram with three regions stacked vertically inside one AWS account boundary. Top region: ingress. Three boxes show the three intake lanes — a web form served by a Lambda Function URL intake-form that writes a brief to DynamoDB, an SES inbound rule set with action S3 PUT to s3://pg-raw-mime/ plus the parser Lambda intake-email-parser that strips the thread and calls Bedrock Haiku 4.5 to read a brief, and a Slack slash command hitting a Function URL intake-slack that runs the same Haiku tidy step. A drive-sync Lambda triggered every 15 minutes by EventBridge Scheduler mirrors the templates, past proposals, and rules docs to s3://pg-templates-source/, and on new past proposals embeds each section with Titan Text Embeddings V2 into S3 Vectors. Middle region: drafting. The build Lambda is triggered by the start-draft action; it reads the clean brief from DynamoDB, embeds the need with Titan V2, queries S3 Vectors for the nearest past sections, computes the price in plain Python from the rate card, assembles a grounded prompt, and calls Claude Sonnet 4.6 once to write five sections; a price-and-date guard re-reads the draft and writes it to s3://pg-drafts and the pg-drafts table. Bottom region: review and delivery. The draft is shown in Slack with Approve, Edit, and Discard buttons; button clicks land on a Function URL Lambda approve-handler that, on approve, renders a branded PDF, saves it to the Drive proposals folder via the Drive API, emails the client via SES outbound, and writes a sent row to pg-audit. CloudWatch Logs collects from every Lambda at 7-day retention. Across the right edge: a small box labelled AWS Budgets alarm at $15 monthly threshold, posting to SNS topic pg-cost-alarm. A note at the bottom: nothing reaches a client without a human approving it — and every action is logged to pg-audit. Ingress Lambda · intake-form Function URL short brief form → DDB pg-briefs status: ready SES inbound rule set pg-inbound-rules action: S3 PUT s3://pg-raw-mime/ trigger: intake-email-parser Lambda · intake-slack Function URL slash command, one line Haiku 4.5 tidy step → pg-briefs Clean brief in DynamoDB pg-briefs · single source Drafting start-draft action Slack/form tap invokes build async target: build Lambda via EventBridge Lambda · build embed + query Vectors price in Python, Sonnet 4.6 writes five, price+date guard S3 Vectors + S3 pg-proposal-vectors s3://pg-drafts DDB pg-drafts (held → review) Review & delivery Slack interactive card with [Approve] [Edit] [Discard] button clicks → Function URL Lambda · approve-handler render branded PDF, save to Drive, SES outbound to client, write pg-audit Lambda · edit-handler opens Google Doc, pulls edits back, re-runs the guard, then renders + sends Nothing reaches a client without a human approving it — every action is logged to pg-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into one brief), drafting (the build Lambda retrieves, prices, drafts, and guards), review and delivery (the human approves and the proposal ships). Every Lambda is event- or action-driven; nothing is synchronous-chained.

Lambda functions

All Lambdas use the arm64 architecture, the smallest memory size that meets latency targets, Python 3.14 runtime, and CloudWatch Logs at 7-day retention. Each function has its own least-privilege IAM role. None run inside a VPC.

  • intake-form — Lambda Function URL (AuthType: NONE, behind a shared-secret token in the form). Serves the short brief form and accepts its POST, writing a row to pg-briefs with status: ready. No model call. Memory: 256 MB. Timeout: 15 s.
  • intake-email-parser — S3 PUT trigger on s3://pg-raw-mime/. Parses MIME, strips quoted replies and signatures, and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0 via global.anthropic.claude-haiku-4-5-20251001-v1:0) to read a brief out of the thread as strict JSON, leaving unfound fields null. Writes to pg-briefs with status: needs-review and posts a Slack card. Memory: 512 MB. Timeout: 30 s.
  • intake-slack — Lambda Function URL; verifies the Slack signing secret. Handles the /proposal slash command; runs the same Haiku tidy step on the one-line input and writes to pg-briefs. Memory: 256 MB. Timeout: 15 s.
  • drive-sync — EventBridge Scheduler target, every 15 minutes. Uses the Google Drive + Docs APIs (service-account credentials in Secrets Manager under pg/drive/sa) to mirror the templates, past proposals, and rules docs to s3://pg-templates-source/ when changed. On a new or changed past proposal, splits it into sections, embeds each with Titan Text Embeddings V2 (amazon.titan-embed-text-v2:0, 1024-dim), and upserts into the S3 Vectors index pg-proposal-vectors. Memory: 512 MB. Timeout: 60 s.
  • build — invoked asynchronously via EventBridge when a brief’s start-draft action fires. Reads the brief, embeds the need with Titan V2, queries pg-proposal-vectors for the nearest sections, computes the price summary in plain Python from the rate card in s3://pg-templates-source/rules.txt, assembles the grounded prompt, and makes one Claude Sonnet 4.6 call (anthropic.claude-sonnet-4-6-20250115-v1:0 via global.anthropic.claude-sonnet-4-6-20250115-v1:0) returning five sections as structured JSON. Runs the price-and-date guard (pure Python), retries once on a structural failure, then writes the draft to s3://pg-drafts/ and a row to pg-drafts with status: ready-for-review or held. Memory: 1024 MB. Timeout: 120 s.
  • approve-handler — Lambda Function URL, public with AuthType: NONE; verifies a Slack signature on the request body. Triggered by Approve and Discard button clicks. On approve, renders the five sections to a branded PDF (a small HTML-to-PDF step), writes it to the Drive Proposals/sent/ folder via the Drive API, sends it to the client via SES SendRawEmail, and writes a sent row to pg-audit. On discard, archives the brief and draft and writes a discarded row. Memory: 512 MB. Timeout: 30 s.
  • edit-handler — Lambda Function URL. Triggered by the Edit button; ensures the linked Google Doc exists (creates it from the draft if needed) and returns its URL. On re-approve, pulls the doc text back via the Docs API, re-runs the price-and-date guard, and hands off to the same render-and-send path as approve-handler. A human-overridden price is logged as an override in pg-audit rather than blocked. Memory: 512 MB. Timeout: 30 s.
  • digest — EventBridge Scheduler target, weekly Sunday 6pm. Reads pg-drafts and pg-audit for the past week; sends a Slack summary of briefs in, proposals sent, and drafts still open. No Bedrock; a plain summary table. Memory: 256 MB.
  • summary — EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month’s pg-audit; calls Bedrock Haiku 4.5 to write a one-paragraph pipeline narrative (briefs, sent, win-rate if outcomes are recorded); emails it via SES. Memory: 512 MB.

Storage

  • DynamoDB · pg-briefs — one row per brief. PK brief_id; attributes: client, need, budget, due_date, lane (form/email/slack), by_user, status (ready/needs-review/drafting/done/archived). On-demand.
  • DynamoDB · pg-drafts — one row per draft attempt. PK (brief_id, draft_index); attributes: status (ready-for-review/held/sent/discarded), price_total, doc_url, pdf_key, guard_result. On-demand.
  • DynamoDB · pg-audit — one row per write action of any kind. PK (brief_id, ts); attributes: action (sent/edited/discarded/override), by_user, before, after. On-demand. No TTL — this is the long-term audit trail.
  • S3 Vectors · pg-proposal-vectors — embeddings of each past-proposal section, 1024-dim, with metadata (proposal id, section type, client industry). Queried per draft for the nearest sections.
  • S3 · pg-templates-source — mirrored section templates, past proposals, and the rules/voice doc as plain text. Versioning enabled.
  • S3 · pg-raw-mime — raw inbound MIME from forwarded threads. Lifecycle to Glacier at 30 days; expiry at 7 years.
  • S3 · pg-drafts — the structured draft JSON and the rendered PDFs. Versioning enabled; lifecycle to Glacier at 90 days.

Bedrock

  • Draft model. anthropic.claude-sonnet-4-6-20250115-v1:0 via the Global cross-Region inference profile. One callsite: build, for writing the five sections. This is the only heavy call in the system, justified because writing a coherent multi-section document is real reasoning work.
  • Cheap model. anthropic.claude-haiku-4-5-20251001-v1:0 via the Global profile. Callsites: intake-email-parser and intake-slack (read a brief), Gate 1 voice touch-ups inside build, and summary (monthly narrative).
  • Embeddings. amazon.titan-embed-text-v2:0, 1024-dim. Callsites: drive-sync (embed past sections on ingest) and build (embed the brief for retrieval).
  • Quotas. Default account quotas are more than enough at SMB volume. The Sonnet draft fires once per started draft.

EventBridge config

  • pg-drive-syncrate(15 minutes). Target: drive-sync Lambda.
  • pg-weekly-digestcron(0 18 ? * SUN *) in TZ. Target: digest Lambda.
  • pg-monthly-summarycron(0 9 ? * 2#1 *) (first Monday at 9am) in TZ. Target: summary Lambda.
  • Start-draft rule — an EventBridge rule on the custom event pg.start_draft (emitted by the intake handlers when the owner taps start) with target build, so drafting runs async off the request that triggered it.

SES inbound and outbound

  • Set the MX record on a dedicated subdomain (e.g. proposals.your-company.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set pg-inbound-rules: one rule with recipient proposals@your-company.com → spam scan → S3 PUT to s3://pg-raw-mime/<message-id> → stop. The S3 PUT triggers intake-email-parser.
  • SES outbound for the approved proposals: verify a sender identity at hello@your-company.com with DKIM and SPF on the parent domain. Out of sandbox by request.

IAM (least privilege per Lambda)

Each Lambda has its own role with policies scoped to exact ARNs. Sketch:

  • build role: dynamodb:GetItem + PutItem on pg-briefs and pg-drafts; s3:GetObject on the templates and rules keys; s3vectors:QueryVectors on pg-proposal-vectors; bedrock:InvokeModel on the Sonnet, Haiku, and Titan ARNs; s3:PutObject on pg-drafts.
  • approve-handler role: dynamodb:PutItem on pg-drafts and pg-audit; s3:GetObject on pg-drafts; ses:SendRawEmail from the verified sender identity; secretsmanager:GetSecretValue on the Drive and Slack secrets; outbound network to www.googleapis.com.
  • edit-handler role: dynamodb:GetItem + PutItem on pg-drafts and pg-audit; secretsmanager:GetSecretValue on the Docs-API service-account secret; outbound network to docs.googleapis.com; the same render-and-send permissions as approve-handler.
  • intake-email-parser role: s3:GetObject on pg-raw-mime; bedrock:InvokeModel on the Haiku ARN; dynamodb:PutItem on pg-briefs; secretsmanager:GetSecretValue on the Slack webhook.
  • drive-sync role: secretsmanager:GetSecretValue on the Google service-account secret; s3:PutObject on pg-templates-source; s3vectors:PutVectors on pg-proposal-vectors; bedrock:InvokeModel on the Titan ARN; outbound network to www.googleapis.com.

Slack interactive flow

Alert and draft messages are posted via the chat.postMessage Web API with Block Kit blocks containing the action buttons (Start draft on a brief card; Approve/Edit/Discard on a draft card). Button clicks are sent by Slack to the configured Interactivity request URL, which is the relevant handler Function URL. Each handler verifies the Slack signing secret on the inbound request, parses the action_id, opens a modal where needed (Edit links the doc; the others are one-tap), and processes the response.

The Slack app needs chat:write, commands (for the slash command), and the Interactivity URL configured. The bot token lives in Secrets Manager under pg/slack/bot-token. The signing secret is pg/slack/signing-secret.

Observability and cost gates

  • CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on "error" + "throttle" + "timeout" + "guard_held" to a CloudWatch metric for alerting.
  • Alarms: build failures > 0 in an hour; price-and-date guard holds > 10% of drafts in 24h (suggests the prompt or rate card drifted); approve-handler signature-verification failures > 5/hour (might mean the Slack secret rotated).
  • X-Ray: off by default. Not worth the cost at SMB volume.
  • AWS Budgets: $15/month threshold, alarm at 80% and 100%, posts to SNS topic pg-cost-alarm subscribed to the admin’s email and Slack.

Config and secrets

Service-account credentials for the Drive, Docs, and Sheets APIs live in Secrets Manager under pg/drive/sa (one service account with scopes for all three). Slack bot token and signing secret under pg/slack/*. SES sender identity lives in IAM and the verified-domain config. The configured timezone, the rate-card location, the banned-claim list reference, and the brand assets (logo, terms page) live in Parameter Store under /pg/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.

Deploy

GitHub Actions with OIDC into a short-lived deploy role — no long-lived AWS keys — running AWS SAM. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for pg-templates-source and pg-drafts so a bad Drive edit or a bad render can be rolled back, and keep the rate card under version control alongside the templates so a price change is reviewable. Total deployable surface: around nine Lambdas, three DDB tables, one S3 Vectors index, four S3 buckets, a handful of EventBridge rules, one SES rule set, and one Budgets alarm.

That’s the full system. Six narrative posts and this engineering reference. If you want to talk about adapting it for your business, see Work with me.

All posts