Just A Good Guy Docs
Sign in

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 developers
On 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.

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 secret sk_ 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.

ItemLooks likeWhere it comes from
Business name and slugJoe's Plumbing, joes-plumbingThe admin's business page, or business.name and business.slug from GET /config
Publishable keypk_2Jd8sQx0Lr5mWb7nVt3cKaOperator: Admin → Businesses → the business → AI and API → Public key (/app/admin/businesses). Owner: Website (/app/website) or Developers (/app/developers).
Intake answerspages, look, about, current site, domain, notesThe "Build it for me" request in Admin → Builds (/app/admin/builds)
Logo and photosfilesThe same request. Files are listed by name; the owner's logo is marked Logo:.
Hosted addresshttps://joes-plumbing.justagoodguy.usThe admin's business page, Website → Host a website for them ("Will be live at")
Domain they wantjoesplumbing.comThe intake's domain answer
Secret keysk_...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:

Text
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 queue

Tip

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

  1. Gather the facts. Read the brief and the intake. List what's missing and ask. See Step 1.
  2. Read the live business data from the public API: services, prices, hours, phone, booking page. See Step 2.
  3. Plan the pages from the template for the business type. See Step 3.
  4. Build from the starter kit, to the quality bar.
  5. Wire booking and chat: the script tag, booking buttons, inline widgets. See Step 5.
  6. Show live services, prices and hours, with the same content written into the page as a fallback. See Step 6.
  7. Search and sharing: titles, descriptions, LocalBusiness JSON-LD with real hours, Open Graph, sitemap. See Step 7.
  8. Check accessibility and speed against the checklist. See Step 8.
  9. Package the site as a zip the uploader accepts. See Step 9.
  10. 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.

FactSourceUsed for
Business name, exactly as they write itIntake, business.nameHeader, titles, JSON-LD
Phone number to publishIntake or operatorCall buttons, footer, JSON-LD. See the note below.
Street address, city, region, postcodeIntake, addressFooter, contact page, JSON-LD, map link
Opening hourshours.booking from the APIHours lists, JSON-LD
Services, durations, pricesGET /servicesServices page, price list
Service area (towns or postcodes)Intake, for businesses that travelService area section, JSON-LD areaServed
What makes them different, in their wordsIntake "About your business"Headline, about text
Look and brand colourIntake "The look you like", logo, widget.colorTheme tokens, --gg-color
Logo, photosIntake filesHeader, hero, gallery
Policies, aftercare, directions, what's includedThe owner's Business info topics (Menu → Business info; the operator can read them with View as), intake notesFAQ, policies and visit pages. Only if given. Never write one yourself.
Social profiles, Google Business ProfileIntake or operatorFooter links, JSON-LD sameAs
DomainIntakeCanonical 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:

Shell
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:

JSON
{
  "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"
}
FieldMeaning
business.bookingPageUrlThe hosted booking page. Use it as the href of every booking button.
business.timezoneIANA timezone. Hours are in this zone.
widget.colorThe brand colour set in the dashboard (Settings → Website chat)
aifalse when the AI is paused. Chat still shows a fallback and a message form.
openNowOpen right now, counting closures and holidays
callNowSomeone can take a live call right now
allowPickStaffCustomers can choose a person when booking
hours.bookingWeekly 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.callsWeekly call hours, for the phone
addressThe 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:

JSON
{
  "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": [] }
  ]
}
FieldMeaning
kindappointment (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)
priceLabelReady 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.
durationMinLength in minutes. Don't show it for quote services.
staffPeople 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

PageSections, in order
HomeHeadline 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 pricesFull live price list with a Book button on each; real policies, if any
TeamOnly real people the owner listed. Add "Book with Dana" buttons when allowPickStaff is true (see Booking with a person).
GalleryReal photos only, with alt text
ContactInline booking widget, address with a map link, phone, hours

JSON-LD type: HairSalon, BeautySalon, NailSalon or DaySpa.

Trades: plumber, electrician, cleaner, landscaper

