The front door
- Seven languages, one siteARABIC SET RIGHT TO LEFT
- A gate before every chatWHATSAPP · WECHAT · TELEGRAM · LIVE CHAT
- Static pages that load at oncePRE-RENDERED, CACHED AT THE EDGE
We replaced an estate agency’s WordPress site with a seven-language site, a CRM and the automations between them.

The old site brought in 74 enquiries in four months. The rebuild went live on 11 June; by 9 November the year’s total was 383.
Rocks Investments · every enquiry into the CRM, running total
Every enquiry is one row in the database, whichever door it came through. These are counts, not estimates.
| Month | Enquiries | Running total | Site visits |
|---|---|---|---|
| OLD SITE · WORDPRESS | |||
| Feb | 6 | 6 | — |
| Mar | 23 | 29 | — |
| Apr | 26 | 55 | — |
| May | 19 | 74 | — |
| NEW SITE · LIVE 11 JUNE | |||
| Jun | 71 | 145 | 15.4k |
| Jul | 83 | 228 | 52.2k |
| Aug | 65 | 293 | 33.6k |
| Sep | 50 | 343 | 34.9k |
| Oct | 33 | 376 | 31.0k |
| Nov TO 9TH | 7 | 383 | — |
March to May on the old site: 23, 26 and 19.
June to October on the new one averaged 60, three times the old rate.
July brought 83 enquiries, more than three times the old site’s best month of 26. 309 have arrived since the rebuild.
What is nextRocks ran on WordPress and Elementor. Its own brief put it plainly: “We are throwing WordPress in the bin!”
| Part | BEFORE · WORDPRESS | AFTER · THE REBUILD |
|---|---|---|
| 01The audience | Written mostly for Golden Visa investors. | Brazilian and international buyers, in seven languages, with Golden Visa as one guide among several. |
| 02The listings | Held in a plugin and custom fields that had no room for a development and its units. | A guided intake. AI writes, scores and translates each listing; developments carry their units; two developer feeds keep them current. |
| 03The enquiries | A WhatsApp tap went straight to an agent’s phone. What happened next relied on trust. | Name, phone and email first, then the chat. Every enquiry lands in the CRM, assigned to the listing’s agent, and is chased if nobody answers. |
| 04The numbers | No view counts for agents, no record of what sold or why. | Every view, search and enquiry is recorded, and a weekly digest shows the funnel and the leads behind it. |
Each choice answered the brief, and each one sent a bill later.
| Choice | WHY WE CHOSE IT | WHAT IT COST LATER |
|---|---|---|
| 01Astro for the siteAPRIL 2026Astro | Speed was the brief’s mission, and the pages had to be plain HTML that search engines and AI assistants can read. Astro builds pages ahead of time and renders on the server only where a page must be live. | Each page carries all seven languages so the switcher is instant. When the catalogue grew by about 640 pages in July, cold pages timed out. Fetching one language was the obvious fix and would have broken the switcher. We shared one fetch between requests for 60 seconds and widened the cache. |
| 02Supabase, not FirebaseAPRIL 2026Supabase | The data is relational: agents, properties, leads, sales, developments, units. Postgres fits that shape, at a fixed monthly price instead of a charge for every read. | Row-level security is powerful and sharp. In June one policy was changed by hand and enquiries were refused for two days. Now every policy change is a migration, and a test fails the build when a policy is too loose. |
| 03Next.js for the CRMAPRIL 2026Next.js | The CRM is an app, not pages: sign-in, forms, and every change made on the server against the same database the site reads. | A heavy bundle. The first move to Cloudflare Workers in September was 1.2 MB over the limit. It moved in October at 6.92 MB of a 10 MB limit, once a 61-second translation job was reworked to fit a 30-second cap. |
| 04One clock for every jobJUNE 2026Cloudflare Workers | The first host allowed two scheduled jobs. So one small worker ticks every 15 minutes and calls each job when it is due. There are 26 today. | No job may run long. The weekly article was one long run and crashed twice in production. It is now twelve short steps, one per tick, each saved before the next begins. |

A public site, a CRM, and one database that both of them and every automation share.
AROUND THE CORE


