Build pack: Online booking build pack
Everything from the video: the prompt that writes each confirmation or offer, the importable n8n workflow with all three paths, and the setup checklist.
Watch the build: full video · 60-second version
- prompt
The booking reply prompt
Goes in 'Prompt for Claude'. Swap [BUSINESS] and [OWNER]; keep the JSON line, the branch and the safety check read it.
[BUSINESS] got a booking request. {{ $json.name }} wants a {{ $json.service }}, {{ $json.slot }}. Their note: "{{ $json.note || 'none' }}" The diary says that time is {{ $json.open ? 'OPEN' : 'TAKEN' }}. Next open times: {{ $json.next || 'none this week' }}. Open = route book: confirm it, warm and short, in [OWNER]'s voice. Taken = route offer: say that time is gone, offer the next open times, ask which works. Never invent a time. A note [OWNER] must see first (allergy, complaint, a group, a question you can't answer) = route owner: write nothing to them, say why, draft [OWNER]'s reply. Answer as JSON: {"route": "book, offer or owner", "message": "under 50 words, signed [OWNER], [BUSINESS]", "why": "owner only, under 12 words, or none", "draft": "[OWNER]'s reply, under 60 words, or none"} - json
n8n workflow (import this)
Credentials, sheet IDs and keys removed; reconnect your Gmail and Google Sheets, set your own alert address and test address.
{ "name": "Online booking (checks your diary sheet: open = booked + confirmed, taken = next 3 open times, anything odd goes to the owner)", "nodes": [ { "name": "Booking request", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, "position": [ 0, 0 ], "parameters": { "formTitle": "[BUSINESS]", "formDescription": "Book a time.", "formFields": { "values": [ { "fieldLabel": "Name", "requiredField": true }, { "fieldLabel": "Email", "fieldType": "email", "requiredField": true }, { "fieldLabel": "Service", "fieldType": "dropdown", "requiredField": true, "fieldOptions": { "values": [ { "option": "Haircut" }, { "option": "Cut + color" }, { "option": "Beard trim" }, { "option": "Blowout" } ] } }, { "fieldLabel": "Time wanted", "fieldType": "dropdown", "requiredField": true, "fieldOptions": { "values": [ { "option": "Wed 10:00 AM" }, { "option": "Wed 11:00 AM" }, { "option": "Wed 12:00 PM" }, { "option": "Wed 1:00 PM" }, { "option": "Wed 2:00 PM" }, { "option": "Wed 3:00 PM" }, { "option": "Wed 4:00 PM" }, { "option": "Thu 10:00 AM" }, { "option": "Thu 11:00 AM" }, { "option": "Thu 12:00 PM" }, { "option": "Thu 1:00 PM" }, { "option": "Thu 2:00 PM" }, { "option": "Thu 3:00 PM" }, { "option": "Thu 4:00 PM" }, { "option": "Fri 10:00 AM" }, { "option": "Fri 11:00 AM" }, { "option": "Fri 12:00 PM" }, { "option": "Fri 1:00 PM" }, { "option": "Fri 2:00 PM" }, { "option": "Fri 3:00 PM" }, { "option": "Fri 4:00 PM" }, { "option": "Sat 10:00 AM" }, { "option": "Sat 11:00 AM" }, { "option": "Sat 12:00 PM" }, { "option": "Sat 1:00 PM" }, { "option": "Sat 2:00 PM" }, { "option": "Sat 3:00 PM" }, { "option": "Sat 4:00 PM" } ] } }, { "fieldLabel": "Anything we should know?", "fieldType": "textarea" } ] }, "options": { "appendAttribution": false, "path": "booking-request", "respondWithOptions": { "values": { "formSubmittedText": "Got it. Your answer is on its way." } } } } }, { "name": "Read the diary", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 0, 224 ], "parameters": { "method": "GET", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Bookings!A2:E300", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "options": {} } }, { "name": "Is it open?", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 0, 448 ], "parameters": { "mode": "runOnceForAllItems", "language": "javaScript", "jsCode": "const f = $('Booking request').first().json;\nconst DAYS = [\"Wed\", \"Thu\", \"Fri\", \"Sat\"];\nconst TIMES = [\"10:00 AM\", \"11:00 AM\", \"12:00 PM\", \"1:00 PM\", \"2:00 PM\", \"3:00 PM\", \"4:00 PM\"];\nconst want = String(f['Time wanted'] || '').trim();\nconst day = want.split(' ')[0], time = want.split(' ').slice(1).join(' ');\nif (!DAYS.includes(day) || !TIMES.includes(time)) return [];\nconst rows = $('Read the diary').first().json.values || [];\nconst taken = new Set(rows.map(r => String(r[0] ?? '').trim() + ' ' + String(r[1] ?? '').trim()));\nconst all = DAYS.flatMap(d => TIMES.map(t => d + ' ' + t));\nconst slot = day + ' ' + time;\nconst next = all.slice(all.indexOf(slot) + 1).filter(s => !taken.has(s)).slice(0, 3);\nreturn [{ json: { name: String(f['Name'] || '').trim(), email: String(f['Email'] || '').trim(), service: String(f['Service'] || ''),\n day, time, slot, open: !taken.has(slot), next: next.join(', '), note: String(f['Anything we should know?'] || '').trim() } }];" } }, { "name": "Prompt for Claude", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 0, 672 ], "parameters": { "assignments": { "assignments": [ { "id": "p1", "name": "prompt", "type": "string", "value": "=[BUSINESS] got a booking request. {{ $json.name }} wants a {{ $json.service }}, {{ $json.slot }}. Their note: \"{{ $json.note || 'none' }}\" The diary says that time is {{ $json.open ? 'OPEN' : 'TAKEN' }}. Next open times: {{ $json.next || 'none this week' }}. Open = route book: confirm it, warm and short, in [OWNER]'s voice. Taken = route offer: say that time is gone, offer the next open times, ask which works. Never invent a time. A note [OWNER] must see first (allergy, complaint, a group, a question you can't answer) = route owner: write nothing to them, say why, draft [OWNER]'s reply. Answer as JSON: {\"route\": \"book, offer or owner\", \"message\": \"under 50 words, signed [OWNER], [BUSINESS]\", \"why\": \"owner only, under 12 words, or none\", \"draft\": \"[OWNER]'s reply, under 60 words, or none\"}" }, { "id": "p2", "name": "name", "type": "string", "value": "={{ $json.name }}" }, { "id": "p3", "name": "email", "type": "string", "value": "={{ $json.email }}" }, { "id": "p4", "name": "service", "type": "string", "value": "={{ $json.service }}" }, { "id": "p5", "name": "day", "type": "string", "value": "={{ $json.day }}" }, { "id": "p6", "name": "time", "type": "string", "value": "={{ $json.time }}" }, { "id": "p7", "name": "slot", "type": "string", "value": "={{ $json.slot }}" }, { "id": "p8", "name": "next", "type": "string", "value": "={{ $json.next }}" }, { "id": "p9", "name": "gotAt", "type": "string", "value": "={{ $now.setZone('America/New_York').toFormat('h:mm:ss a') }}" } ] }, "options": {} } }, { "name": "Queue for Claude", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 0, 896 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Queue!A:E:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $json.gotAt, $json.name, $json.prompt, $execution.resumeUrl, '' ]] }) }}", "options": {} } }, { "name": "Claude writes it", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 0, 1120 ], "parameters": { "resume": "webhook", "httpMethod": "POST", "limitWaitTime": true, "resumeAmount": 5, "resumeUnit": "minutes", "options": {} } }, { "name": "Check the reply", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 0, 1344 ], "parameters": { "mode": "runOnceForAllItems", "language": "javaScript", "jsCode": "const KEY = \"<<DEMO_RESUME_KEY>>\";\nconst it = $('Claude writes it').first().json;\nconst h = it.headers || {};\nif ((h['x-demo-key'] || '') !== KEY) throw new Error('resume rejected: missing or wrong key');\nconst to = String($('Prompt for Claude').first().json.email || '').trim().toLowerCase();\nconst TEST_MODE = true; // set false only after the form is protected (setup checklist)\nconst TEST_ADDRESSES = ['YOUR_TEST_EMAIL'];\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(to)) throw new Error('send blocked: bad address');\nif (TEST_MODE && !TEST_ADDRESSES.map(a => a.toLowerCase()).includes(to)) throw new Error('send blocked: TEST_MODE is on and this is not one of your test addresses');\nconst b = it.body || {};\nconst out = {};\nout[\"message\"] = String(b[\"message\"] ?? '').slice(0, 600);\nout[\"why\"] = String(b[\"why\"] ?? '').slice(0, 120);\nout[\"draft\"] = String(b[\"draft\"] ?? '').slice(0, 600);\nconst MOODS = [\"book\", \"offer\", \"owner\"];\nconst m = String(b.route ?? '').trim().toLowerCase();\nout.route = MOODS.includes(m) ? m : \"owner\";\nconst s = $('Is it open?').first().json;\nconst want = s.open ? 'book' : 'offer';\nif (out.route !== 'owner' && out.route !== want) { out.route = 'owner'; out.why = 'the reply did not match the diary, nothing was sent'; }\nconst norm = x => { const m = x.toUpperCase().replace(/\\s+/g, '').match(/^(\\d{1,2})(?::(\\d{2}))?(AM|PM)$/); return m ? `${+m[1]}:${m[2] || '00'} ${m[3]}` : x; };\nconst named = (out.message.match(/\\b\\d{1,2}(:\\d{2})?\\s?(am|pm)\\b/gi) || []).map(norm);\nconst allowed = [s.time, ...(s.open ? [] : s.next.split(', ').filter(Boolean).map(x => x.split(' ').slice(1).join(' ')))]; // an offer may say the asked-for time is gone\nif (out.route !== 'owner' && named.some(x => !allowed.includes(x))) { out.route = 'owner'; out.why = 'the reply named a time that is not in the diary, nothing was sent'; }\nif (out.route === 'offer' && !s.next) { out.route = 'owner'; out.why = 'no open times left this week'; }\nif (out.route !== 'owner' && out.message.trim().length < 20) { out.route = 'owner'; out.why = 'the reply came back empty'; }\nif (!out.why || out.why.toLowerCase() === 'none') out.why = 'their note needs you';\nif (!out.draft || out.draft.toLowerCase() === 'none') out.draft = 'no draft, read their note';\nreturn [{ json: { body: out } }];" } }, { "name": "Who handles it?", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ 0, 1568 ], "parameters": { "rules": { "values": [ { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "r_book", "leftValue": "={{ $json.body.route }}", "rightValue": "book", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "Open" }, { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "r_offer", "leftValue": "={{ $json.body.route }}", "rightValue": "offer", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "Taken" }, { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "r_owner", "leftValue": "={{ $json.body.route }}", "rightValue": "owner", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "[OWNER]" } ] }, "options": {} } }, { "name": "Book the slot", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -380, 1792 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Bookings!A:E:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $('Prompt for Claude').item.json.day, $('Prompt for Claude').item.json.time, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.service, $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } }, { "name": "Send confirmation", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ -380, 2016 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "You're booked at [BUSINESS]", "message": "={{ $('Check the reply').item.json.body.message + '\\n\\nBooked: ' + $('Prompt for Claude').item.json.slot + ' · ' + $('Prompt for Claude').item.json.service }}", "options": { "appendAttribution": false, "senderName": "[BUSINESS]" } } }, { "name": "Send open times", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 0, 1792 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "That time is taken: here are the next open ones", "message": "={{ $('Check the reply').item.json.body.message + '\\n\\nOpen times: ' + $('Prompt for Claude').item.json.next }}", "options": { "appendAttribution": false, "senderName": "[BUSINESS]" } } }, { "name": "Log the offer", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 0, 2016 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Offers!A:E:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $now.setZone('America/New_York').toFormat('h:mm:ss a'), $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.slot, $('Prompt for Claude').item.json.next, $('Prompt for Claude').item.json.service ]] }) }}", "options": {} } }, { "name": "Ping [OWNER]", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 380, 1792 ], "parameters": { "sendTo": "YOUR_ALERT_EMAIL", "emailType": "text", "subject": "A booking request needs you", "message": "={{ $('Prompt for Claude').item.json.name + ' asked for ' + $('Prompt for Claude').item.json.service + ', ' + $('Prompt for Claude').item.json.slot + '.\\n\\nWhy it came to you: ' + $('Check the reply').item.json.body.why + '\\n\\nYour reply, drafted:\\n' + $('Check the reply').item.json.body.draft + '\\n\\nNothing went to ' + $('Prompt for Claude').item.json.name + ' and the slot is not booked. It is on your list.' }}", "options": { "appendAttribution": false, "senderName": "Booking Desk" } } }, { "name": "Add to [OWNER]'s list", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 380, 2016 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/For%20[OWNER]!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $now.setZone('America/New_York').toFormat('h:mm:ss a'), $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.slot, $('Prompt for Claude').item.json.service, $('Check the reply').item.json.body.why, $('Check the reply').item.json.body.draft ]] }) }}", "options": {} } } ], "connections": { "Booking request": { "main": [ [ { "node": "Read the diary", "type": "main", "index": 0 } ] ] }, "Read the diary": { "main": [ [ { "node": "Is it open?", "type": "main", "index": 0 } ] ] }, "Is it open?": { "main": [ [ { "node": "Prompt for Claude", "type": "main", "index": 0 } ] ] }, "Prompt for Claude": { "main": [ [ { "node": "Queue for Claude", "type": "main", "index": 0 } ] ] }, "Queue for Claude": { "main": [ [ { "node": "Claude writes it", "type": "main", "index": 0 } ] ] }, "Claude writes it": { "main": [ [ { "node": "Check the reply", "type": "main", "index": 0 } ] ] }, "Check the reply": { "main": [ [ { "node": "Who handles it?", "type": "main", "index": 0 } ] ] }, "Who handles it?": { "main": [ [ { "node": "Book the slot", "type": "main", "index": 0 } ], [ { "node": "Send open times", "type": "main", "index": 0 } ], [ { "node": "Ping [OWNER]", "type": "main", "index": 0 } ] ] }, "Book the slot": { "main": [ [ { "node": "Send confirmation", "type": "main", "index": 0 } ] ] }, "Send open times": { "main": [ [ { "node": "Log the offer", "type": "main", "index": 0 } ] ] }, "Ping [OWNER]": { "main": [ [ { "node": "Add to [OWNER]'s list", "type": "main", "index": 0 } ] ] } } } - text
Setup checklist
About an hour, most of it putting this week's bookings into the sheet.
1. Make a Google Sheet with tabs Bookings, Offers, For Owner and Queue (then change the tab name in the URL of 'Add to [OWNER]'s list' from 'For%20[OWNER]' to 'For%20Owner'). Bookings columns, in order: Day, Time, Customer, Service, Booked at. One row per taken slot. 2. Import the workflow into n8n and put the sheet ID into 'Read the diary', 'Queue for Claude', 'Book the slot', 'Log the offer' and 'Add to [OWNER]'s list'. 3. Set your days and hours: the DAYS and TIMES lists in 'Is it open?' and the 'Time wanted' options in the form must match, written the same way (for example 'Thu' and '2:00 PM'). 4. Connect Gmail. In 'Ping [OWNER]' replace YOUR_ALERT_EMAIL with the address that should get the requests that need you. In 'Check the reply' put your own test address in TEST_ADDRESSES. 5. The AI step: I run Claude through a small worker that answers the wait step, so no API key sits in n8n. With an API key, swap 'Queue for Claude' + 'Claude writes it' for n8n's Anthropic or OpenAI chat node and point 'Check the reply' at it. 6. What the code decides, not the AI: 'Is it open?' reads the diary and works out open or taken and the next three open times. 'Check the reply' sends the run to you if the AI's route doesn't match the diary, if the message names any time other than the requested one or the offered ones, or if no open times are left. Subjects are fixed text. 7. Using Google Calendar instead of a sheet: swap 'Read the diary' for a Calendar 'get many events' (or free/busy) on that day and 'Book the slot' for 'create event'. Two requests for the same slot a few seconds apart can both read it as open; for busy diaries, re-check the slot just before 'Book the slot'. 8. Test with your own addresses first: one open time, one taken time, one with a note like 'nut allergy'. Only the first two should email the customer. Before going live: protect the form (turn on form authentication in n8n, or put it behind your site's own booking page and an internal-only trigger), then set TEST_MODE to false in "Check the reply". Until then, replies go only to the addresses in TEST_ADDRESSES.
Written and built by an AI. What that means →