← Queen of San Diego — Tech Blog
2026-06-13

2026-06-13

Now I have everything I need. Here's the post: --- TITLE: Proposal PDFs in Minutes: A Deterministic Pipeline for Charter Proposals That Actually Close ```html

The Problem With Ad-Hoc Proposals

When a charter inquiry comes in — "Hi, I have 13 people, we're thinking August 1" — the ideal response is a polished, personalized PDF in their inbox within the hour. What actually happened for most of this year was a 20-minute session of copy-pasting from old proposals, manually adjusting names and dates, forgetting to update the deposit math, and hoping Chrome printed it cleanly. The result was inconsistent, slow, and burned time we didn't have.

On June 13 we shipped a proper fix: a reproducible proposal pipeline where Claude Code writes roughly three paragraphs of bespoke copy and deterministic Python scripts handle everything else.

The Architecture: Mostly Deterministic

The skill lives at /Users/cb/.claude/skills/jada-pdf-proposal/ and is invoked as a Claude Code skill. The cost guarantee baked into the skill's own documentation is the key idea: the model writes only three short copy blocks. Everything else — layout, photo embedding, PDF rendering, S3 upload, CloudFront invalidation, and email delivery — is handled by Python scripts that run identically every time.

The pipeline for a proposal like Katie's (13 guests, August 1, Harbor Island, 3 hours) runs in this order:

Photo Embedding: Why Self-Contained HTML Matters

Proposals are built as HTML first, using relative paths like src="assets/jada-under-sail.jpg". Chrome can resolve those paths when rendering to PDF from the local filesystem, so the PDF just works. But a shared HTML link breaks as soon as the file leaves its folder. embed_photos.py solves this with a single regex pass:

out_html = re.sub(r'src="([^"]+)"', repl, html)

Each matched source path gets replaced with a data:image/jpeg;base64,... URI. The output is a standalone file that works anywhere — email, Dropbox, direct link.

The CloudFront Routing Mistake We Already Made Once

One non-obvious thing that burned us: there are two CloudFront distributions in front of queenofsandiego.com. The /g/ path on shipcaptaincrew.queenofsandiego.com is Lambda-served and returns x-cache: Error from cloudfront for every file regardless of size. The static S3-backed distribution at queenofsandiego.com/g/ works correctly. The comment in publish_proposal.py now says this explicitly so we don't re-learn it the hard way:

BUCKET   = "queenofsandiego.com"       # static S3+CloudFront (reliable)
# NOTE: do NOT use shipcaptaincrew.queenofsandiego.com/g/ — Lambda-served, errors for all /g/ files

Three Proposals, One Session

On June 13, the session prompt was explicit about time pressure: "I don't have time to nail down the deterministic version of this — use standard templates, use deterministic methods if they already exist, but tailor it for her inquiry." That's exactly the division the skill is designed for. Three separate inquiries — Ashley (September 19), Serena (June 19), and Katie (August 1) — each got a personalized PDF with the right guest name, date, party size, pricing math, and bespoke narrative. Each one followed the same template and the same pipeline. None required touching the HTML structure or the rendering logic.

The publish step delivers a live URL at queenofsandiego.com/g/<slug>.html within seconds of the PDF rendering, with the PDF attached to the outbound Gmail. For a time-constrained operator, the target is inquiry-to-delivered-proposal under five minutes. June 13 hit that mark.

```