Real screens from the site and the CRM, and real code. Pick a step.
Seven languagesSearch from the first screen
Filters that match how buyers thinkEvery listing scored before it shows
Name, phone and email firstWhichever app the buyer uses
Answers feed the AI pipeline
What changed, and whyWhich writing brings leadsA buyer arrives in their own language, on a page that loads at once, and can search from the first screen.
372 curated properties when this was taken, each one scored before it shows, with filters for city, neighbourhood, type and budget.
Before any chat opens, the buyer leaves a name, phone and email, so no enquiry lives only in one agent’s phone.
The enquiry lands in the CRM and alerts the team at once. No contact in 30 minutes: a reminder. Nothing after 24 hours: it opens to every agent.
A guided intake feeds an AI pipeline that writes, scores and translates each listing. It goes live only when it is approved, cleared to sell and written.
The intelligence hub, built in July: what sold, which writing brought leads, and what changed and why. It was folded into a weekly digest in September and is coming back.
Every night the system reads Órulo, a developers’ catalogue, and turns each building and unit into a listing.
A request names the list it wants and which page. A short-lived pass, issued to Rocks alone, proves who is asking. The pass stays on the server, never in a page a buyer loads.
GET /api/v2/buildings?page=1 HTTP/1.1 1
Host: www.orulo.com.br
Authorization: Bearer •••• 2
Accept: application/jsonconst url = new URL(this.cfg.apiBase + path)
for (const [k, v] of Object.entries(params ?? {})) {
if (Array.isArray(v)) {
for (const item of v) url.searchParams.append(k, item) 3
} else {
url.searchParams.set(k, v as string)
}
}
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
},
})
if (!res.ok) throw new OruloHttpError(res.status, …) 4
return (await res.json()) as TMethod, endpoint, query. Get the /buildings list, page 1. The code keeps asking for the next page until it reaches total_pages.
The auth key. An OAuth token, fetched first from Órulo with a client ID and secret kept as server secrets. Shown here as dots.
Repeated parameters. Image sizes go as dimensions[]=2280x1800&dimensions[]=1024x1024. One comma-joined value was refused with “Parâmetros inválidos” until the docs were read.
Failures keep their status. A 404 for one building can be told apart from an outage, so one missing building no longer turns the whole sync red.
The answer is JSON: one page of buildings, then how many there are in all. Each building carries its name, developer, stage and place. Checking it field by field against the live API, in August 2026, found two bugs in the first version.
{
"buildings": [ 1
{
"id": "56377",
"name": "Griffe Campo Limpo - Fase 1",
"status": "Em construção",
"stage": "Lançamento", 2
"developer": { "id": "1161", "name": "EPH" },
"address": { "area": "Jardim Ana Maria", "city": "São Paulo" }, 3
"default_image": { "2280x1800": "https://static.orulo.com.br/…/1773750.jpg" }
},
…
],
"total": 40,
"page": 1,
"total_pages": 4 4
}The list sits under buildings. The first version looked for results, found nothing and imported zero developments, with no error.
Two status fields. status folds a launch into “under construction”; stage keeps it apart, so the mapping reads stage.
The neighbourhood. address.area becomes the neighbourhood buyers filter by.
How many pages. The first version stopped at page 1 and dropped 30 of the 40 buildings.
A building is the item. Its typologies are the variations: the same flat with two bedrooms or three, each with its own size and price. Type, stage and neighbourhood are the categories buyers filter by.
{
"id": "86067",
"type": "Apartamento",
"private_area": 36.53,
"bedrooms": 2,
"bathrooms": 1,
"parking": 1,
"original_price": 359887.54,
"discount_price": 340000
}{
"externalId": "86067",
"developmentExternalId": "56377",
"title": "Apartamento · 2 dormitórios · 36.53m²",
"propertyType": "apartment",
"price": 340000,
"currency": "BRL",
"bedrooms": 2,
"areaSqm": 36.53,
"neighborhood": "",
…
}propertyType: t.type ? (KIND_MAP[t.type.toLowerCase()] ?? "apartment") : null, 1
price: t.discount_price ?? t.original_price ?? 0, 2
title: buildTypologyTitle(t), 3
neighborhood: "", 4Categories. “Apartamento”, “Casa” and “Cobertura” become apartment, house and penthouse, the types the site filters on.
The price. The discounted price wins when there is one.
A name. Typologies have none, so one is built from kind, bedrooms and area, then translated like any title an agent writes.
Inherited. Left empty on purpose: city and neighbourhood come from the building the unit belongs to.
A feed can be wrong, so nothing from it goes live on its own. A unit publishes only when three things are true, and anything an admin corrects by hand is locked so the next sync cannot undo it.
The first clean run, on 24 August 2026: 40 developments, 82 properties, 89 images, no errors.
/**
* A connector unit only goes LIVE on the public site when ALL THREE hold:
* 1. the development is content-`approved` (approval_status='approved') 1
* 2. the development is cleared to sell (sale_authorized=true) 2
* 3. the unit has Rocks copy (ai_review_score IS NOT NULL) 3
*/ * The daily importer overwrites provider-managed columns on every sync. When an
* admin edits one of those columns we add its name to `locked_fields`; the
* importer then SKIPS it (stripLocked) so the override survives re-sync 4A person approves. An admin looks at the development before any AI is spent on it.
Rocks may sell it. Being in a catalogue is not permission to list.
Original words. The provider’s text is never republished. The unit waits until Rocks’ own copy is written and scored.
People win. A price or name fixed by hand stays fixed.
What happens to one enquiry, the two days in June it was refused, and what guards it now.
The enquiry is saved, assigned to the listing’s agent and emailed to them, with the owners copied. If the main route fails, the page saves it a second way and retries.
No logged contact, and the agent is reminded. Only a logged conversation stops the clock. Moving a card on a board does not.
Still nothing, and the enquiry opens to every agent. Whoever claims it, owns it. The buyer never waits on one person’s phone.
On 15 June, four days after launch, enquiries stopped. Nothing looked broken: the form showed its thank-you and no alarm rang.
A security rule on the leads table had been tightened by hand in the database console, and no file recorded the change. Every enquiry was refused with a 401, and the page only retried on a 5xx, so it gave up quietly.
The fix restored the rule as a migration, logged every refusal, and added a check that sends a test enquiry every hour.
✕ POST /rest/v1/leads → 401
42501 · new row violates row-level security policy for table "leads"The cause. A change made in a console, outside the code. Since then the database is changed only by migration files, and a gate fails any release whose migrations are not applied.
Why nobody knew. Every check was green because none of them spoke. Checks now open an issue when they fail and close it when they pass.
A buyer who enquires twice should be one lead, not two agents chasing the same person. The database matches on the phone number as it arrives.
In September a buyer sent the same number with the wrong country code and became two leads. The match now also compares the last eight digits, which survive a wrong or missing country code.
-- National-number suffix: last 8 digits, survives a swapped/missing
-- country calling code or trunk "0" on either side of the comparison.
v_phone_suffix := CASE
WHEN length(v_phone_norm) >= 8 THEN right(v_phone_norm, 8) 1
ELSE NULL
END;
…
ORDER BY (regexp_replace(phone, '[^0-9]', '', 'g') = v_phone_norm) DESC, 2
created_at ASCThe last eight digits. Long enough to identify a person, short enough to ignore the country code.
Exact match first. A full match wins over a suffix match, and the oldest lead stays the real one.
When Rocks began paid campaigns in November, each real enquiry had to be reported to Meta and Google from the server, so the platforms learn which ads bring buyers.
Four kinds of row are never reported. The rule is one small function, and it runs before anything leaves the server.
/** null = may be sent. Bots are never reported, consent or not. */
export function adSignalIneligibility(lead: ConversionLead): IneligibleReason | null {
if (lead.bot_suspected === true) return "bot_suspected" 1
if (lead.is_duplicate === true) return "duplicate" 2
if (/^(zzz|canary)/i.test(lead.name ?? "")) return "test_row" 3
if (!hasAdConsent(lead)) return "no_ad_consent" 4
return null
}Bots. A failed human check marks the row. It is kept for the team and never sent on.
Duplicates. The same buyer twice is one conversion.
Our own tests. The hourly test enquiry is not a customer.
Consent. No “Accept all” on the cookie banner, nothing sent. Agreeing to email is a separate permission.
The site writes small events straight to the database; its public key can add a row, never read one.
When the enquiry form appears, the page sends one line to the database. Nothing in it names the buyer: a random session ID, what happened, and on which page.
POST /rest/v1/property_analytics_events 1
Host: ••••.supabase.co
apikey: •••• 2
Authorization: Bearer ••••
Content-Type: application/json
Prefer: return=minimal 3
{
"session_id": "demo-session-id",
"event_type": "form_view", 4
"property_id": null,
"property_slug": null,
"page_path": "/",
"duration_seconds": null,
"metadata": { "component": "lead-capture-form", "source": "home" },
"photo_index": 0
}
← 201 Created · no bodyEndpoint. The events table, through the database’s REST API.
The public key. The same for every visitor. The database lets it insert events and nothing else; only staff accounts can read them.
Minimal answer. The database replies 201 with an empty body, so nothing comes back to the page.
A fixed list. The event type must be one the database allows; anything else is refused.
Enquiries are not events. Each one is its own row in the leads table, stamped with the time it arrived. The chart and the table at the top count those rows month by month; the events show the way there.
{
"id": "••••",
"property_id": "••••",
"agent_id": "••••", 1
"name": "Demo Buyer",
"phone": "+55 48 90000-0000",
"email": "you@example.com",
"source": "whatsapp_gate",
"status": "new", 2
"created_at": "2026-07-14T13:05:00Z" 3
}Who it belongs to. The public key can add a lead; only the agent it is assigned to, or an admin, can read it.
Where it stands. New, contacted, qualified, then sold or lost. A lead must carry a phone or an email, or the database refuses it.
What the chart counts. One row, one enquiry, by the month it arrived.
On the staging copy of the site, every page tried to send its events with a key the staging database refused. Chrome logged each 401 in the Console as “Failed to load resource”, and Lighthouse counts console errors, so the quality score fell. The fix, on 5 June 2026: send events only from the live domain.
A quieter gap turned up in June: 16k page views, and not one form event. The tracking call needed a property, so the home and landing pages could send nothing, and the database’s list of event types had no form_view or form_submit. Both were fixed, the database first.
✕ Failed to load resource: the server responded with a status of 401
••••.supabase.co/rest/v1/property_analytics_events// Skip tracking on staging/preview domains — the anon key is blocked by RLS
// and the 401 response triggers a browser console error that fails Lighthouse.
const host = globalThis?.location?.hostname ?? ''
if (host && !host.endsWith('rocksinvestments.com')) return null
if (host.startsWith('rockdev.')) return nullNine ideas run today; six were built, used, taken out and are coming back. All obey three rules.

