CASE STUDY 01 · ROCKS INVESTMENTS

From a broken WordPress site to the system an agency runs on.

We replaced an estate agency’s WordPress site with a seven-language site, a CRM and the automations between them.

rocksinvestments.com · a development page
A development page on rocksinvestments.com: SALT Praia Brava, nine apartments for sale in Itajaí, with the price, the payment plan and a button for prices and floor plans
THE LIVE SITE · ONE DEVELOPMENT, WRITTEN AND PRICED FROM THE NIGHTLY FEED
CLIENT
Rocks Investments, a licensed estate agency in Florianópolis
BUILT
First commit 2 April 2026, live 11 June, run by us since
ROLE
Design, build, AI, hosting and support
383ENQUIRIES IN 2026
R$20M+IN SALES HELPED CLOSE
7LANGUAGES, ONE SITE
01 · THE RESULT

From about 20 enquiries a month to about 60.

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.

383 enquiries in 2026 Rebuilt, 11 Jun Old site

Rocks Investments · every enquiry into the CRM, running total

02 · THE NUMBERS

Month by month, from the CRM.

Every enquiry is one row in the database, whichever door it came through. These are counts, not estimates.

Rocks Investments enquiries and site visits per month, February to 9 November 2026
MonthEnquiriesRunning totalSite visits
OLD SITE · WORDPRESS
Feb66—
Mar2329—
Apr2655—
May1974—
NEW SITE · LIVE 11 JUNE
Jun7114515.4k
Jul8322852.2k
Aug6529333.6k
Sep5034334.9k
Oct3337631.0k
Nov TO 9TH7383—
ROCKS CRM · ONE ROW PER ENQUIRY, 2026, TO 9 NOVEMBER · SITE VISITS COUNTED FROM THE REBUILD ON 11 JUNE
About 20 a month before

March to May on the old site: 23, 26 and 19.

About 60 a month after

June to October on the new one averaged 60, three times the old rate.

83 in the best month

July brought 83 enquiries, more than three times the old site’s best month of 26. 309 have arrived since the rebuild.

What is next
03 · THE STARTING POINT

What the old site could not do.

Rocks ran on WordPress and Elementor. Its own brief put it plainly: “We are throwing WordPress in the bin!”

Before and after the rebuild
PartBEFORE · WORDPRESSAFTER · THE REBUILD
01The audienceWritten mostly for Golden Visa investors.Brazilian and international buyers, in seven languages, with Golden Visa as one guide among several.
02The listingsHeld 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 enquiriesA 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 numbersNo 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.
285PROPERTIES ON THE OLD SITE, COUNTED ON DAY ONE
9,001PHOTOS AND FILES TO MOVE, ABOUT 3.5 GB
278 of 292OLD ADDRESSES KEPT EXACTLY AT CUTOVER; 14 MAPPED BY HAND
04 · THE DECISIONS

Four choices, and what each one cost.

Each choice answered the brief, and each one sent a bill later.

The four architecture decisions, why each was made and what it cost
ChoiceWHY WE CHOSE ITWHAT IT COST LATER
01Astro for the siteAPRIL 2026AstroSpeed 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 2026SupabaseThe 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.jsThe 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 WorkersThe 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.
pagespeed insights · desktop
PageSpeed Insights, desktop: 100 for performance, accessibility, best practices and SEO on a destination page of the new site
WHAT ASTRO BOUGHT · A DESTINATION PAGE SCORED ON 6 JUNE 2026, FIVE DAYS BEFORE LAUNCH
05 · WHAT WE BUILT

Three apps, one database.

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

  1. 01 · BUYERSSeven languagesPT EN ES DE ZH AR RU
  2. 02 · THE SITESearch and listingsASTRO · CLOUDFLARE
  3. 03 · THE DOORName, phone, emailTHEN THE CHAT
  4. 04 · THE COREOne databaseSUPABASE · POSTGRES
  5. 05 · THE CRMLeads and listingsNEXT.JS · CLOUDFLARE
  6. 06 · THE TEAMFollow up and closeAGENTS AND OWNERS

