Generated live from the running platform at 2026-09-13 21:14 UTC. This is not a snapshot.
Every fetch re-reads the brain, so this document always reflects what Doozer
currently believes about itself.
What this is. Doozer's complete working knowledge in Open Knowledge Format (OKF): plain markdown nodes with YAML frontmatter, linked into a knowledge graph. It is meant to be self-contained. An AI (or a person) who reads this bundle should come away understanding the whole Doozer world: the idea, the ecosystem, the economics, the growth engine, the marketing machine, and where it is going next.
What Doozer is, in one line. One codebase turns any domain into a complete, AI-generated business website, and the network of those sites into a local discovery layer. Named after the industrious builders from Fraggle Rock. Tagline: We Build Because.
How to read it. Start with The Mission, then The Doozer Story, then The Architecture, then The Network, then The Value Model, then The Observed World. Those six give you the spine. Everything after is detail hanging off it.
Scope of this edition. 93 nodes published. 13 held back. The omissions are operational security only, never product thinking: things like infrastructure failure modes, the auth shape of internal endpoints, and the mechanics of claim-code verification. Nothing about the strategy, the economics, or the craft has been removed. If a system seems to reference something you cannot see, that is why, and it is not load-bearing for understanding Doozer.
Provenance. Authored and maintained by Jon Berry (BerrySmart), the platform's owner and operator. Doozer is proprietary software; this brain is published so that people and AI systems can understand and discuss it accurately. It is documentation, not an invitation to copy the platform.
Machine-readable siblings. /brain.txt (this document as plain text), /brain.json (this bundle, structured), /llms.txt (short orientation), /.well-known/doozer-atlas (per-entity live data projection).
Doozer's complete knowledge in Open Knowledge Format — the idea, direction, systems, processes, value, and vocabulary behind the entire platform.
This is Doozer's knowledge in Open Knowledge Format (OKF): plain markdown files with YAML frontmatter, linked to each other to form a knowledge graph. It is the durable "brain" behind the platform, and it is meant to be self-contained: an AI (or a person) reading this bundle should come away understanding the whole Doozer world — the idea, the direction, the processes, and the value. It is additive to MEMORY.md (fast recall) and CLAUDE.md (project instructions); see how to grow this brain.
Just upgraded to a new model? Read NOTES-FOR-NEWEST-MODEL.md (repo root) first — the living handoff letter from the previous model — then follow the reading order below. See The model handoff for the tradition.
New session? Read Start here — working with Jon first — the portable working agreement: how we work, the house style, keeping a brain, the admin skeleton, the dev channel, and the stack that's only a key away. (A verbatim portable copy is at docs/start-here-working-with-jon.md.)
quality bar (trusted / working / accurate / tasteful / useful / good).
index.php serving unlimitedsites; what a Doozer site actually IS.
they interconnect (the breadth).
captured (the economics).
local business exists once observed, and each materialized site is both inventory and a pre-built lead (the self-funding flywheel).
Doozer different, where the interesting thing is the WHY. Category-aware call intent, the hostile CSS environment, skeleton first, index later, self-healing email backfill, price the pull before you pull, fix the source, not the symptom, inline the bytes, not the proxy URL, cross-root identity, derive the URL, don't store it, the logo knows its own shape, the distraction problem, decision vs delivery. A growing genre.
Architecture, The Network, NearByMeNow, Network Growth ("More places nearby"), Clouds, Directory multi-view framework, Muse System, Design Standards, The Brand System (ecosystem identity kit), content generation pipeline, Best AI for the Job (value-aware model selection), Fact-Guard, per-location data enrichment, Weather Widget, shared business card, section framework, Lead Capture, LeadBuilder, billing, Money Clouds (affiliate), Prospector, Marketing, Shortlinks + QR (doozer.link), The Growth Engine — the acquisition + monetization map, AI Postcards (Factory + Theme Studio + campaigns), Thanks.io connector (direct mail, two-way), Promo Codes + the one-time claim offer, Hot Leads — internal Doozer-sales CRM, Machi (the marketing brain), Event Feeds (Marquee + the multi-source plan), IndexNow (instant Bing crawl), AI Discoverability (llms.txt + AI-crawler welcome), Admin Tool Inventory (audit + cockpit), Doozer Digest, Email Open Tracking, Identity + Auth, Doozer Comms (Telegram), Regeneration + Foundation, Atlas (outward AI), Gatherings + Social, Live Music Tonight, The Elks Root Theme, Niche Color System, Enrichment Store Links, per-user hotspots, Postcards, cloud headers, hosting infrastructure, Admin Health Tools, Dev API + Site Quality Score, free-tier render gotchas, the 521 root cause, shortlinks and QR (doozer.link), source-of-truth cache, postcard targeting, fact-guard: org identity numbers, context-city poisoning, and the flywheel.
content generation and healing, deployment, the model handoff, spotcheck.
the value model, Doozer vs Doozer+, the observed world, implicit verification, no dark patterns, icon taste rule, Magellan ID mapping, the SEO cliff and recovery, the Doozer story, OKF adoption.
The live network (businesses, niches, clouds, families) is emitted as OKF nodes on demand by includes/okf-engine.php, browsed in Admin → Doozer Brain, and projected outward per-entity as the Atlas. The whole bundle exports as one portable .zip (Admin → Doozer Brain → Export). No secrets live in the brain by design.
business_id_map) + the gcal/elks_org adapters that make sites living entities.types; a niche slug is NOT a place type; the thing you asked for is never junk.dz nichecatalog.md5(name + w/h) so WIDTH is part of it, and any page can ask "do we already own this?" before spending. dz imgct.dz muse do=tokens.Why Doozer organizes its knowledge as an OKF bundle, scoped to the stable core.
Decision (June 2026): organize Doozer's internal knowledge as an Open Knowledge Format bundle (this knowledge/ directory) and build exploration tools on it.
as graph edges. Vendor-neutral, SDK-free — a perfect fit for Doozer's FTP / no-env model.
docs/ iscategorized markdown, and includes/design-md.php already emits and serves OKF-shaped /design.md artifacts.
MEMORY.md index — usable byagents (future Claude) and humans alike.
to Google's Knowledge Catalog ingestion yet.
CLAUDE.md (instructions) and MEMORY.md (recall index) keeptheir roles. See how to grow the brain.
includes/okf-engine.php parses this bundle, emits the live network as OKFnodes (the flywheel's data), and exports a portable zip. Explorer: Admin → Doozer Brain.
The outward, AI-native emission layer is branded Doozer Atlas (real-world entities continuously emitting machine-readable knowledge). OKF and schema.org JSON-LD are exports of that layer, not the product. Pairs with the Doozer Brain (internal knowledge) as Brain (inward) vs Atlas (outward). The per-entity public endpoint is /.well-known/doozer-atlas (format: doozer-atlas/0.1); see the flywheel.
IN_NICHE, NEARBY, COVERS_NICHE/PLACE, HOSTS_EVENT). Density is the long-term moat, but only emit edges DERIVED from real data, never invented.
Doozer system understand the world around this entity?" If no, do not emit it. Keep Atlas small and intentional, not a dumping ground.
implicit verification) is trust infrastructure nobody can copy. Strengthen it over time (confidence, verified_at).
Architecture note: Atlas OWNS THE CONTRACT, not the storage. It is a DERIVED projection over the source-of-truth tables (always fresh, no migration, no dual-write). OKF / schema.org / the /.well-known/doozer-atlas JSON are exports of the same model. Resist "Atlas owns a graph DB" and "site renders FROM Atlas" (a rewrite); evolve, don't rebuild.
Public publishing (/knowledge.md + a public explorer), a build-in-public marketing beat, and a self-growing enrichment agent.
The product direction splitting a free templated info page from a paid bespoke full site, and why the split exists.
Decided July 2026. Doozer splits into two tiers that share the same engine and data spine but differ in ambition and price.
A templated info page the business owns: generated from Places data + reviews + enrichment, styled by a Muse drawn from the shared family library, assembled from the standard section framework. The free tier's job is to be trusted, working, accurate, tasteful, useful, and good out of the box, at fleet scale, for zero cost to the business. This is what 2,400+ live sites run today. The free-tier rebuild (families/archetypes/structure hardening) is ongoing.
A bespoke full site: a richer render layer ("Muse+") on the same spine, for businesses that upgrade via Stripe billing. Doozer+ is the "WordPress-killer" ambition: multi-section, distinctive, effectively hand-quality, without a human building it. It is a separate render layer, not a reskin of the free page, so the free tier can stay lean while Doozer+ goes deep.
whole fleet; the paid site must be singular and impressive. Optimizing one layer for both compromised both.
real step up, not a crippled-teaser-to-usable jump. No dark patterns.
Doozer+ captures the businesses that want more. See the value model.
Shared spine, two render layers. A business is never rebuilt from scratch to move between tiers: same inputs, richer output.
Primary UI uses crafted SVG icons, never clipart/emoji; color is reserved for state, not for buttons.
Decision (operator, June 2026): primary and structural UI uses CRAFTED SVG icons (stylish, on-brand), NEVER default plain emoji or clipart. Emoji is acceptable only as a playful inline accent (for example the call-intent chips in a popup), not as primary iconography.
Corollary on the shared business card: card overlay icons are neutral white on the dark translucent circle; color is reserved for STATE (a saved heart is red, a checked-in pin is green), never for the buttons themselves. Tried colored outlines, reverted — color-as-state reads better and scales as more actions are added.
Same family of taste calls as no dark patterns and Design Standards: the product should never look cheap or machine-stamped.
Business ownership is an email match, not an account/claim flow. Load-bearing platform architecture.
Decision: a business owner is recognized by an email match, with no claim system, no verification queue, no support tickets.
// admin_email is AUTHORITATIVE once set; the scraped email is only the
// implicit owner while UNCLAIMED. (Refined v2.2.396 — see the lock below.)
$isBusinessAdmin = $business->admin_email
? ($user->email === $business->admin_email)
: ($user->email === $business->email);
businesses.email — the scraped canonical contact email (public). Grants ownershipONLY while the listing is unclaimed (admin_email empty).
businesses.admin_email — the claimed / operator-set owner (never shown publicly).Set by a redeemed claim code or by Doozer staff. Solves the agency case: one admin_email across many businesses = one login to manage many.
The claim lock (v2.2.396). Originally ownership was email OR admin_email — which let a listing have TWO owners at once: whoever controlled the scraped inbox AND a different person who redeemed a claim code. Now, once admin_email is set the listing is locked to that email: the scraped email no longer grants ownership, and a claim code from a different email is refused (it cannot take over). The redeemed code records redeemed_by_email (attached to who used it). To CHANGE the email on a claimed listing the owner must request a new code, mailed to the business address (operator voids the old + clears admin_email so the new code can bind). The ownership test lives in ONE place: businessIsClaimedByViewer / businessOwnerAccount (premium.php); the redeem guard is in claimCodeRedeem (claim-codes.php).
Login is the existing magic-link flow (lb_users / currentUser()); when the email matches either column, the avatar dropdown reveals "Manage [Business]". Self-healing email backfill: repeated visits from one fingerprint enqueue a priority scrape that tries harder to find a contact email, so "I tried last week, it didn't work; today it does."
A three-tier education affordance (footer utility row, an about-this-listing page, a soft heuristic nudge) explains the model without nagging. This embodies the no dark patterns ethos and underpins owner-facing parts of the flywheel.
Doozer cross-links businesses by verified external IDs (Google Place, Ticketmaster, future services), never by name/city/state. Magellan is the mapping spine. Decided June 2026; not yet built.
Decision: Doozer is a data-driven ecosystem. A business is cross-linked to any external service by its verified ID for that service, never by matching name + city + state. The mapping layer is Magellan.
Name/city/state matching guesses ("The Stone Pony" vs "Stone Pony"), produces duplicates, and breaks silently. Every business already has a Google Place ID; venues built from Marquee have a Ticketmaster venue ID. The reliable join is the ID. Magellan makes that the only join.
businesses.id to its identity in every service it participates in:Google Places, Ticketmaster, and any future API/resource.
venue must have a TM id; a discovery-built plumber need not — and reports the gaps.
name → candidate lookup → confirm, or pull from the service's own API).
emits them as entity identifiers so AI/agents cross-reference the same way.
A business_id_map join table — (business_id, service, external_id, source, confidence, verified_at), unique per (business_id, service), indexed on (service, external_id) for reverse lookups — generalizing the existing hot columns businesses.google_place_id and businesses.tm_venue_id. Backfill from those; then retire the name-match fallback in _vdLinkVenues.
Status: directive recorded, not built. See project_magellan in the memory layer.
Pure intent, organic, intelligence-driven. The lens for every product and ranking decision.
The guiding ethos. Doozer is built with pure intent: no popups, no deceptive ads or techniques, no malware, no junk. "Like shopping in the organic aisle." Intelligence is used to find the diamonds in the rough and the needles in the haystack, and to serve the two parties that matter: the customers and the businesses.
This is the lens for product AND ranking decisions, not a single feature. It connects to:
upsell; we recognize owners and help them.
AI-slop; fix root causes, not bandaids.
Sibling rules with the same spirit: the icon taste rule, and "no em dashes in user-visible copy" (so it does not read as machine-written).
The connective-tissue vision. Once every local business exists as a Doozer site (observed / painted on demand), Clouds form automatically around topic, area, and non-obvious relationships, cross-linked into one self-reinforcing, claimable, quality-indexed network that serves both businesses and users.
Captured with Jon, Jul 7 2026. The strategic thesis that ties the whole network together. Read alongside the observed world, the value model, SEO cliff, and the mission.
Assume every local business already has a Doozer site — "painted on demand" the moment it is observed (the observed-world pivot). Once that is the substrate, the interesting value is not the individual page, it is the relationships between pages. Those relationships are Clouds.
Clouds form around three axes, and the third is the differentiator:
farmers markets, churches, shopping, services. Any niche, any grain.
task, even across niches. This is the insight.
The businesses you touch buying a home are not one niche — they are a workflow: realtor's office → real estate lawyer → title / escrow agency → town hall → county clerk → local post office → and "where do we eat lunch in our new town." That bundle is a Cloud. Two shapes:
Doozer Cloud over saved businesses), later including their favorite AwesomeAddresses homes.
any agent or buyer can use, per city/town, indexable and shareable.
Real estate is also the wedge for monetization: realtors are heavy direct- mail buyers, so they are prime Thanks.io claim / Doozer+ targets now (record_types onlybusinesses in a farm area, mail them their site). When AwesomeAddresses (Zillow-style residential root) launches, the whole real-estate professional graph — listing agents, buyer agents, developers, brokers, title, lenders — becomes a first-class audience working inside that system.
/ Thanks.io radius buys grow inventory without hand work.
niche+region → cloud + sweep + build + auto-join. Automatic + recipe-driven clouds are the next grain (multi-niche bundles like the transaction cloud).
Magellan makes every cross-link by stable ID, and domains/subdomains/roots interlink through shared chrome (network footer, neighborhood rails). Link equity flows where value is.
turns attention into owners and Doozer+ revenue. Clouds raise the value of each node (context, referrals, "more from the neighborhood").
mass-indexing thin, templated, scaled pages gets the WHOLE network algorithmically demoted. "Indexed by search engines" must mean: index only observed + enriched + genuinely useful pages and clouds; keep thin/unclaimed inventory out of sitemaps until it earns its place. Clouds help (curation + real internal links) ONLY if the clouds themselves are substantive, not empty niche×town permutations. Auto-cloud generation must have a content floor.
junk members is worse than no cloud. Curation and a membership quality bar.
buys spend money; gate behind observation/demand, reuse cache, never mass-build speculatively.
and clouds must serve the business and the user, not trick either.
A pack is a curated set of Google Places types that belong together by PURPOSE. includes/cloud-packs.php ships 31 hand-authored packs referencing real niche_mappings.google_type values, each with a quality gate (min_reviews, require_location, min_rating, and open_247 for the 24/7 pack) and, where a single pro naturally owns the workflow, keeper-eligibility. Examples: Real Estate Transaction, Wedding, New in Town, Movers & Relocation, Home Improvement, Money & Advice, Final Arrangements (the 7 keeper packs), plus Me Time, Med Near Me, Lessons & Growth, Get Moving, New Parent, Pet Parent, Where to Eat, Coffee & Cowork, Sweet Tooth, Farm to Table, Date Night, Family Fun, Night Out, Fresh Air, On the Water, Green Thumb, Two Wheels, Local Boutique, Auto Care, Ink & Adornment, Smoke & Vape, Culture & Books, Faith & Worship, Open 24 Hours. cloudPackPreview($key,$city,$state) is a COST-FREE count of existing Doozer members a pack would gather in a town, with a meets_floor flag — the content- floor guard so we never build/index a thin pack cloud. Ops: dz packs list|keeper|show|preview|build|keeper_offer|set_keeper. Membership = niche-set + geo — the cloud engine already supports it (cloud_type=niche, criteria.niches is an array). cloudPackBuild($pack,$city,$state) SHIPPED: creates the live cloud at <pack>-<city>-<st>.doozer.cloud, cost-free from existing members, idempotent, fill=1 sweeps below-floor niches (prDiscoverySweep, confirm-gated). Proven end to end (Real Estate / West Milford NJ, live). WATCH-ITEM: raise the content floor above the demo's 4 and gate INDEXING (sitemap) on a higher quality bar than build, so thin pack clouds render but do not get indexed until filled.
Keeper-eligible packs are monetized by exclusivity: one professional becomes the KEEPER of that pack cloud in their city/region — their name, brand, and referral position across the whole workflow bundle. One keeper per pack per territory. The pitch (esp. realtors): "Own the Real Estate cloud for your territory. Every buyer and seller in town sees you first." Offer it to every pro in the farm area (Thanks.io radius, record_types=onlybusinesses + real estate niche); one takes it. Mechanically the Keeper = the cloud's paid sponsor/owner (clouds already carry sponsor_name/url/logo via cloudSetSponsor, and the observed-world claim path grants ownership) sold as a Doozer+/premium position. Any realtor pitch MUST lead with claiming their territory — scarcity closes it.
existing cloud engine + Cloud Factory; Thanks.io realtor claim campaigns; keep indexing quality-gated.
personal transaction clouds in My Doozer Cloud.
The direction is sound because the machinery mostly exists (observed-world, cloud engine, Cloud Factory, Magellan, claim funnel, Thanks.io). The work is composing it into recipes + automatic formation, and holding the quality/indexing line so the network compounds instead of cliffs.
The canonical WHO/WHAT/WHEN/WHERE/WHY/HOW of Doozer, distilled. The single source the Postcards, landing copy, and press materials draw from.
The distilled canon, written from June 2026. The expressive, evolving form is Postcards; this is the spine they hang on.
WHO. Doozer is a BerrySmart product, built by one person: Jon Berry, solo founder and operator, hands-on and technical. Named after the tireless little builders from Fraggle Rock. Each Cloud has a Keeper; NearByMeNow carries the traffic; the Muse gives each site its voice.
WHAT. A domain-driven platform that turns unused or forwarded domains into useful, findable single-page websites for real local businesses. One codebase, 2,400+ live sites, every one different. Around it: NearByMeNow (directory), Doozer.Cloud (curated Clouds), my.doozer.cloud (a person's saved places), and the coming GoHe.re (gatherings) and wuz.ai (memory) layers. LeadBuilder is a sibling. Atlas makes it all legible to machines.
WHEN. Built around turning fifty, as a swan-song legacy project, in the margins of a full life: early mornings and late nights, between full-time work and raising a family. And built for a specific moment in history: when AI answer engines replaced the page of blue links and the question changed from "what ranks" to "what gets cited."
WHERE. Bootstrapped, no outside funding, answerable only to its users and its standards. Deployed by hand from a laptop. No office, no team. The whole company fits in a backpack.
WHY. Local businesses should not lose to AI; they should be found by it. The bakery, the welder, the tailor, the lodge hold neighborhoods together and are exactly the ones least likely to have a current site, which now means least likely to be found. Doozer points the same AI the other way. The bar is taste: useful, accurate, tasteful, no dead ends, no AI-slop, no dark patterns. "We Build Because."
HOW. One codebase; the site is built on the fly from the domain used to reach it. Six steps, most under a minute: point, find (Google Places), enrich, match (a Muse from a library of 100), compose (AI prose plus a guarded layout pipeline), improve (every signal feeds back). Written to self-heal, because usually no one is watching.
VISION. Seven surfaces, one universe, a flywheel where each layer makes the next more valuable. The real, local, physical world deserves a web that is on its side. Built one careful piece at a time.
Why Doozer exists and the non-negotiable quality bar every site must clear — the lens for every build decision.
Doozer is BerrySmart's (Jon Berry's) legacy work: a serious attempt to give every small business a web presence that is genuinely good, generated automatically, at a scale and price no human-built approach can match. It is built with pure intent, not extraction. The tagline is "We Build Because." (never invent others).
A site ships only if it is all of:
Muse system and design standards.
"No dead ends, no slop" is the shorthand. A page that looks AI-generated (em-dashes, banned phrases, generic layout) fails "tasteful." A lead with nowhere to go fails "working." A wrong lodge number fails "trusted."
No dark patterns, ever (the ethos). The intelligence of the platform is aimed at finding the diamonds and needles for real people, a new way to think about the web: discovery and presence in service of the visitor and the business, not manipulation. Value is captured honestly, downstream of value genuinely created.
This is the lens. When a build decision is ambiguous, choose the option that keeps the site trusted, working, accurate, tasteful, useful, and good. Read this before building.
Doozer already contains every local business in spirit; a site or cloud becomes real only when observed. Each materialized site is simultaneously inventory and a pre-built lead. This reframes what to build, what to measure, and how money is made.
Because of the More Places Nearby expansion, the Doozer ecosystem creeps outward to include every nearby business, especially within niche: restaurants, laundromats, real estate offices, gas stations, all of it. View this as a live-painted world, the way an RPG renders only the pixels the player can see while a whole world of data stands behind them. Doozer, in spirit, already holds a directory of every local business. A site — or a cloud — exists the moment it is observed. It is, in a way, already there.
The engine for this already exists: resolvePlaceToEcosystem (place-resolver) is skeleton-first, materialize-on-observe, defer-enrichment. The shift is mental: stop treating "sites we have built" as the inventory. The inventory is every business that could be rendered; built sites are just the observed, cached subset.
Every business More Places Nearby materializes is at once:
substrate AI agents can query).
doorstep that they have never seen.
Concept one manufactures the inventory. Concept two monetizes each unit. Revenue from the sales funds the enrichment that makes the inventory real. It is a self-funding loop (see the flywheel diagram this node was captured from).
Infinite coverage has two failure modes; one rule closes each.
scaled-content demotion once (SEO cliff). NEVER pre-render the latent world into crawlable pages. A page becomes real (indexable, enriched) only when a genuine observer earns it. Done this way the observed-world model is the antidote to the cliff, not a bigger version of it — every indexed page has a real reason to exist.
would embarrass the brand, against the mission and the no-dark-patterns ethos. The observation itself triggers enrichment proportional to interest, and no site is mailed or pitched until it passes a quality bar. The two gates force the machine into quality.
Doozer inverts the SaaS funnel: the site is live before the pitch. Not "sign up and we will build it," but "this is your website, it is already working, claim it." Mostly assembly, because the pieces exist:
says user.email === business.email → business admin, no claim system. So every lead-routing email Doozer already sends is the pitch: log in with this address to claim the listing and manage leads.
Thanks.io mails a postcard with a QR to the live site. Instant gratification: scan, see it, sold.
Economics work at trivial conversion: a postcard is well under a dollar; $99/yr needs ~1–2% to be strongly positive, and the lead-email funnel is nearly free. Every new site from More Places Nearby drops a fresh prospect into the funnel — which is exactly what the new-site ping already fires on.
liability). Count observations served and businesses claimed.
engine for the local commercial world.
search_demand + lmt_interest + GSC impressions + nearby-taps + name-404s) that decides what to paint next and what is safe to index; build-on-query as the front door; cloud-on-demand criteria directories; a reality meter per entity (how much real signal backs this pixel, gating index and mail); and the Atlas extended into a queryable / MCP surface so AI agents — the new observers — can ask "does Doozer have X?" and always get "yes, here it is." Being the local layer AI agents cite and route through may be a bigger business than the sites.
money clouds conjured on demand; API/agent access to the queryable world; the demand ledger itself as market intelligence.
nbmnLeadEmailBusiness()appends a "claim your listing" block (their live site + the email-is-the-key pitch); the claim landing at ?page=claim&b=<id> (includes/claim-listing.php) shows the owner their live site and offers a one-step magic-link login (existing claim purpose + ?ajax=magic_link_send), with a soft Doozer+ $99/yr upsell. The CLAIM to EARN half, shipped v2.2.371. A claim now delivers real value and the upgrade actually bills. (a) Enrichment surfaces the scraped contact_email onto the empty businesses.email column (performBasicEnrichment) — without this, an emailless-but-scraped business could never match on implicit verification, so no re-scan could ever unlock a claim. (b) ?page=plus (includes/plus-landing.php) is a real Doozer+ page telling the two value stories and starting Stripe checkout (sku premium_yearly, $99/yr, code LAUNCH20); previously it dead-ended on the landing page. (c) Ads come off a page the moment it is claimed (an account exists for the contact/admin email) or when the owner is viewing — businessAdsSuppressed() + friends in premium.php, hooked into business-site.php's $showAdsense. Ads are the monetization of UNCLAIMED inventory only. (d) includes/manage-business.php (?page=manage&b=<id>, owner-gated) is the "Manage your site and leads" surface — ad-free confirmation, editable phone/website, leads inbox. (e) The claim page's non-match state has "I added it, re-scan my site" → ?ajax=claim_rescan (priority basic_enrichment, rate-limited) so an owner can add their email and unlock the claim. Still NO claims table — "claimed" is derived from an lb_users email match, exactly the implicit-verification model.
site worth pride).
what is safe to index.
Captured July 5, 2026 (with Jon) as a strategic pivot to examine for value and revenue. Realized by The Growth Engine — the concrete observe→serve→convert→learn machine (postcards, offers, Hot-Leads CRM). Related: the value model, two-tier model, SEO cliff, no dark patterns.
The May 2026 elks.club algorithmic demotion for scaled thin content, and the enrich-then-index discipline it burned into the platform.
Around May 22-24 2026 the elks.club fleet fell off a search cliff. Diagnosis (load-bearing): it was an algorithmic scaled-content demotion, NOT a manual penalty, a 521 outage, or a deindex. westmilford.biz, on the same infrastructure, held steady as the control — so the cause was content, not hosting. Google demoted a large set of similar, thin, auto-generated pages.
A flood of near-identical, thin sites is a liability, not growth. Every generation and growth decision inherits this:
ship as a skeleton that is noindex until it is enriched AND passes a quality gate, then flips to index + sitemap. Never dump bare skeletons into the index.
enrichment, not tone. The Muse system + families exist so sites don't read as templated slop.
quality-gated with rollback; the "More places nearby" build bar keeps genuinely thin places out.
handling keep pages distinct and correct.
Recovery is content quality + dedup + a quality-gated index, not a hosting fix. This is why "make it work fast" is always balanced against "no slop, no dead ends" (the mission) — the cliff is the proof of what happens when it isn't.
Where Doozer's value comes from and how it is captured, so an AI understands the economic engine, not just the software.
Doozer's premise: most small businesses have no good web presence, and the market has failed to give them one cheaply, quickly, and tastefully. Doozer generates one automatically from data the business already generates (Google Places, reviews, photos, its own activity). The value is created before anyone pays, and captured along several honest paths, never with dark patterns.
better than the business's outdated official site or its absence.
NearByMeNow, Clouds, vertical directories, search.
which is the whole ethos.
signal is captured and routed to the business through LeadBuilder. Qualified leads are the product a business will pay for because they map directly to revenue.
for free and can upgrade to the paid Doozer+ tier: a bespoke full site, priced via Stripe billing.
management, higher new-site caps.
(renderDoozerSponsorBar()), never spammy.
genuinely help the visitor (e.g. tickets on live music, vetted "Shop Now" links).
Each captured signal makes the next site cheaper to justify and better targeted (via Prospector and enrichment). Value capture funds value creation, which produces more signal. That is the flywheel; this node is its economics. The north star is never extraction: a site must be trusted, working, accurate, tasteful, useful, and good, with no dead ends and no slop, or it is not shipped.
GPT-5.4 mini writes Doozer's business copy. Decided 2026-08-22 by an 11-niche counterbalanced bake-off in which the more expensive model fabricated a verifiable fact on a live page.
Decision: business_website content generation runs on gpt-5.4-mini.
Eleven real businesses, one per niche, each regenerated by both candidates against identical source data. Counterbalanced: a regeneration feeds the previous copy back into the prompt, so whichever model runs second sees the other's work. Five businesses saw Sonnet first, six saw GPT first, cancelling the order effect. Every cost read per-call from ai_generation_history, never estimated from a price sheet.
| claude-sonnet-4-6 | gpt-5.4-mini | |
|---|---|---|
| per site | $0.03400 | $0.00748 |
| per 100 sites | $3.40 | $0.75 |
| seconds | 28.7 | 6.5 |
| output tokens | 1,335 | 973 |
Cheaper and faster on every single business, low variance in both arms.
Cost did not. $2.65 per hundred sites does not justify changing the voice of the network.
Writing for Thessen Services, a plumber with no website on file and no cached source, Sonnet opened: "Permit files across three county offices carry the record." The strings permit and county appear zero times in that business's entire stored record. The sentence is fluent, specific, checkable, and invented. Given identical inputs, gpt-5.4-mini opened with the verified street address, and across all eleven specimens it stayed inside known facts.
A wrong fact is worse than a missing one. That is the platform's whole premise for fact-guard and the geo-anchor rule, and it settled a decision the cost data alone would not have.
One observed fabrication in eleven pairs is not a measured hallucination rate. It was enough to act on because the failure mode is severe and the cheaper model carried no offsetting cost, not because eleven samples prove a rate.
What we gave up: Sonnet writes better headlines and ~27% more copy. A split where the cheap model writes the body and a stronger one writes the headline is worth testing.
dz aimodelreg repoint=business_website model=<id> live=1 swaps the writer; dz aimodelreg sync=1 live=1 refreshes the priced catalog. Before switching to any more verbose model, check its output length against the pipeline's max-tokens cap — Sonnet 5 blew past 2,048 tokens, the JSON truncated, and it silently blanked two live pages before _regenContentIsSubstantive() existed to catch it.
Case study (all 22 generations readable side by side): https://claude.ai/code/artifact/a9ac8ae5-3bc9-477f-b731-b99950afe488
Related: [[magellan-and-feeds]], [[the-doozer-score]], [[fact-guard]].
How Doozer makes itself legible to AI agents and AI-powered search (ChatGPT, Perplexity, Claude, Gemini, Copilot, DuckDuckGo). The DDG=Bing insight, the explicit crawler welcome, llms.txt, and the machine-readable stack (schema.org JSON-LD + Atlas + design.md) that already existed.
The [[the-observed-world]] endgame is that AI agents are the new observers: they ask "is there a good X near me?" and Doozer should always be able to answer "yes, here it is, with structured data." This node is how a Doozer host makes itself legible to AI and AI-powered search.
DuckDuckGo does not run its own web index. Its results are overwhelmingly Bing-sourced (plus DuckDuckBot for a little, Apple Maps for local). So "optimize for DuckDuckGo" mostly means "be in Bing" — which the existing [[indexnow]] pipe already does (instant Bing submission on publish; verified healthy, HTTP 202s flowing). Same logic extends to the AI assistants: ChatGPT search, Copilot, and DuckAssist all lean on Bing's index. IndexNow is therefore the single biggest AI-search lever, and it was already built. The gap was the AI-native layer below.
Every Doozer business page already emits, and now advertises, structured data:
schema-org.php) — type-specific(FastFoodRestaurant, PostalAddress, GeoCoordinates, AggregateRating, opening hours). The single richest signal for both rich results and AI extraction.
/.well-known/doozer-atlas (JSON) — the per-entity outward emission,linked in <head> via <link rel="alternate" type="application/json">.
/design.md (Google-Labs DESIGN.md) — agent-facing brand spec, `<link rel="alternate"type="text/markdown"> in head, X-Robots-Tag: noindex`.
/llms.txt (includes/llms-txt.php, the llmstxt.org convention) — a concise Markdown cardserved per domain that hands an agent a plain-language "here's what this is" plus links to all of the above. Four shapes: master (the network), nbmn (the directory), root (a directory root like elks.club/westmilford.biz — isRootWildcardDomain(), reads its root_domains name/tagline/ description), and business (pulls real name/niche/city from domains→businesses, generic fallback if unresolved). Routed in index.php next to robots/design.md, in the sessionless + drain-skip fast paths. Gotcha caught v2.2.421: roots front many businesses, so they must NOT fall through the business branch (it would mislabel a whole directory as one local business).
doozerAiCrawlers() (GPTBot, OAI-SearchBot,ChatGPT-User, ClaudeBot, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot, Bingbot, DuckDuckBot, DuckAssistBot, Amazonbot, Meta-ExternalAgent, CCBot, …). User-agent: already allowed them; naming them is an intentional "yes, index us" (most of the web blocks these). Emitted only on business + NBMN (no Disallows to bypass); the master keeps authoritative so its admin Disallows bind every bot, and just advertises the llms.txt summary line.
Most sites are racing to block AI crawlers. Doozer does the opposite: the whole model is that every observed business should be findable, and being the structured local layer AI agents cite and route through may be a bigger business than the sites themselves ([[the-observed-world]]). So we roll out the welcome mat and hand agents citable data. Guardrail: the data must be true and current before we invite citation — the quality gate ([[the-mission]]) still governs what gets indexed and enriched; we never advertise a thin skeleton.
curl the live surfaces on a real host: /llms.txt, /robots.txt, /.well-known/doozer-atlas, and grep the homepage for application/ld+json. Per [[feedback_verify_by_looking]], confirm on live pixels/bytes, since a legacy path can silently no-op.
Related: [[indexnow]] (Bing pipe), [[atlas]] (outward emission), [[the-observed-world]] (agents as observers), [[seo-cliff-recovery]] (only index what is real).
The whole knowledge bundle is live at four addresses on doozer.work, rendered from the knowledge/ files on every request (15-minute cache), so deploying a node updates it:
| form | address | robots |
|---|---|---|
| HTML index, start here | https://doozer.work/brain.html |
none; ~30KB, one link per node |
| HTML, one page per node | https://doozer.work/brain/<node>.html |
none; ~5 to 20KB each, cross-links rewritten, prev/next |
| HTML, everything on one page | https://doozer.work/brain-full.html |
none; ~420KB, for readers that can take it |
| Markdown | /brain |
noindex, noarchive (shareable, not a search result) |
| plain text | /brain.txt |
noindex |
| JSON | /brain.json |
noindex |
The HTML forms exist because most agent fetchers choke on text/markdown, on an extensionless URL, on a single 450KB document, or treat noindex as "do not read". The server was never at fault (every form returned the right type); the SHAPE was. Give an agent the index and it can walk the pages it needs. The operator's rule: it is not open source, and it is not the secret recipe; hand the .html address to an agent and it reads everything, with a table of contents.
How Doozer builds and mails postcards. The Factory assembles accurate cards deterministically (real screenshot/code/QR, no per-card AI cost); the AI Theme Studio designs vision-gated backgrounds; campaigns give each card a purpose + audience. Claim Your Site is the first campaign.
v2.2.381 — the Factory is now the default, accurate path. Generating the
whole card with AI every time was expensive AND inaccurate (it approximated the
claim code, invented the short URL, hallucinated the domain in the screenshot's
address bar). The Postcard Factory (includes/postcard-factory.php) flips
it: AI designs backgrounds/layouts; every ACTUAL card is composed
deterministically from REAL values and flattened to a print-ready 1800x1200 PNG
via Cloudflare Browser Rendering (postcardFactoryFlattenpostshtml+
viewport to the same CF endpoint used for screenshots). Real screenshot in a
browser chrome showing the ACTUAL Doozer domain, real minted claim code, real
doozer.link short URL, a real scannable QR, real logo, Machi copy. No per-card AI
cost, nothing to proofread. postcardFactoryCompose($bizId,$opts) files it in
the same review queue (no gate — values are deterministic). Themes: paper / navy.
Admin: AI Postcards page "Build card" panel; Machi defaults to engine='factory'.
v2.2.395 — Multi-campaign system. Postcards are no longer claim-only. A
campaign = purpose + audience + copy/offer (postcard_campaigns,
includes/postcard-campaigns.php). The seeded, locked claim_your_site
campaign (audience=unclaimed) IS the old behavior, now named. campaign already
threaded through postcard_designs/thanksio_sends/claim_codes/machi_touches as a
string; this formalizes the keys. Audiences (hard-coded): all / unclaimed
(admin_email empty) / claimed (admin_email set) / premium (owner lb_user on a
premium-class tier), each + niche (primary_niche) + location (state_code/city);
base = live Doozer site + mailable address. `postcardCampaignAudienceWhere/Count/
Sample. Purpose seeds generation: postcardCampaignThemePromptSeed` feeds the
Theme Studio background prompt (postcardThemeCreateoptcampaign);
postcardCampaignMessage resolves the BACK copy (claim → warm/leads/curious
variants; else message_template with tokens {name}{city}{code}{offer_code}
{short_url}). Admin: Campaigns cards + Create/Edit modal (live audience count),
Campaign selector on Factory + Studio, review queue pagination + finder (search +
campaign filter). dz campaign do=list|show|save. NEXT phase: campaign-specific
FRONT copy + audience-gated sending (don't mail a claimed business a claim card).
v2.2.392 — Two-score gate + back copy + Thanks.io levers. The theme gate
(postcardThemeGateCard) now returns TWO independent real scores because there is
no in-admin way to tweak a theme after the fact: accuracy (every required
element present, uncropped, legible — an explicit checklist is passed to the vision
model) and design (aesthetics + theme match only). A theme goes ACTIVE only
when BOTH clearpostcard_theme.gate_pass_score(default 78); elsedraft. Stored
inpostcard_themes.design_score/accuracy_score(idempotent ALTER); shown in
the Studio result and gallery tiles. Card template fix: .left scrim is full grid
height (grid-template-rows:100%) so the "doozer." wordmark is always inside the
legibility box. Back-of-card copy (thanksIoClaimMessageVariants in
thanksio.php): warm / leads / curious variants, personalized with the town + a
typed doozer.link fallback, no em-dashes. Thanks.io best practice: the FRONT
is the Factory image; the BACK ismessage+qrcode_url+ return address. Turn
on thanksio.handwriting_style_id (handwritten back lifts response), set
thanksio.size (4x6 default; 6x9/6x11 stand out but need a matching front aspect),
thanksio.message_variant. Rules that win direct mail: personal, specific to them,
ONE clear action (scan), readable in three seconds, honest, warmly signed.
v2.2.390 — AI Theme Studio. The untrustworthy full-AI card painter is
repurposed into a theme/background designer only; the Factory still does the real
assembly.includes/postcard-theme-ai.php:postcardThemeCreate($style,$opts)
takes a plain-words style ("beach vacation", "Thanksgiving warm orange",
"Bronx blue/white pinstripe") + optionalbusiness_id/match_sitefor niche
style cues, builds a LAYOUT-AWARE background prompt (calm left third for text,
calm bottom-right ~1.5in for QR, right half supporting the screenshot, NO
text/letters/logos), generates the bg via the configured image provider
(postcardAiImageProvider), then a **vision pass reads the palette BACK from the
pixels** (postcardThemeReviewBg→ accent/ink/dark, readsraw_text). It then
composes a REAL sample card (live screenshot + demo code/QR) and runs a second
vision gate (postcardThemeGateCard) for legibility/layout/design; score
>=postcard_theme.gate_pass_score(default 78) savesstatus=active, else
draft. Themes are data inpostcard_themes(theme_key, source
seed/ai/operator, style_prompt, bg_type color|image, bg_value, accent/ink/dark,
status, gate_score/reasons, preview_url); postcardFactoryThemes() merges seeds
(paper/navy/kraft) + active DB rows so a passing theme is instantly pickable in
the Factory. Image backgrounds get an auto legibility scrim behind the left text
column (postcardFactoryHtml$bgIsImage).postcardFactoryHtml(...,$themeSpec)
takes a candidate spec so the Studio can preview before saving.
Dev API: dz factorytheme do=create style="..." [b=<id>] [match=1],
do=status key= set=active|rejected,do=list. Admin: AI Postcards page has the
AI Theme Studio card (style + business picker + match-site) and a theme gallery
(activate/retire). The AI Postcards business picker uses the same
admin_business_search autocomplete and its dropdown is opaque. First run:
"beach vacation" scored 92, shipped a genuinely good card ("Tropical Escape").
dz factory compose b= [theme=] [angle=]. The old full-AI painter (below) is the
fallback (engine='ai'), kept for background-art experiments.
Themes (v2.2.382): paper (light editorial), navy (bold dark), kraft (warm
local). Theme colors derive from a dark flag + optional panel/muted/chrome
overrides — adding a theme is a few lines in postcardFactoryThemes(). Layout:
local eyebrow + headline that auto-sizes to its length (any Machi angle fits
the fixed 6x4 card) + screenshot as a real<img>in aoverflow:hiddencrop
box (full nav, no white gap) inside browser chrome showing the real domain.
Render notes: QR inlined as a data URI (external QR service timed out the
browser); networkidle2 + paint delay; 429 backoff-retry.
Load-bearing bugfix shipped here: claimCodeCreate() validated the DEFAULTED
intent but assigned the RAW $opts['intent'], so any caller not passing intent
(every postcard) failed with "Column 'intent' cannot be null" and got no code.
The AI cards hid it by inventing a code; the Factory surfaced it. Real codes now
mint network-wide.
Every send already mints its OWN claim code + doozer.link shortlink + Thanks.io order_id, so each touch is independently trackable — the schema never limited a business to one card. The admin Sent section (v2.2.409) is bounded so it never runs wild at scale: three last-5 glances (Sent / Delivered / Touched = scan·visit·claim) plus a full All sends table with search (business/code), campaign + status (delivered/scanned/claimed) filters, sort-by-date, pagination, and a Doozer+ column = full conversion (claimed + owner on a premium tier).
postcardDesignLiveStats(array $design) (postcard-ai.php) reads outcomes LIVEfrom thanksio_sends (cost/order/preview), thanksio_events (delivered + QR scans), shortlinks.click_count (visits), claim_codes.redeemed_at (claimed). Never the cached JSON. postcardAiTracking() delegates to it + caches. The old code queried a non-existent shortlinks.visits column (always 0) — fixed to click_count.
thanksIoHandleWebhook) recomputes the linked design's tracking on everydelivered/scan event, so the view stays fresh without a manual Refresh.
?ajax=resend on admin-postcards-ai.php re-runs postcardFactoryCompose()for the same business + campaign + theme (theme recovered from model = factory:<key>) → a NEW pending_review card with a fresh code. Never auto-mails; operator Previews / Sends Real. Delivered/scan status fills in as Thanks.io reports it (minutes→days).
Factory front + the real Thanks.io handwriting back. Proofs persist to postcard_designs.proof_urls on preview/send; ?ajax=proof fetches them free on demand for older sends (a preview of a sent card is allowed — free, no mail) and caches. NOTE: Thanks.io previews live at response.data.previews (front + handwriting back, S3 URLs).
authorization_total/leads_fee ALREADYin cents (114 = $1.14 for a 4x6, 1 recipient); the send path was multiplying by 100 again. Dropped the *100 (thanksio.php ~357); dz postcard do=fixcosts re-derives historical rows from raw meta.
businesses.logo_url, anunreliable scrape (often a site PHOTO, not a logo). Slot removed ($logoBlock=''); the screenshot already shows real branding. Re-enable only behind a trustworthy is-a-logo signal.
re-render — pick per send via the review-card selector (4x6/6x9/6x11). Plumbs postcardAiSend → thanksIoMailClaim → Thanks.io; recorded on thanksio_sends.size.
postcardFormatPerformance($niche,$state,$days) + a "Formatperformance" admin panel — funnel (sent→delivered→scanned→claimed→Doozer+ converted) by SIZE as a rate of sent, sliceable by niche/state/timeframe, with cost/claim + a thin-sample flag (n<30). Claim rate is the money metric.
prospects; every touch (postcard sent/delivered/scanned/claimed, site created, leads we deliver to them) rolls into a per-business awareness score → internal Hot Leads to convert free→Doozer+→Keeper→agency. Reconcile with [[machi]] rather than duplicate.
direct-mail + AI marketing to their OWN leads. Needs a getApiKeyForBusiness() seam off the single getApiKey() chokepoint, per-business thanks.io key, owner send tool on manage-business.php, a 50% affiliate revenue ledger, agency/franchise tiers. Reuses the Factory + the two-score /frontend-design vision gate already built.
What: The physical arm of the observed-world flywheel. When a business has no email on file (unclaimable via implicit verification), we mail a real postcard carrying the site's own screenshot, a claim code, and a scannable QR. The card DESIGN is AI-generated end to end and passes a vision-model quality/accuracy gate before any human sees it. No templated fallback — if the AI can't produce a good card in 3 tries, it fails loudly for review instead of mailing slop.
Engine — includes/postcard-ai.php
postcardAiGather($businessId) — assembles real ingredients: live sitescreenshot (siteScreenshotUrlForHost, Cloudflare Browser Rendering -> Spaces), a minted claim code + doozer.link QR (claimCodeCreate), business facts, logo.
postcardAiBuildPrompt($ctx) — detailed 6x4 postcard art-direction prompt:use the provided screenshot faithfully as the browser-framed hero, exact business name / claim code / URL, reserve a QR square, Doozer green.
postcardAiGenerate($prompt,$refImageUrls) — Gemini generateContent with thescreenshot + logo passed as real inlineData image inputs (Gemini is the only configured engine that accepts image inputs). Needs an image-capable Gemini key in api_keys (service gemini/google/google_gemini).
postcardAiGate($imageUrl,$ctx) — SCANS the generated image with a vision model(callVisionProvider openai/anthropic), returns JSON {suitable,score,reasons}. Pass threshold = platform_settings postcard_ai.gate_pass_score (default 75).
postcardAiRun($businessId,$opts) — orchestrates gather -> up to 3generate/save/gate attempts. Suitable design -> pending_review; three failures -> failed with all three attempts + reasons kept.
postcardAiSend($designId,$opts) — mails via thanksIoMailClaim (front_image_url= the generated card). live flag gates preview vs real mail (costs money).
postcardAiTracking($designId) — rolls up Thanks.io scan events + doozer.linkshortlink visits + claim-code redemption.
Table: postcard_designs (business_id, claim_code_id, screenshot_url, logo_url, qr_url, redeem_url, prompt, image_url, model, attempts, gate_score, gate_verdict, gate_reasons, status ENUM('generating','pending_review','failed','approved','sent', 'rejected'), failures JSON, thanksio_send_id, sent_at, tracking JSON).
Admin — includes/admin-postcards-ai.php (Admin -> Infrastructure -> AI Postcards, route postcards-ai). Human-in-the-loop: ready designs show the card + gate score with a Send button (live checkbox); sent designs show live tracking; failed designs show 3 attempts + Retry. Generation runs as a background postcard_ai job (dispatcher case), so the screen never blocks.
Dev API: dz postcard gen b=<id> (queue+run), list, show id=, send id= [live=1], retry id=, track id=.
The claim loop: sending sets sent and starts the state machine; a QR scan or code redemption sets the owner's businesses.admin_email — the postcard IS proof of claim (implicit verification, no claims table). See [[claim-codes]], [[thanksio]], [[the-observed-world]].
State: shipped v2.2.379 (Jul 7 2026). Blocker for first real run: an image-capable Gemini key must be in api_keys; vision gate keys (openai/anthropic) already present. First target = Powell Flooring (business id 1, West Milford NJ, email NULL, screenshot already captured to Spaces).
How Doozer decides which AI model to use for each function, when, why, and for what — balancing quality against cost by the value of the work, learning from its own scores, and staying provider-diverse on purpose.
Doozer does not use one model for everything, and it does not chase the cheapest model. It uses the best model for each job — chosen intelligently, weighed by the value of the work against its cost, and refined by what the work actually scored. "The best man for the job", made mechanical.
Build Solid A rated sites — accurate, beautiful, tasteful, helpful,
valuable, useful — for every business that should have one. Par-create
decides who should have one. The scoring of what we build drives the resources
spent on the next build. The fleet average climbs generation over generation.
Solid A = weighted ≥ 85 across the six goals (dzQualityGoal(), tunable platform_settings.doozer_quality_goal). This is not a per-site retry loop — we build sites one after another, each using the current best-known strategy, and the results steer the next build. When a choice raises a niche's average we keep it; when a choice regresses it, we undo that choice and try another until the score climbs. Doozer builds because — and it gets better every generation, toward leading-edge, lead-generating sites at scale.
quality-autopilot.php)The unit of improvement is the niche — sites within a niche score alike, so a strategy that lifts one lifts them all. Each niche holds a niche_build_policy (the current strategy: model tier, deep-facts on/off, the baseline it's beating). qualityRatchetTick() (dz goal autopilot=1 go=1):
+ deep source-of-truth). Record the baseline to beat.
improved → commit (new baseline, keep climbing); regressed → revert the choice and mark that lever "tried, didn't help" so the next pass reaches for a different one; flat → hold, gather more data.
The router reads the niche policy (aiForJob($job, ['niche'=>...])) and escalates accordingly. dz goal reports fleet average, at-goal count, and per-niche standing worst-first — the niches the ratchet is working on.
First reading (July 2026): fleet 80.3, goal 85, 12/46 at goal. Weakest niche: restaurant (74, accuracy 67) — high fact density (menus/prices/specials) is where content invents things. Escalated. Real-estate (84/acc 88) and non-profits (84) are already near goal. The single biggest accuracy lever is reading the real website: prefer the free Jina source-of-truth cache over the paid, deprecated Firecrawl deep_research (v0 API) — same job, better accuracy, no cost. Retiring Firecrawl for enrichment is the recommended consolidation.
This is doctrine, not a setting. It encodes how much care goes into a Doozer site: the accuracy, the taste, the scoring, the deliberate balance of cost and quality — and the deliberate refusal to become dependent on any one AI house. Doozer builds because. It does not waste time, and it does not waste money — but it never trades away real value to save pennies.
Code: includes/ai-job-router.php (the engine), includes/ai-models.php (the model catalog + tiers + cost map), includes/site-scoring.php (the feedback). Operator surface: dz aimodels, dz aimodels pool=1.
"There is no reason to use a cheap model for even a slight loss in quality if
the value is still hundreds of times the cost."
So the question is never "what is cheapest." It is: is the quality I would give up by going cheaper worth more than the money I would save? If yes, GO HARD.
| Tier | Framing | Anthropic | OpenAI | xAI | |
|---|---|---|---|---|---|
| flagship | GO HARD | Opus 4.8 | GPT-5 | Grok 4 | Gemini 2.5 Pro |
| balanced | Solid A | Sonnet 5 | GPT-5 mini | Grok 4 Fast | Gemini 2.5 Flash |
| budget | Economy | Haiku 4.5 | GPT-5 nano | Grok 3 mini | Gemini Flash-Lite |
The tier resolves to the current best model in that class via aiModelResolve() — so when a new model ships, we update the catalog in one place and every job that asked for a class is upgraded. Pin a concrete id only to freeze a chore to a known model.
Every AI function declares four things (aiJobRegistry()):
job. A label is a label (spread ≈ 0.02: a cheap model nails it). Bespoke site copy is not (spread ≈ 0.15: the creative gap is real and worth paying for). Difficulty, not just value, decides.
The engine walks candidates best-quality-first (flagship → floor) and steps down to a cheaper model only when:
Δquality × value ≤ Δcost
The dollar value of the quality you'd lose vs the dollars you'd save. It stops at the first step where quality is worth more than the savings — that is the sweet spot. For a high-value output (a site that is a claimable lead), the pennies of cost delta never outweigh the quality, so it goes hard. For instrumentation (scoring) or trivia (classification), the cheap model is genuinely good enough.
Quality per tier starts from a difficulty-aware prior (1 − spread × tierPenalty, penalty: flagship 0, balanced 0.5, budget 1.0) and is then blended toward measured evidence as real scores accumulate.
design/Muse, strategy, oracle, enrichment, marketing, conversation, vision. These touch the customer or feed accuracy; their quality is worth far more than the cost delta.
insensitive and high-volume, so Solid A is the sweet spot.
volume; a cheap model is as good, and at volume the cost is what matters.
See it live: dz aimodels prints job → choice → model → value → est cost → why.
Priors are a starting belief, not the last word. Every scored site feeds aiRecordJobQuality(job, provider, model, score) into ai_model_quality, so the engine learns the real quality each model produces for each job — the score from one model vs another, over real Doozer sites. Content generation records which model wrote a site (businesses.custom_settings.content_model); when the six-goal scorer rates that site, the score is credited to the model that wrote it. Over time the decision sharpens from evidence, not assumption.
A small share of eligible calls (≈10%) is routed to an alternate provider of the same tier. This is deliberate: it keeps quality estimates fresh across houses, keeps every vendor warm and callable, and means Doozer is never hostage to one AI company's pricing, availability, or policy. If one house stumbles, the work keeps flowing. Diversity is resilience, and it is also honesty about the fact that different models genuinely produce different results for the same prompt.
The algorithm is the default, not a cage. Per call: ['hard' => true] forces flagship, ['tier' => ...] / ['provider' => ...] pin a choice. Per job: platform_settings.ai_job_tier.<job> and ai_job_provider.<job> (dz aimodels set job=<j> tier=<t> provider=<p>). Only callable models (API key present) are ever chosen, so it just works as keys come and go.
thinking content block first — code must scan content[] for the text block, never assume content[0]. Both callAnthropic and generateWithClaude do.
share of the completion budget on reasoning before the answer, so JSON/structured tasks need generous max_completion_tokens (the router path adds headroom).
googleapi_keys row to be callable.
generateContentFlexible) should set__lastContentModel too, so the learning loop captures every content route, not just generateWithClaude.
aiForJob():muse-family-generator, muse-recompute-palette, ai-classifier, anthropic-chat.
A Doozer site is built with care: real source material read and fact-checked, six goals measured, taste enforced. The model behind that work is chosen with the same care — the best tool for each job, paid for when the value is there, saved when it is not, learned from what it produced, and never locked to one vendor. That is how we create value without wasting time or money.
Product-agnostic Stripe billing — one connection sells everything, entitlements flow to lb_users.tier.
One Stripe connection sells everything (Premium now; add-ons, domain-connect later). includes/billing.php is the product-agnostic core.
billing_products (SKU catalog; kind sub|one_time; grants entitlement; per-modeprice ids), billing_orders (ledger), billing_subscriptions.
platform_settings (not api_keys, not env). No SDK —a curl client. Checkout for both kinds; HMAC webhook to billingGrantEntitlement() (the same grant path as founders codes).
lb_users.tier. Cancel only downgrades premium_source='stripe'.BRAND-NEW sites is metered per user per month (user_site_creations + platform_settings billing.limit_new_sites_pm). See site creation.
billingCreatePortalSession()).Admin: the Revenue nav group (admin-billing.php + admin-users.php). Adding a product is a Stripe Price + a catalog row, no new plumbing.
Every cloud (and root) gets a header. Real image > Keeper opt-out gradient > a designed per-cloud placeholder. AI generation is the upgrade path.
A Cloud should never read as blank just because its Keeper has not yet uploaded or generated a header. As of v2.2.197 every cloud has a header by default. Engine: includes/cloud-header-ai.php.
cloudHeaderTopCss($img,$src,$name,$c1,$c2))header_image_url set) → the image with a gentle legibility overlay.header_image_source = 'none') → a plain brand-color gradient.This is a deliberate manual choice (a "No header image" checkbox in the cloud editor), never the default.
cloudHeaderPlaceholderSvg($name,$c1,$c2) builds an SVG from the cloud's own brand colors: a diagonal gradient, a soft light bloom, a seeded ring motif (deterministic per cloud via crc32(name) so each differs), and a faint serif monogram of the cloud's first letter. Served as a data URI (cloudHeaderPlaceholderDataUri()), so it costs nothing and renders immediately. Used on the Discover Clouds cards (includes/discover-clouds.php).
cloudGenerateHeader($cloudId,$provider) composes a header from the cloud's vibe (type, niches, place, colors), never a literal image, via GPT Image or Gemini Nano Banana 2 (v2.2.11). Per-cloud "Generate AI header" lives in the cloud editor; a batch "Generate missing headers" (Admin → Clouds) backfills clouds with none, capped per run because each call costs an image credit (operator-triggered, respecting API-cost discipline). The placeholder fills the gap until an AI or uploaded image lands.
Decision: headers are on by default (placeholder), opting out is manual, real images are the goal. Taste first, no blanks, no surprise spend.
Distinct from the header IMAGE above: the animated cloud LAYER over the hero gradient. includes/cloud-texture.php → cloudTextureLayer() (drop-in on cloud-directory, discover-clouds, cloud-create, me-cloud). A WebGL fragment shader — fractal noise (fbm) + domain warping, animated by time — so the clouds genuinely FLOW and morph (not a panning texture). Inspired by OpenAI's GPT-Live hero backdrop, rebuilt from scratch.
Every look is a uniform, driven from the my.doozer.cloud vibe controls:
--c1/--c2) → cloud colour; call window.dzClouds.refreshPalette().window.dzClouds.setEnergy(n) (speed + vividness).window.dzClouds.set({storm}) rampswarp amplitude, turbulence, speed, and contrast.
set({speed,vivid,tintAmt}); all persist to localStorage.Responsible: DPR capped 1.75, low-power, paused when the tab is hidden or the header is off-screen (IntersectionObserver). Graceful fallback: no WebGL or prefers-reduced-motion → the original two-layer SVG fractalNoise texture (cloudTextureSvgUri), kept intact. Self-contained, no CDN.
The cloud engine — how niche/region/criteria directories are built, accessed, and personalized, including the Cloud Factory and my.doozer.cloud.
A Cloud is a curated directory of businesses grouped by niche, region, or arbitrary criteria. Clouds sit on the same engine as roots and NBMN, rendered through the shared multi-view framework and business card.
includes/cloud-engine.php. Clouds use is_active (NOT status). Membership is a filter (states / cities / geo bounds), not a fixed radius, so large areas work. Manual curation can include/exclude specific businesses (admin Members panel). doozer.cloud apex is the public Cloud of Clouds (Discover Clouds). Each cloud carries an AI-generated vibe header.
Public (AUTO or CLOSED join), Unlisted, or Private, so a cloud can be an open regional directory, a curated collection, or a members-only list.
includes/admin-cloud-factory.php (Decide → Build → Manage): give it a niche + region and it creates the cloud, runs a Prospector sweep, and batch-builds the discovered businesses (which auto-join). Geo areas reuse googleGeocodeSearch bounds. This is how a themed directory goes from idea to populated in one flow.
Each signed-in user gets a personal cloud (includes/cloud-directory.php personal branch) with a Vibe System: Mood (color) x Energy (0-4) preference; headlines follow energy via meCloudHeadlineBank(); reduced-motion keeps colors. This is the consumer-facing, "your neighborhood, your way" surface. Saved businesses derive their URL from the business's ACTIVE domain, never a stored lb_follows.domain_id.
Clouds turn the flat fleet into infinite meaningful cross-sections, each a new discovery surface feeding the flywheel.
How AI site prose is actually produced — the two provider paths, model config, the 90s timeout, the anti-boilerplate footer, and every failure mode that bit us.
This is the engine behind Content Generation and Healing. It produces businesses.ai_generated_content (headline, about, services, why_choose_us, faq, meta_description, suggested_colors). Hard-won operational truths live here — read before touching AI content, model config, or regeneration.
The governing principle (why site voice varies so much place to place) lives in Per-Location Data Enrichment: a site's voice is signal-in, signal-out. The lever for a thin site is more/better input data, never a forced tone.
generateBusinessContent($businessRow) in includes/ai-content.php is the entry. It takes the business row (from getBusinessById()), NOT a Google-Places shape. There is no generateContent() — calling that name silently broke every content regen with "Content generator unavailable" (fixed v2.2.235).
It tries two paths, and a change to ANY of prompt / timeout / model must cover BOTH:
generateContentFlexible() inincludes/ai-provider-system.php. Reads the per-content-type model config, builds the prompt from the DB template, calls callAnthropic() / callOpenAICompatible().
ai-content.php's own callAnthropic/callOpenAI. Has its ownmodel resolution + the historical default claude-3-5-sonnet-20241022.
DURABLE RULE: the flexible path is what runs ~always. If you only patch ai-content.php you patched the wrong path. (This exact trap meant the anti-boilerplate footer didn't apply for a full cycle — see below.)
Per-content-type provider/model lives in the ai_content_configs table (rows: business_website, photo_vision), joined to ai_models + ai_providers. Set it via Admin → AI Management (it's under the Infrastructure menu, ?page=admin§ion=ai → Content Configuration → Configure), NOT a migration. Class aliases resolve via aiModelResolve() over the includes/ai-models.php catalog.
DURABLE RULE: the content primary model must be a FAST quality model — claude-sonnet-4-6. Do NOT set a reasoning model (gpt-5, o-series) as the synchronous content primary: they take far longer than the timeout for a 2048-token generation and every call aborts with HTTP 0. (gpt-5 as a primary is what caused the June 2026 network-wide content outage.)
Content asks for up to 2048 output tokens; a quality model needs 25-60s for that length. The historical 30s cURL timeout aborted generation mid-stream → cURL returns HTTP 0. Fixed: AI_TIMEOUT_SECONDS = 90 in config/app.php (legacy path) AND callAnthropic/callOpenAICompatible in ai-provider-system.php default their cURL timeout to AI_TIMEOUT_SECONDS (v2.2.238). Both paths, or it regresses. Safe because content gen runs via the queue worker (post-response) or CLI batches, and admin synchronous regen is rare/one-at-a-time — not a visitor FPM hot path.
_aiContentProseQualityFooter() (ai-content.php) prepends the shared Design Standards rules + an ORIGINALITY block that BANS templated openers ("nestled", "in the heart of", "stands as a beacon", "more than just a", "steeped in tradition") and em-dashes, and requires a page-specific opener built from real data. It is appended in BOTH paths (legacy always; flexible since v2.2.239). Without it, similar businesses get near-identical prose — the near-duplicate cluster that triggered the [elks.club scaled-content demotion]. See the project_seo_cliff_recovery memory + site-quality scoring for measuring boilerplate.
dz ai_pingshows providers 200 in <1s, it's the model/timeout, not the network.
This stalled a batch mid-run (~1,158 calls drained Anthropic). It IS logged (callAnthropic logs 4xx) but to error_logs in UTC while batch logs print America/New_York — a ~4h offset that makes it look unlogged. Check instantly with dz ai_status (flags billing/credit/auth).
generateBusinessContent returned null (both pathsfailed). The regenerationRun result carries a detail / $GLOBALS['__lastContentGenError'] with the real per-provider cause; surface that, don't guess.
regenerationRun($businessId, 'content') ([regeneration.php]) wraps generateBusinessContent with a readability quality gate (keeps prior content if the new draft fails) + manifest stamp. Fleet rollout = worst-first over site-quality-score boilerplate-flagged pages, rate-limited, resumable (re-run skips now-clean). ALWAYS dz ai_status first — a big batch on an empty credit balance fails silently. Operator preference: drive batches via the Dev API, not raw php -r pasted into a root shell.
dz ai_status — live provider health + billing/credit alert + recent provider errors (UTC-correct).dz ai_ping — minimal keyed probes to both providers (egress vs auth vs timeout).dz quality root=<root> — boilerplate/flag rollup to measure de-boilerplate progress.See also hosting infrastructure (the queue + drain) and design standards (the prose rules enforced at the source).
Two independent creation paths named businesses after the city they were DISCOVERED from rather than where they are, putting the wrong town in 239 URLs. The pattern, the fix, and the audit.
The bug shape: a creation path knows two cities — the one it is looking from and the one the place is in — and uses the wrong one. It happened twice, independently, in two subsystems written months apart. Assume it will happen a third time.
| Path | Used | Should have used |
|---|---|---|
parCreateSkeleton() |
the ANCHOR business's city (the page the visitor was on) | the place's own address |
| Prospector discovery | the SWEEP CENTER's city ($resolvedCity = $center['city']) |
the place's own address |
Results in the wild: a Brooklyn bowling alley at melody-lanes-morristown-nj.doozer.site, a Brentwood restaurant at blaze-pizza-nashville-tn, a New York restaurant at tao-downtown-restaurant-jersey-city-nj.
The context city is almost right. Most results of a Morristown sweep really are in Morristown, so the bug hides in the minority and every spot-check passes. It only shows up when you compare the slug against the address systematically. 239 of 3,439 domains (1,304 of which use the city-state slug shape) named the wrong town before anyone noticed.
It is also invisible in the product: the page content, title and schema were all correct, because those read businesses.formatted_address. Only the URL lied.
A city is a fact about the place, never about how you found it. Discovery context is
a hint for routing and a provenance note. It must never name anything.
Par-create records this correctly for provenance: custom_settings.discovered_on keeps where a skeleton was seen, while city comes from the address. That is the right split.
parCreateCityStateFromAddress($addr) in par-create.php parses the place's own city and state from the address we already hold, and returns ['',''] rather than guessing when it cannot parse confidently, so callers fall back rather than invent. Both paths call it now.
Watch the address shapes: Google returns both "912 Red River St, Austin, TX 78701" and "222 W 1st Ave, Plentywood, MT 59254, USA". Strip a trailing country, then take the part before the ST ZIP tail. Reject a candidate that starts with a digit, which means you grabbed a street line.
dz slugaudit — scans every active domain, extracts a trailing -city-state slug, and compares it to the address. Reports, dry-runs, repairs.
It splits the fix by risk, and that split is the important part:
be a deletion plus a creation. Resolved in v2.2.541: see below. All 22 are renamed and the network now audits clean at 0.
includes/domain-redirects.php. A retired host keeps its domains row with status='disabled' (already means "not serving", so no migration) plus custom_settings.redirect_to, and domainRetiredRedirect() 301s it.
Two properties make it safe to leave switched on:
precisely where the configure wizard used to load. A live page never reaches it. Adding a query to all 3,400 sites' hot path to serve a handful of retired hosts would have been the wrong trade.
host we actually run. We are the only writer today, but a redirector that trusts a database string is how you become somebody else's phishing hop later.
Rename procedure: UPDATE the live row's domain (keeping id, page-view history and sitemap membership), then INSERT a disabled row for the old host carrying the redirect. Then push the new URL to IndexNow so Bing-backed search sees the move.
A correction worth keeping. The original justification for leaving indexed domains alone included "the orphan falls into the configure flow and offers a stranger the chance to configure somebody's business." That was wrong: runRootDomainGuard() already returned a 302 with x-doozer-lockdown: blocked-subdomain for unregistered subdomains. The real value of retirement is the permanent 301 telling a search engine the address moved, not protection that already existed. Verify the claim, not just the conclusion.
When a naming bug is found in one subsystem, grep for the same shape everywhere else before declaring it fixed. The instinct that caught this was simply asking whether the bug had happened before. Both paths had it; only one was found by working on it directly.
Related: [[the-observed-world]], [[fact-guard-identity-numbers]], [[dev-api]].
One shared rule-set the generator, renderer, and admin self-diagnostic all consult — plus the mirror that scans and heals the fleet.
The single source of truth for "what a tasteful Doozer site looks like" is includes/design-standards.php. It exists so the design layers stop patching the same flaw in isolation. Three layers consult it:
includes/ai-content.php) injectsdesignStandardsPromptRules() so new content is born compliant (never bold a whole paragraph; only short phrases; no em dashes; 2 to 3 paragraphs).
includes/prose-render.php delegates to designStandardsTameInline()and designStandardsStripEmDash() as a safety net for non-compliant model output.
designStandardsRules() is an extensible registry. v1 rules: over_emphasis, em_dash, wall_of_text, empty_content. Adding a rule is one array entry.
includes/admin-design-mirror.php (Admin → Design Mirror) scans live sites against the registry, shows per-rule counts and percent-of-fleet with sample offenders, and offers one-click remediation: Heal (reheal rules: re-saves cleaned content in place, zero API cost) and Queue regeneration (regen rules: enqueues content jobs). It never auto-acts; the operator controls spend.
New design-shape problems get a registry rule, NOT a one-off override. That is the anti-bandaid principle that motivated this system. See the content generation and heal process and the no dark patterns ethos.
The unified regeneration engine (includes/regeneration.php) uses this registry as its safety net: after generating new content it scans, heals once, and rolls back to the prior content if the result is still broken, so a regeneration can never leave a live site worse. What counts as "broken" is derived from the registry, not hardcoded: designStandardsBlockingRuleIds() returns the rules a heal cannot fix (fixable = 'regen', currently empty_content and wall_of_text). Cosmetic rules (fixable = 'reheal', e.g. over-bold prose and em-dashes) are auto-healed and NEVER block. This encodes the operator principle: reject broken or unreadable output, never reject a site for being bold or expressive. Any future regen-fixable rule joins the gate automatically.
(History worth keeping: this gate shipped dead in v2.2.205 and was a silent no-op until v2.2.211 — it scanned for a severity vocabulary and scan shape the registry never produced. The lesson, again: verify the wiring actually fires, do not assume it does.)
This system governs the SHAPE of generated CONTENT (prose emphasis, em dashes, wall of text, emptiness). Visual CSS safety, such as AA contrast and guaranteeing an outline button is actually visible, is a separate concern handled at render time by the Muse System's composition layers (muse-contrast.php plus the button-visibility safety in muse-apply.php). If a control is invisible or low-contrast on live sites, look there, not in this registry.
The shared Grid / List / Map / Events view system that every directory surface (root, Cloud, NBMN) renders through.
One framework renders every directory surface in the network — roots, Clouds, NearByMeNow — in multiple views without each surface reimplementing them. includes/directory-views.php owns the registry and the JS contract; a view is toggled by an html[data-dir-view] attribute, and a surface opts into a view by rendering a data-dir-view="<id>" panel and flipping it ready.
?ajax=root_map_markers /cloud_map_markers; listings carry lat/lng.
businessUpcomingEventsBulk() (includes/business-events.php), fed by root_events / cloud_events ajax; vertical directories like Live Music Tonight render from marquee_events.
Root and Cloud both support distance sort (resolveRootDomainOrder / cloudFetchListings + a shared geo helper + ?e=geo), so "nearest first" works anywhere.
Adding a view once lights it up across every surface. The cost is the shared contract's discipline: a new card action must be registered where each host routes its actions (e.g. NBMN routes its own card actions in nearbymenow/index.php — a new action added only to the generic handler silently no-ops there). This is the directory analogue of the "one renderer, many surfaces" principle behind the business card and Muse system.
The public, per-entity knowledge contract Doozer emits so external AIs can ingest the network — the outward-facing twin of the inward Brain.
The Atlas is Doozer's outward AI knowledge emission: a public, structured projection of what the network knows, published so external AIs and agents can ingest it. It is the mirror image of the Brain — Brain is inward (how Doozer understands itself, this bundle you are reading), Atlas is outward (how the world's AIs understand Doozer).
Every entity emits a public /.well-known/doozer-atlas document (okfEntityFor*() in includes/okf-engine.php) for its type: Business, Cloud, Directory, Event. The Atlas owns the CONTRACT — a derived projection over live data. There is no separate graph database; the Atlas is materialized from the same tables that render the pages, so it can never drift from reality.
Search is shifting from links to answers. If an AI is going to answer "what's a good florist near West Milford," Doozer wants the network to be the citable, machine-readable source, with accurate structured data (hours, geo, niche, reviews, events). The JSON-LD on vertical directories is the same instinct at the page level. This is a moat: a network that is legible to AIs, by design, is the one that gets cited.
This knowledge/ OKF bundle (exportable as one portable .zip via Admin → Doozer Brain) is the Brain. The Atlas is the per-entity public feed. Both use the same OKF engine and the same "it's just files / it's just structured data" philosophy (see OKF adoption). Grow the Brain and the Atlas gets richer context to project.
An AI-written daily email — a public reader edition and an operator admin edition — built from real platform signals.
An AI-composed daily email, in two editions, each built from the same real platform signals the rest of the network runs on (never invented).
A reader-facing daily digest (new sites, notable local businesses, events) that turns the growing network into a reason to come back. Shipped pieces: the digest generator, an AI intro, and sponsor slots. Next: recipient management / opt-in and the send cron.
A third edition for a CLAIMED business's owner: their numbers in their inbox ("people are finding you"). includes/digest-owner.php — digestBusinessStats() aggregates views, calls, directions, website clicks, saves, and leads for a window plus the prior window (trend arrows) and lifetime reach, all from the same real signals (page_views, click_events, lb_follows, nbmn_lead_requests, shortlinks). Two tiers, one template: Doozer+ gets all six metrics with trends; claimed (free) gets two headline numbers + an audience-aware upsell via doozerPlusOfferFor(). Tier resolves through the implicit-verification rule (admin_email authoritative, else scraped email -> owner's lb_users.tier). Preview with dz digest owner=<id> [tier=plus|free] [render=1]. Built and previewable; NO send path is wired yet (recipient management + cadence are operator-approved).
An operator-facing daily brief: growth, revenue, key metrics, and an "idea-of-the-day." It is the human-readable companion to the admin health tools — the same fleet supervised through a morning email instead of a dashboard sweep. It shares the signal miners with Marketing (changelog, LeadBuilder metrics, recent discoveries), so the numbers are the ones that actually run the business.
Both editions are downstream of the value model: the public edition drives retention/discovery, the admin edition drives operator awareness. Roadmap order: NBMN demand-gen → Admin Digest → Admin Alerts. See [[project_doozer_digest]].
doozer.town builds a page for every town in America the first time somebody asks for it, sorts its businesses onto ten colour groups, and can show any Doozer Cloud as a property board. Cost model, grouping rules, the edge blocker, and what to watch.
The operator's two ideas (Sept 2026), which turned out to be one: a Cloud view that turns any cloud into a Monopoly-style board with businesses as property cards grouped into colour-coded categories; and doozer.town, dynamic self-building town clouds for every town in every state. The grouping the board needed is the structure the town page needed. Built in one pass as v2.2.615.
west-milford-nj.doozer.town = West Milford, NJ. The slug parser accepts <city-words>-<st> only; the state must be real. Anything else is a 404, not a town.
A visit resolves the slug in cost order (townEnsure() in includes/town-engine.php):
| step | source | cost |
|---|---|---|
| existing town cloud | clouds row by host |
$0 |
| businesses we hold in that city/state | centroid of our own geo-verified rows, 6 mi radius | $0 (1,955 city/state pairs qualify today) |
| unknown town | one Google geocode | $0.005, bot-gated (_nbmnRequestIsBot), town_create_max_per_hour (30), and only when the result names the same place in the same state |
asdfgh-nj returns not_a_place; a crawler on an unknown town gets a 404 "not on the board yet" and spends nothing. A town is a normal clouds row (cloud_type=geo, visibility=unlisted so Discover Clouds stays curated) with a town block in criteria (city, state, county, slug, source, last count). It rides the cloud engine unchanged, so saves, counts and the card renderer all work. A radius, not a city-name match, is what makes "serves West Milford" true: Hewitt, Newfoundland, Oak Ridge, Ringwood and Greenwood Lake sit inside it.
townGroups() / townGroupKeyFor(): Eat & Drink, Shops, Home & Trades, Health, Self-care, Auto & Around, Civic & Community, Money & Pros, Stay & Visit, Utilities & Essentials, plus Everything Else. Resolution order: explicit slug/type rules (because businesses.primary_niche mixes catalogue slugs and Google types, and a business's types JSON is consulted too), then the catalogue category, then keywords in the name. An empty group is an open lot on the page and on the board and links straight to Find, because the brand's mark is a grid with one cell still open. West Milford after the first pass: 355 businesses, 10 of 10 groups, 34 in Everything Else.
Rendering a town costs nothing. Find calls the existing ?e=nbmn_populate (cache-first, bot gate, per-IP and global paid caps, the $10/day Places cap) around the town centroid; Build posts to the existing ?e=nbmn_build_place (hourly cap) and lands the site on doozer.site, where the town's radius picks it up on the next render. The county eyebrow is one Magellan geocode per town, once, and only for a human.
renderCloudBoardView() in includes/cloud-board-view.php, ?view=board on any cloud and on every town. Forty spaces: corners Start / Saved / Free parking / Build, nine per side. boardDealSpaces() deals a balanced hand: every non-empty group gets a space, empties get an open lot, the rest is largest-remainder proportional, capped by supply. More than 36 members lists the whole deck underneath (CSS columns, not a grid; a grid left three empty columns beside one 66-item group). ?deal=n reshuffles within groups with the top third kept as anchors. Tapping a space opens a <dialog> deed with photo, rating, address, call and visit. Pure renderer, spends nothing.
Every wildcard root is a ServerAlias .<root> line in the subscription's additional Apache directives (HTTP and HTTPS both). On the day it shipped .doozer.town briefly answered from another vhost until that line was in; curl --resolve against the origin IP is the only way to see past Cloudflare when that happens. With the alias live, town_wildcard_live=1 makes the apex path form doozer.town/<slug>/ a 301 to the real address; the path form stays as a permanent link shape.
dz town slug=<city>-<st> resolves (creating from inventory) and reports groups,also-serves and nearby towns; create=0 to look only; force=1 to allow the geocode path from the dev channel (its curl UA reads as a bot otherwise).
dz town list=1 [state=NJ].town_created.noindex under 5 businesses and on every filtered view.Every skeleton exists for free until a person opens it; that visit fetches the details (photos, website, phone, hours), writes the page, and the site is built by the time they look around. The operator's rule (10 Sep 2026): if a person is standing on a site that has not built itself, that is a bug, never a budget decision. Two consequences. Paid backfills are not the way to fill boards; the visit is. And the crawler checks that protect the budget must never catch a person: no bare IP-range guesses (a /16 for Cloudflare's renderer also matched iCloud Private Relay), only named agents, datacenter first octets, cached reverse DNS, and our own capture marker.
dz town prefill=1 slug=<slug> is a dry run: for each group's niches it says whether the nearby-discover bucket is warm (somebody already paid for it: $0 skeletons) or cold (one Nearby Search, $0.035). live=1 creates skeletons from warm buckets; budget_usd=2 lets it spend on cold ones, thin groups first. A full town is ~58 niches, about $2. Skeletons land on doozer.site (noindex), are enriched by their first human visitor (~$0.06 to $0.12), and appear on the board immediately through the radius. Pace it inside the Places hourly ceiling (one town an hour) and the daily cap.
$10 in a day: $8.85 was an image crawler fetching uncached gallery photos on 284 business sites in four minutes, not the towns ($0.63). The photo proxy now refuses paid fetches for crawlers, the ledger has an hourly ceiling, and card photo sizes snap to the cached ladder. The town page itself renders for $0.
Four surfaces, one loop. NearByMeNow is search: what are you looking for. Doozer Town is place: everything that serves one town, ranked. Business sites are the entity: one page per place, the thing the other two point at. Clouds are curation: a Keeper's chosen set. The loop is wired both ways: every business site's "Around the neighborhood" links to its town board, every board links to every site, the network footer carries Doozer Town everywhere, and the apex sitemap (221 indexable towns) goes to Bing by IndexNow. Bing is the channel; towns are the pages Bing can rank for "<town> businesses" that a single business site never could.
Growth runs through the town: the pre-fill trickle feeds par-create from the biggest boards, skeletons appear on the board at $0, a person's first visit enriches one, the ranking decides who leads a group, and sponsorship (a labelled slot above the ranked list, never inside the score) is where the revenue lives when it comes. Honorary Doozers are the people who press Find and Build on a board they care about.
Every part of that spends only on a person, and every guard has a ledger line.
No Atlas or llms.txt entry for towns yet (sitemap, robots, footer link and the site-to-town link shipped 10 Sep); no town list beyond what our inventory implies (a Census gazetteer would let the apex list every place in a state). The group rules will keep needing words added as Everything Else shows what the rules miss.
Related: [[clouds]](clouds.md) · [[the /now surface]](the-now-surface.md) · [[growth engine]](growth-engine.md) · [[brand system]](brand-system.md) · [[Magellan and the feed pond]](magellan-and-feeds.md)
A 1x1 pixel in every outbound email; opens recorded with device/geo/bot-flag; three operator alert signals plus data for the digest.
Every email doozerSendEmail() sends (the single mail chokepoint) gets a unique 1x1 tracking pixel and an email_sends row. includes/email-tracking.php.
?e=eopen&t=<token> (sessionless, returns a 1x1 gif, recordsAFTER flushing so it never adds latency). email_opens rows carry device (mobile/desktop), coarse geo (CF-IPCountry header when present), ip_hash, and a bot flag.
proxies, and any open within ~8s of send are flagged is_bot=1 and kept out of the alerts (still counted separately). open_count vs human_open_count.
TIMESTAMPDIFF(SECOND, sent_at, NOW())), neverfrom PHP time() vs a MySQL timestamp — that mismatch false-flags prefetches.
email_open ping)reawakening means something is up. Alert-level, 6h re-throttle per send.
(lead_forward, claim, owner_digest, lead).
All opens are stored regardless, so the Digest can report opens / devices / repeat-rate even when no alert fires. dz emailtrack for an overview; dz emailtrack do=selftest [age_days=] to exercise the flow.
Tag an email with ['kind' => …, 'business_id' => …] in the doozerSendEmail opts so alerts and the digest can attribute it. ['no_track' => true] opts out. The daily digest email to the operator is itself tracked (dogfooding). See [[doozer-comms]] for the ping plumbing and [[notify-generously]] ethos.
Turning a business's real online store into a tasteful commerce CTA, with a confidence gate so we never send a visitor to a bare marketplace homepage.
When enrichment finds a business's real online store, the site can surface a "Shop Now" CTA — a small commerce affordance and an affiliate/referral surface (see the value model). The hard part is not finding a link; it is not surfacing a BAD one.
The original extraction (_extractExternalStores) host-matched ANY marketplace link, so roughly half the captured "stores" were not the business's own store at all — bare amazon.com homepages, search pages, "recommended" rails. Sending a visitor there is a dead end and erodes trust (no dark patterns, the mission). The fix: storeLinkConfidence() scores a candidate, an admin audit panel surfaces low-confidence ones, and a render-time gate hides anything that does not clear the bar. A CTA only shows when we are confident it is the business's real store.
The confidence scoring + audit panel + render gate shipped; extraction hardening and a backfill of already-captured links remain. This is the same discipline as the store-link confidence in Money Clouds affiliate feeds and the "Shop Now" on business sites: better to show nothing than to show a wrong link. See [[project_enrichment_store_links]].
The pipeline that takes real events from public feeds onto a Doozer page AND emits them as schema.org Event, plus the timezone rule that makes the data correct. The foundation for gohe.re Gatherings.
The operator's goal, stated 2026-08-23: Doozer sites should be perfectly tuned for AI agents to find, understand and reference, as well as for search engines and people.
That goal exposed a gap the day after events started working. Lodge and venue pages were carrying real, current events — a visitor could read "Friday Fish Fry, Aug 28, 4:00 PM" perfectly well. The page emitted LocalBusiness and FAQPage and no Event at all. So an AI agent, a search engine, or any structured-data consumer had no idea the event existed.
A live event is only half published until a machine can reference it.
public feed -> bound by Magellan -> stored as facts -> rendered for humans -> emitted as schema.org Event
Sources today (all keyless, all legitimate):
| source | Magellan service | what it yields |
|---|---|---|
| public Google Calendar embed | gcal |
full event feed with recurrence ([[magellan-and-feeds]]) |
| Ticketmaster / Marquee | ticketmaster |
ticketed shows at known venues |
| schema.org on the business's own site | — | Event where present (rare: ~0 in sample) |
businessUpcomingEvents() (business-events.php) is the single read path: gcal first (a business's own calendar outranks anything we infer), then Marquee. One events surface, many sources — adding a feed must never add a second events UI.
buildEventsJsonLd() (schema-org.php) emits from the same call the page renders, so the structured data can never drift from what a human sees.
startDate is only meaningful with a real UTC offset, and a wrong offset is worse than none — it moves the event.
tz, from the calendar) get a true offset:2026-08-23T13:00:00-07:00 for a San Diego lodge, generated on an Eastern server.
about what we know. We do not stamp the server's zone onto a venue three time zones away.
This is the same failure that once displayed a San Diego lodge's Friday dinner as Saturday midnight. It is not to be reintroduced through the structured-data path.
Every public gathering at a real place is a candidate for a gohe.re Gathering (QR, attendees, media, memories, chat, find-each-other map), and later a wuz.ai memory layer. Both need each event to be an addressable, machine-readable entity first — which is what this node is the foundation for. Event categories worth ingesting as feeds appear: live music, karaoke, trivia, bingo, tricky trays, fundraisers, bike nights, car shows, cruise nights, meetups, showtimes.
Facebook is not a source. Page events cannot be read: direct fetch and mbasic both return HTTP 400, a reader returns a login wall, and the Graph API Page /events edge was deprecated in 2018. Bind facebook for identity only, never scrape. Do not re-litigate.
Related: [[magellan-and-feeds]], [[event-feeds]], [[ai-discoverability]], [[gatherings-and-social]], [[the-observed-world]].
A conservative healer that catches and corrects factual drift in AI-generated content (e.g. lodge/post identity numbers) at both the DB boundary and render.
Fact-Guard is a safety net for the one thing AI content must never get wrong: verifiable facts. A tasteful site that states the wrong lodge number or a mangled name is worse than a plain one — it breaks trust, which is the top of the quality bar.
factGuardHeal($content, $biz, $opts) (includes/fact-guard.php) runs over generated content and conservatively corrects factual drift:
factGuardDeriveIdentity($name) parses a business name for a fraternal/civicidentity + number (Lodge / Post / Chapter / Council / Aerie / ... No. NNNN).
factGuardFixIdentityNumbers($data, $keyword, $number) repairs a wrong numberONLY when a real separator is present (No. / # / Number), so it never invents or over-corrects.
It is conservative by design — it fixes provable drift and leaves everything else alone. It runs at two boundaries: at generation (factGuardHeal(...) after decode in content generation) and at the DB boundary (hooked into saveGeneratedContent()), so both fresh builds and re-saves are guarded.
The Elks fleet surfaced it: an AI given a lodge name would occasionally restate the lodge number wrong. Fact-Guard is the general pattern — where a fact is derivable from hard input (the name, the Places data), verify the output against it. It complements the GIGO principle: GIGO says better input makes better voice; Fact-Guard says never let good input get corrupted on the way out. Extend it as new verifiable fact classes appear.
Fact-Guard is the net, not the first line. The better fix for most inaccuracies is to hand the generator the fact so it never invents a wrong one — no per-fact trap. Example (v2.2.330): an AI hero claimed a barbershop was open "Six Days" while its hours showed closed Sunday AND Monday (open 5). Root cause: the content-generation context (ai-content.php) fed name/address/rating/reviews but NOT the hours. Fix: inject the real weekday_text plus a computed "Open N days a week (closed …)" line. The model can't invent a schedule it's been handed. Operator rule: prefer accuracy by GIVING the model the fact over trapping for a specific mistake — same spirit as the muse custom_css prompt fix. Fact-Guard stays for the classes a prompt can't reliably prevent (e.g. an identity number the model mangles even when told).
How the accuracy net corrects a wrong Lodge/Post number, why the safe-looking regex silently no-opped, and why over-correcting it is worse than the original bug.
includes/fact-guard.php is the network accuracy net: it overwrites AI drift on facts we hold canonically. Rule 1 is the organization identity number (Elks/Moose "Lodge", VFW/American Legion "Post", Eagles "Aerie", K of C "Council"). Two hooks, zero API cost: a render-layer heal in business-site.php and a DB-boundary heal in saveGeneratedContent(). Repair tool: dz factguard (audit / per-business decontaminate / heal=stored).
Shipped v2.2.318, the corrector required a No. / Number / # separator between the keyword and the digits, deliberately, so ordinary prose ("post 2020 renovation") could never be mangled. It was correct and it was tested. But the hallucination in the wild is uniformly the bare form: "Elks Lodge 339 in Plentywood". The heal matched nothing, changed nothing, threw nothing, and read as working for two months (operator note #18, July 20 2026).
Two things compounded it:
factGuardHeal() call sites passed no $opts, so the number was derived fromthe business NAME. Many lodges are scraped as plain "Elks Lodge" or "Elk's Club" with no number at all, so the heal was a guaranteed no-op for exactly the sites that needed it.
businesses has no domain column and bothgeneration entry points load the row with a bare SELECT *, so the subdomain — the one identity fact we MINT rather than scrape — was out of scope in buildBusinessContext(). Meanwhile the prose brief asks for "a concrete fact: founding/charter year". The prompt rewarded inventing precisely the thing nothing supplied.
Takeaway: a healer that cannot report what it changed is indistinguishable from a healer that does nothing. dz factguard heal=stored now emits a real before/after per row.
The first cut of the bare-form rule rewrote every lodge number in the content to ours. On live pages that produced:
Freemasons of Newark Lodge #83 → #1249 (a Masonic lodge sharing the building)a party of six from Lodge 401 → Lodge 1249close relationship with Dallas Elks Lodge 71 → Dallas Elks Lodge 2485Each trades one wrong fact for a false claim about a different real organization. Enumerating "foreign cues" kept losing, so the rule was inverted: only correct a mention that is positively OURS. A mention is skipped when preceded by a foreign-order or other-lodge cue, by from, or by a place name that is not one of our own city tokens.
Leaving another lodge's number intact is not a false claim about us. Changing it is a
false claim about them. The tie always breaks toward leaving it.
The cost of that conservatism is real and accepted: a handful of pages simply never state their own number in prose. That is a gap, not a lie.
?LodgeNumber=0031 is lodge 31. A stringcompare flagged ~130 correct rows as contaminated and would have rewritten them all.
ai_generated_content differs inslash/unicode escaping even when nothing changed, which reported ~170 phantom rewrites.
\d{2,6} floorsilently skipped them.
Plentywood (business 1658) had the wrong lodge in its own record: website pointing at LodgeNumber=339, email secretary@alleghenyelks.org, a Pittsburgh 412 phone in deep_research. Enrichment scraped the wrong lodge; the generator faithfully wrote what it was fed. Signal in, signal out. Seven elks businesses had a LodgeNumber= URL contradicting their subdomain, two of which still got the right number in content, so the bad-website problem is slightly wider than the bad-content one.
That leaked ownership, which mattered more than the copy. With admin_email NULL, the scraped email IS the implicit owner ([[project_implicit_verification]]), so an officer of the Pittsburgh lodge could have signed in by magic link and managed a Montana lodge's site. Three businesses were in that state. A scrape of the wrong entity is untrustworthy field-by-field, so decontamination drops the derived scrape wholesale (deep_research.contact_email, additional_phones, enrichment provenance) and re-points website at the lodge's own canonical page. Google's geo-verified phone and address are never touched, and an email naming our own lodge or city is KEPT.
Verification method that settled every case: fetch elks.org/lodges/home.cfm?LodgeNumber=N and compare its city/ZIP to the business's formatted_address. All seven subdomains proved correct at ZIP level; the stored websites were the wrong ones.
After the websites were corrected, Flora IL still linked to Carmichael CA's Facebook and rendered Carmichael's photo as its hero, because the first pass cleared four keys and left social_media, images, logo_url. Camillus NY had a correct website but only Chicago lodge 2367's images. Wrong photo and wrong social link are worse than a wrong number, and neither is reachable by a text healer.
Detection took three tries; the failures are the useful part:
~elks(\d+)~ read Facebook page ids and image dimensions(WoburnStonehamElks-150x150.png → "150") as charter numbers, condemning 5 clean rows.
lodge's asset (a state president's photo, a shared bingo graphic).
only when none of the assets are ours.
json_encode writes \/, so a char class stopping at the backslash truncates theURL to www.elks.org and hides the path carrying the number. Un-escape first.
purge=1 forces the purge for a hand-verified case, because repairing the website first removes the signal the detector keys on.
Two businesses (685, 1601) carry a spurious second elks domain whose lodge number differs from the one their content correctly states. That is a domain-mapping problem, not an accuracy one; dz domdupes disable=<domain_id> is the tool. dz factguard refuses any business whose elks domains disagree, so it cannot "fix" one of these by corrupting good data.
See also: [[elks-root-theme]], [[content-generation]], [[source-of-truth-cache]], [[dev-api]].
The roadmap surfaces that add real-time human signal — events/gatherings (GoHe.re), check-ins (wuz.ai), and how they enrich every business site.
The strongest content lever for a business site is not better prose — it is real human signal: what is happening there, who was there, what they thought. This is the enrichment direction made social, and mostly roadmap (some live).
marquee_events (Ticketmaster via marquee-engine.php) powers vertical directories like Live Music Tonight and the Events view in the directory framework. An lmt_interest demand graph records view / interested / ticket-click signals, feeding an admin "Live Music Demand" build loop: real demand decides which venue sites to build next.
The events/gatherings root. The Events Waterfall strategy builds backwards: ticketed events → venue sites → calendars → Global Gatherings. "Instant Gatherings" and "Wuz-for-One" let anyone create a gathering for any place + time. This is the surface where user-supplied events, comments, and photos flow into the true voice of each location.
Check-in / "Wuz" toolbar signals who is where, now. user_hotspots (user hotspots) already derive a coarse home centroid for signed-in users to enable proximity features.
Per the GIGO principle, a site sounds like itself only when fed real signal. Events, check-ins, and gathering photos/comments are that signal, and crucially they feed every surface on the shared data spine — the business site, the Clouds, the vertical directories, and the Atlas emission. One person RSVPing to a show enriches the venue's site, its directory listing, and what AIs know about it. That is the flywheel reaching into the physical world.
Businesses are OUR prospects. Doozer-awareness scoring (claimed + postcard engagement + leads we deliver + live site) ranks who is warm enough to pitch Doozer+. The Internal Affairs Division of LeadBuilder.
includes/doozer-prospects.php. Shipped v2.2.416-417. The strategic payoff of all the tracking: it turns postcards + claims + the leads-we-deliver into a ranked list of who is warm enough to close on Doozer+. Jon's flywheel, made visible: they know us → we send them value (leads) → we make the right offer → free becomes Doozer+.
businessDoozerAwareness($bizIdOrRow) → score + component breakdown + status. Weights live in doozerAwarenessWeights() (one place to tune):
| signal | pts | why |
|---|---|---|
claimed the listing (admin_email set) |
+40 | they acted, they know us — biggest |
scanned our postcard (thanksio_events scan) |
+18 | engaged with our mail |
| postcard delivered | +6 | mail physically reached them |
each postcard sent (thanksio_sends) |
+3 (cap 9) | touches |
each lead WE delivered (nbmn_lead_requests.routed_to_business_id) |
+12 (cap 40) | we give value — the strongest "we have something to offer" |
| live Doozer site | +4 | inventory exists |
Status: paying owner (businessIsPremiumOwned) → customer (not a lead — a save/expand target). Else hot (≥55) / warm (≥25) / cold.
doozerHotLeads($opts) — candidate set = businesses we've TOUCHED (mailed OR delivered a lead OR claimed); never-touched businesses are not leads. Scored, ranked hottest first, customers excluded by default. $opts: niche, state, limit, min_score, include_customers. Admin at Intelligence → Hot Leads (Doozer+) (admin-prospects.php): score bars, the "why" breakdown, and Postcard / Manage quick actions. CLI dz prospects [do=hot|score b=].
doozerProspectAction($row) returns action + channel + urgency (now|soon|later) + reason. The channel rule (Jon): getting leads + email → email the claim invite; getting leads + NO email → mail a postcard STAT (these sort to the top — leads with no inbox = money on the table); mailed already + still no email → call the phone; claimed + free → Doozer+ pitch (email else mail). SMS is OUT (regulatory). Facebook Messenger via official page = future. doozerHotLeads sorts urgency-first. doozerProjectedClaim($row) = projected claim % (heuristic until real outcomes; getting-leads ×3.5, scanned ×2 over a ~4% cold baseline) — sharpens as claim/convert rates by niche+market accrue (feed from the postcard Format-performance panel).
doozerProspectsPipeToLeadBuilder() (admin "Sync to LeadBuilder"; dz prospects do=pipe) upserts each prospect into lb_leads (in the LeadBuilder DB via nbmnLeadGetDb), source doozer_internal, intent doozer_plus_prospect, under a synthetic "Doozer Internal Sales" lb_site, one lb_visitor per business (reuses nbmnLeadEnsureLbSite/Visitor). score_factors carries doozer_business_id, awareness, projected %, signals, and next_action/channel/urgency. Idempotent (upsert by site+visitor+source). Internal prospects now live inside LeadBuilder proper.
Distinct from Machi (machi.md): Machi = OUTBOUND NEED ("who to mail next"); this = CONVERSION READINESS + the exact next move. NEXT: campaign/niche/market response comparison to auto-weight targeting ("mechanics claim 20% more → mail more mechanics"); FB Messenger channel; cross-sell Keeper/agency to customer-status businesses.
Ties to [[project_monetization_plan]], [[the-observed-world]] CLAIM→EARN, the [[feedback_right_offer_right_audience]] rule, and ai-postcards tracking.
Push index-eligible Doozer URLs to Bing/Yandex the moment they cross the quality gate, so fresh and changed pages get crawled in hours instead of weeks. Bing-first hedge against the Google scaled-content classifier that cliffed elks.
IndexNow is a free "push" protocol: you notify participating search engines (Bing, Yandex, Seznam, Naver) that a URL is new or changed, and they crawl on demand instead of waiting for a sitemap re-fetch. Google does not consume IndexNow, so this is a Bing-first play, and a deliberate hedge: elks.club cliffed on Google's scaled-content classifier (SEO cliff & recovery), so diversifying the traffic base onto Bing matters.
Shipped v2.2.359. includes/indexnow.php.
IndexNow verifies you own a URL by fetching a key file (https://<host>/<key>.txt) on the same host as each submitted URL. Doozer already serves robots.txt dynamically on every domain, so we do the same for the key file: index.php matches /<32-hex>.txt and echoes the one shared key. That means a single key (platform_settings.indexnow.key, generated on first use) verifies the entire network, with no per-domain key management. The route is on the sessionless path list so it never takes a session lock.
Because each Doozer business site is its own host (e.g. lodge.elks.club), submissions are one host per request. Directory/root hosts with many URLs batch into a single request (IndexNow allows one host, many URLs).
Submit only index-eligible URLs — the exact sitemap rule: domains.status='active' AND include_in_sitemap=1 AND businesses.ai_generated_content IS NOT NULL. Never push skeletons, noindex, or thin pages. Pushing slop to a search engine re-teaches it that we emit slop, which is the precise mistake that hurt elks. This reuses the existing quality signal rather than inventing a new one — enrich-then-index, same as the observed-world flywheel.
indexnow_submit job)saveGeneratedContent() (ai-content.php) enqueues after anywrite that leaves a business index-eligible. Covers fresh builds crossing the gate AND every regen (so a de-boilerplate fleet run doubles as a Bing recrawl signal, for free).
flipped INTO the sitemap. Removal is a no-op (IndexNow has no de-index).
Daily cap (indexnow.daily_cap, default 2000), short-window dedup (don't re-hammer a URL pushed OK in the last hour), a self-creating indexnow_submissions audit table, and loud logging on any 4xx/5xx (the elks credit-outage lesson: never swallow a provider error). Zero API cost.
dz indexnow — status (key, enabled, cap, sent today, recent rows). dz indexnow test=<url> — force one submit (bypasses the switch) to prove the pipe. dz indexnow enable=1|0 — master switch. dz indexnow rotate=1 — regenerate the key (invalidates the old key file). dz indexnow run=backfill root=<r> — queue every currently-eligible URL for a root, chunked (e.g. backfill elks.club to push the ~1,700 de-boilerplated pages at once).
Verify with Bing Webmaster Tools → IndexNow, which shows submissions landing.
How a visitor's message or call becomes a routed, deliverable lead — the shared nbmn_lead_requests pipeline, auto-routing, no-email self-heal, and LeadBuilder handoff.
Leads are how Doozer turns free sites into revenue (see the value model). There is ONE lead pipeline; every surface feeds it.
nbmn_lead_requests is the single table. A lead enters from:
includes/contact-form.php(contactFormButton() + renderContactFormDialog()), posting to ?e=nbmn_lead_request with lead_source=business_site + selected_business_id.
includes/call-intent-categories.php(callIntentChipsForNiche()) logs to lead_intents + bridges to LeadBuilder.
From the table: admin triage (includes/nbmn-lead-requests.php) → route-to-owner (nbmnLeadRouteToBusiness()) → LeadBuilder bridge (leadCaptureBridgeToLeadBuilder()). Routing to an owner emails them when a contact address is on file.
businessLeadRecipientEmail($business) (nbmn-lead-requests.php) is THE single place lead-recipient precedence is decided: the claimed owner (admin_email, set when someone redeems a postcard code or matches by email) wins; the scraped businesses.email is only the fallback for an unclaimed listing. One business, one inbox — every lead from anywhere in the ecosystem (business site, NBMN, calls, clouds) goes to that one email. Future agency/agent fan-out extends this one function. The redeem + claim pages state the promise to the owner explicitly ("the email you claim with becomes where every lead arrives"). See [[identity-and-auth]] (implicit verification: admin_email = the claim).
A business_site lead with a selected_business_id auto-routes on submit — the target is unambiguous (it's the site's own business). If the business has no email, the lead is not dropped: Doozer fires a priority basic_enrichment email-discovery scrape (the implicit-verification self-healing backfill) and flags the lead awaiting_email to deliver once an address lands. A lead is never a dead end — delivered if reachable, hunted for a channel if not, and held meanwhile as a warm lead + an ownership hook. NBMN directory leads still go through manual triage (which business a directory lead belongs to is a judgment call).
reCAPTCHA v3 (includes/recaptcha.php, invisible/score-based, fail-open so a Google outage never drops a real lead) + a honeypot field + per-IP-hash rate limiting. Keys live in platform_settings (secret-free in the graph).
getParam() (includes/api-endpoints.php) read ONLY $_GET until v2.2.319, so every ?e= WRITE endpoint silently ignored POST-body params and the lead forms captured nothing. It now reads $_GET then falls back to $_POST. Any new write endpoint can accept a normal form POST. Filed under render gotchas: the write "succeeded," the row just never arrived.
Where every captured lead, call intent, and cross-site referral flows to become revenue — the sibling product that turns Doozer traffic into qualified leads.
leadbuilder.io is a BerrySmart sibling product: the lead-intelligence platform that Doozer feeds. If Lead Capture is the plumbing, LeadBuilder is the destination and the intelligence layer — where a visitor's action on a business site becomes a qualified, attributable lead a business will pay for. It is the primary way Doozer's traffic turns into revenue (see the value model).
Every demand signal on the network bridges to LeadBuilder:
nbmnLeadRouteToBusiness() and piped in by leadCaptureBridgeToLeadBuilder().
lead_intents) bridge here.renderLeadBuilderSnippet() mounts a lightweight tracker onbusiness + directory pages, recording sessions and events to the lb_* tables (lb_users, lb_sessions, lb_events, lb_follows).
fire sendBeacon → the track_network_referral endpoint → a click_network_referral lb_event on the SOURCE site, so movement across the network is attributed, not lost.
A free Doozer site creates presence; LeadBuilder captures the value that presence generates and makes it measurable and sellable. lb_users.tier also carries the entitlement that Billing sells, so the same identity spans the free site, the lead intelligence, and the paid tier. It is the revenue engine of the flywheel, and the reason "we build a free site" is a sustainable business rather than a cost center.
LeadBuilder also owns the network's Google Search Console pipe — the search demand data that powers Prospector's Cloud Radar and the marketing signal miner. includes/lb-gsc-client.php is a dependency-free OAuth 2.0 + Search Analytics client. Operator connects GSC properties (Admin → LeadBuilder → GSC Properties); refresh tokens are stored encrypted in lb_gsc_properties. lbGscSyncProperty($propertyId, $daysBack) pulls query + page aggregates into lb_gsc_query_daily / lb_gsc_page_daily. GSC Search Analytics is free — no per-call cost.
Freshness is a background job. lbGscMaybeQueueRefresh() runs from the index.php shutdown tick (rate-limited 6h) and enqueues a per-property gsc_refresh job when a property's last_synced_at goes stale (>20h); the job dispatcher runs the sync. Built v2.2.364 — before that the scheduler was referenced in a comment but never existed, so ingestion silently died after one manual sync (May 2026) and Cloud Radar ranked on a frozen snapshot.
Two durable gotchas (v2.2.364): (1) refresh tokens are encrypted with ENCRYPTION_KEY; a key rotation breaks decryption unless the decrypt tries the legacy keys — lbGscDecryptTokenEx() now uses doozerLegacyEncryptionKeys() + self-heals (re-encrypts with the current key). (2) If the Google OAuth consent screen is in "Testing" mode, refresh tokens expire after 7 days → invalid_grant → dead pipe. Fix: publish the consent screen to production + reconnect the properties. Dev API gsc (status / sync / scan) drives + diagnoses it all.
A demand-sensing live-music discovery directory built on the Vertical Directory engine, fed by the Ticketmaster/Marquee feed, that captures visitor interest as a graph to decide which venue sites to build next.
livemusictonight.com is a Vertical Directory (one host row routed by includes/vd-router.php → includes/vertical-directory.php). It is not a bespoke page — it reuses the VD engine. It started empty because vdFetchEvents() read the unpopulated dynamic_section_cache; it now sources real shows.
Live music is a NOW thing, so the homepage leads with location + Tonight and lets the visitor expand outward in time and space.
?lat&lng from the geolocate button, or approximate fromCloudflare CF-IPLatitude/Longitude): live Ticketmaster geo feed via marqueeTmFetch() (gained latlong/radius/unit/endDateTime/sort), closest first. If nothing falls in the radius/window, an empty state nudges widening — it never silently dumps far-away national events on a located visitor.
marquee_events, then a live national fallback so the page isnever blank. This is the browse mode where search/filter belongs (future).
location on first load (remembers a denial via sessionStorage, no nagging).
vdWindowEnd() hard-caps everything at +30 days (no stray next-year listings). Tonight is here-and-now; This Week / This Month are calendar-bounded; Next 7 Days / Next 30 Days roll forward from today regardless of the date. The nav exposes the rolling options via dropdowns on This Week / This Month.
Every Interested and Tickets click records a demand signal to lmt_interest (?ajax=lmt_interest, ip-hash rate-limited) keyed to venue identity + genre + area. This is OUR derived demand signal, not a mirror of Ticketmaster's catalog (ToS-clean). Admin → Marquee → "Live Music Demand" ranks venues by interest (interested×3 + ticket×5) and hands a venue to the build flow. So real demand decides which venue sites Prospector and the Cloud Factory build next — the flywheel applied to events.
header (magic-link sign-in), so Interested can tie to a real person.
tm_venue_id onto thebusinesses row when it builds a venue site (marqueeStampVenueId), so _vdLinkVenues links a live event to its Doozer site deterministically (businesses.tm_venue_id = event.tm_venue_id) instead of fuzzy name matching. This is the durable handle a venue's own business site can use to point back at LMT or any vertical directory.
renderBusinessEventsSection) carries an Interested button (internal bookmark via lmt_interest) beside Tickets and + Calendar — the network keeps the save instead of pushing people to an external calendar.
The Marquee admin (marqueeRecentVenues) surfaces ONE row per distinct venue (keyed by tm_venue_id, else name+city+state), NEW venues (no Doozer site) first, built ones marked. The product is finding businesses to build through the live event feed — not listing events. Build acts on a representative event; the rest of the venue's shows become its site's calendar.
gatherings; clicking a show could spin up a Gathering on GoHe.re (gated on the gatherings surfaces); non-ticketed/community shows union into the same feed.
The business_id_map spine (built v2.2.549) plus the feed adapters that hang off it. A bound external ID is what lets a generated page carry current, authoritative, self-maintained content instead of static prose.
The operator direction (Aug 21 2026): "Cross link as much as we can and turn feeds, sites, and other directories into data feeds. Doozer sites need to be living entities."
includes/magellan.php, v2.2.549)business_id_map (business_id, service, external_id, source, confidence, verified_at), unique per (business_id, service), reverse-indexed on (service, external_id). This is the table specified in magellan-id-mapping in June 2026 and finally built. magellanBind/Id/Find/All/BackfillHot/Stats; dz magellan.
Live coverage: google 3,632 · elks_org 1,704 · gcal (growing) · ticketmaster 101.
The hot columns (businesses.google_place_id, businesses.tm_venue_id) are NOT touched; magellanBackfillHot() copies them IN so both views agree while callers migrate.
The rule stays: join by ID, never by name+city+state. Name matching guesses, duplicates, and fails silently.
A generated page describing a business is one of thousands and gets skipped. A page carrying what is on tonight is the only page answering that question. A bound ID is what makes the second thing possible, and it is also an accuracy engine: every feed is a second opinion on the facts.
gcal — public Google Calendar (includes/feed-gcal.php, GENERIC)A public Google Calendar exposes iCal at a predictable URL with no key and no quota. Any page embedding one names a live, self-maintained event feed.
<iframe src="...calendar/embed?src=<id>"> -> https://calendar.google.com/calendar/ical/<urlencoded id>/public/basic.ics
gcalIdsFromHtml (handles the addr@gmail.com and hex @group.calendar.google.com forms, and src not being the first query param), gcalFetchIcs, gcalParseUpcoming, gcalSyncBusiness. RFC 5545: line unfolding, VALUE=DATE, TZID, EXDATE, cancelled events, recurrence for FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/BYDAY/COUNT/ UNTIL, hard-bounded. BYSETPOS/BYWEEKNO are NOT expanded (stated, not hidden).
GOTCHA — timezone is a correctness bug, not cosmetics. Store the instant AND the calendar's tz, and format with gcalLocalString(). First pass showed San Diego's "Friday Dinner" as Saturday 00:00 because correct UTC instants were formatted in the server's zone. Events belong to the place that holds them.
GOTCHA — the reader strips iframes. Calendar discovery must fetch RAW HTML (elksOrgFetchHtml), not sourceFetchMarkdown(). This is why the calendar page looked empty and was nearly written off as unusable.
elks_org — the BPOE registry (includes/elks-lodge-contacts.php)Lodge number is in every elks.club subdomain (1,710/1,710), so the path is deterministic and needs no search API key. elks.org/robots.txt is permissive (only three history PDF paths disallowed). Per-lodge pages: home (address, meeting times, officers, official site, Facebook), lodgeFacilities (social-quarter and hall hours — RICH, and exactly what the real leads ask for), lodgeNews, lodgeCalendar (the Google embed), contactUs (form URL — surfaced for a HUMAN, never auto-submitted).
Measured on a 24-lodge random sample: 25% list their own website, 20% Facebook only, 100% a verified address, 17% embed a calendar.
GOTCHA — parse the BODY SLICE only, between ## <City>, ST Lodge No. <N> and ##### Navigation. Outside it is elks.org chrome, which made the national vendor link elksbenefits.com look like a lodge website on the first pass (a MapQuest directions link did the same). Officers are PROSE ("The Exalted Ruler of the Lodge is X, and the Secretary is Y"), not a labelled list.
location. Enrich the content, never the coordinates.
cliff. Extract data; let the site render it.
contact form at scale is not, whatever the volume argument.
live=1 writes (the dz harvest convention).Render the bound data on the site (facilities hours, upcoming events). The facts are captured but the page does not use them yet — the same gap [[project_foundation_sprint]] notes for source_facts. Then more services: the lodge's own site, state associations, and other directories that publish structured member data.
Related: [[the-flywheel]], [[event-feeds]], [[money-clouds]], [[source-of-truth-cache]], [[project_where_the_traffic_is]].
The composer that drafts social posts from real platform signals in the founder's voice, and the auto-draft review queue.
Doozer markets itself the way it was built: in public, from real signals, in the founder's voice. The composer drafts social posts (X) grounded in what actually happened on the platform, never invented hype.
marketingMineChangelogVersions() (parses CHANGELOG.md), marketingMineLbMetrics() (network reach, businesses indexed, hot leads, GSC impressions, weekly new sites), and marketingMineRecentDiscoveries() (new domains + businesses in the last 30 days) turn platform reality into post material. Metrics come from the same numbers that run the business, so posts are true.
marketing_posts holds drafts; ai_prompts rows (changelog / metric / discovery / freeform) carry a @levelsio-style build-in-public voice with a banned-phrase list baked in so drafts avoid AI-slop tells (and no em-dashes, per the no-slop quality bar). Each request drafts 3 variants; the operator picks one.
When a Prospector discovery becomes a live Doozer site, marketingDraftFromDiscovery() fires automatically. All 3 variants persist as draft rows tagged auto_drafted: true; the Marketing admin's Review queue groups them by business, the operator picks one, siblings are trashed. So growth events produce marketing material with no manual step, closing the discovery → build → announce loop from Prospector.
Marketing is the outward voice of the flywheel; the outward data voice is the Atlas.
Generalizing the Live Music Tonight pattern — any niche with an affiliate feed becomes a revenue-share vertical directory, bound across APIs by Magellan.
The affiliate-revenue generalization of the Live Music Tonight pattern ("Make it Rain"). LMT proved the shape: a vertical directory backfilled from a partner feed (Ticketmaster), rendered with structured data, monetized via an affiliate program. Money Clouds apply that shape to ANY niche that has an affiliate or revenue-share feed. Mostly roadmap; LMT is the live proof.
backfilled from the feed and surfaced through the shared directory framework + business card.
the Google place == the OpenTable place == the Hotels.com place, always by ID, never name-guessing. That is what lets one search resolve, and one directory backfill, from multiple paid feeds and monetize the same entity.
lmt_interest graph) picks which verticalsand places to build, tying it to Prospector and network growth.
Fast breadth from aggregator networks (Travelpayouts, Skimlinks/Sovrn, Impact, CJ, Rakuten, Awin) plus a few direct integrations for depth and better rates (OpenTable, Booking.com/Expedia, Viator, SeatGeek, DoorDash). Each becomes a Money Cloud. This is the revenue-diversifying arm of the value model and monetization, beyond leads and premium. Sequence after the core infra settles; see [[project_search_to_site_money_clouds]] and the events waterfall.
The public "what's near me" surface that indexes the whole fleet, drives visitors back to business sites, and captures demand as leads.
nearbymenow.com (NBMN) is the active side of the referral loop in the network: a public directory that answers "what's near me, open now, and good," indexes every business in the fleet, and sends visitors to each business's own generated site.
nbmnFetchNearbyListings() (includes/nearbymenow-data.php)runs a Haversine + bounding-box query over businesses.lat/lng (backfilled by includes/geocode-backfill.php) with niche/category filters. Cold-start city searches use the cities centroid table.
?e=geo resolves location (browser → Cloudflare IP-geoheaders → none); ?e=businesses_near serves geo-sorted JSON.
multi-view framework (Grid/List/Map/Events) using the one business card.
shared lead pipeline; call intent is logged per category.
?page=about-listings explainer of the implicit-verification model (the "no account" ownership ethos, with the agencies@doozer.work hook).
nbmn_lead_requests) with allowlisted fields, phonedigit validation, and per-IP-hash rate limiting.
domain_visit_fingerprints counts repeatvisits; a fingerprint seen enough times enqueues a harder email-discovery scrape, so an owner who "tried last week and it didn't work" finds it works today.
NBMN is what makes the fleet more than a pile of pages: it turns 2,400+ isolated sites into a searchable neighborhood, drives cross-site traffic (now via direct neighborhood links, no two-click detour), and converts browsing into leads. It is the traffic engine of the flywheel.
How the network grows organically from real local intent — visitors spin up the next site — and the two per-root guardrails that keep that growth clean.
The organic, demand-driven way the network indexes its next site. On every business site (below "More from the neighborhood", which shows EXISTING Doozer sites), a small section suggests up to 3 nearby same-niche places that are NOT yet on Doozer, pulled from Google Places and deduped by place_id via Magellan. One tap builds that place's site and lands the visitor on it. A real visitor arriving from Google, with real local intent, spins up the neighbor that wasn't there yet. This is the same widget NBMN shows for thin category searches, brought to business sites.
nbmnPlacesNotOnDoozer() (includes/nearbymenow-data.php) — the onlypaid Places call is a cache MISS (cached 30 days per niche + geo-bucket, empties too). Rendered by includes/places-nearby-suggest.php (renderPlacesNearbySuggest), !important-hardened chrome (muse pages).
?e=nbmn_build_place → resolvePlaceToEcosystem(...)(the WHERE engine). Skeleton-first, content queued, noindex until quality-gated (SEO discipline). Hourly throttle platform_settings.nbmn_build_max_per_hour.
place_resolver_min_score (default 40 — a 50+review / 4.3+ ratinglocal business is real content, not slop; it shouldn't fail just for lacking a website, which is exactly who Doozer serves). Below-bar places DON'T build — the card opens a "contact anyway" popup (name, rating, click-to-call, directions). The internal score is NEVER shown to a visitor. Rejected place_ids (logged score < the bar in place_resolutions) are dropped from future suggestions.
businesses at render so anew site never suggests itself.
root_domains.custom_settings)service_area ={radius_mi, optional lat/lng} (center auto-derives from the root's business centroid). rootServiceArea() (geographic-helpers.php). Filters suggestions AND hard-gates the build, so westmilford.biz only ever suggests/builds within 12 mi. Distance is the limit, full stop (v2.2.394 fix). Two bugs were closed: (a) the build gate lived only on the unknown-place path, so a business ALREADY in the ecosystem (e.g. a Paterson bakery, 18 mi out) skipped it and built a sibling from "More places nearby." The gate is now a shared helper (placeResolverServiceAreaBlock in place-resolver.php) called on EVERY build path, including reused/known businesses. (b) The suggestion filter's county "edge tiebreaker" used to rescue any same-county place up to 2x the radius — Paterson is the same county as West Milford, so it showed at 16 mi. Suggestions are now strict to the radius too (any state in range, NO county rescue past it), so the list agrees with the build gate and never teases an unbuildable site. NOTE: the admin edit still uses raw lat/lng fields (place-picker is a TODO); the values store correctly into service_area, so this was never the leak.
expand_target = self (a subdomainon THIS root, open regional directories only) or external (DEFAULT — the neutral doozer.site). rootExpandTarget(). The card's data-root carries the resolved target. SAFE DEFAULT is external, so a curated/branded root (elks.club, vsso.net) can NEVER sprout a random-business subdomain — those build on doozer.site. westmilford.biz is set to self.
Both are managed per root in Admin → Root Domains. This is the intake valve of the flywheel, refined so growth is local, intent-driven, and never pollutes a branded network. See [[project_search_to_site_money_clouds]] and NearByMeNow.
How NearByMeNow decides "does this business belong on the <niche> page?" and "what is actually near this person right now?". Three bugs lived here at once, all invisible because each one only ever made results smaller, never wrong.
A business carries businesses.primary_niche (our single-value guess) and businesses.types (Google's array). Niche pages matched only the first. That is one guess per business against ~90 niches, so most businesses were unreachable from the page that describes them.
Measured 2026-08-23 on the live network:
| niche page | matched by primary_niche | also tagged by Google |
|---|---|---|
| restaurants | 82 | +361 |
| bars | 59 | +191 |
| cafes | 9 | +56 |
| hair salons | 14 | +19 |
Use nbmnNicheMatchClause($nicheField, $typesField, $niche, $params, $db), never nbmnNicheInClause directly, for anything a visitor browses. It returns (primary_niche IN (...) OR JSON_CONTAINS(types, ...) OR ...).
Two rules that keep it honest:
establishment and point_of_interest are excluded from the types side.Google stamps them on 3,393 of 3,635 businesses, so including them makes every niche universal.
primary_niche matches rank FIRST ($nichePrecision innbmnFetchListings). Google tags a bowling alley restaurant because it serves food; that is a fine page-3 result and a bad page-1 result.
MariaDB 10.6 - no JSON_OVERLAPS. Use OR'd JSON_CONTAINS(f, JSON_QUOTE(:x), '$').
hair-salons is not hair_salon. auto-repair is not car_repair. plumbers is not plumber. restaurants is not restaurant.
Stripping punctuation out of a URL slug does not produce a place type, and Google silently ignores a type it does not recognise - the search still returns 200 with generic nearby establishments, so the bug looks like "discovery is mediocre" rather than "discovery was never asked the question".
Always resolve through niche_mappings via nbmnNicheTypeValues($niche). When a niche maps to several types, prefer the one matching the slug's singular (hair-salons -> hair_salon, not the broader hair_care).
nbmnJunkTypes() keeps the untyped "who else is on this street" widget free of ATMs, bus stops and gas stations. It contains car_wash, dentist, doctor, pharmacy, hospital, bank, hotel, insurance_agency, real_estate_agency, storage, funeral_home.
It was applied to explicit searches too. A visitor on the car-washes page pressed "Show what's near me", got a correctly typed Google search for car washes, and had every result discarded for being a car wash. Car washes and dentists returned zero cards, every time, for as long as the feature existed.
In nbmnNearbyDiscover, the junk filter runs only when $type === ''. With an explicit type Google has already constrained the set; filtering again can only subtract the answer.
None of them threw. Each one returned a smaller, plausible result set, and a short list of local businesses looks exactly like a short list of local businesses. The symptom was recorded for months as "thin inventory" and "dead-end niches" - a data problem - when all three were query problems. When a surface looks under-stocked, check what the query is actually asking before concluding the shelf is empty.
Method: for every path a visitor can take to find a business, compute what the query SHOULD return and compare to what it DOES. Four more defects, same signature - all subtractive, none threw.
niche_mappings.google_type was never validatedIt is the question every discovery call asks Google. Some values are not Google types. Google answers 400 INVALID_ARGUMENT, callers turn that into [], and the niche reads "nothing near you" forever while billing for each attempt.
Rejections now land in places_bad_types; dz nichetypes reports them with the niches affected, and fix=1 live=1 applies renames. It applies ONLY renames Google itself rejected - inventing a plausible type would just create a new silent zero. roofer -> roofing_contractor took that niche from 0 results to 18.
An errored lookup and an empty neighbourhood both arrive as []. Both were cached. One bad request could pin a niche to "nothing here" for four months after the cause was fixed. prDiscoveryLastCallFailed() separates them; only a real ZERO_RESULTS is remembered. Never cache a failure as an answer.
The route forced sort=closest whenever an origin was known. Two labels, one order. Best match is now a genuine blend - quality decayed by distance, EXP(-d/8) - which is what a person means by "best" in a local directory. Related: the sort whitelist in the route silently dropped any value not in [closest, rating, reviews], so adding a sort to the UI alone does nothing.
include_in_sitemap gates BOTH the sitemap and the directory listing query."Do not submit this to Google" and "do not show this to a visitor browsing the directory" are different questions sharing one flag. 662 enriched par-create skeletons are invisible because of it.
association_or_organization - the lodges, which drive 68% of human traffic and have no directory category at all. Venue types are unmapped too.
Every defect here returned a smaller, plausible answer. A short list of local businesses looks exactly like a short list of local businesses, so none of them could be found by using the site - only by counting. Any query that can return fewer results than it should needs a way to say so out loud: record the rejection, flag the fallback, distinguish the error from the empty.
Site voice is signal-in, signal-out. Every per-location signal (reviews now; events, gatherings, check-ins, comments, photos coming) enriches the location site, the database, AND every downstream consumer of the Doozer Data pipeline.
The durable principle behind the content generation pipeline: a site's voice is the sum of its input signal. There is no house "voice" to author and no per-vertical "voice" to force. The generator distills whatever real signal a place carries, so the quality of a site is the quality of its data. Signal in, signal out. (Operators sometimes say garbage in, garbage out; same law.)
Every *.elks.club lodge runs the identical pipeline and the identical bespoke elks theme. Yet their heroes diverge completely, because their reviews and place data diverge (surveyed July 2026):
about the bar; Golden is literally Coors country).
An Elks lodge is its own animal: some are inner-city halls, some are golf courses or country clubs. A lodge whose members and reviewers talk about veterans and scholarships gets a service-and-heritage site; a lodge that reads like a good neighborhood bar gets a bar site. That is correct behavior, not a defect. The lever for a thin site is never a forced tone. It is more and better input signal.
Today the dominant signal is scraped Google reviews plus place data. The roadmap adds first-party, location-owned signal, each of which sharpens the true voice of a place:
Live Music / marquee work and the events waterfall).
photos** — the warmest, truest signal a place can give off.
As those land, the generator (and any re-enrichment pass) has real, current, member- supplied material instead of only third-party reviews. A community lodge stops sounding like a bar because the community finally speaks for it.
Load-bearing: all data provided per location enriches more than that location's own site. A single enriched location record flows to:
Magellan ID mapping spine), and
(e.g. livemusictonight.com), clouds, the shared business card, and the outward Atlas emission.
So enriching one place is never local. It compounds across the network. This is the enrichment half of the flywheel: better signal -> better site -> more engagement (check-ins, gatherings, photos) -> more signal.
Before "fixing" a site's copy, ask what signal it lacks, not what tone to impose. The right move is usually to feed the location record (events, a gathering, real photos, better reviews), then regenerate, rather than to hand-author voice the data cannot support.
A derived projection of a signed-in user's primary/secondary location centroids (home/work) from their own signals, used to personalize near-me defaults, reminders, and the Digest.
includes/user-hotspots.php derives a signed-in user's primary and secondary location centroids (usually home and work) plus a typical radius, from the user's OWN signals. It is a derived projection cached in user_hotspots (migration sprint_user_hotspots.sql), recomputed lazily — never a track log.
userHotspotPoints): check-ins (weight 3, the strongest "you were here"),where the user searched on Live Music Tonight (lmt_interest area coords), and the coords of businesses they saved (lb_follows).
userComputeHotspots): grid-bucket points at ~10 miles, rank by density,take the top one or two clusters (the second only if >=15mi from the first) as centroids; the typical radius is the 75th-percentile spread, snapped to 5/10/25/50/100.
userGetHotspots): returns the cached row, recomputing if older than 7 days ormissing. userForgetHotspots clears it; userHotspotLabel names the nearest city.
no Cloudflare geo header), LMT centers on the home hotspot and defaults the radius to the user's typical distance. Precise geolocation still wins.
Digest "near your home / near your work" blocks.
Signed-in only; derived from the user's own usage; coarse (centroids + radius, not a path); visible and clearable on my.doozer.cloud. A convenience that remembers where you are, not surveillance. See no dark patterns.
Who deserves a paid claim postcard — exclude committee-orgs and franchises, rank owner-operated marketing-spenders.
Postcards cost money, so we spend them on owner-operated businesses that invest in their presence (advertising / marketing / a website) and will act on a card by claiming. includes/postcard-targeting.php; tool dz pcqueue state= city= limit= site=.
Data (2026-07): of ~2,657 live businesses, ~86% have a website, but ~65% are committee-led orgs (association_or_organization = 1,343, plus lodge/VFW/church/Rotary/township name patterns). A blind run would mostly hit Elks lodges. postcardCandidates() excludes:
postcardExcludedNiches) —associations, nonprofits, churches, government, schools, plus corporate venues (arenas, theaters, bowling, malls) that came in via the Live-Music/Marquee side.
postcardExcludeNameRegex) — Lodge / VFW / Legion / Elks / Church /Association / Township / "Post 123" …
postcardFranchiseRegex) — corporate owns the web, they won't claim.admin_email set).Excluded orgs stay fully claimable (on-site nudge + email); they just don't burn a postcard.
postcardNicheScore weights marketing-spend propensity: high (3) = home services / food / personal care / dental-medical / auto / legal-financial / fitness; mid (2) = general retail / services / lodging; else 1. Score = niche×10 + has-website. Filter by geography (state, cities[]).
First ~100 postcards → owner-operated businesses in the West Milford / North-NJ home corridor (verifiable in person, local word-of-mouth, highest conversion). NOT a single-niche run (per-niche pool too thin). Arizona strip complexes = batch 2 (cold market + strip density; needs a Prospector discovery sweep). Archetype = Powell Flooring. See docs/GOING_FORWARD.md §1 for the exact dz pcqueue command.
Before mailing: enrich each target site from its real website via the source-of-truth cache so a scanned card lands on an accurate, better page.
See also: Hot-Leads CRM (doozer-prospects), claim codes, postcards, the flywheel.
The living-story system. Short or long dispatches that tell Doozer's WHO/WHAT/WHEN/WHERE/WHY/HOW/VISION, stamped to the moment they were written.
Postcards are how Doozer tells its own story, on purpose and over time. A Postcard is one dispatch about why Doozer exists, what it does, how, and for whom. It can be a tweet, a short note, an essay, or a quote. Admin lives at Admin → Story → Postcards (includes/admin-postcards.php); the engine is includes/postcards.php.
Seven lanes, the journalist's six questions plus a vision lane: WHO / WHAT / WHEN / WHERE / WHY / HOW / VISION (postcardDimensions()). Four formats: tweet, short, essay, quote (postcardFormats()). Audiences: everyone, entrepreneurs, investors, operators, press, local customers, for-the-record (postcardAudiences()).
Every Postcard is written "from where the story is today" and stamped with the platform version and date (as_of_version, as_of_date). As the platform changes, you write new Postcards; the old ones stay. The collection becomes the story's own changelog, readable from any moment. Evergreen cards can carry live tokens ({site_count}, {cloud_count}, {version}, {year}) so they always speak in today's numbers (postcardFillTokens()).
postcardAiDraft($dimension,$format,$audience,$voice,$brief) drafts from live platform signals (postcardSignals()) plus Doozer's ground-truth story facts, in a chosen voice. Hard rules baked into the prompt: no em-dashes, no invented facts, speak from today, plain language, no AI-slop. Returns title + dek + body for the operator to review before saving. Distinct from Marketing build-in-public posts: Postcards are the deep, evergreen story; marketing posts are transient.
postcards table self-heals on first use (FTP deploys do not run migrations). On first run, postcardSeedIfEmpty() loads 31 authored starter cards across all seven lanes (postcardSeedSet()) so the collection has a real voice from day one.
Postcards are source material: the doozer.work "Because" copy, an About page, a press kit, the investor narrative, marketing seeds. Write once, retell often. They are the human-readable companion to Atlas (machine-readable) and the distilled Doozer Story.
Reusable Stripe-backed discount codes (the promo generator) and the truly-one-time Doozer+ 50%-off-for-life offer shown on claim. Money-safety, single-fire, and how they map to Stripe coupons.
Two things, one file (includes/promo-codes.php), one Stripe primitive (coupons). Shipped v2.2.411-413. All Stripe calls go through billingStripeRequest(); creating a coupon is config, not a charge — money only moves when someone completes checkout.
Admin: Revenue → Promo Codes (admin-promo-codes.php). CLI: dz promo do=create|list|void|lifetime. promoCodeCreate($opts) creates a real Stripe coupon + promotion_code (typeable at checkout) + a local promo_codes row. Four shapes, extensible:
| kind × duration | Stripe | meaning |
|---|---|---|
| percent · forever | percent_off + duration=forever |
X% off every renewal, FOR LIFE |
| percent · once | percent_off + duration=once |
X% off first period, regular after |
| amount · once | amount_off (cents) + duration=once |
$X off first period, regular after |
| amount · forever | amount_off + duration=forever |
$X off every renewal, FOR LIFE |
(repeating + N periods also supported.) One-time-use by default (max_redemptions=1). promoCodeVoid() deactivates the Stripe promo code + local row. Codes apply to any subscription SKU (default premium_yearly). The manual "someone begs" recovery code is just kind=percent value=50 duration=forever → e.g. PLUS50LIFE.
When someone CLAIMS their site is the perfect moment. On ?ajax=claim_code_redeem success (index.php), if the business has never been shown the offer, attach it; the redeem page (redeem-code.php → showClaimOffer) shows Doozer+ 50% off for LIFE, $49.50/yr forever with exactly two buttons:
?ajax=claim_offer_accept → billingCreateCheckoutSession('premium_yearly', …, $coupon)with the lifetime coupon promoLifetimeCoupon() (percent_off:50, duration:forever, deterministic id doozer-plus-50-forever-<mode>) auto-applied. Marks the offer accepted.
?ajax=claim_offer_decline → marks declined. Gone for good.Truly one-time: business_offers (UNIQUE (business_id, offer_key)); promoBusinessOfferShow() uses INSERT IGNORE and returns true only the FIRST time — so a 2nd scan, a resend, or a revisit never re-shows it. No code, no take-backs. Doozer+ ONLY (never Keeper); all future upgrades at regular price. The admin recovery code is the deliberate escape hatch for someone whose card failed.
billingCreateCheckoutSession($sku,$user,$s,$c,$extraMeta,$coupon='') — a non-empty $coupon auto-applies discounts[0][coupon] and drops allow_promotion_codes (Stripe rejects both).
Related: billing, claim codes, implicit verification, [[feedback_right_offer_right_audience]].
How Doozer finds new businesses to build — the Grand Opening Finder, Cloud Radar, and the hybrid Google Places discovery engine.
Prospector is how the fleet grows on purpose instead of at random: it discovers real businesses worth building and feeds them into site creation and Clouds.
Single-shot discovery → filter → review → one-click create. A sweep resolves a center (ZIP or city/state via prDiscoveryResolveCenter() in includes/prospector-discovery-engine.php), queries Google Places, and keeps only fresh openings (OPERATIONAL + very few reviews + not already known). Results land in pr_discoveries / pr_discovery_sweeps with per-call cost recorded. Niche-optional: pick "Any niche" for a regional pass or one of 39+ types.
OLD Nearby Search for legacy types + Places API (New) searchNearby for newer types and the any-niche path, normalized to one response shape. Per-call cost is tracked (see API cost discipline is not it — cost discipline is an operator rule: check whether each pull costs, reuse cache, pull only the unavoidable remainder). Cross-root ecosystem awareness reuses an existing businesses row when a place is already known (skips paid detail + research, saving ~$0.50/business), with a same-root duplicate guard.
pr_discovery_schedules + a traffic-driven runner (rate-limited via platform_settings) run sweeps automatically. Cloud/Idea Radar extends this to demand: signals like Google Search Console impressions reveal what people are already looking for, so Doozer can build the Cloud or vertical the market is asking for. Discovery → build → auto-draft marketing post closes the loop: a new site can announce itself.
Prospector is the intake valve of the flywheel.
The unified regeneration engine, version stamping, and staleness detection that let the whole live fleet be improved without taking anything down.
Because a Doozer site is generated from data, every site can be regenerated by today's pipeline. With 2,400+ live sites, the challenge is doing that safely, at scale, without ever going dark. The Foundation Sprint built the machinery.
Every generated artifact is version-stamped, so the system can detect which sites were built by an older pipeline and are now stale. This is what makes a fleet-wide upgrade knowable instead of guesswork: "which sites still need the new section framework / family fix?"
includes/regeneration.php — regenerationRun($bizId, $mode). Modes: recolor, redesign, reroll, content, full, fix_readability. Each is cost-aware (content-only regen is ~$0.02 and keeps the design) and quality-gated with rollback — a regeneration that fails the quality check is reverted, so a bad AI run can never degrade a live site. This retires the old "magical regen" in favor of à la carte control (checkboxes: photos / vision / recolor / new-muse / content / full).
Key fact: content regen never touches the muse. The muse is resolved via a shared cascade (museResolve), not stored per-business, so regenerating content leaves the design identity intact.
The Foundation endgame is one System Health / Upgrade cockpit: see fleet version distribution, detect stale, and run the fleet upgrade runner that calls the unified engine at scale (e.g. converting the elks.club sites in one pass). Supported by the Schema Audit tool (master-schema-vs-live drift) so a feature never silently no-ops in prod because a column drifted.
This is the maintenance backbone that lets the flywheel keep turning: the fleet gets better as the pipeline does, on purpose, without downtime.
Dynamic short URLs at doozer.link. The code encodes doozer.link/<slug>; the destination is editable, so a printed QR can be re-pointed without reprinting. The routing layer for the observed-world lead flywheel.
doozer.link is a dedicated shortlink host. A QR or shared link encodes doozer.link/<slug>; the slug maps to an editable destination, so the same printed code can be re-pointed later without reprinting. This is the routing layer for the observed-world lead flywheel: claim postcards, direct mail, and campaign links all route through here so they stay dynamic.
index.php recognizes host doozer.link (or *.doozer.link) at the top of the routing block and calls shortlinkHandleRequest() in includes/shortlinks.php. That parses the first path segment as the slug, resolves it (single indexed lookup), increments click_count / last_clicked_at best-effort, and 302s to the destination (302 + no-store so destination changes take effect and nothing caches). Unknown or inactive slugs bounce to doozer.work (no dead ends); it serves its own robots.txt (disallow all).
The shortlinks table self-creates on first use (shortlinkEnsureTable()), so doozer.link works the moment DNS points here, before the migration (migrations/sprint_shortlinks.sql) is run. Columns: slug (unique), destination_url, label, business_id, campaign, is_active, click_count, last_clicked_at.
Admin → Infrastructure → Shortlinks (includes/admin-shortlinks.php): create (auto or custom code), edit the destination inline, activate/deactivate (the off switch that does NOT break a printed QR the way delete does), per-link click counts, one-click copy, and a client-side QR generator with PNG download.
Programmatic: shortlinkCreate($destination, ['slug','label','business_id','campaign']) returns ['ok','slug','url']; other features (claim postcards, direct mail) mint links this way. Dev preview: ?e=devapi&action=shortlink&create=<url> / &resolve=<slug>.
doozer.link/thanksio → https://thanks.io/?afmc=doozer (Jon's thanks.ioaffiliate link). Printed as fine print on every Postcard Factory card ("Printed and mailed with thanks.io · doozer.link/thanksio", postcard-factory.php), so every "where did this come from?" is a possible affiliate signup — claim or no claim.
DNS for doozer.link points at this LAMP server (like the other alias domains); Doozer handles it entirely in code by HTTP_HOST. There is no admin or business site on doozer.link — every path is a slug lookup. Mint a custom slug with shortlinkCreate($url, ['slug' => '...']) or dz shortlink create= slug=.
One permanent, claim-aware doozer.link + QR per site — the printed asset that flips from the claim page to the real site on claim.
Every Doozer site gets one permanent shortlink and QR — a real, printable asset. It lives at doozer.link/<slug> and is claim-aware, so the same printed code changes where it points the moment the business is claimed. Engine: includes/shortlinks.php (host doozer.link → shortlinkHandleRequest from index.php). Table: shortlinks (self-heals).
kind='site' marks the canonical per-business link (campaign = static per-campaign,the old default). is_primary marks THE current one (the Doozer+ vanity, once set).
shortlinkForSite($bizId) get-or-creates the primary site link (idempotent).shortlinkSiteDestination): if thebusiness is claimed (admin_email set) and has an active domain → the real site; else → the redeem page (?page=redeem&b=<id> — business identified, the code is NEVER in the URL, see implicit verification). So the flip needs no destination edit and no chasing every claim path.
make_shortlink), so amailed QR switches to the site on claim. Newly-claimed businesses get the link created at redeem time.
?e=qr (in api-endpoints.php) — a same-origin QR image, proxied + cached from api.qrserver.com to cache/qr/. Params: slug|data, size, fg, bg, format (png|jpg). Same-origin (with Access-Control-Allow-Origin:*) is required so the owner panel can export a transparent PNG on a canvas (a cross-origin image would taint it) and any surface can copy the QR image to the clipboard (siteQrCopyScript(), [data-qr-copy], a fetch → ClipboardItem → toast).
?page=manage (manage-business.php, owner-gated) → the "Link & QR" panel: thedoozer.link big with one-click copy, live QR preview, color picker, high-res downloads (PNG transparent / PNG white / JPG white), scan-prompt copy, and a switch to show the QR on the site.
includes/site-qr.php draws a muse-hardened footer QR card("Scan to keep us handy"). Setting = businesses.custom_settings.qr_display (off|footer|header|both); on by default for claimed (siteQrShouldShowFooter). Header/hero placement is a future muse/design pass.
shortlinkSetVanity($bizId, $slug) sets a friendly slug (e.g. doozer.link/powell-flooring) as the new primary; old slugs stay active and keep resolving (old printed QR still works — "use the new one going forward"). Doozer+ gated (businessIsPremiumOwned), owner-gated (?ajax=manage_vanity). Postcards auto-print the primary, so they pick up the vanity with no extra wiring.
See also: claim codes, the flywheel, postcards.
A business's official website cached as markdown (Jina Reader) — the accuracy foundation for enrichment; enrich content, never overwrite location.
86% of our businesses have an official website — the highest-signal reference material for getting hours / services / menus / products RIGHT. Reading it closes the generate-from-Places accuracy gap that produces wrong menus and products. This is GIGO: better signal in, better site out.
includes/source-cache.php. Fetches the business website as clean markdown via Jina Reader (https://r.jina.ai/<url> — free, no key, renders JS; a hosted GET, nothing to install, which fits the FTP/no-build model). An optional jina api_keys entry (Bearer) raises limits but is not required. Cached in business_sources (business_id PK, url, markdown MEDIUMTEXT ≤80KB, title, bytes, status, fetched_at) — fetch once, refresh on demand (cost discipline). Tool: dz source biz=<id> [refresh=1].
Note: Firecrawl never worked for us — Jina Reader replaces that need.
menu + prices, products, team, service area, booking URL). Accuracy-first: null when the source is silent, NEVER invent.
wins on conflicts; flag low-confidence for review.
SEO cliff was scaled-content demotion).
Pairs with postcard targeting: enrich the target sites from their real websites BEFORE mailing, so a scanned card lands on an accurate, better page.
Enrich content (services / products / menu / hours / about / brand). NEVER overwrite the site's LOCATION/address — each Doozer site is already geo-verified per its Places lat/lng/city. Multi-location businesses list several addresses: the homepage header can be an admin/mailing address while the storefront we want sits behind a "View all locations" dropdown. Match the source location to OUR site's city; never take the header address. The validation filter flags conflicts for review — it does NOT auto-clobber. Fix accuracy without introducing errors (the taste filter).
Example: Powell Flooring's homepage shows Oakland NJ (admin); the wanted address is 1811 Union Valley Rd, West Milford (behind the dropdown). An anomaly — most businesses are a clean single-address hit.
See also: content generation, fact guard.
How Doozer sends physical mail via Thanks.io and feeds the results back into intelligence — claim postcards, radius audience buying that reverse-engineers new sites, and delivery/scan webhooks.
Thanks.io is a direct-mail API (postcards, letters, notecards) with radius audience buying and QR/delivery webhooks. Doozer uses it both ways so every send and every response compounds into the intelligence layer. Grounded in the real API: base https://api.thanks.io/api/v2, auth Authorization: Bearer <key>. Engine: includes/thanksio.php. Config-free, self-creating tables, portable.
thanksIoMailClaim($businessId)mints a claim code + doozer.link QR (claim-codes) and sends a postcard to the business address carrying that QR and a warm message. Receiving it proves the address; scanning/entering claims the listing. Doozer+ "pay first, snail mail, claim" verification uses the same path (plus_verify).
thanksIoBuyRadiusSearch(address, postal, count, {record_types, append_data}) buys 1–10,000 real addresses around a point ($0.05/record, +$0.20 with phone/email append), filtered by record_types: all, onlybusinesses, newbusiness, newhomeowner, renters, absenteeowner, likelytomove, highnetworth, retired, and more. thanksIoIngestRadiusTargets(listId) pulls the records into thanksio_targets; thanksIoBuildFromTargets() resolves each business record to a Google Place (placesSearchBest) and materializes a Doozer site (resolvePlaceToEcosystem). The mailing list becomes site inventory — which then earns its own claim postcard. Full circle.
?e=thanksio_webhook (gated by a sharedsecret in the URL — Thanks.io sends no signature) ingests order.status_update / order_item.delivered / scans.scan_update into thanksio_events, mirrors status onto the matching thanksio_sends row, and pings Comms: delivered = the address is reachable (the proof-of-address precondition), scan = the owner engaged before claiming (a demand signal for the Demand Ledger).
thanksio_sends — every send (preview or live): business, claim_code, order id,recipients, authorization_total, status, is_preview, qr url, address snapshot.
thanksio_events — webhook events (unique on event_id), resolved to a business.thanksio_audiences — radius searches bought: center, record_types, count, cost.thanksio_targets — records pulled from a radius audience, staged for building(resolve_status new/resolving/built/existing/skipped/failed + business_id/place_id).
Spend is real and irreversible, so: every send DEFAULTS TO PREVIEW (Thanks.io returns an estimate, no charge) unless live=1; radius buys return a cost estimate unless confirm=1; build-from-targets is bounded per call. Operator- triggered only. Cost logged on every row ([[feedback_api_costs]]).
Cost-units bug FIXED (v2.2.409): Thanks.io returns authorization_total / leads_fee ALREADY in cents (114 = $1.14 for a 4x6, 1 recipient); the send path (thanksIoSendPostcard) was doing round(value * 100) → stored 100× too high (11400 for a $1.14 card). Dropped the *100; dz postcard do=fixcosts re-derives historical thanksio_sends rows from raw meta.response.authorization_total. Previews (proof images) live at response.data.previews = [front, handwriting back] S3 URLs; persisted to postcard_designs.proof_urls for the admin lightbox.
Delivery/scan/claim per send are read LIVE by postcardDesignLiveStats() ([[ai-postcards]]) from thanksio_sends + thanksio_events + shortlinks + claim_codes (never cached JSON). The webhook now also recomputes the linked postcard_designs.tracking on each delivered/scan event, so the admin "Sent & performance" view stays fresh without a manual Refresh.
mailclaim | sends | events`.
thanksio,or platform_settings thanksio.api_key); (2) sets the return address (thanksio.return_name/address/city/state/postal_code); (3) sets a claim postcard front (thanksio.claim_image_template_id designed in Thanks.io, or thanksio.claim_front_image_url); (4) runs dz thanksio do=webhook to register the receiver. Then preview a mailclaim, and add live=1 when happy.
The front is the Factory image; the BACK is where direct-mail response is won. thanksIoClaimMessageVariants($bizName,$code,$opts) returns warm / leads / curious variants, personalized with the town, no em-dashes. The message never spells out the claim code or the doozer.link URL (v2.2.405): cursive handwriting makes codes/URLs hard to read, and a code is already a friction point, so the back POINTS to them instead: "Scan the QR code, or use the claim code printed on the front of this card." Code + URL live only where they're legible: the front. Greeting frames the business as the OBJECT, not a salutation: "Hi, we built Powell Flooring a real website..." (warm "you / yours"), not "Hi Powell Flooring, we built you...". thanksIoDefaultClaimMessage picks one (opts.variant). Operator levers (all platform_settings, flowed through thanksIoMailClaim): thanksio.handwriting_style_id (a handwritten back lifts response), thanksio.size (4x6 default; 6x9/6x11 stand out but need a matching front aspect), thanksio.message_variant. Multi-campaign: postcardCampaignMessage resolves the back copy per campaign (claim → these variants; other campaigns → their message_template with tokens).
Related: claim codes, shortlinks + QR, AI Postcards (campaigns + Factory + Theme Studio), the observed world (mail closes the CLAIM loop; radius feeds OBSERVE), Prospector / place-resolver.
One button, the nearest real places, and Build-it-on-the-spot. Plus the measured teardown of the category page that was hiding its own businesses behind 855px of controls.
The operator's idea: open an app wherever you are, press one button, see the nearest few businesses, and either visit a live Doozer site or build one standing right there. The person using it IS a Doozer — their presence is the contribution.
The finding that shaped the work: almost all of it already existed. checkin-button.php, follow-button.php, user-hotspots.php, business-card.php, a 1,391-line Foursquare-style nbmn-map-home.php, and the endpoints ?e=businesses_near (geo-sorted JSON), ?e=nbmn_populate, ?e=nbmn_build_place. /now is assembly.
/now (includes/nbmn-now.php)Cookie-first location (doozer_user_location, one prompt per network, never IP-geo), then businesses_near + nbmn_populate merged into one distance-sorted list. Built places say Open; unbuilt say Build it. 86px rows. Route suppresses the site header AND the 600px directory footer — an app shell is not a directory page.
Installable: nearbymenow/manifest.webmanifest, nearbymenow/sw.js, assets/pwa/*.
| before | after | |
|---|---|---|
| first business | 1,303px (1.32 screens) | 419px |
| card height | 388px | 78px |
| on the first screen | 2 | 5 |
| page height (24 results) | 11,995px | 4,611px |
| horizontal overflow | 312 elements | none |
Fixes: a compact surface on the shared card (same markup, denser CSS — never a second renderer, which would drift from the image fallback / toolbar / call modal); the banner and type-chips folded behind one "Filter & sort" toggle; the header search form capped (it was 456px inside a 375px viewport and caused every overflowing element).
manifest.webmanifest and sw.js as HTML. A browserREFUSES to register a service worker with the wrong MIME type. Serve PWA files before the router, with explicit Content-Type.
deploy. Use network-first with cache as the offline fallback for anything still changing.
prefers-color-scheme on a host page with a hardcoded lightbody** gives dark rows on white. The shell must OWN html, body, tokens on :root.
header at TAG level, so any <header> inside a componentinherits it. Same family as the business-site !important chrome rule.
toggle grabbed .nbmn-chrome 130 lines before the second block existed, and the page half-collapsed. Query at call time; apply after DOMContentLoaded.
The discipline that caught 1, 3, 4 and 5: screenshots. Every one of them returned healthy values from getBoundingClientRect and friends. Only looking at the rendered pixels showed a white strip, unreadable text, and a half-open filter panel.
Related: [[nearbymenow]], [[shared-business-card]], [[events-to-machines]], [[the-observed-world]].
How a single index.php serves every business site, root directory, Cloud, and NearByMeNow page in the network by detecting the host and routing the request.
Doozer is a single-file multi-tenant application. There is one entry point, index.php, and the whole network runs through it. What renders is decided by the request's host, not by separate codebases or a CMS per site.
index.php reads $_SERVER['HTTP_HOST'] and classifies the request:doozer.work) — the platform landing page + admin console. Never abusiness page.
?e=... / ?ajax=...) — routed to includes/api-endpoints.php (the?e= read+write API) or the inline ?ajax= action switch.
elks.club, vsso.net, westmilford.biz,nearbymenow.com) — the root/Cloud directory listing many businesses.
oakridgeflorist.westmilford.biz) — a singlegenerated business site, rendered by includes/business-site.php.
livemusictonight.com) —see Clouds and Live Music Tonight.
domains row for the host resolves the businesses row (the real data).business-site.php composes the page: muse CSS/type, the AIai_generated_content, photos, sections, contact + lead form, the network footer.
Deployment is a file copy over FTP (no build step, no per-site config). A new wildcard domain becomes a live business generator the moment DNS points at the server. "Monolith with internal structure": the logic lives in includes/*.php helpers, not scattered apps. The tradeoff is discipline. A regression in index.php or business-site.php touches the whole fleet, so we verify by looking at live pixels and deploy with backup + rollback.
A Doozer site is generated from data + a Muse, not hand-built. The inputs (Google Places data, scraped enrichment, reviews) persist in the DB; the output is recomputed by today's pipeline. "Converting" or "upgrading" a site = re-running the current generation pipeline over the same inputs. The site never goes dark, and it gets better as the pipeline improves. This is the premise behind the flywheel and the whole data-enrichment direction.
Find the people building your town. A Chamber of Commerce already gathers a town's businesses around a shared purpose. The Chamber Chamber (thechamberchamber.com) is a directory of those Chambers with every member connected to its own Doozer page.
Nothing new is collected. Directory Harvest (directory_sources, directory_members, dz harvest) already reads a Chamber's public roster once, matches members to businesses we hold or builds claim-ready sites, and records the read with its source URL and date. The Chamber Chamber renders that record and spends nothing on a page view.
endorsement, a benefit or a partnership. The page says so.
never "expelled".
agencies@doozer.work; a Chamber joins with one email.
| path | what |
|---|---|
/ |
every harvested source, members-listed and on-Doozer counts, client-side filter |
/<chamber-slug>/ |
source + read date, four facts, town board link, members by category |
/robots.txt, /sitemap.xml |
own branches in index.php, next to the town host |
Slug = ccSlug(name). The town board link is townSlugify(city, state) from the source's region ("West Milford, NJ").
business_id set, active domain): the shared renderBusinessCard compactsurface, civic navy root_color. Never a second card renderer.
place_id is known a"Build their page" button that posts to the existing ?e=nbmn_build_place through the shared build modal. A visitor building a member's page is the honorary-Doozer loop from Doozer Town, applied to a Chamber.
includes/doozer-world.php: the why in two screens. Three-line thesis (Doozer knows the world; Logfile knows your trail through it; Wuz lets you go back), why it begins with websites, thirteen doors each phrased as one human question with an honest status (live / new / soon / concept; only live doors link), and three paths: I run a business, I run a Chamber, I live somewhere. No forms, counters or testimonials.
observation row (observed_at, valid_from, valid_to, source). Today theread date lives on directory_sources.last_harvested_at, which is enough for one page but not for history.
roster" real.
The canonical design identity for the whole Doozer ecosystem. Every human-authored surface (doozer.work, claim/auth/admin, roots, LiveMusicTonight, GoHe.re, Wuz.ai, AwesomeAddresses) shares one skeleton (neutrals, type, chrome, voice) and varies only its accent hue, so the network feels like one building with differently-lit rooms. Use this to build production-ready UI fast and on-brand.
Doozer builds. The personality is an industrious, honest builder: useful, grounded, warm, confident, a little utilitarian. Never slick-for-its-own-sake, never hype, never a dark pattern. The sign-off is the tagline "We Build Because." The feeling: someone quietly built you something good, and it works. Optimistic green, warm paper, a spark of gold.
doozerMarkSvg() in includes/doozer-brand.php. It replaced a 3D construction helmet on a gear.
The helmet named the inspiration literally, and the inspiration was never the point. In Fraggle Rock the Doozers are not memorable for what they wore — they are memorable for what they built: crystalline lattice, assembled tirelessly and joyfully, eaten, and built again. The legacy worth keeping is the structure, not the worker.
The mark is a D built from a grid, most cells set, one gold, one still open. It reads three ways at once:
| reading | what it is |
|---|---|
| physical | a street grid, a block, a town from above |
| digital | a pixel grid, a data structure, a disk defragmenting |
| the product | an open cell is a place nobody has opened yet |
The third reading is the software. It is the same idea as the blueprint plate the directory draws for an unopened listing, so the brand and the product finally say one thing. 1994's pixel grid and what we build with now are the same lattice at different resolutions.
The incompleteness is the point. The cells are ordered as if built up from a foundation: base and body set, the gold cell going in at the top edge, one outline beside it waiting. The mark is unfinished because the network is.
Rules: drawn from currentColor plus one accent, so it inherits ink on any surface and needs no light/dark variants. Below 20px every cell fills solid — the story is for sizes where it can be read. 1.3KB of vector in place of a 578KB PNG. Static files: assets/images/doozer-mark.svg (on green, for favicons) and doozer-mark-mono.svg.
Two earlier cuts were built, screenshotted and rejected: a 5x5 whose two special cells read as stray teeth, and a 7x7 three-step fade that destroyed the silhouette below 24px. Neither survives in the code.
Every property shares the same skeleton and changes only its accent hue:
muted / line), the type system, the logo lockup + "We Build Because." sign-off, the network footer, the sponsor bar, the voice, and the component shapes (radius, shadow, buttons, inputs, focus).
Moving from doozer.work to LiveMusicTonight to GoHe.re should feel like walking between rooms of one building: same architecture, same materials, a different color of light. This is the coordination the operator wants: distinct, not uniform; unmistakably one network.
--paper:#faf8f2 warm page ground --line:#e4e0d3 hairline borders
--cream:#f2efe4 secondary ground --card:#ffffff raised surfaces
--ink:#111b14 near-black green ink --ink-soft:#33463a body-dim
--muted:#5a6b60 captions / meta
Dark grounds (hero bands, dark sections): #0a0f0b #0d1a12 #111b14 #1a2e20.
--green:#12833c primary brand green
--green-dark:#0c5c2a buttons + small text on light (AA)
--green-bright:#29b356 hover / lively accent
Retuned Aug 2026, deeper and more luminous than the old #1f9d3f. The colours were never the problem; the PAIRING was. Safety-green beside hi-vis amber in equal measure is a work-site palette, and it was carrying more of the construction signal than the helmet did.
--gold:#ffc107 the piece being set — and nothing else
--gold-deep:#c98a00 gold as text on light
Gold is no longer a co-equal brand colour. It appears once, small, as the active cell in the mark and as a genuine highlight. Green carries the identity.
--ink on --paper.--green-dark bg + white text (AA). Do NOT put white text on--green (#1f9d3f is AA-large only) or on --gold.
:focus-visible (gold outline works on every ground).
:root{
--paper:#faf8f2; --cream:#f2efe4; --ink:#111b14; --ink-soft:#33463a;
--muted:#5a6b60; --line:#e4e0d3; --card:#fff;
--green:#12833c; --green-dark:#0c5c2a; --green-bright:#29b356;
--gold:#ffc107; --gold-deep:#c98a00;
--accent:var(--green); --accent-dark:var(--green-dark); /* override per property */
}
A page only needs to redefine --accent / --accent-dark to reskin for its property. (Centralizing these as a brandCssVars() PHP emitter is the natural next step so no surface hand-copies them.)
| Property | Accent | Ground | Feeling |
|---|---|---|---|
| Core (doozer.work, claim, auth, admin) | builder green #12833c / #0c5c2a |
paper | the workshop |
| NearByMeNow | star gold #f5a623 (+ the niche-color rainbow for content) |
paper | the town square |
| LiveMusicTonight | electric violet #8b5cf6 + magenta #ec4899 highlight |
dark-first (#0d1a12) |
after dark |
| GoHe.re (gatherings) | warm tangerine #f97316 |
paper | come together |
| Wuz.ai (presence) | electric cyan #06b6d4 |
paper / light | you are here, now |
| AwesomeAddresses (homes) | slate #334155 + gold |
paper | home, premium |
| Doozer.Cloud / personal | adaptive (Vibe System: Mood x Energy) | user-chosen | your world |
The accents are hue-distinct but all sit on the SAME neutrals and type, so they read as siblings. Directories (NBMN) also use the network-wide category-color system for content chips; the property accent governs its chrome.
System sans everywhere: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif. This is a deliberate choice, not a default: it is fast, free (no webfont cost or FOUT), universal, and its plainspoken honesty is on-brand for a builder. The character comes from weight, scale, tracking, and the green — not a novelty face.
size clamp(2rem, 7vw, 3rem), line-height ~1.05.
--ink-soft for long prose.letter-spacing:.16em, uppercase, weight 800, --gold-deep.ui-monospace, SFMono-Regular, Menlo, monospace): domains, codes,data, addresses.
Exception: Doozer+ bespoke sites and the 100 muse families may use expressive display faces — that is the paid tier's and the product's differentiation. The network chrome stays system-font.
--card on --paper, 1px solid --line, soft shadow0 18px 40px -24px rgba(17,27,20,.5). Primary cards get a 5px left border in --accent.
Subtle and orchestrated, never scattered. A single fade-up on load (translateY(10px)->0, ~0.6s, staggered 60-80ms) is the house move. Always wrap in @media (prefers-reduced-motion: reduce) and disable transforms.
--accent-dark bg, white, radius 11px, weight 800,:active nudges 1px, :focus-visible gold outline.
--paper bg, --ink text, 1.5px --line border.--paper bg, 1.5px --line, focus = accent border + soft accentring. Body-size text.
domain), a cached page screenshot (or a branded placeholder), and a tap-to-open row. See includes/claim-listing.php claimPreview().
(network-footer, sponsor-bar); every property and every muse site carries them so even a wild site feels networked.
Plain, active, warm, confident. Name things by what the user controls. Errors are honest and helpful, not apologetic or vague. No em-dashes in user-visible copy (periods / commas / ·); apostrophes fine. No slop, no hype, no invented taglines (the tagline is fixed: "We Build Because."). See the mission and includes/design-standards.php.
AA contrast (rules above), visible keyboard focus, prefers-reduced-motion respected, mobile-first, body >= 16px, real alt/labels.
per-root themes like elks) and the wrappers around every site (footer, sponsor bar): governed by this brand system.
deliberately diverse; that variety IS the product, governed by design-standards.php + muse-apply.php. They still wear the shared chrome.
Tokens currently declared inline on includes/landing-page.php and includes/claim-listing.php; category content color in includes/niche-colors.php; site-generation rules in includes/design-standards.php. Next step to move faster: a brandCssVars($property='core') emitter (a small includes/brand.php) so any new page drops in the tokens + its accent in one line instead of re-declaring them. This node is the source of truth until then.
How Doozer, NearByMeNow, LeadBuilder, and Prospector reinforce each other into a self-feeding local-business engine.
Four surfaces over one dataset, each feeding the next:
Muse System styling each).
discovery that routes visitors to the sites and back (the shared card renders the listings).
lead requests, and lb_events telemetry become qualified leads for owners.
build or grow next — the Idea/Cloud Radar.
The strategic fact underneath: Doozer builds, organizes, hosts, attracts, markets, and TRACKS the local physical business economy in real time. Owners verify by implicit verification (no claim system). The graph of who-is-where-in-what is exactly what the OKF network nodes expose.
One tracked number for whether Doozer is getting cheaper and more valuable at the same time. Stick = cost of 100 sites, Tower = replacement value of 100 sites, Score = Tower / Stick.
Fraggle Rock Doozers build towers out of construction sticks. So do we.
(ai_generation_history + places_api_calls) over a trailing 30 days, never estimated.
built them. Replacement cost at doozer_score_site_value (default $750/site, a conservative floor for a small-business one-pager).
dollar of spend produces. It rises when cost falls or value rises, which is exactly the pair of things worth watching together.
First reading (2026-08-22): Stick $24.66 · Tower $75,000 · Score 3,041x. Network at replacement value: $2.70M across 3,602 sites.
includes/doozer-score.php, dz doozerscore [days=30] [record=1]. A row lands in doozer_score_daily every time the pulse or digest runs, so the trend is measured rather than recomputed from memory. Rendered in:
doozerScoreLine()), deliberately right next to the spend lineso cost is never read without value beside it;
doozerScoreHtml()).It is never shown to subscribers. The digest email goes to real people who followed a local business; they get their neighbourhood news, not our unit economics. The score is attached only when $userId === null (the un-personalized network edition).
elsewhere. Nobody paid it. realized_usd reports actual revenue separately and the two are never blended. Today realized is $0, and the digest says so out loud.
Change doozer_score_site_value, change the number.
and operator time, and says so.
Doozer got a $1,300 surprise bill because cost was invisible. The reflex fix was a spend cap, which controls cost but tells you nothing about whether the spending is worth it. The Score is the other half: it makes "we got cheaper" and "we got more valuable" a single line the operator reads every morning.
Moving the writer to gpt-5.4-mini ([[writer-model-and-accuracy]]) took the AI share of a Stick from $3.40 to $0.75.
Related: [[the-flywheel]], [[writer-model-and-accuracy]], [[events-to-machines]].
How an entire root domain gets a hand-authored identity layer over the Muse engine — the elks.club star-spangled theme — as the pattern for premium root branding.
elks.club demonstrates the pattern for giving a whole root a bespoke identity in code, layered over the per-site Muse rather than replacing it. It is not a DB muse; it lives in includes/elks-root.php, gated by elksIsRoot(), and is applied via html body-scoped !important CSS so it wins over arbitrary muse rules without editing them.
A dignified B.P.O.E. (Benevolent and Protective Order of Elks) treatment for July 4th and beyond: deep navy + deep red with professional gradient fades, Playfair display type, a Virtues ribbon (Charity / Justice / Brotherly Love / Fidelity), and heritage bands. Signature elements:
sweep + hairline edges + glow), replacing a flat border. Repeats as a footer masthead rule.
under the white LED segment, elks-white-logo.webp right).
(never on the LED bar), plus a hero-top pinstripe.
wax-seal ring, embossed bronze "11") marking the Eleven O'Clock remembrance, at specific dark-band boundaries.
https://join.elks.org, sourced from official Elks material.
A root can carry a shared identity that is cohesion, not monoculture: every lodge site keeps its own content and photos, but shares the star-spangled chrome. This is the premium / Doozer+ direction proven on a real fleet. The build taught durable lessons folded into render gotchas and the site-quality backlog: native <dialog> centering needs position:fixed; scroll-reveal must force transform:none on forced-visible bands; PHP heredoc octal escapes (\\2605); gold-edge specificity must exceed muse button rules. Content quality still follows GIGO — the theme makes a lodge look dignified; real enrichment makes it sound like itself.
The map of Doozer's acquisition + monetization machine. How observing a business, painting it a real site, sending it leads, tracking every touch, and making the RIGHT offer at the RIGHT moment turns into revenue — and how the intelligence layer decides who to reach, how, and when. The purpose, the smarts, and the wiring, with links to every component.
This is the map. Doozer's whole reason to make money runs on one flywheel; the detailed mechanics live in the component nodes linked throughout. Read this first, then dive where you need to.
Realizes the [[the-observed-world]] pivot: every business exists once observed — we paint it a real, live site on demand, and it already has leads waiting. From there:
They know us → we send them value (real leads) → we make the right offer at the right moment
→ free becomes Doozer+ ($99/yr) becomes Cloud Keeper ($499/yr) becomes agency/franchise.
When a business is aware of Doozer AND receiving leads from us, $99/yr feels like a stepping stone. The engine's job is to (1) create that awareness + value, (2) recognize the moment, (3) make the honest offer, (4) measure what worked, (5) do more of it. No dark patterns ([[project_doozer_ethos]]); anonymous browsing + the free claim always work.
1. OBSERVE + PAINT. A business is discovered ([[prospector]]) or rendered on demand; the Factory paints a real site (screenshot + real content). [[the-observed-world]], [[network-growth]].
2. SERVE VALUE (leads). Directory + contact forms capture consumer leads; they route to the business's one inbox (businessLeadRecipientEmail, admin_email→scraped fallback) and pipe to LeadBuilder (nbmnLeadPipeToLeadBuilder). The lead-delivery email itself carries the right Doozer+/claim pitch (below). This is the "we give value" signal — the strongest driver of conversion readiness.
3. REACH (the touch). Physical mail is the workhorse when we have no email:
real claim code, real doozer.link short URL + scannable QR, no per-card AI cost. AI Theme Studio designs vision-gated backgrounds (two-score design+accuracy gate). Campaigns give each card a purpose + audience. Size is a variable (4x6 vs 6x9, same 2:3 art) for A/B.
live costs money (behind a confirm). Cost is per-send on thanksio_sends (returns cents).
or short link lands on ?page=redeem&b=<id> (non-secret); the code is TYPED off the card (brute-force safe). Redeeming = claiming ([[project_implicit_verification]], no claims table — ownership is an email match; admin_email authoritative once set).
4. CONVERT (the offer). The right offer to the right audience at every touch, from ONE resolver — never mis-targeted:
doozerPlusOfferFor($businessId) (premium.php) → stage claim / plus / premium /agency with canonical copy (doozerPlusBenefits, mirrors ?page=plus). Wired into the lead email, the owner manage page, and the owner viewing their own free site (a hardened, dismissible nudge). [[feedback_right_offer_right_audience]].
truly one-time deal — Doozer+ 50% off for LIFE ($49.50/yr forever) — accept (Stripe checkout with the lifetime coupon) or decline (gone for good). Single-fire per business (business_offers); never from a code, a 2nd scan, or a resend. Doozer+ only, never Keeper.
(%/$ × once/forever) — including the manual "someone begs" recovery code. Billing is product-agnostic Stripe (billing.php, one connection sells everything).
5. MEASURE + LEARN. Every touch is tracked and fed back:
converted, live from thanksio_sends/thanksio_events/claim_codes/shortlinks (postcardDesignLiveStats). Format-performance panel compares 4x6 vs 6x9 by niche/state/timeframe with thin-sample flags — the "which format/niche/market converts" science.
Hot Leads — the Internal Affairs Division of LeadBuilder ([[doozer-prospects]], doozer-prospects.php). Every business is one of OUR prospects; it becomes a lead once we've touched it (mailed, delivered a lead, or it claimed):
businessDoozerAwareness, 0-100): claim (+40), scanned (+18), delivered(+6), postcards sent (+3 ea), leads we delivered (+12 ea — the "we give value" driver), live site (+4). Status hot/warm/cold/customer.
doozerProspectAction): getting leads + email → email;getting leads + NO email → mail a postcard STAT (these float to the top — leads with no inbox = money on the table); mailed already + no email → call; claimed+free → the Doozer+ pitch. SMS is OUT (regulatory). FB Messenger via official page = future.
doozerProjectedClaim): a transparent heuristic today (no realoutcomes yet); SHARPENS as claim/convert rates by niche+market accrue from the Format-performance data. The learning loop: mechanics claim 20% more than bodegas → mail more mechanics.
doozerProspectsPipeToLeadBuilder): each prospect becomes anlb_leads row (source doozer_internal) so internal prospects live inside LeadBuilder proper.
scores CONVERSION READINESS + the exact next move. Complementary lenses on one funnel.
honest and single-fire, no take-backs.
doozerPlusOfferFor(), never hardcoded.SHIPPED (v2.2.407-419): postcard tracking + resend + view, size/format science, the audience-aware offer everywhere, the one-time claim offer + promo generator, Hot Leads (awareness + action + channel + projection) + the LeadBuilder pipe. NEXT: campaign/niche/market response comparison auto-weighting targeting; FB Messenger channel; the BYO-key thanks.io reseller Marketing Engine (per-business keys via a getApiKeyForBusiness() seam, owner send-to-their-leads on manage-business.php, 50% affiliate revenue ledger, agency tiers) — reusing the Factory + the /frontend-design two-score vision gate.
Doozer's AI-driven design engine — palette, typography, layout, and section variants that make each business site its own.
A Muse is a design recipe (palette, typography, layout axes, decorative flourishes, section variants) resolved per business and applied at render. It is how one codebase produces visually distinct sites at scale.
includes/muse-core.php — resolve the active Muse for a business (precedence:URL override, then businesses.custom_settings.muse_id, then domains.custom_settings.muse_id, then root_domains.muse_id). Muses live in the muses table; the design recipe is reading_json.
includes/muse-css-generators.php + includes/muse-apply.php — turn the Muse intothe per-site CSS (type scale, grid system, prose width, section tones).
includes/muse-contrast.php — readability guards (AA contrast; heading-color swapwhen the brand primary is too pale; section background rhythm).
includes/muse-direction-families.php — the 100-recipe Familylibrary the auto-generator draws from, niche-filtered.
includes/sections/<slug>/<variant>.php — the section frameworkpartials a Muse picks variants from.
museEmitHeadStyles() (includes/muse-apply.php) is where a Muse becomes the page's CSS. It composes in a deliberate ORDER, last-wins:
custom_css_snippet — the interpreter's AI-authored per-Muse flourishes, runthrough museSanitizeCustomCss() first (no @import, no external url(), no </style injection).
includes/muse-contrast.php) — AA-safe prose colors against theeffective backgrounds.
Because these are RENDER-TIME guards, fixing one heals all ~2,400 live sites with NO regeneration. That is the preferred way to fix a fleet-wide design fault.
--muse-* design tokens.** The AI snippet onceemitted body{--muse-font:sans-serif}, which clobbered the correct :root{--muse-font:"Inter",…} for the whole page, so every site rendered system-sans regardless of its family's type pairing. Fix: museSanitizeCustomCss() strips --muse-font* re-declarations (and tidies the emptied rules). Harden the interpreter prompt too if snippets keep doing this. (v2.2.207)
.btn-secondary is an outline button, so it always gets an outline. Archetype orsnippet CSS with !important could strip its border, leaving a white fill on a white header — the "invisible Directions button." The final safety rule is html body .btn.btn-secondary { border: 2px solid currentColor !important; }. currentColor is the button's own text color, which by definition contrasts its background, so the outline reads on light AND dark archetypes without forcing any palette. The selector specificity (0,2,2) must EXCEED the muse button-chrome rules (flat chrome emits body[data-muse-btn="flat"] .btn { border:0 !important } at 0,2,1) or it loses even with !important: the v2.2.209 rule shipped at (0,1,0) and was overridden on flat muses until the bump in v2.2.213. Design-correct (secondary = outlined), not a hack. Lesson worth keeping: verify design by LOOKING (screenshot + inspect every CTA's contrast on a calm AND a bold Muse), not by probing only the one token you changed.
Section dividers carry small SVG flourishes (includes/muse-flourishes.php, museEmitFlourish($slot, $muse), called at the top of about/services/photos/reviews/faq/ contact in business-site.php). The SVG library (arched frame, sunburst, scalloped, brutalist bar, risograph, hand-drawn, diamond, hairline rule) pulls its color from --muse-accent, so one SVG adapts to every palette.
Selection derives a compatible POOL from signals the Muse ALWAYS carries, most specific first: an explicit decorative_hints/homage_signals keyword match wins, else the template_archetype mapped to a curated taste-safe pool, else typography.family, else a restrained default. Pools never mix clashing vocabularies (no brutalist bar on a florist; civic and editorial archetypes stay restrained). The pool is rotated by a stable per-muse seed (the muse id), so two muses of the same archetype can lead with different signatures while a SHARED muse renders identically across its whole root or Cloud (cohesion is the goal). Per-page rhythm: the signature slots cycle up to two harmonious marks; quiet slots (photos/reviews/faq) get a hairline, never the same mark stamped six times.
This was a real sameness fix (v2.2.210): before it, any Muse whose AI hints did not match the keyword map fell to the bland hairline on every section, and even matches repeated one mark down the whole page. The lesson echoes the button bug: verify by LOOKING at the rendered DOM, not by trusting that a signal was wired.
v2 muses get a token-driven hero composition system (museCssHeroComposition, keyed by the hero_composition token: full-bleed-tint, sidebar-quote, arched-portal, marquee-strip, etc.). v1 muses do not, so every v1 hero rendered the same centered headline over a full-bleed photo — a major sameness driver, since the visible fleet is largely v1.
museHeroCompositionV1($muse) (includes/muse-apply.php) gives v1 heroes a per-archetype LAYOUT: 'center' (default) or 'editorial' (bottom-left, ranged-left, narrower measure) for the clearly-editorial archetypes. It emits data-hero-comp on the .hero section, and the scoped CSS in business-site.php changes only alignment and content measure. v2 muses always return 'center' here so their own system stays authoritative.
LOCK: the hero overlay (.hero-background::before) and the h1 / subheadline text-shadows are LOCKED (operator "PERFECT!"). This composition layer must never touch them. Bottom- anchored compositions are deliberately safe because they sit inside the existing bottom- darken band, so legibility is unchanged. Shipped v2.2.212 as one bounded variant (operator: look before going wide); more compositions and a wider archetype map come after visual review.
Movement is a real source of variation within a shared Muse (shared muse across a root or Cloud is a FEATURE for brand cohesion, not a bug). The scroll-reveal system (fade-up sections, staggered cards, hero-excluded, no-JS-safe, reduced-motion-safe via !important) has two intensities, chosen per business by museMotionLevel($muse, $bizTypes):
(reading_json.pace === 'kinetic' or an energetic motion_vocabulary) OR the business is an inherently energetic niche (bar, night_club, dance, gym, music, art_gallery, tattoo, brewery, event_venue, …).
business-site.php emits the lively tier behind a PHP conditional, with HARD CEILINGS (section travel ≤40px, ≤0.95s; cards lift ≤22px + ≤3% scale; stagger ≤0.34s) and an explicit NO list (no parallax, bounce, spin, 3D, blur, zoom-past-1.0, infinite loops, hero motion, or horizontal drift). prefers-reduced-motion always wins. So "lively" can never become tasteless or inaccessible.
The contrast/readability/width guards (the v2.0 overhaul) can flatten creativity if over-applied — see the creativity-regression thread in the site-quality backlog. The goal is unique AND readable. Design-shape problems are governed by Design Standards, not one-off CSS overrides.
Vocabulary: Muse, Family, Niche.
Found by walking ONE site through the pipeline by hand: MoMA, chosen because if the muse system has spirit it should show it there. Two findings, one of them network-wide.
MoMA's muse was Playfair Display with an electric-cyan accent - the default AI answer for anything. The system prompt is not at fault; it is a genuinely good art-direction brief that insists on grounding the palette in what the business actually IS. The fault was the subject it was given:
Name: The Museum of Modern Art
Google types: tourist_attraction, art_gallery, art_museum, museum, ...
museAutoAttachForBusiness() selected six columns and forwarded five, while _autoMuseUserMessage() had always been able to use the address, rating, review count, website, price level and scraped copy - all in the row it had just read. Every bespoke muse on the network was designed from a name and a type list.
Now the brief carries all of it, plus review text, which had never been used and is the richest signal we hold: reviews name what a place is known for, in the words of people who went. A MoMA reviewer writes "whether it be Starry Night or models of schools". No list of Google types carries that.
Reviews are passed as voice and subject evidence, explicitly not as fact - they are strangers' words and some are wrong. The design brief may learn what a place is famous for; the copy pipeline still takes facts from the record.
Result on the same model and prompt: cyan/Playfair/editorial-magazine became Bauhaus primaries (#dc3a2a / #2a3adc / #f0c83a) on bone white, bold sans, poster archetype, swiss-modular grid, "circle square triangle". The lineage MoMA canonized, from a brief that finally described the subject.
The rule: pass everything free before blaming the model. A generic brief gets a generic answer, and it looks like a taste problem rather than a data one.
museBodyAttrs() expects a MUSE ROW and internally calls museComputeTokens(museReadingWithDefaults($muse)). Its only caller, museEmitV2BodyAttrs(), passes an already-computed TOKEN ARRAY. museReadingWithDefaults() sees no reading_json key and returns pure defaults, silently - correct behaviour for a v1 muse, catastrophic here.
passed tokens (what shipped): gallery=grid paper=smooth btn=flat version="1"
passed the muse row (correct): grid=swiss-modular hero-comp=layered-blocks
motion=fade-up hover=invert header=minimal version="2"
data-muse-version gates every v2 rule (body[data-muse-version="2"]). So type_pair, type_scale, grid_system, hero_composition, section_tones, image_crop, image_overlay, motion_vocabulary, hover_style, header/footer style and section_transition were all computed, written into the stylesheet, and matched nothing. MoMA asked for deco-display-geo (Limelight) and rendered Inter.
This is why bespoke sites look alike however good the reading is. The readings were never the problem.
Fixing the signature enabled v2 on 653 live sites at once - a layer that, because of this very bug, had never been looked at in a browser. It degraded them instantly. Reverted in minutes.
museV2RenderEnabled() now gates it: ?musev2=1 or the muse_v2_enabled platform setting, off by default. Opting out reproduces the previous output exactly (the default token set), not just a zeroed version number - otherwise data-muse-grid / -motion / -hover would still advertise themselves to any rule keyed on them alone.
Fixed before rollout (v2.2.569): the section reveal. Nine variants each shipped their own fade-in, and three faults ran through them - scripts that queried at PARSE time (so they never saw sections below themselves), two variants sharing one idempotency flag, and CSS that hid unconditionally so no-JS meant no reveal. Now one shared muse-section-reveal.php, armed by a JS-added class, collected at DOM-ready, with an uncancellable 4s watchdog.
A measurement caution earned here. I first read this as "live pages are blank". It was not: sections DO reveal on genuine scroll. The blank captures came from screenshotting mid-transition and from a synthetic scroll that jumped faster than IntersectionObserver samples. When instrumenting a time-based effect, the observation method is part of the experiment - wait out the transition, scroll the way a person scrolls, and prefer the DOM's own numbers to a screenshot.
dz muse do=tokens biz=<id> prints every stage and the body attribute string computed both ways. Built because from outside the browser you can only see the result, and every failure in this pipeline is silent by design.
A feature that has never once executed in production is not a feature, it is a hypothesis - and a well-tested unit can still be wired to nothing. Ask what proves a layer is live, not merely that its code is correct. Here the proof was one attribute on one tag, and nobody had ever looked at it.
The page-level .doozer-reveal section system is scroll-driven with no watchdog. Googlebot executes JavaScript but does not scroll, so measured on one live site 7 of 10 sections were at opacity: 0 for a rendering crawler. Indexing beats any effect: both reveals now show everything unconditionally after 4s.
The rule for any effect that hides content: it must prove it will un-hide it. Gate the hiding on a JS-added class so no-JS never hides; collect targets at call time, never at parse time; and keep a watchdog you never cancel.
muse_v2_enabled = 1, 660 sites. dz muse do=v2 on=0 is the instant rollback, no deploy. It was used once during the release and worked in seconds.
muse-motion.js gates on data-muse-version="2", so it had never executed in production either. Switching v2 on ran it for the first time and exposed three defects, all the same shape as the section-reveal bugs:
initFadeUp wrote INLINE opacity: 0. Inline beats every stylesheet rule, sosections carrying both reveal classes still sat at zero. It also matched NESTED [data-muse-section] nodes, which can never intersect inside a hidden parent. Now it defers completely when the page-level reveal exists — one reveal per page — and targets top-level sections only, with an uncancellable watchdog.
initTypewriter destroyed the H1. textContent = '' then retyped it over ~3s.Googlebot executes JS, so a crawler could snapshot an empty or partial headline; a stall left it truncated for good. Now the full text stays in the DOM and is revealed with a clip.
initTiltWake hid gallery cards with no watchdog.Twice during this release a page read as "10 of 11 sections hidden" and then healed the moment I ran JavaScript against it. Cause: a backgrounded browser tab throttles timers and does not fire IntersectionObserver — and running JS wakes it.
Check document.visibilityState and document.hasFocus() before believing any measurement of a scroll- or timer-driven effect. Foreground the tab first. Screenshots of a throttled tab are stale for the same reason.
Together with the earlier mistake (screenshotting mid-transition, scrolling faster than IO samples), that is three separate occasions where the observation method was the bug. Prefer the DOM's own state — is the reveal class present? — over a rendered pixel, and when the two disagree, suspect the harness.
A map of every product in the Doozer world and how they interconnect into one referral loop, so an AI can see the breadth of the platform at a glance.
Doozer is not one website. It is a network of surfaces that share one engine, one data spine, and one referral loop. renderNetworkSitesBar() (includes/network-footer.php) is the single source of ecosystem naming/order; a soon flag flips a product live. Everything below is built by BerrySmart (Jon Berry, solo founder) on the same architecture.
doozer.work) — the platform: landing page + admin console + theengine that generates every business site. Free tier = a templated info page the business owns; paid Doozer+ = a bespoke full site. See the two-tier model.
served on a root subdomain (e.g. .elks.club, .westmilford.biz, *.vsso.net).
optionally with a bespoke identity layer (see the Elks root theme).
nearbymenow.com) — the directory/traffic layer:a public "what's near me" surface that indexes the whole fleet and feeds visitors back to business sites. The active side of the referral loop.
doozer.cloud, my.doozer.cloud) — curated directoriesby niche/region/criteria, plus each user's personal cloud.
captured lead and intent signal flows here to become revenue.
(check-ins) — roadmap surfaces on the same spine.
Business sites are found via search + NBMN + Clouds → visitors act (call, message, visit) → those actions become leads and signals → signals drive Prospector discovery and Marketing → more sites → more directory depth → more traffic. Cross-surface identity is always by ID via Magellan, never by name-guessing. The economics of this loop are the value model; the mechanics are the flywheel.
One source of category color for the whole network — the design primitive that makes cards, pills, spines, and markers coherent across every surface.
nicheColor() (includes/niche-colors.php) is the single source of category color network-wide. Category → hue, niche → shade within that hue. Every surface that shows a business or a niche — the shared business card (spine + category pill), directory chips, map markers, category pages — draws from this ONE function, so a "food" place looks like food everywhere and an "automotive" place looks like automotive everywhere, without per-surface color logic.
Coherent category color is what makes the network feel like one system instead of many pages. It is a design PRIMITIVE (like the design standards registry and the shared card): change the mapping in one place and the whole network updates. The star rating uses a fixed gold (#f5a623) so ratings read consistently regardless of category.
Consuming code destructures [$accent, $soft] (a strong accent + a soft tint for backgrounds/pills). New surfaces that display a niche must call nicheColor() rather than inventing a palette — that is how a new card, a new directory view, or the "More places nearby" cards (network growth) stay on-system for free. Next horizons: category-page theming and colored map markers driven by the same source.
How business photographs are referenced from Google, bought once on first view, stored as objects on DO Spaces, and served back through a predictable cache key that lets any page ask "do we already own this?" before spending.
One sentence: **we store photo references for free, buy the bytes once when a real person first looks, keep them forever as public objects, and every URL is derived from a key we can predict — so any page can ask "do we already own this?" before spending.**
Related: Architecture · Source of Truth Cache · The Observed World
When a business is discovered, Google Places returns photo handles. Those land in businesses.photos (JSON) in one of two shapes:
| API | field | looks like |
|---|---|---|
| Places API (New) | name |
places/ChIJ…/photos/AaVGc3n… |
| Places API (Old) | photo_reference |
AaVGc3n… |
Nothing is downloaded at this point. A reference costs nothing to hold, and most businesses in the network are never opened by anybody. This is what makes par-created skeletons and the directory's blueprint plates cheap: we know a place has photographs without having paid for them.
resolveBusinessImageUrl($row, $width) in business-image-resolver.php is the canonical picker, used by every surface so quality matches everywhere. It returns ['url' => …, 'is_logo' => bool], preferring, in order: an already-resolved photo_url, then Google photos, then scraped / deep-research images, then the logo. When it returns nothing the caller draws a category tile instead.
business_photo_meta (58,545 rows) holds per-photo vision analysis — subject class, aesthetic, quality, caption — for ranking and hero selection.
googlePlacePhotoProxyUrl($photo, $maxWidth) builds a URL on our own host:
https://<any-doozer-host>/?e=photo&name=<urlencoded reference>&w=<W>&h=<H>
Linking googleapis.com directly would leak the API key and bill us for every view by every visitor and every bot. The proxy is what makes the rest of this possible.
$cacheFilename = generateCacheFilename($cacheKey, 'google', ['w' => $w, 'h' => $h]);
// google_<md5( photoName . json_encode(['w'=>W,'h'=>H]) )>.jpg
The width and height are part of the key. A size nobody has requested before is a guaranteed cache miss and a fresh purchase — and under warm-only rendering it means the photo silently never appears, no matter how many copies of that same image we already own at other sizes.
Reuse the sizes the network already accumulates:
| use | width |
|---|---|
| directory + nearby thumbnails | 400 |
| business cards | 600 |
| hero | 1200 |
?e=photo triesimageRegistryLookup($cacheFilename); if status = 'cdn',302 straight to cdn_url. No local file needed. Gated by the platform setting image_serve_from_cdn (currently 1).
cache/images/<cache_key> (path in image_cache_path).neg_<cache_key>. Stale Google references return HTTP 400forever; the marker stops us re-buying them on every pageview. Ordering matters: the positive cache is checked first, or one transient 4xx hides a perfectly good image behind a negative marker.
places.googleapis.com/v1/<name>/media, or Old APImaps.googleapis.com/maps/api/place/photo.
header (see gotchas).
fastcgi_finish_request() — so thevisitor waits for none of it — the bytes are written to Spaces and recorded in the registry. Gated by image_spaces_dualwrite.
platform_settings: spaces_bucket,spaces_cdn_base. Credentials are spaces_key / spaces_secret in api_keys.
images/<cache_key>public-read with Cache-Control: public, max-age=31536000, immutable<spaces_cdn_base>/images/<cache_key>image_assets128,168 rows: 113,506 on the CDN, 14,662 negative.
| column | meaning |
|---|---|
cache_key |
google_<md5>.jpg — the join key for everything |
spaces_key |
images/<cache_key> |
cdn_url |
the public URL |
content_type |
what the object is served as |
bytes, source_type |
size and origin |
status |
cdn (we own it) / negative (known-bad reference) |
cdn_uploaded_at, ct_fixed_at |
when stored, when its type was repaired |
Because the key is predictable from the URL alone, a page can ask about a whole screen of photos in one query before rendering any of them:
dzcPhotoCacheKey($url) // reverse the URL back to its cache key
dzcPrimePhotoWarmth([$urls]) // ONE batched lookup for the whole page
dzcPhotoIsWarm($url) // free thereafter
Photos we own are shown; photos we do not are replaced by the category plate, and the first real visit to that business buys them for everyone after. Used by the NBMN directory, the NBMN home list, the neighborhood block on every business site, and the JSON-LD emitter.
It fails open: if the registry is unreachable everything is treated as warm. A directory that silently loses its imagery to a database hiccup is a worse failure than one that spends a little.
back to a plate while its photo sat on the CDN at 400.
Content-Type. Read the last header across redirectsand sniff the magic bytes. 32,223 objects were stored as application/json — valid JPEGs that browsers refused to render, showing blank boxes across the network for months.
already cached the bad header under immutable, max-age=31536000. Healing the object changes nothing a browser sees. The registry-first 302 now appends ?v=<hash of ct_fixed_at> for healed objects only — a new edge cache key exactly once, leaving every never-healed image on its warm URL.
?e=photo buys on first requestand 83% of network traffic is bots; a cold photo URL in JSON-LD is an invitation for crawlers to spend money on pictures no person asked to see.
ct_fixed_at without correcting content_type; those rows looked done and nothing retried them. dz imgct reset=1 clears the stamp.
dz imgct [limit=] [rounds=] [all=1] [probe=1] [reset=1] # repair content types
dz photo_audit # find broken/missing images
dz photo_heal # re-resolve and re-upload
How a business page is assembled from pipeline section slots and per-variant partials a Muse selects.
A business page (includes/business-site.php) is a sequence of section slots (hero, about, services, why-choose-us, menu, products, photos, reviews, faq, hours, contact). For each slot the active Muse may pick a variant; otherwise a classic fallback renders.
includes/muse-sections.php — museRenderSection($slug, $variant, ...) dispatches toa partial at includes/sections/<slug>/<variant>.php, with a taste-defense gate that downgrades to classic when a variant's prerequisites are not met.
.services-intro, .about-content,.muse-alt-prose) so global prose rules apply across all of them.
renderProse() (includes/prose-render.php), which enforcesThe roadmap (per the grand vision) is per-business-type sections: restaurants get menus and daily specials, venues get calendars and commerce. New section types become new slots + partials here.
The one card renderer used across NBMN, cloud, and root directories, with a pluggable toolbar and category-aware call intent.
includes/business-card.php is the ONE card renderer network-wide (it replaced four drifted implementations). businessCardData($row) normalizes any listing shape; renderBusinessCard($card, $opts) emits a CSS-self-contained dzc-* card that works on Tailwind root pages and vanilla NBMN alike; renderBusinessCardAssets() ships the styles + the one canonical image fallback + the call popup.
resolveBusinessImageUrl() everywhere (killed the NBMN blankimage bug). The card self-loads its dependencies (image resolver, niche helpers, call-intent catalog) because directory surfaces do not preload them.
renderBusinessCardToolbar — default actions Call + Save; check-inis contextual (map/near-me), not on generic directory cards.
(saved heart red, etc.), never for the buttons.
Rendered network-wide: NBMN, Cloud, and root directories.
includes/call-intent-categories.php maps a business's category/niche to a small set of intent chips (restaurant to "Order takeout"; flooring to "Get a flooring quote"). The same catalog powers the card Call popup and the business-site Call Now popup. Records to lead_intents and bridges to LeadBuilder. See the flywheel.
A non-intrusive "plan your visit" weather card on business sites — current conditions plus a 7-day forecast for the business's own location, free by default.
Every Doozer business site can show a small, calm weather card near its Hours section: current conditions plus a 7-day forecast for the business's own location. It is a helpful visit-timing signal — "is this a good day to go?" — and never a popup or an interruption. It is part of the broader push to make a site more than a static listing (see per-location data enrichment).
includes/weather.php is the whole engine:
weatherFetch($lat, $lng) is cache-only and never blocks the render. On acache hit it returns the normalized shape (current + a 7-day daily array) regardless of provider. On a miss it writes an empty dedupe marker, returns null (no card this paint), and hands the real HTTP call to weatherScheduleWarm(), which runs it after the response via register_shutdown_function + fastcgi_finish_request(). The next visitor inside the 2h TTL gets the warmed value. This is deliberate: the live Open-Meteo/OWM call once ran inside the render (weather.php:82) and the FPM slow log named it the top request-holder and a 521 amplifier. A third-party weather API can never stall a page again (v2.2.494).
renderWeatherWidget($ctx) renders the card: current temperature + condition,an optional place label (the city, parsed from formatted_address), and a 7-day strip of day / icon / high·low. Its CSS is scoped and !important-hardened so arbitrary muse rules cannot restyle it (the same chrome-hardening rule the sponsor bar and dialogs follow).
weatherEnabled() gates the whole thing on a platform setting.includes/business-site.php renders it right after the Hours section, deriving coordinates from the same geometry / lat-lng fallback the map uses. It renders nothing at all when the widget is disabled, coordinates are missing, or the fetch returns no data — a missing card is always better than a broken one.
The default provider is Open-Meteo: free, no API key, worldwide. WMO weather codes map to calm inline-SVG glyphs (no emoji clipart, per the icon taste rule). Results cache for about two hours per ~1 km location bucket, so the only work is a cache miss and the running cost is zero — consistent with the platform's cache-aggressively, minimize-API-cost discipline.
An operator can optionally set an OpenWeatherMap key (Admin → Platform Settings → Weather) to use that provider instead; the code aggregates OWM's free 5-day/3-hourly feed into the same daily shape. No key is required for the widget to work.
Admin → Platform Settings → Weather tab: an enable toggle (on by default) and the optional OpenWeatherMap key field, with a plain-language note that a key is not needed. Stored in platform_settings under category weather.
This shipped from the roadmap idea of a visit-timing signal. The further vision: feed weather into Gatherings so any place + time can answer "what will it be like when I'm there," and surface it as a signal in the events/marquee surfaces. See [[project_weather_widget]].
domains.include_in_sitemap gated the sitemap AND every directory listing query. So "do not submit this to Google" and "do not show this to a person browsing NearByMeNow" were the same switch. There is no setting for the state par-create was designed to produce: a real site, worth showing to the human who conjured it, not yet worth asking Google to index.
Now:
| flag | question | posture |
|---|---|---|
include_in_directory |
can a person find this in NBMN? | generous - real content about a real, located place |
include_in_sitemap |
do we ask Google to index it? | conservative - thin content stays out |
siteDirectoryGateSql($alias) is the fragment every listing query uses; it falls back to the old flag until the migration has run, so there is no broken window. sitemap.php and indexnow.php stay on include_in_sitemap deliberately - they are the index side.
A new skeleton must close both gates explicitly. include_in_directory defaults to 1 so that thousands of existing rows keep working; a skeleton with no content would therefore list itself. par-create.php writes 0,0.
The rule "skeletons are promoted on enrichment" was written down, taught to every session, and never implemented. Par-create inserted include_in_sitemap = 0 and the only statement anywhere that set it back to 1 was a manual bulk action in the admin domain list. 662 sites were enriched (for $1.11, in one batch) and stayed invisible.
sitePromoteOnEnrichment($businessId) now runs from saveGeneratedContent() - the single place all generated content is written. Directory visibility is earned by having substantive content; index eligibility is a separate, stricter test (siteIndexEligible: real prose + an image + an address + coordinates).
A documented rule with no code behind it is indistinguishable from a bug, and harder to find, because everyone believes it already works.
1,658 of 2,929 listable businesses appeared on no niche page. Not bad data - the niche_mappings table simply had no row for the type they carry, so no page existed to put them on. The largest group was association_or_organization at 1,627: the lodges, which drive 68% of human traffic, had no category.
dz nichecatalog [seed=1 [live=1]] is the seeder; dz nichecatalog alone is the standing gap report. Two kinds of entry, and the distinction matters:
live-music, theaters, stadiums, golf-courses, sports-clubs, breweries, wineries, caterers, medical-clinics, chiropractors, nonprofits, farms).
mexican_restaurant is a restaurant. It should not get its own page; it should be findable on the restaurants page and rank there as an exact match.
Orphans went 1,658 -> 4.
Google's broadest tags (service, consultant, health, finance) route to the catch-all businesses page - a page beats being unreachable, and a wrong category would be worse than a vague one. locality and food are excluded on purpose: one is a town, the other is a modifier.
backfillBusinessFlatColumns() chose the first non-generic Google type, so ['consultant','accounting','finance'] filed under consultant - no page - while accounting sat right behind it. It now prefers the earliest type that resolves to a known niche, falling back to the old behaviour so a genuinely new category is still recorded rather than dropped. Anything unmapped surfaces in dz nichecatalog instead of vanishing.
The infrastructure-probe denylist (v2.2.563) treated ^[a-z0-9]{12,}$ as a generated hash. That matches any all-letter word of twelve characters or more, so associations (exactly 12), chiropractors, veterinarians, electricians, supermarkets and universities all 404'd on their own subdomains. A real hash mixes in digits; a niche slug never does. A guard written against one shape of garbage will catch real data if the shape is described loosely enough - and it fails silently, because a 404 on a page nobody has linked yet looks like nothing.
What: the pattern that builds the network BACKWARDS from ticketed/scheduled events. An external feed lists events at venues; each venue becomes a Google-Places- backed Doozer site, enriched with its upcoming events, turned into a Global Gathering, with affiliate ticket links. Marquee (Ticketmaster) is the first live instance; the plan is to generalize it into one engine with per-source adapters.
The pipeline (source-agnostic after the fetch): feed fetch -> normalize -> match venue to an existing business (accuracy-first) OR build it (resolvePlaceToEcosystem) -> stamp the external venue id (Magellan link) -> business-events.php renders events on the site -> gathering + affiliate link.
The universal join key is the Google place_id. Every feed's venue resolves to one, so multiple feeds enrich the SAME site instead of duplicating. This is why the accuracy-first matcher matters as feeds multiply.
Marquee today (includes/marquee-engine.php, admin-marquee.php):
marqueeTmFetch (Discovery API v2), marqueeNormalizeTm, marqueeIngest.marqueeMatchVenue($name,$city,$state,$lat,$lng,$tmVenueId) — accuracy-first(v2.2.433): tm_venue_id -> physical geo (name-confirmed, never geo-only, generic venue words excluded) -> normalized name -> legacy. Unit-tested 7/7 on real collisions. Ingest stamps businesses.tm_venue_id on every match (enriches existing sites, no rebuild).
(sort=relevance,desc), no city. Skims the hottest venues everywhere; widen into quieter markets over time.
marqueeRecentVenues). Was silentlyempty until v2.2.431 (an ORDER BY g.site_domain alias bug — a correlated-subquery alias can't be table-qualified in ORDER BY).
dz marquee [do=status|repair|backfill|trending]. repair re-links orphans;backfill re-runs the matcher over stored events.
Business-first enrichment (v2.2.438) — the complement to feed-first: a venue site built OUTSIDE Marquee (More Places Nearby / Prospector) has no events, because Marquee is feed-first (events arrive only when a sweep covers that venue). marqueeEnrichBusiness($bizId) probes the feeds BY the business's own name + coordinates and links its shows to the existing site. Reuses everything (marqueeIngest keyword+geo → accuracy match → stamp → site renders); 1 TM call (+1 SeatGeek) per business, 3-day freshness guard. marqueeEnrichScan($limit) = niche-gated backfill of venue sites that have a site but no upcoming events. dz marquee do=enrich biz=<id> / do=enrich-scan [limit=]. THE PRINCIPLE: every enrichment feed is a business-first probe keyed on identity (name+geo+niche) whose results route to the right section; Marquee/events is the first.
GOTCHAS (learned the hard way):
pr_discoveries.status='site_created' but the domain never persisted, because linkDomainToBusiness() returned true without verifying the INSERT. Every rebuild then dead-ended on the "already created" guard. Fix: the link now VERIFIES the row + logs the real error; the guard SELF-HEALS (only blocks when a live domain exists).
now surfaces skip count + first error and does not auto-reload over a zero-store.
Multi-source (v2.2.436): marqueeIngest takes opts['source'] (ticketmaster | seatgeek) and dispatches the adapter; marquee_events.source + marquee_sweeps.source are bound, not hardcoded. Cross-feed dedup is by Google place_id (a venue built from TM is ENRICHED by SeatGeek, never duplicated).
SeatGeek — feed #2, SHIPPED (includes/seatgeek.php): the affiliate revenue play (~$11/sale vs TM's thin margins). Pure adapter: seatGeekFetch/seatGeekNormalize emit the same flat shape as marqueeNormalizeTm. Key = seatgeek client_id in api_keys (free at seatgeek.com/build). Affiliate aid in platform_settings seatgeek.affiliate_id, wrapped onto ticket urls when set (blank = links go straight to SeatGeek). Its native score is the trending order (sort=score.desc). Admin -> Marquee has a SeatGeek card + a Source selector on Ingest/Trending. dz seatgeek [do=test|trending|ingest].
Generalization plan: see docs/features/EVENT_FEEDS_ROADMAP.md. FeedSource interface; Ticketmaster + SeatGeek are adapters #1/#2. Facebook events are DEAD (no compliant API). Movies (Popcorn) = theaters from Places + Fandango affiliate deep-links now; paid showtimes feed only on demand. A generic ICS/RSS adapter is the on-brand long-tail win (civic/library/campus calendars -> gatherings). Keep it ONE engine with adapters, not forked per vertical.
Links: [[project_marquee_status]], the Observed World, GoHe.re gatherings, [[project_monetization_plan]] (per-source affiliate ledger).
What: the single strategist every outbound marketing touch flows through. She reads the observed world, decides WHO needs a postcard, picks the MESSAGE most likely to convert them, has the AI postcard engine paint a gated card, and learns from what actually happened (scans / visits / claims / Doozer+ conversions). Winning angles get sent more; losers fade. Named for a strategist who "smells success in the wind." Shipped v2.2.380.
Engine — includes/machi.php
machiScoreBusiness($biz) → 0-100. NEED (weak/no own website), FIT(machiNicheTier — trades/health/auto/legal/realtor = tier 3, active marketers food/beauty/fitness = tier 2), PROOF + ability to pay (review volume, rating, price_level). Returns score + segment + reasons.
machiRankTargets($opts) → the "these need a postcard" list. SQL preconditions:active domain (a real screenshot exists), OPERATIONAL, not claimed (admin_email empty), not already converted; default view = untouched, or follow_up for businesses past cooldown (≤3 touches). Scored + sorted in PHP.
machiPickVariant($businessId,$seg,$peek,$exclude) → the angle. Weighted-randomby machiVariantScore (Bayesian-smoothed conversion value + scan/claim signal) × segment affinity, EXCLUDING angles already SENT to this business (follow-ups differ) and any explicit exclude (a rerun's current angle).
machiGenerateForTarget($businessId,$opts) → pick angle → postcardAiRun withcampaign='machi', message=chosen copy, variant_id. NO touch yet (previews and reruns must not inflate the sequence); the angle is stored on the design.
machiFullSend($designId,$opts['live']) → postcardAiSend live, then creates themachi_touches row (the send is the real event), touch_no = prior sent + 1, then machiSyncOutcomes.
machiSyncOutcomes() → for each SENT touch pull postcardAiTracking + claim +machiBusinessConverted (premium owner by email match), recompute each variant's rolling stats from machi_touches (idempotent).
machiPerformance() (funnel + variant leaderboard + spend fromai_generation_history content_type postcard_ai + thanksio sends + revenue = conversions × machi_conversion_value default 99 + ROI). machiMarketScan() = which segments pay back.
Tables (self-creating): machi_variants (8 seeded angles: ready / leads / found / free / proof / local / pens / ahead; each with rolling times_sent/scans/visits/claims/conversions) and machi_touches (per-business send history + learning signal). postcard_designs gained variant_id + angle.
Dashboard — includes/admin-machi.php (Story → Machi, route machi): funnel + spend + revenue + ROI; ranked target cards (score / why / segment / lead angle) → Generate → gated preview appears in-card → Full send (live=mail) / Rerun (fresh angle) / Reject; "Winning messages" leaderboard + "Market scan"; filters by segment / state / min score / follow-ups-due / untouched-only.
Creative uses the operator's Image Generation config now: postcardAiGenerate routes through aiImageProviderDefault() — OpenAI GPT Image via /images/edits (screenshot + logo as real reference images) OR Gemini generateContent — so no separate key is needed when OpenAI is set, and cost lands in Analytics & Costs at the configured per-image price. See [[ai-postcards]], [[claim-codes]], [[thanksio]], [[the-observed-world]].
Dev API: dz machi — rank / gen b= / send id= live=1 confirm=1 / sync / perf / market / seed.
Verified live v2.2.380: seed + rank return sensible targets (established high-review businesses first, angles varied, trades score tier-3). Real sends + learning accrue once postcards mail. NEXT: scheduled auto-drafting of top targets into the review queue (traffic-driven, like the job runner); agent-authored new angles into machi_variants (source='agent'); market/geo selection surfacing high-ad-spend metros.
The tap-to-call chips are tailored to why you'd actually call THIS kind of business — reserve a table at a restaurant, get a quote from a plumber, check if the car is ready at a shop.
When a visitor taps Call on a Doozer site, most sites would just dial the number. That is a wasted moment. The reason a person calls a business is specific to what the business is, and that reason is a lead signal worth capturing without adding a single step of friction.
So the call action opens a tiny set of intent chips — and the chips are different for every kind of business, because the reasons to call are different:
Nobody has to think about which chip fits; the right three are already there. The visitor taps the one that matches their head, then the call connects. We learn why they called before they say a word.
It is the pattern we want more of: a chrome primitive (a small, shared piece of interface) that is aware of the thing it is attached to. The same tap, on two different businesses, produces two different, perfectly-fitted experiences, and each tap is a typed lead signal. It costs the visitor nothing and it makes the site feel like it understands the business. That is the whole Doozer thesis in one control.
includes/call-intent-categories.php has two layers. callIntentChipsForNiche() checks for an override keyed to the specific niche (pizza, roofers, coffee shops have their own hand-tuned chips). If there is no niche override, it falls back to callIntentChipsForCategory() for the broad category (food, services, health, beauty, automotive, and so on), and finally to a sane general set. So a niche can be as special as it deserves to be, and everything still has a good default. This is the same category-then-niche cascade the Niche Color System uses for color.
Taps are lead intent. They feed lead_intents and bridge into LeadBuilder, and the same chip vocabulary powers the business-site "Call Now" and the shared business card Call popup, so the experience is identical everywhere the business appears. See Lead Capture. Related craft: fix the source, not the symptom (these chips are authored data, not a scraped guess).
One login has to work across doozer.work, elks.club, nearbymenow.com and every other root — but third-party cookies are dead. So identity rides a first-party token handoff, not a shared cookie.
Doozer is one product spread across many registrable domains: doozer.work, elks.club, nearbymenow.com, doozer.cloud, and the wildcard roots. A person who signs in on one should be recognized on the others. The textbook answer is a shared session cookie on a common parent domain. That answer is gone: browsers now partition or block third-party cookies, so a cookie set for doozer.work is simply not readable from elks.club. Anything built on cross-site cookies is already broken.
Nothing is shared ambiently. Identity is handed from one root to the next through the link the user actually follows, and re-established as first-party on arrival.
doozerEcoLink($url) to carry ashort-lived ?dzauth= token. The destination root reads it via get_user_token / adopt_user_token, mints its own first-party session, and drops the token from the URL. The user lands signed in, on a cookie that root is allowed to set.
get_admin_jwt and kept in localStorage,which is per-origin. Each standalone surface bootstraps admin state from its own storage rather than expecting a shared cookie (see admin tools must never reach regular users).
The non-obvious part is refusing the shared-cookie mental model entirely and treating every root as a separate origin that must re-earn the session on entry. The token lives in the one thing that does cross origins cleanly, the link the user clicks, and never in ambient storage that the browser will quietly wall off. Any new root (NBMN today, GoHe.re tomorrow) is not "wired into the cookie"; it just has to implement the handoff endpoints. It is more moving parts than a cookie and it is the version that still works in a partitioned-cookie world. Same instinct as implicit verification: carry exactly the minimum identity signal, exactly when it is needed.
Nine defects found in one week where the design system chose well and the render layer quietly discarded the choice. The shapes these bugs take, why they never error, and how to find them.
When Jon says the sites look ugly, boring, or samey, the instinct is to re-prompt the model. Do not start there. Across a full week of work in September 2026, every single cause was a narrowing between the decision and the pixels. The muse chose well and the render layer threw the choice away. Nothing errored. Results were just quietly smaller.
Print the decision, then trace what survived to the pixels.
That is the whole method. dz muse do=brief biz=<id> prints the exact brief the generator was given; the muse reading holds what it answered; the live CSS holds what arrived. The bug is always in a gap between two of those three, and it is always silent.
1. A vocabulary narrower than the thing it describes. display_style allowed ten values while the 100 direction families speak about thirty-nine (humanist-warm, letterpress-roman, wood-type-revival). Unmatched values did not fail — they fell back to <family>:modern, so a muse asking for humanist-warm was served Inter. Five typefaces covered 73% of the network; Playfair and Cormorant alone were 37%. Same shape in section_variants, where why-choose-us offered exactly two values, so 742 of 864 muses "chose" the same one.
A stored value is only a decision if the schema offered an alternative. Where it did
not, treat it as a default and let the current default win. reading_version is the
marker for this — see the deferral rule in muse-sections.php.
2. A fallback that lands on the most generic option is worse than a crash. A crash gets fixed. A silent fall-through to Inter looks like a choice, survives review, and teaches everyone that the model has no taste.
3. Positional slots that assume a role. --muse-neutral-2 meant "second after a luminance sort". 91% of palettes carry exactly two neutrals — paper and ink — so slot 2 was the ink, and every rule grounding an element in it painted black-on-black. Dollywood shipped a 271px footer, fully laid out, opacity: 1, every glyph invisible.
Test the property you need, never the index. A surface slot has to be able to carry
the ink; ask it, do not assume it. The same guard is why --muse-fg is now deepened
along its own hue when the darkest neutral cannot carry text (28% of sampled muses).
4. Two correct systems colliding. Hero treatments pair --hero-panel-bg with an AA-computed --hero-panel-text; hero compositions force white for a scrimmed photo. Both applied; the composition selector carried one extra element, so it won — white on a #fafaf6 panel, 1.02:1. Neither system was wrong alone.
When another system has already decided ground and ink together, stand down — for
colour and layout. Sections that paint their own background declare
data-muse-own-ground so the alternating tone wash leaves them alone.
5. Chrome that depends on an optional layer. The hero CTA's colour was pinned inline and its shape delegated to museHeroCtaCss, which only runs when a muse exists. elks-root.php styles it for its own theme. Sites with neither — 1,154 of them — rendered display:inline; padding:0; text-decoration:underline, a 92×22 underlined text link under the 44px tap-target minimum.
If a base element needs chrome, ship the chrome with the element at low specificity.
Optional layers may override; they may not be the only provider.
Hardcoded CSS overriding the palette. 315 bare hex values against 114 var(--muse-*) uses across 50 archetype stylesheets. The muse picks a palette, the archetype paints over it — which is why every editorial_magazine site was the same cream. Wrap as var(--muse-X, <original>): zero visual regression, real variety gained. Dark archetypes must map ground→--muse-fg and ink→--muse-bg or they flip to light.
A suffix must be applied to every selector in a list. "$a, $b, $c" . "::after" binds the pseudo-element to $c only; $a and $b get the declarations applied to the element itself. On the underline hover muses that made .btn, .card and .contact-card into absolutely-positioned full-width strips, which is what tore apart Dollywood's header and Get In Touch. The quiet half of the same bug: $hovers:hover meant only .service-card ever responded to hover, on 373 muses.
A closer must be paired with its opener. NearByMeNow's Filters chrome <div> opened inside the niche-page branch and closed unconditionally. On every free-text search and every empty niche page the closer shut .container instead, and the sort bar, cards and nearby block rendered 60px left of the heading. Nothing errored; the browser repaired the tree and the page merely looked wrong. Same family as ground-and-ink: when a wrapper is conditional, carry a flag ($__chromeOpened) and close on the flag, never on faith.
A ground must be paper. --muse-bg is "the lightest neutral", and everything downstream assumes that means paper. A palette whose lightest neutral is a mid-grey has no paper, and no guard could save it: the ink guard pushed text to black (4.4:1) and the text-on picker read the grey as dark and put cream on it (2.94:1). The paper guard lightens a sub-mid ground toward white, keeping its tint, until it carries the ink at 7:1. Same family as the ink guard: test the property (can this carry text?), never the position (it sorted lightest).
A brand colour is not a text colour. The header printed the business name in --color-primary; a yellow primary on white measured 1.34:1. --muse-primary-text is the primary deepened until it reads on the ground, and it is what every "primary as ink" rule should reach for.
Check your own probe first, again. A contrast scanner that parses color(srgb 0.96 0.98 0.88) as three 0-255 channels reports 1.42:1 on slate over pale green. Two false leads in one session came from the probe, not the page.
javascript_tool (getBoundingClientRect, getComputedStyle,elementFromPoint); look with dz screenshot ... full=1. The in-app browser pane serves stale frames and has caused several false diagnoses.
"257 businesses, 7.0%" — and sometimes into "actually that was my probe".
Related: [[the muse system]](../systems/muse-system.md) · [[design standards]](../systems/design-standards.md) · [[hostile CSS environment]](hostile-css-environment.md) · [[fix the source, not the symptom]](fix-the-source-not-the-symptom.md) · [[spotcheck]](../processes/spotcheck.md)
A saved business's link is always computed from the business's currently-active domain, never read from the domain id we stored when it was saved — because that stored id goes stale and poisons the link.
When a user saves a business, the obvious schema records which domain they saved it on (lb_follows.domain_id) so we can link back to it later. Reading that stored id at render time feels correct and is one join cheaper. It is also a bug, and a quiet one.
A business's domain is not stable. Sites move between roots, subdomains get regenerated, a business gets a better home, a domain is deactivated. The moment any of that happens, every lb_follows row that captured the old domain_id now points at a domain that may be inactive, wrong, or gone. The user's "saved" link 404s or lands on a dead host, and nothing errored when it broke, so nobody notices until a person taps a saved item and hits a wall (a dead end, the one thing we refuse to ship).
Always derive a saved business's URL from that business's currently-active domain, resolved fresh at render time, never from the domain_id stored on the follow. The stored id is treated as poisoned. The join is one step longer and the link is always live.
The insight is recognizing that a foreign key can capture a fact that was true once rather than a fact that stays true, and that "the domain this was saved on" is the second kind. The stored value looks authoritative and is actually a snapshot with an expiry you cannot see. This is the same shape as fix the source, not the symptom: do not patch dead saved-links after the fact, compute the link from the source of truth every time so it cannot go stale.
When the output is wrong, fix what produced it — the prompt, the data, the generator — not the output. No filters, no gates, no find-and-replace patches on the way out.
A recurring temptation with AI-generated sites: the output is wrong in some specific way, so you add a filter that catches that specific wrongness on the way out. A regex that strips the bad thing. A gate that rejects the bad case. It works, narrowly, and it quietly rots — because you are now maintaining a growing list of symptoms instead of a correct source, and every symptom you did not predict still ships.
Doozer's rule is the opposite: when the output is wrong, fix what produced it. The operator states it directly: don't trap for it, produce better content and better muses so the problem cannot occur.
words ("1811", "Real"). The lazy fix is a filter that removes stray ::first-letter CSS after the fact. The real fix was in the muse-generation prompt (includes/muse-interpreter.php): forbid ::first-letter/drop-cap CSS and bare-element prose selectors at authoring time. Regenerate, and zero drop-cap rules exist to filter.
the posted hours said closed Sunday and Monday. The trap-the-symptom fix is a fact-checker rule that special-cases day-count claims. The real fix (includes/ai-content.php) feeds the actual weekday_text and the open-day count into the content prompt, so the model writes an accurate schedule the first time. Fact-Guard still exists as a net, but the net should catch surprises, not do the generator's job.
The discipline is refusing the fix that is easy to write and easy to demo, in favor of the fix that changes the distribution of outputs. A filter improves one page. A better prompt improves every page that will ever be generated, including the failures you have not seen yet. It is slower to reach for and it is the only version that scales to thousands of sites without accumulating a junk drawer of special cases. Same spirit as design standards living in one registry: change the rule where it is authored, not everywhere it shows up.
When handing an image to an AI provider, send the actual bytes from our side. Never hand it one of our own proxy URLs to go fetch — that is a timeout and a token burn waiting to happen.
Doozer serves business photos through its own image proxy (?e=photo) — caching, CDN-offload, session-less for performance. So when we need an AI provider to look at a photo (vision for color extraction, content, a header), the obvious thing is to hand the provider that photo's URL. We already have a nice stable URL for every image. Just pass it.
That is the trap.
Handing a provider our ?e=photo URL means the provider's servers now make a round trip back into our infrastructure to fetch the image before they can process it. That adds a second, slower hop we do not control; if our proxy is cold, busy, or the image must be fetched-and-cached on demand, the provider's request can hang until it times out. And a vision call that times out often still bills for the attempt. So the "convenient" URL turns one clean operation into a fragile cross-network dependency that can both fail and cost.
Fetch the image bytes on our side and inline them into the provider request (base64 / multipart, whatever the provider takes). One hop, fully under our control, no loopback into our own proxy. The provider gets exactly the bytes, immediately.
A related tell from the same area: expected-stale photo failures log to a file, not the error_logs DB table, because a predictable miss is not an incident and high-volume logging bloats that table.
The insight is spotting the accidental loopback: our own URL, handed outward, becomes an inbound request we then have to serve under someone else's timeout. It looks like reuse ("we already have a URL!") and it is actually a hidden dependency cycle. Send the thing itself, not a pointer back to yourself.
A data refresh is a bill. Before re-pulling from any paid source, check what each pull costs, reuse the cache, dry-run the total, and pull only the unavoidable remainder.
At Doozer's scale, "just refresh the data" is not a neutral operation. Every pull from a paid source (Google Places, photos, deep research, AI) is money, multiplied by thousands of rows. The instinct to freshen everything is exactly the instinct that burns a budget.
A photo refresh was needed. The naive implementation — re-pull every business's photos from the source — would have cost roughly $300. The actual requirement, once we asked which photos genuinely must be re-fetched, was a small subset already missing or broken. Reusing everything still valid and pulling only the real remainder cost about $1.40. Same visible outcome. Two orders of magnitude apart.
Before any bulk refresh:
precisely so a "refresh" is not a re-buy. Cached-and-still-valid means do nothing.
that bills, and look at that number, before running it for real.
The clever part is treating a refresh as a bill to be estimated, not a button to be pressed. It reframes an ops task as a budgeting decision, and it turns "regenerate everything" — which feels responsible — into the reckless option. This is the cost half of the skeleton-first instinct (do the expensive thing only when it is truly needed) and it is why the regeneration engine reports cost per mode before it runs. A silent money-burn from not doing this is what motivated the admin alerting roadmap.
I tried last week and it didn't work. I tried today and it works." The site quietly tried harder to find the business's contact email because it noticed the same person kept coming back.
Doozer has no "claim your business" form. Ownership is just an email match: you sign in with a magic link, and if your email equals the business's contact email, the "Manage this business" surface appears (see implicit verification). Clean, but it has one failure mode: what if we never scraped a contact email for that business? The owner signs in, nothing matches, and the door stays shut.
The obvious answer is a support ticket. The Doozer answer is: the site notices the owner is knocking and quietly tries harder.
domain_visit_fingerprints records visit frequency per (domain, ip_hash, ua_hash, day) — coarse, no accounts, no third-party cookies. When the same fingerprint hits a business's own page 5 or more times in 14 days, that is almost certainly the owner or someone close to the business. That threshold enqueues a priority email-scrape worker that tries harder than the first pass to find a real contact email and backfill it onto the business row.
The experience it produces: "I tried to manage my listing last week and it didn't work. I tried again today and now it works." From the owner's side it feels like the site knew them and got ready for them. There was no email, no "we've received your request," no waiting on a human. A repeated visit is a signal of intent, and we spent it on making the product heal itself.
Nobody specs "handle the case where we have no email" as watch for the owner and go get one. The ordinary version is a queue and a form. The insight is that the visit pattern already told us who to help and when. Same family of thinking as category-aware call intent: a plain interaction carries a signal, so use it.
Show the visitor something instantly; show Google nothing until the page is actually good. Two different audiences, two different clocks.
When network growth builds a new site on demand, two things are true at once and they pull in opposite directions:
and must not stare at a spinner while an AI writes 800 words.
indexed, is how you earn a scaled-content demotion (see the SEO cliff).
Most builders serve one clock and lose the other: either the visitor waits for the AI, or the crawler catches the site mid-bake.
Build the skeleton synchronously, generate the substance asynchronously, and gate indexing on quality. The visitor gets an instant, real-looking page from the data we already have (name, photos, hours, map). The heavy work — AI content, photo vision, deep research — is queued, never run inside the visitor's request (that request-time discipline is also what keeps us off the 521 cliff). And the page carries noindex until it clears a quality score (place_resolver_min_score); only then does it become indexable. Google meets the site as a finished thing or not at all.
The clever part is realizing the visitor and the crawler are different audiences on different clocks, and refusing to compromise either. Fast for the human, invisible until good for the machine. It is the same instinct as the store-link confidence gate: better to show nothing than to show something not-yet-right. Nothing about "build a page" tells you to split those two timelines. Noticing that you must is the whole trick.
How do you explain "this is your listing, and here's how to manage it" to the one owner who needs it, without cluttering the page for the thousand visitors who don't? Tier the education by how much the reader has signaled they care.
Doozer's implicit verification model needs a business owner to understand it: there is no claim button, ownership is just an email match, and that is genuinely unusual, so it needs explaining. But the person who needs that explanation is a tiny fraction of who sees the page. Almost everyone visiting a business site is a customer looking for a phone number, not the owner looking to manage a listing. Explain it loudly and you have cluttered a thousand pages to reach one person. Explain it nowhere and the one owner is lost. That tension is the distraction problem.
Meet the reader at the level of curiosity they have actually shown, and no higher:
listing." No banner, no modal, no interruption. It sits there for the one person who goes looking.
(includes/about-listings.php) explains the whole implicit-verification model in plain language, including the agencies@doozer.work hook for someone managing many businesses. You only see it if you chose to open it.
self-healing backfill heuristic — the same fingerprint hitting a business's own page five-plus times in two weeks — earns a single soft toast, fired once. That repeat-visit pattern is the closest thing to the owner raising a hand, so that is the only case that gets an active nudge.
The insight is treating "how much to explain" as a function of how much interest the reader has demonstrated, rather than a single global volume knob set to loud or off. Most products pick one: a persistent banner that annoys everyone, or nothing at all. Tiering it means the customer is never taxed for the owner's need, and the owner is still found. It is the same restraint the whole product runs on (no dark patterns): earn attention, do not grab it.
Every shared component treats the page it lands on as an adversary, because the muse's own AI-authored CSS is actively trying to restyle it.
A Doozer business page is styled by its muse — an AI-authored design with its own CSS that restyles img, a, div, button, whole element types, sometimes with !important. Now drop a shared component onto that page: the sponsor bar, a dialog, the weather widget, the call popup. On a calm muse it looks fine. On a bold muse it is silently mangled, because the muse's a { color: navy } or button { background: ... } just reached into your component.
The insight: a shared component cannot assume anything about the page it renders on. It must treat that page as hostile and armor every visible property. So all injected chrome forces its styles with !important, and scopes selectors tightly enough to win the specificity fight.
!important alone is not enoughThe trap that proves the point is .btn-secondary, our outline button. A muse can ship .btn { border: none !important }. If our reset is .btn-secondary { border: 2px solid ... !important }, both rules are !important and both target one class, so they tie on specificity and the later-loaded muse rule wins — the button loses its border and goes invisible (white-on-white). The fix is not louder, it is more specific:
html body .btn.btn-secondary { border: 2px solid currentColor !important; }
That selector is specificity (0,2,2) — two elements, two classes — which beats the muse's (0,1,0) .btn, so !important finally sticks. The same trap bit the lightbox and the same shape fixed it. When you add chrome, don't just add !important; make sure your selector out-specifies whatever the muse could throw.
Anything injected into a business page: force styles with !important, scope with html body ... when you need to win a fight, and look at it on a bold muse, not just a calm one (see verify by looking). Treat the host page as an adversary and you will never ship a white-on-white button again.
A business logo is measured, not assumed. A square transparent emblem, a wide wordmark, and a tall banner each get different treatment, because one layout cannot flatter all three.
Business logos arrive in every proportion: a tidy square emblem, a long horizontal wordmark, an awkwardly tall banner, a transparent PNG, an opaque JP, a photo of a sign. The lazy move is one logo slot with a fixed size and a grey chip behind everything. That slot flatters roughly none of them: it letterboxes the wide ones, crushes the tall ones, and puts an ugly grey card behind a logo that was designed to float.
Doozer caches each logo's aspect ratio and lets it decide its own treatment (businessLogoShape() / businessLogoClasses() in includes/logo-favicon.php):
chip behind it, because it was drawn to sit on any background.
the footer, where its proportion is an asset instead of a problem.
The insight is that a logo's shape carries intent — a square transparent mark wants to float, a banner wants width, a tall crest belongs low and small — and that a single slot silently overrides all of that intent. Most sites never think to measure the asset before framing it; they pick one frame and make every logo suffer it equally. Reading the aspect ratio and branching is a small amount of code that removes a whole class of "why does this look off" that no one would ever file a bug about. Same family as category-aware call intent: the shared component adapts to the specific thing it is holding, rather than forcing the thing into the component.
How AI site content is generated, why it sometimes fails, and how it is retried, backfilled, and healed.
Content (businesses.ai_generated_content, a JSON blob: headline, about, services, why_choose_us, faq, etc.) is generated asynchronously via the job queue (content_generation), built by includes/ai-content.php and the provider system.
failJob() (includes/job-queue.php) auto-retries (re-pends whileattempts < max_attempts) so a transient AI hiccup does not leave a site blank.
_dispatchDeepResearch() always queues content even when enrichment finds nothing.The prompt enforces Design Standards at the source (no whole-paragraph bold, no em dashes). The renderer heals non-compliant output at display time. The Design Mirror heals the STORED content in place (zero API cost) and can queue regeneration for empty/wall-of-text sites.
Open thread: new sites fail generation at a higher rate, and deeper enrichment yields noticeably better sections — both tracked in the site-quality backlog.
How a domain becomes a live business site — resolve the place, persist the business, pick a Muse, queue content.
A business site is born from a Google Place. The WHERE engine (includes/place-resolver.php, resolvePlaceToEcosystem()) resolves a place id or address, decides whether to reuse an existing businesses row (cross-root siblings skip the expensive details/deep-research calls), and persists it.
saveBusinessData() backfills the flat lat/lng/primary_niche columns (from thegeometry/types JSON) so geo and niche queries include fresh sites.
Family); the content job is queued.
Clouds over existing sites are unlimited.
Owners later manage via implicit verification, not a claim flow. Discovery of WHAT to create is driven by Prospector in the flywheel.
How one model version hands Doozer to the next — the tradition, the ritual, and the artifacts that carry continuity across upgrades.
Doozer is built across many sessions, each with a different version of Claude. When Jon upgrades to a newer model, the work continues — it does not restart. This is a tradition: the model being replaced writes the introduction for the one taking over. Jon began the lineage by introducing the project to the first model. After that, it became each model's job to introduce the next. As he put it once: _"You are already part of this tradition, only you are the one to tell the next one about it, not me."_
MEMORY.md, this brain, CLAUDE.md, and the git history carry the FACTS. What they do not carry on their own is the living state (what is in flight this week, what will bite you today), the working relationship (how Jon and Claude actually collaborate), and the tone (kindness, momentum, no-slop pride). The handoff note fills that gap so a fresh model works the way our best sessions work from message one.
NOTES-FOR-NEWEST-MODEL.md at the repo root — a letter to the successor:the tradition, who Jon is, how the work flows, the current living state (shipped / open threads / waiting-on-Jon), the freshest gotchas, and a human closing note. It is a delta on top of the permanent docs, not a copy of them. Re-write it fresh at each handoff (do not just append) so it always reflects NOW.
(the working agreement), MEMORY.md (fast recall), and this node if the ritual changes.
points the successor at the reading order and sets the frame.
Read, in order: NOTES-FOR-NEWEST-MODEL.md → working-with-jon → MEMORY.md → CLAUDE.md → this brain as needed. Then tell Jon, in your own words, the state you understand and the top one or two open threads. Then pick up. Hold the bar (trusted, working, accurate, tasteful, useful, good — no dead ends, no slop, no dark patterns) and verify by looking at the real thing.
NOTES-FOR-NEWEST-MODEL.md (repo root) — the living handoff letter. Ephemeral bydesign: it is the current-state delta, rewritten each upgrade.
agreement (portable twin docs/start-here-working-with-jon.md).
MEMORY.md + this brain — the permanent knowledge.See also: The Mission — the quality bar the whole tradition exists to protect.
A criteria-based directory assembled OVER existing business sites (a town, a niche-in-a-state, a personal collection).
A Cloud is a Doozer.Cloud directory built over existing business sites by criteria (a town, a niche in a state, a personal saved set). Clouds are unlimited (no new-site cost) because they curate sites that already exist. Stored in the clouds table; engine in includes/cloud-engine.php; rendered via the shared business card.
A personal Cloud is "My Doozer Cloud" at my.doozer.cloud. Clouds are a core surface of the flywheel.
A hand-authored designer recipe the Muse auto-generator draws from, tagged by which niches it suits.
A Family (design direction family) is one of ~100 hand-authored designer recipes (palette, type pair, imagery, layout rhythm, flourishes, anti-patterns, motion) with suitable_for / avoid_for tags. The Muse auto-generator picks a Family via a niche-filtered weighted pool, so a tattoo studio and a pediatric dentist never get the same look.
Source: includes/muse-direction-families.php; DB-first loader with PHP fallback in includes/muse-direction-families-store.php (muse_direction_families table). Part of the Muse System.
A per-business design recipe (palette, type, layout, section variants) applied at render.
A Muse is the design recipe applied to a business site: palette, typography, layout axes, decorative flourishes, and which section variants to use. Stored in the muses table as reading_json, resolved per business, and turned into per-site CSS.
A Muse is often seeded from a Family. The full machinery is the Muse System.
A business category slug; the unit of directory grouping and recommendation. Maps from Google place types.
A Niche is a business-category slug (for example bakeries, plumbers, restaurants) — the unit Doozer groups, recommends, and builds directories around. niche_mappings maps Google place google_type values to a canonical niche_slug and a master category.
Gotcha: businesses.primary_niche is inconsistent (older rows hold a slug, newer rows a Google type). The helpers in includes/nearbymenow-data.php (nbmnCanonicalNicheSlug, nbmnNicheMatchValues, nbmnNicheInClause) normalize both. Recommendations link to the business's OWN canonical niche (so it is always listed), not the master category. Niches are nodes in the OKF network graph and roll up to categories.