Skip to content

Goal

Writing a proposal after every discovery call takes hours, so they go out late, when the client has cooled off. Build a generator that prices from your own catalog, writes the story, and gives the client a page they can accept on the spot.

Prerequisites

  • Modules 05 (forms, Gmail), 15 (AI with a template fallback), 22 (guardrails, Merge before shared steps)
  • For the live part: an OpenAI key and Gmail

Concepts

  • The catalog is the source of truth. Services, tiers, prices, weeks and deliverables live in Settings. The AI never sets a price; code does.
  • Fit the budget. Start with the cheapest tier of each requested service, then upgrade one service at a time while the total (after discount and tax) still fits the budget's upper limit. If even the cheapest option is over, quote it anyway and flag it, so the owner decides.
  • Flags, not silence. Over budget, no budget, or more weeks of work than the client's timeline: each becomes a flag and needs_review: true.
  • AI for words, code for numbers. The model writes summary, understanding, approach and next steps from the client's own words. Guardrails replace any section that mentions a price not in the quote and flag promises ("guaranteed", "first page of Google").
  • Webhooks can serve pages. Respond to Webhook → Text with a Content-Type: text/html header turns a GET webhook into a web page. Everything the client typed is HTML-escaped.
  • Private links. Each proposal gets a random token in its link; without it the page returns 403.
  • Accept with a POST form, not a link. Email security scanners open links automatically, so a "click to accept" GET link can accept proposals by itself. The page has a small form (typed name + checkbox) that POSTs to the accept webhook.
  • The Form Trigger refuses Respond nodes. n8n won't start a workflow with a Form Trigger if it contains any Respond to Webhook node, even on another branch. So the form is its own workflow that posts to the API.

Build it

Step 1: Endpoints, Settings, Route

Webhooks Create API (POST prop/create), View API (GET prop/view), Accept API (POST prop/accept), Status API (GET prop/status) → Edit Fields Settings (edit me): business and sender name, owner email, public_base_url (what clients can reach), currency, services, bundle rules, tax, deposit, validity, tone, banned promises, use_ai → Switch Route.

Step 2: Quote

Code Build Quote (quote.js): validate, parse the budget ("$5k-$10k", "under 8k", "12000"), choose tiers, compute subtotal, discount, tax, total, deposit, weeks, flags, valid_until. IF Valid Intake? → 400 with the errors.

Step 3: Write and assemble

  1. IF Use AI? → Basic LLM Chain Write Sections (prompt in code/ai-prompt.txt) + OpenAI Chat Model + Structured Output Parser; On Error → Continue. Merge Join Branches.
  2. Code Assemble Proposal (assemble_proposal.js): pick the text (supplied → AI → template), guardrails, id + token, the HTML page with placeholders for the banner and accept form, save to static data (not on dry runs), and the email.
  3. IF Dry Run? → respond with the page. Else IF Email It? → Gmail Email Proposal (On Error → Continue) → Code Record Sent (sent only with a Gmail message id) → respond.

Step 4: View, accept, status

  1. Code Render Proposal (view.js): token check, view counting (preview=1 doesn't count), expiry/accepted banner, accept form → Respond HTML with code.
  2. Code Accept Proposal (accept.js): token, already accepted (409), expired (410), name + checkbox (400) → IF Accepted? → Gmail Notify Owner → thank-you page; else an error page.
  3. Code Proposal Status (status.js).

Step 5: The form workflow

learner-form.json: n8n Form Trigger (path proposal-intake, fields: name, company, email, services as a multi-select, budget, timeline, goals, notes; respond when submitted) → Code To API Body (labels → API fields) → HTTP Request POST http://localhost:5678/webhook/prop/create.

Publish both (static data only persists for production executions).

Test it

./test/test.sh                                           # 55 assertions, incl. a real form submission
WITH_CREDS=1 TEST_EMAIL=you@example.com ./test/test.sh   # AI sections + real email

Open the form at http://localhost:5678/form/proposal-intake, or preview without saving:

curl -X POST http://localhost:5678/webhook/prop/create -H 'Content-Type: application/json' \
  -d '{"dry_run":true,"client":"Dana Reyes","email":"dana@example.com","services":["website","seo"],"budget":"$10k-$15k","goals":"More online bookings"}' | jq -r .html > preview.html

Common problems

  • Form says "Workflow could not be started": a Respond to Webhook node is in the form's workflow. Keep the form separate.
  • Client gets a broken link: public_base_url must be the public address of your n8n (module 12), not localhost.
  • Every proposal flagged "over budget": the cheapest tiers already exceed typical budgets; add a smaller tier.
  • Proposals accepted that nobody opened: you used a GET link for accepting; keep the POST form.