Developers
AI website build guide
The instruction book for AI coding agents that build a small-business website on Just a Good Guy, or connect an existing one. What to gather, what to paste, the quality bar, and how to ship and check it.
For developersOn this page
Important
AI agents: read this whole page before you write any code. Every attribute, method, event and endpoint below is checked against the source. Don't use names that aren't on this page or its linked references.
- This page as plain Markdown: /docs/ai-build-guide.md
- This page plus every reference page in one file: /llms-full.txt
- Starter site to copy: /docs/starter-site.zip (browse the files)
What Just a Good Guy is
- An AI receptionist for small businesses. It answers the website chat, the phone and texts.
- It books customers into real open times, and takes call-back requests, messages and quote requests for the owner.
- A website connects with one script tag, the widget. It adds a chat and booking button, inline booking and other widgets, and lets any button open booking or chat.
- A public API with a publishable
pk_key gives a web page live services, prices and hours, and accepts call-back and message forms. A REST API with a secretsk_key, plus signed webhooks, is for servers. - It can host the site too: on a free address like
https://joes-plumbing.justagoodguy.us, on the business's own domain, or on the business's own server.
Rules you never break
- Never put a secret key (
sk_...) in anything a browser loads. No HTML, no JavaScript, no public repo. Web pages only ever use the publishable key (pk_...). - Never invent facts. No made-up prices, reviews, star ratings, awards, years in business, licences, staff, opening hours or service areas. Use the intake answers, the live API and what the operator tells you. If a fact is missing, leave it out or ask.
- Book only through Just a Good Guy. Use the widget, its buttons or the public API. Never build a form that emails a "booking request" instead.
- Load the widget once per page, with the business's own key.
- Inline SVG for icons. Never emoji or Unicode symbols as icons.
- No native date, time or
<select>pickers in forms you build. Use buttons, radio buttons or text fields. (The widget already does dates and times with buttons.) - No tracking without consent. Don't add analytics, pixels or cookies unless the owner asks and the visitor agrees. The widget sets no cookies.
- Show the server's error message as it is. The API's
{ "error": "..." }messages are written for customers.
What you are given
The operator (or the owner) gives you these. Ask for anything missing before you start. You can't work out the key from the business name.
| Item | Looks like | Where it comes from |
|---|---|---|
| Business name and slug | Joe's Plumbing, joes-plumbing | The admin's business page, or business.name and business.slug from GET /config |
| Publishable key | pk_2Jd8sQx0Lr5mWb7nVt3cKa | Operator: Admin → Businesses → the business → AI and API → Public key (/app/admin/businesses). Owner: Website (/app/website) or Developers (/app/developers). |
| Intake answers | pages, look, about, current site, domain, notes | The "Build it for me" request in Admin → Builds (/app/admin/builds) |
| Logo and photos | files | The same request. Files are listed by name; the owner's logo is marked Logo:. |
| Hosted address | https://joes-plumbing.justagoodguy.us | The admin's business page, Website → Host a website for them ("Will be live at") |
| Domain they want | joesplumbing.com | The intake's domain answer |
| Secret key | sk_... | Only if the site has its own server. AI and API → Secret API keys in the admin, or Developers for the owner. Shown once. |
The "Build it for me" form asks the owner for:
- Pages you want: any of Home, About, Services, Prices, Gallery, Team, Reviews, FAQ, Contact
- About your business, The look you like, Your current website, Domain you'd like to use
- Logo and Photos (PNG, JPG, WebP, GIF, SVG or PDF, up to 15 MB each)
- Anything else we should know
A hand-off brief from the operator looks like this. Ask for one in this shape:
Business: Joe's Plumbing
Slug: joes-plumbing
Publishable key: pk_...
Hosted address: https://joes-plumbing.justagoodguy.us
Domain wanted: joesplumbing.com
Pages: Home, Services, About, Contact
Look: clean, blue, friendly; likes example.com
About: (the owner's own words)
Current website: (address, if any)
Phone to publish: (the number on their Google profile)
Files: logo.svg, van.jpg, team.jpg
Deliver: site.zip for the build queueTip
Build and test against a test business first. The operator can make one from the Test as a customer card on the admin overview. Bookings, call-backs and messages you send while testing are real: they alert the owner and text or email whoever's details you typed.
The golden path
- Gather the facts. Read the brief and the intake. List what's missing and ask. See Step 1.
- Read the live business data from the public API: services, prices, hours, phone, booking page. See Step 2.
- Plan the pages from the template for the business type. See Step 3.
- Build from the starter kit, to the quality bar.
- Wire booking and chat: the script tag, booking buttons, inline widgets. See Step 5.
- Show live services, prices and hours, with the same content written into the page as a fallback. See Step 6.
- Search and sharing: titles, descriptions, LocalBusiness JSON-LD with real hours, Open Graph, sitemap. See Step 7.
- Check accessibility and speed against the checklist. See Step 8.
- Package the site as a zip the uploader accepts. See Step 9.
- Deploy and verify the live site: widget loaded, data live, buttons working. See Step 10.
Step 1: Gather the facts
Collect these before you design anything. Note where each one came from.
| Fact | Source | Used for |
|---|---|---|
| Business name, exactly as they write it | Intake, business.name | Header, titles, JSON-LD |
| Phone number to publish | Intake or operator | Call buttons, footer, JSON-LD. See the note below. |
| Street address, city, region, postcode | Intake, address | Footer, contact page, JSON-LD, map link |
| Opening hours | hours.booking from the API | Hours lists, JSON-LD |
| Services, durations, prices | GET /services | Services page, price list |
| Service area (towns or postcodes) | Intake, for businesses that travel | Service area section, JSON-LD areaServed |
| What makes them different, in their words | Intake "About your business" | Headline, about text |
| Look and brand colour | Intake "The look you like", logo, widget.color | Theme tokens, --gg-color |
| Logo, photos | Intake files | Header, hero, gallery |
| Policies, aftercare, directions, what's included | The owner's Business info topics (Menu → Business info; the operator can read them with View as), intake notes | FAQ, policies and visit pages. Only if given. Never write one yourself. |
| Social profiles, Google Business Profile | Intake or operator | Footer links, JSON-LD sameAs |
| Domain | Intake | Canonical URLs, sitemap |
Note
business.phone in the API is the business's Just a Good Guy number when it has one, otherwise its own number. Publish the number the owner gives out (the one on their Google profile, for consistency). If the two differ, ask which one.
Step 2: Read the live business data
The public API is open to any origin and needs only the publishable key. Fetch the two endpoints you need:
KEY=pk_your_key
curl -s "https://justagoodguy.us/api/public/config?key=$KEY"
curl -s "https://justagoodguy.us/api/public/services?key=$KEY"The starter kit does this for you and writes the results into the pages: node tools/bake.mjs pk_your_key. See The starter kit.
What config gives you
GET /api/public/config, abridged:
{
"business": { "name": "Maple Street Barbers", "slug": "maple-street-barbers", "phone": "+15552013344", "timezone": "America/Chicago", "bookingPageUrl": "https://justagoodguy.us/b/maple-street-barbers" },
"widget": { "color": "#1f6feb", "position": "right", "label": "Book or ask a question", "showOnMobile": true },
"assistantName": "Assistant",
"ai": true,
"openNow": false,
"callNow": false,
"allowPickStaff": true,
"hours": {
"booking": [
{ "weekday": 1, "label": "Monday", "ranges": [{ "startMin": 540, "endMin": 1020, "label": "9:00 AM – 5:00 PM" }] },
{ "weekday": 7, "label": "Sunday", "ranges": [] }
],
"calls": []
},
"address": "12 Main St, Springfield, IL, 62701"
}| Field | Meaning |
|---|---|
business.bookingPageUrl | The hosted booking page. Use it as the href of every booking button. |
business.timezone | IANA timezone. Hours are in this zone. |
widget.color | The brand colour set in the dashboard (Settings → Website chat) |
ai | false when the AI is paused. Chat still shows a fallback and a message form. |
openNow | Open right now, counting closures and holidays |
callNow | Someone can take a live call right now |
allowPickStaff | Customers can choose a person when booking |
hours.booking | Weekly booking hours: weekday 1 (Monday) to 7 (Sunday), times in minutes after local midnight, plus a ready-made label. An empty ranges list means closed. |
hours.calls | Weekly call hours, for the phone |
address | The address on file, as one string. Use the intake for the separate parts. |
The response has a few more fields the widget uses (greeting, siteId, turnstileSiteKey, practiceAi). See Public API: config.
What services gives you
GET /api/public/services:
{
"services": [
{ "id": "svc_j0bkxg20s55jtenm", "name": "Haircut", "description": "Wash, cut and style.", "kind": "appointment", "durationMin": 30, "priceLabel": "$35", "staff": [{ "id": "stf_996s6vakq60vvw2j", "name": "Dana" }] },
{ "id": "svc_qk3z9ewfvs2ce56f", "name": "Wedding party grooming", "description": null, "kind": "quote", "durationMin": 0, "priceLabel": null, "staff": [] }
]
}| Field | Meaning |
|---|---|
kind | appointment (at the business), visit (at the customer's address), class (set sessions with limited spots) or quote (priced first, can't be booked at a time) |
priceLabel | Ready to show, like $35, From $40 or Price varies. null when no price is set: show nothing, or "By quote" for quote services. Never make one up. |
durationMin | Length in minutes. Don't show it for quote services. |
staff | People who do it. Empty when customers can't choose. |
Services come in the order the owner set. Only active services are listed.
Step 3: Plan the pages
Use the pages the owner asked for. Every page has the same header (logo, name, menu, Book button) and footer (name, address, phone, hours, links, the chat privacy note). Put the most useful action first on phones: book, call or ask.
Barber, hair or beauty salon, spa
| Page | Sections, in order |
|---|---|
| Home | Headline with Book / Call / Ask buttons; live hours card with "Open now"; top services with prices; "Ask before you book" question buttons; short about; location |
| Services and prices | Full live price list with a Book button on each; real policies, if any |
| Team | Only real people the owner listed. Add "Book with Dana" buttons when allowPickStaff is true (see Booking with a person). |
| Gallery | Real photos only, with alt text |
| Contact | Inline booking widget, address with a map link, phone, hours |
JSON-LD type: HairSalon, BeautySalon, NailSalon or DaySpa.
Trades: plumber, electrician, cleaner, landscaper
| Page | Sections, in order |
|---|---|
| Home | Headline with Book a visit / Call / Get a quote; the towns they cover; main services; how a visit works (only what's true); live hours |
| Services | Visit services with "At your address"; quote services with Get a quote |
| Service area | Towns or postcodes from the intake. Visits outside a service's area are refused at booking, so make the list match. |
| Get a quote | Inline contact widget tied to the quote service, or your own form through the API |
| Contact | Call-back widget (it shows Call now when someone can answer), phone, hours |
JSON-LD type: Plumber, Electrician, HVACBusiness, RoofingContractor, Locksmith, HousePainter, GeneralContractor or MovingCompany. Add areaServed with the towns.
Classes and studios: yoga, fitness, dance, lessons
| Page | Sections, in order |
|---|---|
| Home | Headline with Book a class; this week's classes; prices; teachers |
| Timetable | Upcoming sessions from GET /slots with spots left, each with a Book button |
| Prices | From the API; memberships and passes only as the owner describes them |
| Teachers | Real teachers only |
| Contact | Address, hours, message form |
JSON-LD type: ExerciseGym or SportsActivityLocation.
Clinics and appointments: physio, dental, therapy, vet
| Page | Sections, in order |
|---|---|
| Home | Headline with Book an appointment (or Request an appointment if the business approves each booking); treatments; practitioners; location and hours |
| Treatments | Live list with durations, and prices if the owner shows them |
| Practitioners | Real names and qualifications, exactly as given |
| New patients | What to bring and what happens, from the owner |
| Contact | Booking widget, call-back widget, address, hours |
Ask visitors not to put medical details in message forms. JSON-LD type: MedicalClinic, Dentist, Physiotherapy, Optician or VeterinaryCare.
Step 4: Build to the quality bar
Start from the starter kit. Plain HTML, CSS and a little JavaScript is the right tool for these sites. Don't add a framework, a CSS library or jQuery.
Mobile first
Design at 375px wide first. No sideways scrolling at any width from 320px up.
Tap targets at least 44 by 44px. Body text at least 16px.
Put
viewport-fit=coverin the viewport tag, so the widget can keep clear of the iPhone home bar:HTML<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">Leave room at the bottom of the page for the floating button (about 96px), so it never covers the last line of the footer.
Fast
- Aim for under 100 KB of your own HTML, CSS and JavaScript, not counting photos, fonts and the widget.
- Load one font family, or two at most, from Google Fonts in the
<head>, withdisplay=swapandpreconnect. - Photos: WebP or AVIF where you can, sized for the screen, under 200 KB for the largest. Always set
widthandheight. Addloading="lazy"to everything below the first screen. - No sliders, carousels, autoplay video, or embedded maps on the home page. Link to a map instead.
- Load the widget with
asyncand your own script withdefer. The widget's loader is under 1 KB; the rest loads in the background. - Version your CSS and JS file names or URLs (
styles.css?v=2) when you change them. Hosted sites cache them for an hour.
Accessible (WCAG 2.2 AA)
- One
<h1>per page, then headings in order. Landmarks:header,nav,main,footer. - A "Skip to content" link first in the page.
- Text contrast at least 4.5:1 (3:1 for large text and for borders of controls). Check every colour you change.
- A visible focus outline on every link and button.
- Real
<a>and<button>elements for anything clickable. Never a<div>with a click handler. - Every form field has a visible
<label>. Hints sit below the label and are tied witharia-describedby. Errors say what to fix. - Every image has
alttext that says what's in it, oralt=""if it's decoration. - Links make sense on their own. "Book a haircut", not "click here". If several buttons say "Book", add the service name in visually hidden text.
- Respect
prefers-reduced-motion. Don't animate on load.
Clear actions
- The three actions every small-business visitor wants: Book, Call, Ask a question. Put all three in the first screen on phones.
- Button text says exactly what happens: "Book a time", "Call (217) 555-0142", "Get a quote".
- If the business approves each booking, say "Request a time", not "Book".
Content
- Name, address and phone (NAP) exactly the same on every page, in the JSON-LD and on the owner's Google profile.
- Write short, plain sentences in the owner's voice. No filler, no marketing clichés, no lorem ipsum.
- Only real reviews, with the owner's permission, attributed as the owner says. No star ratings unless they come from a source you can link to.
- A footer note about the chat: who runs it, that it's an AI, that messages are kept, and that it sets no cookies. Link to a privacy page. The starter kit has both.
- A
404.htmlthat links home and to booking.
Step 5: Wire booking and chat
The script tag
Put this once on every page, just before </body>, with the business's key:
<script src="https://justagoodguy.us/w.js" data-key="pk_your_key" async></script>It adds the floating chat and booking button, renders inline widgets, and makes booking buttons work. It never sets cookies, and it doesn't store anything in the browser until the visitor opens the chat or sends a form.
On Just a Good Guy hosting (and the host app), the server adds this tag before </body> of every page for you, but only to pages that don't already load it. Keep the tag in your files anyway: the site then works on any host and in a local preview, and you control the options below.
Watch out
A page with its own tag keeps the key written in it. If the business ever replaces its publishable key (Developers → Replace this key), the old key stops working at once and those pages lose the widget and live data until you put the new key in and upload again (node tools/bake.mjs pk_new_key does it in the starter kit). Pages that rely on the server's tag pick up the new key by themselves. If the owner can't call you when that happens, leave the tag out of hosted pages and let the server add it; you lose data-font and the other options, so set --gg-font instead.
Script options
All optional except data-key. Put them on the same <script> tag.
| Attribute | What it does |
|---|---|
data-key="pk_..." | The publishable key. Required. |
data-launcher="false" | No floating button. Use it when the page only has inline widgets and your own buttons. |
data-position="left" or "right" | Side for the floating button. Overrides the dashboard. |
data-color="#a61e2d" | Brand colour. Overrides the dashboard; --gg-color in your CSS overrides this. |
data-open="chat" or "booking" | Open that view as soon as the page loads |
data-font="inherit" | Use the page's font instead of loading Figtree |
data-api="https://..." | The server to talk to. Defaults to where the script came from. |
If the tag appears more than once on a page, only the first one to run is used and the rest are ignored, even if they have different keys. Keep exactly one.
Contact forms
A contact page needs no special code: a plain <form> with no action, a <textarea> and an email or phone field is picked up by the widget and goes through the business's email system (inbox thread, an email back to the customer, a text and email to the owner). Give every field a visible <label>. Add data-gg-contact to be explicit, data-gg-thanks="..." for your own thank-you line, and never point the form at another service.
<form data-gg-contact>
<label for="c-name">Name</label> <input id="c-name" name="name" autocomplete="name" required>
<label for="c-email">Email</label> <input id="c-email" name="email" type="email" autocomplete="email">
<label for="c-phone">Phone</label> <input id="c-phone" name="phone" type="tel" autocomplete="tel">
<label for="c-msg">Message</label> <textarea id="c-msg" name="message" required></textarea>
<button type="submit">Send message</button>
</form>Buttons that open booking or chat
Add one attribute to any <a> or <button>. Clicking it opens the panel. Give links a real href to the booking page, so they still work if the widget can't load.
<a href="https://justagoodguy.us/b/your-business-slug" data-gg-book="">Book now</a>
<a href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Haircut">Book a haircut</a>
<button type="button" data-gg-open="callback">Call me back</button>
<a href="contact.html#message" data-gg-ask="Do you take walk-ins?">Do you take walk-ins?</a>| Attribute | Opens |
|---|---|
data-gg-book="" | Booking at the first step |
data-gg-book="Haircut" | Booking with that service picked. Use the service id (safest) or its name. Names match exactly first (ignoring case), then by "contains". A quote service opens its quote form. |
data-gg-open="chat" | The panel on a view: chat, booking, callback or contact |
data-gg-ask="Is there parking?" | Chat, and sends that question for the visitor. If the AI is paused, it opens the message form instead. |
- If an element has more than one,
data-gg-askwins, thendata-gg-book, thendata-gg-open. - The click's normal action is cancelled, so the
hrefis only used when the widget isn't running. - Elements added later (menus, modals) work without extra code.
- They work with
data-launcher="false"too.
More: Buttons and triggers.
Inline widgets
Put a placeholder element where the widget should appear. It renders inside its own shadow root, so your CSS can't break it.
<div data-gg-widget="booking">
<p>Loading the booking form. You can also <a href="https://justagoodguy.us/b/your-business-slug">book on our booking page</a>.</p>
</div>Anything inside the placeholder shows until the widget draws itself, then disappears. If the widget can't load, it stays. Always put a fallback link or phone number there.
data-gg-widget | Shows | Options |
|---|---|---|
booking | The booking flow: service, person, day and time buttons, details, confirm | data-gg-service (id or name), data-gg-staff (staff id), data-gg-source="booking_page" |
chat | The AI chat, inline | data-gg-height in pixels (default 480, at least 320) |
services | Services with price labels and a Book button on each | none |
callback | Call now when someone can answer, otherwise a call-back request form with time-window buttons | none |
contact | A message or quote request form | data-gg-service (service id) |
hours | Weekly hours with an Open now or Closed now badge, address and phone | none |
manage | One customer's booking (from their private link) | data-gg-token (required) |
offer | A waitlist offer | data-gg-token (required) |
- Inline widgets draw their own small headings ("Services", "Hours", "Chat with us", "Request a call-back"). Put each one in a section with your own
<h2>, and don't repeat the same words right above it. - Each element renders once. To change options, replace the element.
- If the page only uses inline widgets and your own buttons, add
data-launcher="false"to the script tag.
More: Widgets reference.
Booking with a person
There is no button attribute for a staff member. Use the inline widget or the JavaScript API, with the staff id from GET /services (staff[].id):
<div data-gg-widget="booking" data-gg-staff="stf_996s6vakq60vvw2j"></div>
<button type="button" id="book-dana">Book with Dana</button>
<script>
window.GoodGuy = window.GoodGuy || function () { (GoodGuy.q = GoodGuy.q || []).push(arguments) };
document.getElementById('book-dana').addEventListener('click', function () {
GoodGuy('book', { staff: 'stf_996s6vakq60vvw2j' });
});
</script>This only matters when allowPickStaff is true.
The JavaScript API
The widget loads async, so your code may run first. Paste this queue stub before your own calls; calls made early run in order once the widget is ready.
<script>
window.GoodGuy = window.GoodGuy || function () { (GoodGuy.q = GoodGuy.q || []).push(arguments) };
GoodGuy('on', 'booking.created', function (b) { console.log('Booked', b.serviceName, b.whenLabel); });
</script>
<script src="https://justagoodguy.us/w.js" data-key="pk_your_key" async></script>Call methods as GoodGuy('method', ...args). Once loaded, GoodGuy.method(...) works too.
| Method | What it does |
|---|---|
open(view?, opts?) | Opens the panel on 'chat', 'booking', 'callback' or 'contact'. For booking, opts can be { service, staff }. |
close() | Closes the panel |
toggle(view?) | Opens or closes it |
ask(text) | Opens chat and sends text as the visitor's message |
book({ service?, staff? }) | Opens booking from the start, with a service (id or name) and person (staff id) picked |
identify({ name?, phone?, email? }) | Prefills the visitor's details. Nothing is sent until they submit something. |
mount(element, type, options?) | Renders an inline widget into an element. options: service, staff, source, height, token. |
on(event, fn) / off(event, fn) | Subscribe and unsubscribe |
config | The loaded /config object, or null until it arrives |
version | The widget version |
Events
| Event | When | Callback gets |
|---|---|---|
ready | Settings loaded | The config object. Subscribing late still runs it once. |
open | Panel opened | { view } |
close | Panel closed | nothing |
message | A chat message was sent or arrived | { role, content }; role is user, assistant or owner. Assistant messages also have cards. |
booking.created | A booking was made in the booking flow or in chat | id, status, serviceName, staffName, whenLabel, manageUrl, source. From chat also start (ms). |
callback.created | A call-back was requested | { id, windowLabel, source } |
lead.created | A message or quote request was sent | { id, source } |
source is widget or booking_page from the booking flow, widget from the call-back and message forms, and chat when the AI did it. status can be confirmed or pending_approval (the business approves each booking).
Send bookings to analytics only if the owner already has analytics with visitor consent:
GoodGuy('on', 'booking.created', function (b) {
if (Array.isArray(window.dataLayer)) {
window.dataLayer.push({ event: 'booking_created', booking_status: b.status, booking_source: b.source });
}
});Events only run in the visitor's browser. To record bookings reliably, use webhooks.
More: JavaScript API.
Match the widget to the site
Set these on :root in your stylesheet. They reach inside the widget's shadow root.
:root {
--gg-color: #a61e2d; /* brand colour for buttons, links and the floating button */
--gg-radius: 12px; /* corner radius; default 18px */
--gg-font: 'Archivo', system-ui, sans-serif;
}- Any colour works: the widget works out readable shades for text and focus rings in light and dark mode.
- To use the page's font and skip loading Figtree, add
data-font="inherit"to the script tag. Make sure the font is loaded on the page. - The widget follows the visitor's light or dark setting, not your site's own switch.
- Under 520px wide the panel opens full screen. The floating button sits 16px from the bottom corner at
z-index: 2147483000.
More: Styling the widget.
The booking page and QR code
Every business has a hosted booking page and a QR code for it:
https://justagoodguy.us/b/your-business-slug
https://justagoodguy.us/b/your-business-slug/qr.svg- Use the booking page as the
hrefof every booking button, so booking works without JavaScript and on builders that can't run the widget. - The QR code is an SVG for print: a counter sign, a flyer, a van. Don't put it on the website itself.
- Customers' confirmations link to their own change-or-cancel page,
https://justagoodguy.us/m/<token>. You don't need to build one.
Step 6: Show live services, prices and hours
Write the services, prices and hours into the HTML when you build (from Step 2), then refresh them in the browser from the API. The page is never empty: if the API can't be reached, the written content stays.
Rules for live data:
- GET requests carry the key as
?key=. That avoids a CORS preflight. (X-GG-Keyas a header works too.) - Time out after about 8 seconds. Keep the written content on any error.
- Only replace a list when the API returned something. Never replace content with an empty list.
- Insert text with
textContentor escape it. Never put API strings intoinnerHTMLunescaped. /configand/serviceshave no rate limit. Fetch them once per page view.
The starter kit's app.js does all of this. The core of it, which you can use on its own:
<ul id="services">
<li><strong>Haircut</strong> $35 <a href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Haircut">Book</a></li>
</ul>
<dl id="hours">
<div><dt>Monday to Friday</dt><dd>9:00 AM – 5:00 PM</dd></div>
</dl>
<script>
(function () {
var KEY = 'pk_your_key';
var API = 'https://justagoodguy.us/api/public';
function get(path) {
var ctrl = new AbortController();
setTimeout(function () { ctrl.abort(); }, 8000);
return fetch(API + path + (path.indexOf('?') < 0 ? '?' : '&') + 'key=' + encodeURIComponent(KEY), { credentials: 'omit', signal: ctrl.signal })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); });
}
function add(parent, tag, text) {
var el = document.createElement(tag);
el.textContent = text;
parent.appendChild(el);
return el;
}
Promise.all([get('/config'), get('/services')]).then(function (r) {
var cfg = r[0], services = r[1].services;
if (services.length) {
var list = document.getElementById('services');
list.textContent = '';
services.forEach(function (s) {
var li = add(list, 'li', '');
add(li, 'strong', s.name);
li.appendChild(document.createTextNode(' ' + (s.priceLabel || (s.kind === 'quote' ? 'By quote' : '')) + ' '));
var a = add(li, 'a', s.kind === 'quote' ? 'Get a quote' : 'Book');
a.href = cfg.business.bookingPageUrl;
a.setAttribute('data-gg-book', s.id);
a.setAttribute('aria-label', (s.kind === 'quote' ? 'Get a quote: ' : 'Book ') + s.name);
});
}
if (cfg.hours.booking.some(function (d) { return d.ranges.length; })) {
var dl = document.getElementById('hours');
dl.textContent = '';
cfg.hours.booking.forEach(function (d) {
var row = add(dl, 'div', '');
add(row, 'dt', d.label);
add(row, 'dd', d.ranges.length ? d.ranges.map(function (x) { return x.label; }).join(', ') : 'Closed');
});
}
}).catch(function (err) {
console.warn('Live data unavailable, keeping the written content:', err.message);
});
})();
</script>Open now
config.openNow is true when the business is open right now, counting closures and holidays. Show it as a small badge next to the hours. Leave the badge out of the written HTML (it can't know the time), and only show it once the API answers.
A class timetable
For class services, GET /slots returns the upcoming sessions with the spots left:
curl -s "https://justagoodguy.us/api/public/slots?serviceId=svc_wgxx2f70khaabvmm&days=14&key=$KEY"{
"timezone": "America/Chicago",
"days": [
{ "date": "2026-10-20", "label": "Tuesday, Oct 20", "slots": [{ "start": 1792508400000, "staffIds": [], "label": "10:00 AM", "seatsLeft": 8, "sessionId": "cls_dtm5gr0n7cqef908" }] },
{ "date": "2026-10-27", "label": "Tuesday, Oct 27", "slots": [{ "start": 1793113200000, "staffIds": [], "label": "10:00 AM", "seatsLeft": 8, "sessionId": "cls_syyh4npnyx3b2zzf" }] }
]
}dayscan be up to 21.startis milliseconds since 1970 (UTC); show the ready-madelabels.sessionIdandseatsLeftare only on class slots.- This endpoint allows 120 requests per 10 minutes per visitor, so fetch it for a few services, not dozens. Link each session to booking with
data-gg-book="<service id>".
Callback and quote forms
Two ways. Either works with the business's inbox and alerts.
Use the widgets (least code, handles every case):
<div data-gg-widget="callback"><p>Call us on <a href="tel:+12175550142">(217) 555-0142</a>.</p></div>
<div data-gg-widget="contact" data-gg-service="svc_qk3z9ewfvs2ce56f"><p>Email <a href="mailto:[email protected]">[email protected]</a>.</p></div>Or build your own form and send it to the API. This matches the site's design exactly. A message or quote request:
curl -s -X POST "https://justagoodguy.us/api/public/leads" \
-H "X-GG-Key: pk_your_key" \
-H "Content-Type: application/json" \
-d '{ "name": "Sam Lee", "phone": "+15555550123", "details": "Quote for six groomsmen on June 12", "serviceId": "svc_qk3z9ewfvs2ce56f" }'{ "lead": { "id": "led_vrybxfp0rkfcc9yb" } }A call-back request:
curl -s -X POST "https://justagoodguy.us/api/public/callbacks" \
-H "X-GG-Key: pk_your_key" \
-H "Content-Type: application/json" \
-d '{ "name": "Sam Lee", "phone": "5555550123", "reason": "Question about prices" }'{ "callback": { "id": "cbk_rkbq55gzwpaqsyk6", "windowLabel": "Monday, Oct 19 at 9:00 AM – 11:00 AM" } }| Endpoint | Body | Notes |
|---|---|---|
POST /leads | name, details (up to 3,000 characters), and phone or email; serviceId optional | Returns 201. 6 per hour per visitor. |
POST /callbacks | name, phone; reason (up to 500) and windowStart optional | Returns 201. Leave out windowStart for the next window, or pick one from GET /callback-windows. 6 per hour per visitor. |
- Send JSON with
Content-Type: application/jsonand the key inX-GG-Key. - On an error, show
errorfrom the response as it is, for example "Add a phone number or email so we can reply." - Hosted sites only serve files (GET and HEAD). A form can't post to the site itself; it has to call the API from JavaScript.
- Without JavaScript the form can't send. Show the phone number and email in a
<noscript>note.
The starter kit's contact page has a complete, accessible message form wired this way.
Step 7: Search and sharing
Every page's <head>:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Joe's Plumbing | Plumber in Springfield, IL</title>
<meta name="description" content="Leaks, drains and water heaters in Springfield, IL. Book a visit online.">
<link rel="canonical" href="https://www.joesplumbing.com/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Joe's Plumbing">
<meta property="og:title" content="Joe's Plumbing | Plumber in Springfield, IL">
<meta property="og:description" content="Leaks, drains and water heaters in Springfield, IL. Book a visit online.">
<meta property="og:url" content="https://www.joesplumbing.com/">
<meta property="og:image" content="https://www.joesplumbing.com/og-image.jpg">
<link rel="icon" href="favicon.svg" type="image/svg+xml">- Titles under 60 characters: what, where, who. Descriptions under 155 characters.
og:imagemust be a real photo or logo image, 1200 by 630, JPG or PNG. Leave the tag out if there isn't one.canonicalandog:urluse the final address: the custom domain if there is one, otherwise the hosted address.- Add
robots.txtandsitemap.xmllisting every page.
LocalBusiness JSON-LD
Put this on the home page, with facts from the intake and hours from the API:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Plumber",
"name": "Joe's Plumbing",
"url": "https://www.joesplumbing.com/",
"telephone": "+12175550188",
"address": {
"@type": "PostalAddress",
"streetAddress": "40 Oak Ln",
"addressLocality": "Springfield",
"addressRegion": "IL",
"postalCode": "62704",
"addressCountry": "US"
},
"areaServed": ["Springfield", "Chatham", "Rochester"],
"openingHoursSpecification": [
{ "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], "opens": "08:00", "closes": "17:00" }
]
}
</script>Turning hours.booking into openingHoursSpecification:
| API | JSON-LD |
|---|---|
weekday 1 to 7 | dayOfWeek "Monday" to "Sunday" |
startMin 540 | opens "09:00" (24-hour, HH:MM) |
endMin 1020 | closes "17:00"; use "23:59" for 1440 |
empty ranges | leave the day out |
| two ranges in one day | two entries |
Group days with the same hours into one entry. The starter kit's tools/bake.mjs does this conversion and writes it into the page. Only add sameAs (social profiles), logo, image and priceRange when you have the real values.
Step 8: Check accessibility and speed
Go through this list for every page, at 375px and at 1280px wide:
- No sideways scroll. Nothing overlaps.
- Tab through the page: the skip link comes first, every control shows a focus outline, the order makes sense.
- Every image has suitable
alttext; every form field has a visible label. - Text contrast passes 4.5:1, in light and (if you support it) dark mode.
- One
<h1>, headings in order, landmarks present. - Book, Call and Ask are in the first screen on a phone.
- Booking buttons have the booking page as
href. Phone links usetel:+1.... - Services, prices and hours on the page match the API.
- No invented facts. Search the files for leftover sample text.
- The page works with JavaScript turned off: content shows, links go to the booking page.
- Total of your own HTML, CSS and JS under 100 KB; images sized and lazy-loaded.
If you can run them: Lighthouse on mobile should score 90 or more for Performance, Accessibility, Best Practices and SEO, and axe should report no serious or critical issues.
Step 9: Package the site
Make a .zip of the site files. The uploader checks it like this:
| Rule | Detail |
|---|---|
| Home page | index.html at the top of the zip. If everything sits in one folder, that folder is lifted up a level. Otherwise the upload stops. |
| Size | Up to 150 MB zipped, 400 MB unzipped, 5,000 files |
| Left out | __MACOSX, .DS_Store, Thumbs.db, .git folders, .env and .env.* files |
| Rejected | A zip with a path that leaves its folder (../), an absolute path or a drive letter |
| Format | The operator's build queue takes .zip only. The owner's dashboard also takes a folder. |
How the hosting serves it:
- Clean URLs.
/aboutservesabout.html;/about/servesabout/index.html. Links likeabout.htmlwork too. - 404. Your
404.htmlis used for any missing address, with status 404. It's served at any depth, so its links, styles and scripts must start with/(/styles.css,/). - Hidden files. Files and folders starting with
.are never served, except/.well-known/. - Caching. HTML is sent with
no-cache; everything else is cached for an hour. Change the file name or add?v=2when you change CSS or JS. - Static only. GET and HEAD. No PHP, no server code, no form posts to the site.
- Compression and HTTPS are handled for you.
- The widget tag is added before
</body>of every HTML page unless the page already has a<script>whosesrcends in/w.jsand that hasdata-key. A page with no</body>gets it at the end. The owner can turn this off per site.
Make the zip from inside the site folder, then check index.html is at the top:
cd my-site
zip -r ../site.zip . -x 'tools/*' 'README.md' '*.DS_Store'
unzip -l ../site.zip | headStep 10: Deploy and verify
From the operator's build queue
- Open Admin → Builds (/app/admin/builds) and find the request.
- Tap Upload finished site (.zip) and pick the zip. It goes live on the business's hosted address straight away, the request moves to Owner reviewing, and the owner gets an email.
- Check the live site (below). Send the owner a reply on the request with the address.
- When the owner is happy, set the status to Live. They get an email each time the status changes.
The same upload is on the business's admin page under Website → Host a website for them, with the version list, the custom domain and the Add the AI to every page switch. Every upload is kept as a version; the owner can roll back in their dashboard.
Watch out
Hosted sites are served by a paired host server (Admin → Hosts), or by the business's own server. If none is connected, an upload is accepted but nothing serves it. If the hosted address doesn't load after an upload, check that a host is online.
From the owner's dashboard
The owner can upload the same zip, or the folder itself, in Website (/app/website). Big uploads go up in pieces.
Addresses
- Hosted address:
https://<slug>.justagoodguy.us, live as soon as the upload finishes. - Custom domain: add it (without
wwworhttps://) on the site in the dashboard or the admin page. At the domain's DNS host, add a CNAME forwwwpointing at the target the dashboard shows (by default<slug>.justagoodguy.us), and forward the bare domain tohttps://www.(or use an ALIAS or ANAME record if the DNS host has one). The dashboard's DNS check turns ready whenwwwpoints at the target. See Use your own domain. - The business's own server: the host app serves folders from
/opt/goodguy/sites/. Name the folder after the domain. To stop the widget being added to one folder, put a.goodguy.jsonfile in it containing{ "injectWidget": false }. See Use your own server.
Verify the live site
Do all of these on the live address, not a local copy.
- The widget tag is there once.
curl -s https://joes-plumbing.justagoodguy.us/ | grep -c '/w.js'prints1. - It loads. Open the page in a browser. The floating button appears (unless you used
data-launcher="false"). In the browser console,GoodGuy.versionprints a version andGoodGuy.config.business.slugprints the right slug. - The requests succeed. In the network panel:
/w.jsand/w/app.<hash>.jsanswer 200,/api/public/config200,/api/public/events204, and/api/public/services200 if the page shows live services. The console has no[GoodGuy]warnings. - Live data shows. Services, prices and hours match the dashboard. "Open now" is right for the business's local time.
- Every button works. Book opens booking (with the right service, where set). Ask sends the question. Call starts a call on a phone. Each
hrefis the real booking page. - Inline widgets render and their fallback text disappears.
- Booking works end to end. Walk through the booking flow to the confirm step. Only book for real with the operator's agreement, using their own details, then cancel from the link in the confirmation.
- Allowed sites. If the business's key has an allowed-sites list, it must include every address the site answers on, apart from its hosted address and connected custom domains (see below).
- The install check. For a site on another host, use the owner's Website → I already have a site → Check install. It passes once a page with the widget has been visited from that site in the last 30 days. Visit one live page first; the first visit records the site's address, so the check passes straight away.
- Phones. Check at 375px: the panel opens full screen and closes with its X.
Allowed sites
By default a publishable key works on any website. If the owner or operator lists allowed sites (Developers for the owner, Websites allowed to use their AI on the admin page), requests from any other site get 403 with "This site isn't allowed to use this booking assistant."
- The list is checked against the browser's
Origin. An entry is an exact origin (https://joesplumbing.com), a host name (joesplumbing.com, any scheme) or a wildcard (*.joesplumbing.com, subdomains only, entered in the owner's Developers page). joesplumbing.comdoes not coverwww.joesplumbing.com. Add both.- The business's own hosted address (
https://joes-plumbing.justagoodguy.us) and its connected custom domains always work, list or no list, and so does the booking page on https://justagoodguy.us. List the other sites only. localhostand pages opened from a file (file://) are refused while a list is set. Leave the list empty while you build.
Connecting an existing website
When the business already has a site, you only add the widget, buttons and maybe an inline booking widget. You don't need to rebuild anything. Paste the script tag once, sitewide, before </body>.
| Platform | Script tag (whole site) | Inline widget or booking button on a page |
|---|---|---|
| Plain HTML | Before </body> on every page, or once in the shared footer file | Paste <div data-gg-widget="booking"></div> and data-gg-book links where you want them |
| WordPress | A "header and footer code" plugin, footer box; or before </body> in a child theme's footer.php. Clear any cache plugin afterwards. | A Custom HTML block with the <div data-gg-widget="booking"> or a <a data-gg-book=""> link. The normal Button block can't take data attributes, so link it to the booking page instead. |
| Wix | Settings → Custom code → Add Custom Code, All pages, Body - end. Needs a paid plan and a connected domain. | Wix's Embed HTML element is a separate frame: put the script tag (with data-launcher="false") and the <div data-gg-widget="booking"> together inside it and give it enough height, or link a button to the booking page. |
| Squarespace | Settings → Code injection → Footer. Only on some plans. | A Code block with the inline widget or a data-gg-book link |
| Shopify | Online Store → Themes → Edit code → layout/theme.liquid, before </body>. Repeat after changing themes. | A Custom Liquid section with the inline widget, or data-gg-book links in templates |
| Webflow | Site settings → Custom code → Footer code, then Publish. Needs a paid site plan. | An Embed element for the inline widget. Add data-gg-book to any button with Custom attributes in its settings. |
| GoDaddy and others | A sitewide "custom code", "footer code" or "tracking code" box | If code only goes in a box on one page, that box is a frame: use an inline booking widget inside it, or link buttons to the booking page |
After installing, run the verify list. The owner's dashboard check passes once a page with the widget has had a visit. Full steps: Add the widget to your site.
Note
If the site sends a Content-Security-Policy header, allow the widget's server in script-src and connect-src, and Google Fonts in style-src and font-src (or use data-font="inherit"). See Content Security Policy.
For single-page apps (React, Vue and so on): add the script tag once in the app shell. Triggers work on elements rendered later; render inline widgets from code with GoodGuy('mount', element, 'booking', { service: 'Haircut' }).
Server-side integration
Only when the site has its own backend (a members area, an order system, a CRM sync). Everything a normal business site needs works from the browser with the publishable key.
Watch out
The secret key (sk_...) can read and change every booking and customer. Keep it in a server environment variable. Never send it to a browser, never commit it, never put it in a static site.
REST API
| Base URL | https://justagoodguy.us/api/v1 |
| Auth | Authorization: Bearer sk_... |
| Format | JSON in and out. Times are ISO 8601 in UTC. |
| Rate limit | 600 requests per minute per key |
| Spec | /api/openapi.json |
| Request | What it does |
|---|---|
GET /v1/services | Active services: id, name, kind, durationMin, priceCents, description |
GET /v1/staff | Active team members |
GET /v1/availability?service_id=&staff_id=&from=&days= | Open times, grouped by local date. days 1 to 31. |
GET /v1/bookings?from=&to=&status= | Bookings in a range |
POST /v1/bookings | service_id, start (exactly as availability gave it), customer: { name, phone or email }, optional staff_id and notes |
GET /v1/bookings/{id} | One booking |
POST /v1/bookings/{id}/reschedule | start, optional staff_id and notify |
POST /v1/bookings/{id}/cancel | Optional reason and notify |
GET /v1/customers?q=&limit=, POST /v1/customers, GET /v1/customers/{id} | Find, create or update (matched by phone or email), get |
GET /v1/callbacks, GET /v1/leads | Open call-back requests; new messages and quote requests |
curl -s "https://justagoodguy.us/api/v1/availability?service_id=svc_j0bkxg20s55jtenm&days=3" \
-H "Authorization: Bearer $JAGG_SECRET_KEY"- There are no idempotency keys. If a create request times out, list bookings for that time before you retry. A retry of a booking that went through fails with
409because the time is taken. - Errors are
{ "error": "..." }with400,401,404,409or429.
Full reference: REST API.
Webhooks
Set them up in Developers (owner only): an https:// URL and the events you want (booking.created, booking.rescheduled, booking.cancelled, callback.created, lead.created, customer.created, conversation.closed, or *). You get a whsec_ signing secret once.
- Each request has
X-JAGG-Signature: t=<unix seconds>,v1=<hex>.v1is HMAC-SHA256 of<t>.<raw body>with the secret. Check it against the raw body with a constant-time compare, and reject timestamps more than 5 minutes off. - The body is
{ id, type, created, data }.idstays the same on retries: store it and skip duplicates. - Answer
2xxwithin 10 seconds, then do the work. Each delivery is tried up to 8 times, with waits that double from 2 minutes to about 2 hours. - Events can arrive out of order. Fetch
GET /v1/bookings/{id}for the latest state.
import crypto from 'node:crypto';
export function verifyJaggSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((kv) => { const i = kv.indexOf('='); return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()]; }));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Full reference, with Express, Flask and PHP handlers and a test vector: Webhooks.
Troubleshooting
| What you see | Why | Fix |
|---|---|---|
"That site key isn't valid." in the widget, 401 from the API | Wrong or revoked key | Copy the key again from the admin page or the dashboard. Revoking a key makes a new one. |
| "Missing site key." | No data-key, or it doesn't start with pk_ | Fix the script tag. Never use an sk_ key here. |
"This site isn't allowed to use this booking assistant." (403) | The key has an allowed-sites list without this address | Add every other address the site answers on, including www.. (The hosted address and connected custom domains are always allowed.) See Allowed sites. |
"This booking assistant is turned off." (403) | The business is suspended | Ask the operator |
| No floating button on phones | Show on phones is off in Settings → Website chat, or the tag has data-launcher="false" | Turn it on, or remove the attribute |
| Two tags, or the wrong business | Your tag and another one with a different key | Keep one tag per page. Only the first one to run is used. |
| The widget stopped working after a key change | The page has its own tag with the old key | Put the new key in every page and upload again. See the warning under The script tag. |
| Buttons do nothing | The widget didn't load (blocked script, typo in the tag), or another script cancels the click | Check the console for [GoodGuy] warnings and the network panel for /w.js |
| Widget blocked by the browser | The site's Content-Security-Policy | Allow https://justagoodguy.us in script-src and connect-src |
| "Mixed content" errors | The script tag uses http:// on an https:// page | Always use https:// |
| Old styles or text after an upload | CSS and JS are cached for an hour; w.js for 5 minutes | Add ?v=2 to changed file names; hard-reload |
429 "You're going a bit fast…" | Rate limit per visitor (forms: 6 per hour) | Wait 60 seconds. Don't test forms in a loop. |
| Live data works on the hosted address but not in your preview | Allowed-sites list, or a file:// page | Preview over http://localhost with an empty list |
| A form shows "Add a phone number or email so we can reply." | The API's own check | Show it as it is; that's the fix the visitor needs |
Upload says it can't find index.html | The home page isn't at the top of the zip, or there are several top folders | Zip from inside the site folder |
| The floating button is stuck inside a box | The builder put your code in a frame | Use the sitewide code setting, or an inline widget plus booking-page links |
| Custom domain shows an error page | DNS not pointed yet, or the domain isn't saved on the site | Run the DNS check in the dashboard. Changes can take up to a day. Tell the operator if it stays broken. |
The starter kit
A complete small-business site that already does everything on this page. Plain HTML, CSS and JavaScript, no build step.
curl -fsSLO https://justagoodguy.us/docs/starter-site.zip
unzip starter-site.zip
cd starter-site
node tools/bake.mjs pk_the_business_keytools/bake.mjs (Node 18 or later) puts the key in every page, swaps in the real booking page, writes the live services, prices and hours into the HTML, fills the JSON-LD opening hours, and saves the raw data to tools/business.json.
| File | What it is |
|---|---|
index.html | Home: headline, Book / Call / Ask, live hours card with "Open now", top services, suggested questions, short about |
services.html | Full live price list with Book buttons |
about.html | The business's story |
contact.html | Inline booking widget, call-back widget, message form through the API, address and hours |
privacy.html | Privacy page that covers the chat assistant |
404.html | Not-found page with root-relative links |
styles.css | Mobile-first styles; colours and fonts are tokens at the top; light and dark |
app.js | Live services and hours with fallback, the message form, the booking event hook |
favicon.svg, robots.txt, sitemap.xml | Placeholders to replace |
README.md | What to replace, how to preview, how to zip |
Then replace the sample business, re-theme the tokens, add the real logo and photos, and set the real address in canonical, og:url, the JSON-LD, robots.txt and sitemap.xml. The README has a grep command that must find no leftover sample text. Browse every file in the kit at /docs/starter-site/.
A complete one-file page
The smallest page that does everything that matters: the widget, booking buttons with fallbacks, an inline booking widget and quote form, live services and hours over written content, and JSON-LD. Replace the sample business, the key and your-business-slug. You can see it at /docs/examples/one-page.html.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Oak Lane Plumbing | Plumber in Springfield, IL</title>
<meta name="description" content="Leaks, drains and water heaters in Springfield, IL. See our services and book a visit online.">
<link rel="canonical" href="https://www.example.com/">
<meta property="og:type" content="website">
<meta property="og:title" content="Oak Lane Plumbing | Plumber in Springfield, IL">
<meta property="og:description" content="Leaks, drains and water heaters in Springfield, IL. Book a visit online.">
<meta property="og:url" content="https://www.example.com/">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f5e7a'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600;700&display=swap">
<style>
:root { --brand: #0f5e7a; --brand-hover: #0b4a61; --ink: #13212b; --ink-2: #4b5a64; --line: #d8e0e5; --bg: #ffffff; --bg-2: #f2f6f8; --gg-color: #0f5e7a; color-scheme: light dark; }
@media (prefers-color-scheme: dark) { :root { --brand: #7cc7e3; --brand-hover: #a3d9ec; --ink: #e8eef2; --ink-2: #b2c0c9; --line: #2b3b46; --bg: #0f1a21; --bg-2: #15242d; } }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 1.0625rem/1.55 'Instrument Sans', system-ui, sans-serif; }
.wrap { max-width: 64rem; margin: 0 auto; padding: 0 1.25rem; }
h1, h2, h3 { line-height: 1.15; margin: 0 0 .75rem; }
h1 { font-size: clamp(2.25rem, 8vw, 3.5rem); }
h2 { font-size: clamp(1.625rem, 5vw, 2.25rem); }
h3 { font-size: 1.125rem; margin: 0; }
p { margin: 0 0 1rem; }
a { color: var(--brand); }
:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
.skip { position: absolute; left: .75rem; top: -4rem; padding: .75rem 1rem; background: var(--bg); border: 2px solid var(--brand); }
.skip:focus { top: .75rem; }
header { padding: 1rem 0; border-bottom: 1px solid var(--line); }
header .wrap { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.name { font-weight: 700; font-size: 1.25rem; color: var(--ink); text-decoration: none; }
section { padding: 3rem 0; }
.alt { background: var(--bg-2); }
.lede { color: var(--ink-2); font-size: 1.1875rem; max-width: 36rem; }
.actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 3rem; padding: .625rem 1.25rem; border: 2px solid var(--brand); border-radius: 10px; background: var(--brand); color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }
.btn:hover { background: var(--brand-hover); border-color: var(--brand-hover); }
.btn.ghost { background: transparent; color: var(--brand); }
.btn svg { width: 1.25em; height: 1.25em; flex: none; }
@media (prefers-color-scheme: dark) { .btn { color: #0f1a21; } .btn.ghost { color: var(--brand); } }
.services { list-style: none; margin: 0; padding: 0; display: grid; gap: .75rem; }
.services li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem 1rem; padding: 1rem; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
.meta { margin: .25rem 0 0; color: var(--ink-2); }
.hours { margin: 0; max-width: 26rem; }
.hours div { display: flex; justify-content: space-between; padding: .5rem 0; border-bottom: 1px solid var(--line); }
.hours dd { margin: 0; color: var(--ink-2); }
.box { border: 1px solid var(--line); border-radius: 10px; padding: .5rem; min-height: 10rem; background: var(--bg); }
footer { padding: 2rem 0 7rem; border-top: 1px solid var(--line); color: var(--ink-2); }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Plumber",
"name": "Oak Lane Plumbing",
"url": "https://www.example.com/",
"telephone": "+12175550188",
"address": { "@type": "PostalAddress", "streetAddress": "40 Oak Ln", "addressLocality": "Springfield", "addressRegion": "IL", "postalCode": "62704", "addressCountry": "US" },
"openingHoursSpecification": [
{ "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], "opens": "08:00", "closes": "17:00" }
]
}
</script>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header>
<div class="wrap">
<a class="name" href="#main">Oak Lane Plumbing</a>
<!-- data-gg-book opens the booking panel. The href is the fallback if the widget can't run. -->
<a class="btn" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="">Book a visit</a>
</div>
</header>
<main id="main">
<section>
<div class="wrap">
<h1>Leaks, drains and water heaters, fixed.</h1>
<p class="lede">Plumbing for homes in Springfield and nearby towns. Book a visit online, call us, or ask a question.</p>
<div class="actions">
<a class="btn" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></svg>
Book a visit
</a>
<a class="btn ghost" href="tel:+12175550188">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>
Call (217) 555-0188
</a>
<a class="btn ghost" href="#quote" data-gg-ask="Do you cover my area?">Do you cover my area?</a>
</div>
</div>
</section>
<section class="alt" aria-labelledby="services-title">
<div class="wrap">
<h2 id="services-title">Services</h2>
<!-- Written from the API when the page was built; refreshed live by the script below. -->
<ul class="services" id="services">
<li><div><h3>Drain cleaning</h3><p class="meta">From $120</p></div><a class="btn ghost" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Drain cleaning" aria-label="Book Drain cleaning">Book</a></li>
<li><div><h3>Water heater replacement</h3><p class="meta">By quote</p></div><a class="btn ghost" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Water heater replacement" aria-label="Get a quote: Water heater replacement">Get a quote</a></li>
</ul>
</div>
</section>
<section aria-labelledby="book-title">
<div class="wrap">
<h2 id="book-title">Book online</h2>
<!-- The inline booking widget. The text inside shows until it renders, and stays if it can't. -->
<div class="box" data-gg-widget="booking">
<p>Loading the booking form. You can also <a href="https://justagoodguy.us/b/your-business-slug">book on our booking page</a>.</p>
</div>
</div>
</section>
<section class="alt" aria-labelledby="quote">
<div class="wrap">
<h2 id="quote">Ask for a quote</h2>
<div class="box" data-gg-widget="contact">
<p>Call us on <a href="tel:+12175550188">(217) 555-0188</a>.</p>
</div>
</div>
</section>
<section aria-labelledby="hours-title">
<div class="wrap">
<h2 id="hours-title">Hours</h2>
<dl class="hours" id="hours">
<div><dt>Monday to Friday</dt><dd>8:00 AM – 5:00 PM</dd></div>
<div><dt>Saturday and Sunday</dt><dd>Closed</dd></div>
</dl>
</div>
</section>
</main>
<footer>
<div class="wrap">
<p><strong>Oak Lane Plumbing</strong>, 40 Oak Ln, Springfield, IL 62704. <a href="tel:+12175550188">(217) 555-0188</a></p>
<p>Booking and our chat assistant are run by Just a Good Guy. The assistant is an AI. It keeps your messages so we can follow up, and it doesn't use cookies.</p>
</div>
</footer>
<!-- The widget: once per page, at the end of the body. -->
<script src="https://justagoodguy.us/w.js" data-key="pk_REPLACE_ME" async></script>
<script>
// Queue stub: GoodGuy(...) calls made before the widget loads run once it's ready.
window.GoodGuy = window.GoodGuy || function () { (GoodGuy.q = GoodGuy.q || []).push(arguments); };
// Live services and hours. The written content above stays if this fails.
(function () {
var tag = document.querySelector('script[src*="/w.js"][data-key]');
var key = tag.getAttribute('data-key');
var api = new URL(tag.src).origin + '/api/public';
if (/REPLACE/.test(key)) return;
function get(path) {
return fetch(api + path + (path.indexOf('?') < 0 ? '?' : '&') + 'key=' + encodeURIComponent(key), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); });
}
function el(tagName, text, cls) {
var e = document.createElement(tagName);
if (text) e.textContent = text;
if (cls) e.className = cls;
return e;
}
Promise.all([get('/config'), get('/services')]).then(function (r) {
var cfg = r[0];
var services = r[1].services;
if (services.length) {
var list = document.getElementById('services');
list.textContent = '';
services.forEach(function (s) {
var li = el('li');
var info = el('div');
info.appendChild(el('h3', s.name));
info.appendChild(el('p', s.priceLabel || (s.kind === 'quote' ? 'By quote' : ''), 'meta'));
var a = el('a', s.kind === 'quote' ? 'Get a quote' : 'Book', 'btn ghost');
a.href = cfg.business.bookingPageUrl;
a.setAttribute('data-gg-book', s.id);
a.setAttribute('aria-label', (s.kind === 'quote' ? 'Get a quote: ' : 'Book ') + s.name);
li.appendChild(info);
li.appendChild(a);
list.appendChild(li);
});
}
var days = cfg.hours.booking.filter(function (d) { return d.ranges.length; });
if (days.length) {
var dl = document.getElementById('hours');
dl.textContent = '';
cfg.hours.booking.forEach(function (d) {
var row = el('div');
row.appendChild(el('dt', d.label));
row.appendChild(el('dd', d.ranges.length ? d.ranges.map(function (x) { return x.label; }).join(', ') : 'Closed'));
dl.appendChild(row);
});
}
}).catch(function (err) {
console.warn('[site] Live data unavailable, keeping the written content:', err.message);
});
})();
// A booking was made in the widget or in chat.
GoodGuy('on', 'booking.created', function (b) {
console.log('Booked', b.serviceName, b.whenLabel, b.status);
});
</script>
</body>
</html>