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 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:
embed_photos.py inlines boat photos as base64 data URIs so the HTML is self-contained for sharing--headless --disable-gpu --no-pdf-header-footer --print-to-pdfpublish_proposal.py uploads both files to s3://queenofsandiego.com/g/<slug>.{html,pdf}, invalidates CloudFront, and sends via the Gmail API
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.
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
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.