AROUND THE CORE

  • AIWrites and translatesONE GUARD, US$10 A MONTH
  • SCHEDULE26 jobs, day and nightCLOUDFLARE CRON WORKER
  • FEEDSDevelopers’ listingsÓRULO · DWV
THE ROCKS SYSTEM · ONE DATABASE BEHIND THE SITE, THE CRM AND EVERY AUTOMATION
01
THE SITE · ASTRO

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
02
THE CRM · NEXT.JS

The team’s desk

  • Every lead, its agent and its historyHOT, WARM OR COLD, SCORED NIGHTLY
  • Listings through a guided intakeAPPROVED, CLEARED TO SELL, WRITTEN
  • A weekly digestTHE FUNNEL AND THE LEADS BEHIND IT
03
THE CORE · SUPABASE

The engine room

  • One Postgres database303 MIGRATIONS, 83 API ROUTES
  • Jobs that run on their ownLEADS CHASED EVERY 15 MINUTES
  • Photos and email on their own servicesCLOUDFLARE R2 · RESEND
rocksinvestments.com · the markets
The markets page on rocksinvestments.com: four coastal cities, each with its price per square metre, growth and number of listings
THE SITE · FOUR MARKETS, EACH WITH ITS OWN FIGURES
rocks crm · new property
The Rocks CRM: the guided wizard for a new property, on step 5 of 6, media and trust signals, test account
THE CRM · A NEW PROPERTY IN SIX STEPS, TEST ACCOUNT
06 · HOW IT WORKS

Follow one enquiry through it.

Real screens from the site and the CRM, and real code. Pick a step.

rocksinvestments.com · and the system behind it
The rocksinvestments.com home page in English: coastal property in Santa Catarina, with search by location, type, price and bedsSeven languagesSearch from the first screen
rocksinvestments.com: 372 curated properties across Santa CatarinaFilters that match how buyers thinkEvery listing scored before it shows
The contact gate on rocksinvestments.com, with placeholder text: name, phone and email before the chat opens, in WhatsApp, WeChat, Telegram or live chatName, phone and email firstWhichever app the buyer uses
Real code: the lead escalation engine in the Rocks CRM, from run-lead-escalation.ts
The guided listing intake in the Rocks CRM, step 5 of 6: media and trust signalsAnswers feed the AI pipeline
The intelligence hub in the Rocks CRM: what changed and whyWhat changed, and whyWhich writing brings leads

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

07 · THE LISTINGS FEED

A developer’s catalogue, read, mapped and gated.

Every night the system reads Órulo, a developers’ catalogue, and turns each building and unit into a listing.

01
Ask

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.

THE REQUESTGET · ÓRULO API
GET /api/v2/buildings?page=1 HTTP/1.1 1
Host: www.orulo.com.br
Authorization: Bearer •••• 2
Accept: application/json
THE CODE THAT SENDS ITCONNECTORS/ORULO/INDEX.TS · TRIMMED
const 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 T
  1. 1

    Method, endpoint, query. Get the /buildings list, page 1. The code keeps asking for the next page until it reaches total_pages.

  2. 2

    The auth key. An OAuth token, fetched first from Órulo with a client ID and secret kept as server secrets. Shown here as dots.

  3. 3

    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.

  4. 4

    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.

REAL CODE FROM THE ROCKS REPOSITORY · THE KEY IS REDACTED
02
Read

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.

THE RESPONSE200 · APPLICATION/JSON · TRIMMED
{
  "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
}
  1. 1

    The list sits under buildings. The first version looked for results, found nothing and imported zero developments, with no error.

  2. 2

    Two status fields. status folds a launch into “under construction”; stage keeps it apart, so the mapping reads stage.

  3. 3

    The neighbourhood. address.area becomes the neighbourhood buyers filter by.

  4. 4

    How many pages. The first version stopped at page 1 and dropped 30 of the 40 buildings.

A REAL RECORD FROM THE LIVE API, 24 AUGUST 2026, KEPT IN THE TESTS · PUBLIC LISTING DATA, NO PERSONAL DATA
03
Map

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.