PageSections, in order
HomeHeadline with Book a visit / Call / Get a quote; the towns they cover; main services; how a visit works (only what's true); live hours
ServicesVisit services with "At your address"; quote services with Get a quote
Service areaTowns or postcodes from the intake. Visits outside a service's area are refused at booking, so make the list match.
Get a quoteInline contact widget tied to the quote service, or your own form through the API
ContactCall-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

PageSections, in order
HomeHeadline with Book a class; this week's classes; prices; teachers
TimetableUpcoming sessions from GET /slots with spots left, each with a Book button
PricesFrom the API; memberships and passes only as the owner describes them
TeachersReal teachers only
ContactAddress, hours, message form

JSON-LD type: ExerciseGym or SportsActivityLocation.

Clinics and appointments: physio, dental, therapy, vet

PageSections, in order
HomeHeadline with Book an appointment (or Request an appointment if the business approves each booking); treatments; practitioners; location and hours
TreatmentsLive list with durations, and prices if the owner shows them
PractitionersReal names and qualifications, exactly as given
New patientsWhat to bring and what happens, from the owner
ContactBooking 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=cover in 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>, with display=swap and preconnect.
  • Photos: WebP or AVIF where you can, sized for the screen, under 200 KB for the largest. Always set width and height. Add loading="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 async and your own script with defer. 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 with aria-describedby. Errors say what to fix.
  • Every image has alt text that says what's in it, or alt="" 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.html that 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:

HTML
<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.

AttributeWhat 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.

HTML
<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.

HTML
<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>
AttributeOpens
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-ask wins, then data-gg-book, then data-gg-open.
  • The click's normal action is cancelled, so the href is 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.

HTML
<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-widgetShowsOptions
bookingThe booking flow: service, person, day and time buttons, details, confirmdata-gg-service (id or name), data-gg-staff (staff id), data-gg-source="booking_page"
chatThe AI chat, inlinedata-gg-height in pixels (default 480, at least 320)
servicesServices with price labels and a Book button on eachnone
callbackCall now when someone can answer, otherwise a call-back request form with time-window buttonsnone
contactA message or quote request formdata-gg-service (service id)
hoursWeekly hours with an Open now or Closed now badge, address and phonenone
manageOne customer's booking (from their private link)data-gg-token (required)
offerA waitlist offerdata-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):

HTML
<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.

HTML
<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.

MethodWhat 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
configThe loaded /config object, or null until it arrives
versionThe widget version

Events

EventWhenCallback gets
readySettings loadedThe config object. Subscribing late still runs it once.
openPanel opened{ view }
closePanel closednothing
messageA chat message was sent or arrived{ role, content }; role is user, assistant or owner. Assistant messages also have cards.
booking.createdA booking was made in the booking flow or in chatid, status, serviceName, staffName, whenLabel, manageUrl, source. From chat also start (ms).
callback.createdA call-back was requested{ id, windowLabel, source }
lead.createdA 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:

JavaScript
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.

CSS
: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:

Text
https://justagoodguy.us/b/your-business-slug
https://justagoodguy.us/b/your-business-slug/qr.svg
  • Use the booking page as the href of 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-Key as 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 textContent or escape it. Never put API strings into innerHTML unescaped.
  • /config and /services have 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:

HTML
<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:

Shell
curl -s "https://justagoodguy.us/api/public/slots?serviceId=svc_wgxx2f70khaabvmm&days=14&key=$KEY"
JSON
{
  "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" }] }
  ]
}
  • days can be up to 21. start is milliseconds since 1970 (UTC); show the ready-made labels.
  • sessionId and seatsLeft are 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):

HTML
<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:

Shell
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" }'
JSON
{ "lead": { "id": "led_vrybxfp0rkfcc9yb" } }

A call-back request:

Shell
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" }'
JSON
{ "callback": { "id": "cbk_rkbq55gzwpaqsyk6", "windowLabel": "Monday, Oct 19 at 9:00 AM – 11:00 AM" } }
EndpointBodyNotes
POST /leadsname, details (up to 3,000 characters), and phone or email; serviceId optionalReturns 201. 6 per hour per visitor.
POST /callbacksname, phone; reason (up to 500) and windowStart optionalReturns 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/json and the key in X-GG-Key.
  • On an error, show error from 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>:

HTML
<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:image must be a real photo or logo image, 1200 by 630, JPG or PNG. Leave the tag out if there isn't one.
  • canonical and og:url use the final address: the custom domain if there is one, otherwise the hosted address.
  • Add robots.txt and sitemap.xml listing every page.

LocalBusiness JSON-LD

Put this on the home page, with facts from the intake and hours from the API:

HTML
<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:

APIJSON-LD
weekday 1 to 7dayOfWeek "Monday" to "Sunday"
startMin 540opens "09:00" (24-hour, HH:MM)
endMin 1020closes "17:00"; use "23:59" for 1440
empty rangesleave the day out
two ranges in one daytwo 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 alt text; 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 use tel:+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:

RuleDetail
Home pageindex.html at the top of the zip. If everything sits in one folder, that folder is lifted up a level. Otherwise the upload stops.
SizeUp to 150 MB zipped, 400 MB unzipped, 5,000 files
Left out__MACOSX, .DS_Store, Thumbs.db, .git folders, .env and .env.* files
RejectedA zip with a path that leaves its folder (../), an absolute path or a drive letter
FormatThe operator's build queue takes .zip only. The owner's dashboard also takes a folder.

How the hosting serves it:

  • Clean URLs. /about serves about.html; /about/ serves about/index.html. Links like about.html work too.
  • 404. Your 404.html is 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=2 when 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> whose src ends in /w.js and that has data-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:

Shell
cd my-site
zip -r ../site.zip . -x 'tools/*' 'README.md' '*.DS_Store'
unzip -l ../site.zip | head

Step 10: Deploy and verify