Every call checks the monthly cap first, then logs its model, tokens and cost.
US$10 A MONTH, HARD STOPEach listing’s copy is scored out of 100. Below 80, it is rewritten before anyone sees it.
ONE THRESHOLD · 7 LANGUAGESPrices, sizes and visa thresholds come from the database. The AI arranges and translates them; it never writes them.
FACTS FROM FIELDS, NOT FROM THE MODEL
26 jobs run on their own, around the clock: leads chased every 15 minutes, developer feeds synced, translations healed and SEO repaired every two hours, and saved-search alerts emailed to buyers every week.
Every idea, one by one| THE IDEA | WHAT IT DOES FOR PEOPLE | HOW IT IS BUILT |
|---|---|---|
| RUNNING TODAY · 9 | ||
| 01Listings written from facts | A developer’s feed arrives as raw fields. Buyers read original copy in Rocks’ voice, never the provider’s text. | Claude writes from the database fields under a written content standard. A review scores the result out of 100; under 80, it is rewritten. |
| 02Seven languages | Every listing and article reads in Portuguese, English, Spanish, German, Chinese, Arabic and Russian. | English first, then one batch call for the other six. A queue, a second provider and a daily health check keep it moving. |
| 03One researched article a week | The site answers what buyers are searching for now, without repeating itself. | A brief built from Search Console demand and competitor gaps. A topic that collides with a published page is dropped. Twelve saved steps, one every 15 minutes. |
| 04Price list to listings | A developer sends a PDF price list. The team checks draft units instead of typing them in. | Claude turns the PDF’s text into units. An admin sees every price change and confirms before anything publishes. |
| 05Chat to CRM note | An agent pastes a WhatsApp chat or a screenshot and gets a tidy note on the lead. | Claude extracts it, the agent edits and approves, and only the approved text is stored. |
| 06Photos in the right order | The strongest photo leads, and a floor plan is never the cover. | Vision ranking of each gallery, and a classifier that tells a floor plan from a photo. |
| 07Matching buyers to listings | Each lead shows the listings that fit, by budget, place, type and behaviour. | Plain arithmetic, not a language model: four scores weighted 0.35, 0.30, 0.20 and 0.15, kept in a versioned table. |
| 08Seen by AI search | We know whether AI assistants mention Rocks when asked about property in Santa Catarina. | A script asks a grid of questions and counts mentions of Rocks and its competitors, inside a budget per run and per month. |
| 09SEO that repairs itself | A page with a broken title or a missing description is fixed before anyone notices. | Rules repair first. AI is used only when the rules cannot, and the page is checked again. |
| BUILT, THEN TAKEN OUT ON 8–9 SEPTEMBER 2026 · 6 · COMING BACK | ||
| 10The weekly learning pass | Matching and article topics improved from what actually brought enquiries. | Weights moved at most 0.05 a week, needed a minimum sample, and stepped back on their own if the new version did worse. |
| 11The intelligence hub | One screen telling the owner what changed this week, and why. | The numbers were worked out in code. Claude only put them into words, under rules against adding anything. |
| 12A response score for agents | Each agent saw how fast they answered, with a coaching email built from their own numbers. | Scored in SQL on business hours, with templated emails. No model involved. |
| 13The lead sorter | Agents cleared new leads as a daily game instead of a list. | A triage board that locked each day until the queue was done. |
| 14Ten writers | Articles in ten voices for different kinds of buyer. | Ten invented personas, replaced by one real, named author. |
| 15Social drafts | Two or three ready-to-post drafts a day. | Drafted for a person to post. Nothing posted itself. |
On 8 and 9 September we simplified the system: 136 files out in three commits. Nothing was lost. Each idea is in the history with its tests, and they return one at a time, the learning pass first.
Every AI call checks the month’s spend first. The hard question was what to do when that check itself fails.
The first version let the call through. A database hiccup could then mean unlimited spend. Now an unverifiable budget stops the AI, and the site carries on without it.
Measured over 30 days in July 2026: US$8.31.
} catch (err) {
…
// Fail CLOSED — the budget is unverifiable, so we cannot allow spend. 1
console.error('[AI Guard] Budget check failed — failing closed:', err)
return {
allowed: false, spent: 0, cap: 10, 2
remaining: 0, percentUsed: 0,
isWarning: false, isExceeded: false, isError: true, 3
}
}Closed, not open. If we cannot prove there is budget left, there is none.
The cap. US$10 a month, set in the database, in the environment and at the provider.
An error, not a limit. Callers can tell “out of budget” from “could not check”, and say so to the team.
Translation depended on one provider, and between June and November it was unreachable six times: credits ran out, a key was revoked, our own cap was hit. Each time, new listings sat untranslated until someone noticed.
Now each translation runs down a chain. A provider that fails for an account reason is skipped, then tested again after one hour, then two, four, eight and twelve.
export const TRANSLATE_PROVIDERS = ["anthropic", "workers-ai"] as const 1
const CIRCUIT_BASE_MS = 60 * 60 * 1000 // first retest after 1h
const CIRCUIT_MAX_MS = 12 * 60 * 60 * 1000 // never wait more than 12h
/** Backoff for the Nth consecutive trip (1-based): 1h, 2h, 4h, 8h, 12h, 12h… */
export function circuitRetestDelayMs(tripCount: number): number {
const n = Math.max(1, Math.floor(tripCount))
return Math.min(CIRCUIT_BASE_MS * 2 ** (n - 1), CIRCUIT_MAX_MS) 2
}The chain. Claude first for quality, then a model on Cloudflare with its own US$3 cap.
The back-off. Doubling waits, saved in the database so every server agrees on which provider is down.
Since launch: hosting it for less, catching faults before the team does, fixing the ones that got through.
In July the running cost looked too high, and AI was the suspect. We measured before changing anything: AI was US$8.31 a month and the database 172 MB. The cost was plan fees, not usage. One heavy job went from every 15 minutes to hourly.
Beyond unit tests, contract tests hold the promises that broke once: the app and the database agree on every column, no security rule is too loose, the AI budget fails closed. A release with an unapplied migration does not ship.
A test enquiry every hour. Checks on translations, dead photos, feed limits and page speed. A failing check opens one issue, adds to it while it fails and closes it when it passes.
THREE FAULTS WORTH TELLING
One building’s registration arrived as a bare number, not text. Trimming it crashed the import and took the whole catalogue with it, quietly, for three weeks. Every free-text field now passes through one function that makes it text first.
1,395 photo records pointed at files that had never been uploaded, left by the spring import. Matching by what a picture looks like, not its name, recovered 144 originals from the old site’s export. A weekly check now looks for dead photos.
Galleries showed one photo and the articles were empty. The live build was missing its database settings and had silently used a local copy. A build without them now fails loudly instead of shipping.
Rikki led it and reviews every release; AI coding agents (Claude, Cursor, Jules) write and test the code.
The audiences, the order of every page, the design system and the copy, written once and translated into seven languages.
The site, the CRM, the database and the automations, with tests and a written log of every decision.
Every day since launch: fixes, new features, and both apps moved to Cloudflare in September and October 2026.
The system catches every enquiry. The next step is bringing even more buyers to it.
Neighbourhood and property-type pages in each language, built only where there are enough listings to fill them.
The Golden Visa guide and its country comparison are still climbing in search. Getting them to the top is the next target.
Matching and topics that improve from what brought enquiries, with the same limits as before: small steps, enough data, and an automatic step back.
NEXT CASE Quicklitrack
Read the caseWhether you are hiring or building, the first step is the same: one conversation with Rikki Chandler.
Rikki connects customers to systems, fixes what breaks and explains it so it sticks.
Tell us the one thing that should change. We start from that question and a written test of how we will know.
Every door leads to the same person.