IN · ONE ÓRULO TYPOLOGY
{
  "id": "86067",
  "type": "Apartamento",
  "private_area": 36.53,
  "bedrooms": 2,
  "bathrooms": 1,
  "parking": 1,
  "original_price": 359887.54,
  "discount_price": 340000
}
OUT · ONE ROCKS LISTING
{
  "externalId": "86067",
  "developmentExternalId": "56377",
  "title": "Apartamento · 2 dormitórios · 36.53m²",
  "propertyType": "apartment",
  "price": 340000,
  "currency": "BRL",
  "bedrooms": 2,
  "areaSqm": 36.53,
  "neighborhood": "",
  …
}
THE MAPPINGMAPTYPOLOGY() · TRIMMED
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: "", 4
  1. 1

    Categories. “Apartamento”, “Casa” and “Cobertura” become apartment, house and penthouse, the types the site filters on.

  2. 2

    The price. The discounted price wins when there is one.

  3. 3

    A name. Typologies have none, so one is built from kind, bedrooms and area, then translated like any title an agent writes.

  4. 4

    Inherited. Left empty on purpose: city and neighbourhood come from the building the unit belongs to.

REAL INPUT AND OUTPUT FROM THE CONNECTOR’S TESTS · REAL MAPPING CODE
04
Gate

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.

THE PUBLISH GATECONNECTORS/PUBLISH-READINESS.TS · REAL COMMENT
/**
 * 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 FIELD LOCKSCONNECTORS/FIELD-LOCKS.TS · REAL COMMENT
 * 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 4
  1. 1

    A person approves. An admin looks at the development before any AI is spent on it.

  2. 2

    Rocks may sell it. Being in a catalogue is not permission to list.

  3. 3

    Original words. The provider’s text is never republished. The unit waits until Rocks’ own copy is written and scored.

  4. 4

    People win. A price or name fixed by hand stays fixed.

REAL COMMENTS FROM THE CONNECTOR CODE · THE COUNTS ARE FROM THE DECISION LOG, 24 AUGUST 2026
08 · THE LEAD ENGINE

No enquiry is dropped. We learned why the hard way.

What happens to one enquiry, the two days in June it was refused, and what guards it now.

01
AT ONCE

The agent is told

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.

02
AFTER 30 MINUTES

A reminder

No logged contact, and the agent is reminded. Only a logged conversation stops the clock. Moving a card on a board does not.

03
AFTER 24 HOURS

Open to everyone

Still nothing, and the enquiry opens to every agent. Whoever claims it, owns it. The buyer never waits on one person’s phone.

01
Two days of silence

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.

WHAT EVERY ENQUIRY GOT BACKRECREATED FROM THE FIX
✕ POST /rest/v1/leads  →  401
  42501 · new row violates row-level security policy for table "leads"
  1. 1

    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.

  2. 2

    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.

THE ERROR IS RECREATED FROM THE MIGRATION THAT FIXED IT, 16 JUNE 2026
02
One person, one lead

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.

THE MATCHMIGRATION · LEAD_PHONE_SUFFIX_DEDUP.SQL · TRIMMED
-- 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 ASC
  1. 1

    The last eight digits. Long enough to identify a person, short enough to ignore the country code.

  2. 2

    Exact match first. A full match wins over a suffix match, and the oldest lead stays the real one.

REAL SQL FROM THE ROCKS REPOSITORY, 10 SEPTEMBER 2026
03
What the ad platforms are told

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.

WHO MAY BE REPORTEDCONVERSIONS/ELIGIBILITY.TS · REAL CODE
/** 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
}
  1. 1

    Bots. A failed human check marks the row. It is kept for the team and never sent on.

  2. 2

    Duplicates. The same buyer twice is one conversion.

  3. 3

    Our own tests. The hourly test enquiry is not a customer.

  4. 4

    Consent. No “Accept all” on the cookie banner, nothing sent. Agreeing to email is a separate permission.

REAL CODE FROM THE ROCKS REPOSITORY, 3 NOVEMBER 2026
09 · THE TRACKING

What visitors do, written down as it happens.

The site writes small events straight to the database; its public key can add a row, never read one.

01
One event

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.

THE REQUESTPOST · SUPABASE REST · DEMO VALUES
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 body
  1. 1

    Endpoint. The events table, through the database’s REST API.

  2. 2

    The public key. The same for every visitor. The database lets it insert events and nothing else; only staff accounts can read them.

  3. 3

    Minimal answer. The database replies 201 with an empty body, so nothing comes back to the page.

  4. 4

    A fixed list. The event type must be one the database allows; anything else is refused.

THE REAL REQUEST SHAPE FROM SITE/SRC/LIB/SESSION.TS · KEYS REDACTED, VALUES ARE DEMO DATA
SEEN

Where they looked

  • A page was openedpage_view
  • An article was openedinsight_view
  • A listing or a photo was openedview · photo_view
  • How long they stayed, from 3 secondsdwell_time
ACTED

What they tried

  • A search was runsearch
  • A filter was changedfilter_used
  • A listing was clickedclick
  • A map pin was clickedmap_pin_click
ENQUIRED

How close they came

  • The form was shown, then startedform_view · form_start
  • The form was sentform_submit
  • They typed, then leftform_abandon
  • They tapped through to chatwhatsapp_click
02
The enquiry itself

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.

ONE ROW IN LEADSREAL COLUMNS · DEMO VALUES
{
  "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
}
  1. 1

    Who it belongs to. The public key can add a lead; only the agent it is assigned to, or an admin, can read it.

  2. 2

    Where it stands. New, contacted, qualified, then sold or lost. A lead must carry a phone or an email, or the database refuses it.

  3. 3

    What the chart counts. One row, one enquiry, by the month it arrived.

COLUMNS FROM THE ROCKS SCHEMA · EVERY VALUE IS DEMO DATA, NO REAL LEAD
03
In the Console

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.

CONSOLE · STAGINGRECREATED FROM THE FIX
✕ Failed to load resource: the server responded with a status of 401
  ••••.supabase.co/rest/v1/property_analytics_events
THE FIXSITE/SRC/LIB/SESSION.TS · REAL CODE
// 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 null
THE CONSOLE LINE IS RECREATED FROM THE COMMIT THAT FIXED IT · THE CODE IS REAL
10 · THE AI IDEAS

Fifteen AI ideas, and what happened to each.

Nine ideas run today; six were built, used, taken out and are coming back. All obey three rules.

rocks crm · writing an insight
Writing an insight in the Rocks CRM: step 2, the cover, with a generated blue-hour cover of the coast and its alt text written automatically
AN INSIGHT, STEP 2 OF 3 · THE COVER GENERATED, ITS ALT TEXT WRITTEN FOR IT
  1. 01
    A budget it cannot pass

    Every call checks the monthly cap first, then logs its model, tokens and cost.

    US$10 A MONTH, HARD STOP
  2. 02
    A score before anything goes live

    Each listing’s copy is scored out of 100. Below 80, it is rewritten before anyone sees it.

    ONE THRESHOLD · 7 LANGUAGES
  3. 03
    No invented facts

    Prices, 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
rocks · cron worker
Real code: 12 of the 25 scheduled jobs that run the Rocks system, from the cron worker
REAL CODE · 12 OF THE SCHEDULED JOBS, SCHEDULE FIELDS ONLY · 25 WHEN THIS WAS TAKEN, 26 NOW
The schedule

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
Fifteen AI ideas built for Rocks: what each does, how it is built, and whether it runs today
THE IDEAWHAT IT DOES FOR PEOPLEHOW IT IS BUILT
RUNNING TODAY · 9
01Listings written from factsA 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 languagesEvery 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 weekThe 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 listingsA 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 noteAn 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 orderThe 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 listingsEach 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 searchWe 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 itselfA 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 passMatching 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 hubOne 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 agentsEach 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 sorterAgents cleared new leads as a daily game instead of a list.A triage board that locked each day until the queue was done.
14Ten writersArticles in ten voices for different kinds of buyer.Ten invented personas, replaced by one real, named author.
15Social draftsTwo 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.

01
The budget fails closed

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.

WHEN THE BUDGET CANNOT BE READAI/GUARD.TS · REAL CODE · TRIMMED
  } 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
    }
  }
  1. 1

    Closed, not open. If we cannot prove there is budget left, there is none.

  2. 2

    The cap. US$10 a month, set in the database, in the environment and at the provider.

  3. 3

    An error, not a limit. Callers can tell “out of budget” from “could not check”, and say so to the team.

REAL CODE FROM THE ROCKS REPOSITORY · A TEST FAILS THE BUILD IF THIS EVER OPENS
02
When the AI goes dark

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.

THE BACK-OFFAI/TRANSLATE-PROVIDERS.TS · REAL CODE
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
}
  1. 1

    The chain. Claude first for quality, then a model on Cloudflare with its own US$3 cap.

  2. 2

    The back-off. Doubling waits, saved in the database so every server agrees on which provider is down.

REAL CODE FROM THE ROCKS REPOSITORY, 5 NOVEMBER 2026
11 · RUNNING IT

Seven months of keeping it up, and cheap.

Since launch: hosting it for less, catching faults before the team does, fixing the ones that got through.

  1. 2 APRFirst commitASTRO · NEXT.JS · SUPABASE
  2. 11 JUNLive, 70 days later173 REDIRECTS
  3. 27 JULThe cost auditAI US$8.31 · DB 172 MB
  4. 4 SEPSite to Cloudflare WorkersCRM ON A STOPGAP HOST
  5. 11 OCTCRM to Cloudflare Workers6.92 MB OF 10 · 36 MS START
  6. TODAYOne host, one clock26 JOBS · ONE TICK
THE HOSTING PATH · DATES AND MEASURES FROM THE DECISION LOG AND THE MIGRATION NOTES
01
MEASURE FIRST

The audit that moved nothing

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.

02
TESTS THAT GUARD PROMISES

The build refuses

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.

03
CHECKS THAT SPEAK

Hourly and daily

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

01
THE FEED · 3 WEEKS

A number where a word should be

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.

02
THE PHOTOS · 1,395 ROWS

Photos that were never there

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.

03
LAUNCH DAY · 11 JUNE

The quiet fallback

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.

283TEST FILES
40AUTOMATED CHECKS AND PIPELINES
26SCHEDULED JOBS ON ONE CLOCK
12 · WHO DID WHAT

One studio, from the brief to support.

Rikki led it and reviews every release; AI coding agents (Claude, Cursor, Jules) write and test the code.

01
DESIGN

The brief, the screens

The audiences, the order of every page, the design system and the copy, written once and translated into seven languages.

02
BUILD

Site, CRM and core

The site, the CRM, the database and the automations, with tests and a written log of every decision.

03
RUN

Hosting, fixes, costs

Every day since launch: fixes, new features, and both apps moved to Cloudflare in September and October 2026.

70 daysFROM FIRST COMMIT TO LIVE
3,669COMMITS ON 155 DAYS SINCE 2 APRIL 2026
303DATABASE MIGRATIONS

NEXT CASE Quicklitrack

Read the case
14 · CONTACT

Two doors. Pick yours.

Whether you are hiring or building, the first step is the same: one conversation with Rikki Chandler.

DOOR 1 · FOR HIRING TEAMSHiring for integration, solutions or implementation?

Rikki connects customers to systems, fixes what breaks and explains it so it sticks.

  • Technical integration and onboarding
  • Solutions and implementation
  • Customer engineering and support
Read the CVThe full story
DOOR 2 · FOR CLIENTSA business that should learn from its own data?

Tell us the one thing that should change. We start from that question and a written test of how we will know.

  • AI systems that run your operations
  • Product search and recommendations
  • Design, film and training for your team
Start a projectSee the work
STUDIO
ArtiFxn Design
FOUNDER
Rikki Chandler
BASED
Florianópolis, Brazil
TIME
UTC−3, working with teams across time zones

Every door leads to the same person.