From the operator's build queue

  1. Open Admin → Builds (/app/admin/builds) and find the request.
  2. 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.
  3. Check the live site (below). Send the owner a reply on the request with the address.
  4. 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 www or https://) on the site in the dashboard or the admin page. At the domain's DNS host, add a CNAME for www pointing at the target the dashboard shows (by default <slug>.justagoodguy.us), and forward the bare domain to https://www. (or use an ALIAS or ANAME record if the DNS host has one). The dashboard's DNS check turns ready when www points 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.json file 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.

  1. The widget tag is there once. curl -s https://joes-plumbing.justagoodguy.us/ | grep -c '/w.js' prints 1.
  2. It loads. Open the page in a browser. The floating button appears (unless you used data-launcher="false"). In the browser console, GoodGuy.version prints a version and GoodGuy.config.business.slug prints the right slug.
  3. The requests succeed. In the network panel: /w.js and /w/app.<hash>.js answer 200, /api/public/config 200, /api/public/events 204, and /api/public/services 200 if the page shows live services. The console has no [GoodGuy] warnings.
  4. Live data shows. Services, prices and hours match the dashboard. "Open now" is right for the business's local time.
  5. Every button works. Book opens booking (with the right service, where set). Ask sends the question. Call starts a call on a phone. Each href is the real booking page.
  6. Inline widgets render and their fallback text disappears.
  7. 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.
  8. 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).
  9. 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.
  10. 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.com does not cover www.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.
  • localhost and 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>.

PlatformScript tag (whole site)Inline widget or booking button on a page
Plain HTMLBefore </body> on every page, or once in the shared footer filePaste <div data-gg-widget="booking"></div> and data-gg-book links where you want them
WordPressA "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.
WixSettings → 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.
SquarespaceSettings → Code injection → Footer. Only on some plans.A Code block with the inline widget or a data-gg-book link
ShopifyOnline 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
WebflowSite 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 othersA sitewide "custom code", "footer code" or "tracking code" boxIf 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 URLhttps://justagoodguy.us/api/v1
AuthAuthorization: Bearer sk_...
FormatJSON in and out. Times are ISO 8601 in UTC.
Rate limit600 requests per minute per key
Spec/api/openapi.json
RequestWhat it does
GET /v1/servicesActive services: id, name, kind, durationMin, priceCents, description
GET /v1/staffActive 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/bookingsservice_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}/reschedulestart, optional staff_id and notify
POST /v1/bookings/{id}/cancelOptional 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/leadsOpen call-back requests; new messages and quote requests
Shell
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 409 because the time is taken.
  • Errors are { "error": "..." } with 400, 401, 404, 409 or 429.

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>. v1 is 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 }. id stays the same on retries: store it and skip duplicates.
  • Answer 2xx within 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.
JavaScript
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 seeWhyFix
"That site key isn't valid." in the widget, 401 from the APIWrong or revoked keyCopy 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 addressAdd 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 suspendedAsk the operator
No floating button on phonesShow 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 businessYour tag and another one with a different keyKeep one tag per page. Only the first one to run is used.
The widget stopped working after a key changeThe page has its own tag with the old keyPut the new key in every page and upload again. See the warning under The script tag.
Buttons do nothingThe widget didn't load (blocked script, typo in the tag), or another script cancels the clickCheck the console for [GoodGuy] warnings and the network panel for /w.js
Widget blocked by the browserThe site's Content-Security-PolicyAllow https://justagoodguy.us in script-src and connect-src
"Mixed content" errorsThe script tag uses http:// on an https:// pageAlways use https://
Old styles or text after an uploadCSS and JS are cached for an hour; w.js for 5 minutesAdd ?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 previewAllowed-sites list, or a file:// pagePreview over http://localhost with an empty list
A form shows "Add a phone number or email so we can reply."The API's own checkShow it as it is; that's the fix the visitor needs
Upload says it can't find index.htmlThe home page isn't at the top of the zip, or there are several top foldersZip from inside the site folder
The floating button is stuck inside a boxThe builder put your code in a frameUse the sitewide code setting, or an inline widget plus booking-page links
Custom domain shows an error pageDNS not pointed yet, or the domain isn't saved on the siteRun 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.

Shell
curl -fsSLO https://justagoodguy.us/docs/starter-site.zip
unzip starter-site.zip
cd starter-site
node tools/bake.mjs pk_the_business_key

tools/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.

FileWhat it is
index.htmlHome: headline, Book / Call / Ask, live hours card with "Open now", top services, suggested questions, short about
services.htmlFull live price list with Book buttons
about.htmlThe business's story
contact.htmlInline booking widget, call-back widget, message form through the API, address and hours
privacy.htmlPrivacy page that covers the chat assistant
404.htmlNot-found page with root-relative links
styles.cssMobile-first styles; colours and fonts are tokens at the top; light and dark
app.jsLive services and hours with fallback, the message form, the booking event hook
favicon.svg, robots.txt, sitemap.xmlPlaceholders to replace
README.mdWhat 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.

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>

Search the docs