Skip to content

Part 7 of 7 · Offer letter generator series ~7 min read

Engineering reference: the offer letter generator architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the document store, and the single narrow model call.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The offer letter generator drawn with AWS service namesThree boxes across the top outside the AWS account. Terms capture, arriving through a form or through SES inbound as a forwarded note. The Clause library, stored versioned in S3. And SES outbound, carrying drafts and clause requests. Inside the account, three groups. S3 holding templates and sent PDFs, and SQS carrying one draft queue. Three Lambda functions named capture, assemble and record. And two DynamoDB tables named offers and clauses. A note gives the region as us-east-1, one account, and states that sent documents are immutable and versioned in S3.AWS ACCOUNTTerms captureform, or SES inboundClause libraryS3, versionedSES outbounddrafts, requestsS3 + SQStemplates, sent PDFs,one draft queueLambda x3capture, assemble,recordDynamoDB x2offers, clausesingroundsoutus-east-1. One account. Sent documents are immutable and versioned in S3.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Storage
  • Database
  • App integration
  • Front-end & mobile

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
ol-captureFunction URL + S3 (SES)Nine fields from a form, or one Bedrock call on a note20s / 512 MB
ol-assembleSQS draft queueClause matching, variable substitution, PDF and diff60s / 1024 MB
ol-recordFunction URLStores the sent PDF, records acceptance, hands to onboarding20s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
ol-capture-rolebedrock:InvokeModel, sqs:SendMessageOne model arn; the draft queue
ol-assemble-roles3:GetObject/PutObject, dynamodb:GetItemTemplates and drafts prefixes; the clauses table, read
ol-record-roles3:PutObject, dynamodb:PutItem, ses:SendEmailThe sent prefix, write-once; the offers table; one identity

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: offers

PK   offer_id          S   ofr_2026_07_20_3c8e
SK   version           S   v1 | v2
     candidate         S   Kwame Osei
     role              S   Field service engineer
     basis             S   salaried | hourly | fixed_term
     terms             M   the nine confirmed fields
     clause_versions   L   [{clause_id, version}]
     state             S   drafted | sent | accepted | declined | superseded
     pdf_key           S   s3://offers-sent/ofr_.../v1.pdf   (write-once)
     covering_email    S   s3 key of the message as sent
     drafted_by        S   hiring manager
     sent_by           S   whoever approves
     accepted_at       S   with the route it arrived by

Versions are appended. A renegotiated offer is v2 and v1 stays, because
v1 is sitting in somebody’s inbox whatever the database thinks.

Table: clauses

PK   clause_id         S   probation-3-months
SK   version           S   v2
     body              S   the approved wording, with variable markers
     variables         L   [name | number | date]
     approved_by       S   not the person who submits terms
     approved_at       S   2026-01-14
     last_used         S   2026-07-20
     use_count         N   41

`last_used` drives the annual stale-clause review. A clause nobody has
used in a year is either obsolete or quietly wrong.

Inbound and outbound

  • The terms form is static files in S3 behind CloudFront, reached through a signed link. Nine fields, no login.
  • Forwarded notes arrive through an SES receipt rule writing to S3. The read produces the same nine fields and always routes to the same confirmation screen.
  • Sent PDFs go to a bucket with object lock in governance mode. A sent offer is not something that should be quietly replaceable.
  • Clause approval links are signed, scoped to one request, and refuse a submission from the same address that captured the terms.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used only to turn a free-text note into the nine fields.
  • It never writes wording. There is no code path in which model output reaches a document; its output populates a confirmation form.
  • Output is a JSON schema with all nine fields nullable. Pay, start date and role are refused rather than inferred where the note does not state them.
  • Grounded with the clause library’s option lists, so probation and notice come back as one of your approved options or as null.
  • Not called at all when the form lane was used, which is most of the time once people get used to it.

Things worth knowing before you build it

  • Store the PDF as sent, with object lock. Re-rendering is the one shortcut here that produces a document nobody signed.
  • Hold clause options rather than free values. It is more clauses to maintain and it makes an unapproved probation length impossible rather than merely unlikely.
  • Keep the covering email. Offers are frequently qualified in the message body rather than the attachment.
  • Refuse a clause approved by the person who submitted the terms. Without that separation the approval step is decorative.
  • Hand the start date to onboarding rather than retyping it. Three copies of a date is where onboarding errors come from.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts