What Courtyard does
Courtyard makes small businesses discoverable inside AI assistants. A business connects its website, Courtyard extracts a from it, publishes that as a live ai.{hostname} site, registers it with search engines and , and gives the owner an , Eve, they can talk to about their own business.
I worked on the backend of the main product, and my term split into two halves. The first was discovery and content: getting published sites by Google, Bing, IndexNow and Brave, AI answer-engine caches, and building the image pipeline that fills a customer’s media library from their own website. Along the way I built the eval suite for Eve.
The second was the growth loop. A stranger texts our number, gets a free analysis of how visible their site is to AI assistants, signs up for an account entirely over text, and pays over text too. No browser at any point.
Go-live registration
Telling search engines a site exists, the moment it can be reached.
When a customer’s site went live, nothing told Google, Bing or IndexNow it existed. Pages published later were never resubmitted, and there was no way to know whether a submission had worked.
Going live is messier than a flag flipping. has to propagate, and the site’s on is issued asynchronously. Until that certificate is applied, the site isn’t reachable over HTTPS, even when DNS already points at it.
So registration is one job with a fixed order. It polls the site for liveness: four attempts ninety seconds apart, each fetch with a ten-second timeout. If the certificate issues during that window, the job applies it itself, because nothing else in the system does. Once the site answers, it fans out to seven providers with , and it saves every provider’s result before deciding whether to fail, so even a run where everything failed leaves a record of what was tried and why.
index providers
Real indexing. These, and only these, decide whether a site shows as healthy.
cache warmers
Warm AI answer-engine caches. Reported, but a failure here can never turn a site red.
How it got that shape
Every revision was forced by a discovery, not by theorising.
The first version hooked the wrong place. I put the trigger where I happened to see the site’s active flag flip. A QA review found three structural problems: duplicate jobs from , four other code paths that flip the same flag without ever creating the job, and a permanently stuck state if job creation crashed after the flip. The “fires once on go-live” guarantee wasn’t enforced anywhere.
I couldn’t fix the four-paths problem without understanding how certificates, CloudFront tenants and the two DNS setup flows actually worked, so I stopped and read. That surfaced two things I hadn’t known.
Certificate issuance isn’t certificate application. The certificate authority issues it, but HTTPS doesn’t work until it’s applied, and after the flag flipped nothing reliably applied it. A job running ninety seconds after DNS verification would have failed in production for most users.
Failed jobs are . The queue has no automatic retry. If the handler throws, the job is dead.
Those two facts together are why the final design is one job with a wide window that applies the certificate itself, rather than a short job assuming something else did. I also moved the trigger to the one point where both DNS setup flows converge, so a single insertion point covers every path. The window grew across revisions too, from three attempts thirty seconds apart: once a job only gets one shot, it has to cover the worst case of issuance plus propagation, not the typical one.
The advisory lock bug, the one I’d lead with. To keep registration , I wrote a helper backed by a Postgres that deduplicated against jobs in a started state. Separately, as , I removed a guard so certificate activation kept running on every publish as a fallback. Each change was correct on its own. Together they were a bug: the helper now ran on every publish, and a job that had already succeeded no longer counted, so every future publish created a fresh registration job and hit all four provider APIs again.
the bug
Dedup on started jobs only
- Publish 1No job running: create one. It succeeds.
- Publish 2×Nothing started, succeeded doesn’t count: new job.
- Publish 3×Same again: all four provider APIs hit again.
the fix
Dedup on started or succeeded
- Publish 1No job yet: create one. It succeeds.
- Publish 2A succeeded job exists: skip.
- Publish 3Skip. Fires once per business lifetime.
The fix was small. The lesson is the shape of the mistake: neither change was wrong, and the interaction only existed because I’d reasoned about them in separate sittings.
The Bing tag, and infrastructure disagreeing with code. Bing verification needs a meta tag live on the site. My first version patched it straight into the stored index page in , which worked once and was then wiped by the next publish. The fix was to inject Bing’s token into the page’s alongside Google’s, so the template renders it on every publish.
Verification still failed. The tag was provably in storage, but CloudFront’s was still serving the old HTML when my check ran thirty seconds later. Waiting sixty seconds fixed it. It’s the clearest example I have of the code being correct and the deployed system still being wrong.
- Attempt 1Registration patches the verification tag straight into the stored index page.
- Next publishThe publish pipeline rewrites the page. The tag is gone.
- FixThe token is injected into page frontmatter, so the template renders it on every publish.
- Check at 30sThe tag is provably in storage, but the edge cache is still serving the old HTML.
- FixWait 60 seconds before checking. Frontmatter persistence is the long-term fix.
A miss worth being honest about. Weeks after the first version shipped, a fifth activation path was added that I had no visibility into, and registration never fired for it. The original four were something I found and fixed. The fifth one found me.
The index job
Resubmitting only what changed, as a job of its own.
Search submission originally lived inside the knowledge publish job, so retrying a failed indexing run meant re-running the entire publish pipeline, with nothing to monitor or debug on its own. I pulled it out into its own job with its own lifecycle, logging and retry.
The first version was a hardcoded orchestrator. I rewrote it behind a , and later three more providers were added in three pull requests with zero orchestrator changes, which is the proof the abstraction earned its keep.
Changed pages travel from the publish job on a new metadata column, so the job submits only what changed, and all nine places that trigger a publish got that behaviour without being touched. The handler treats the metadata as untrusted: it reads the hostname from the business record, parses the metadata behind a strict , and falls back to every visible page if it’s missing or malformed.
Deleted pages are tracked separately from changed ones, because providers want different things. IndexNow uses a to de-index, Google relies on the , and Bing recrawls:
| provider | on a deleted page | why |
|---|---|---|
| IndexNow | Submits the deletion | the 410 tombstone tells engines to de-index |
| Skips it | the sitemap already carries the change | |
| Bing | Submits it for recrawl | so the removal is picked up |
The , and why order matters. Provider errors are built from raw HTTP responses and written somewhere an admin reads them, so I wrote a six-pattern redactor, with errors capped at 500 characters. have to be stripped first. Otherwise the generic key-and-value rule matches “authorization:” as the key, consumes the word Bearer as its value, and leaves the real token exposed.
raw provider error
authorization: Bearer tok_abc123
key-value rule first: “Bearer” is eaten as the value
authorization: [redacted] tok_abc123
bearer rule first: the token itself is removed
authorization: Bearer [redacted]
The image pipeline
Filling a media library from a customer’s own website.
Customer knowledge pages had no imagery, and scraping a small-business site naively returns hundreds of spacer GIFs, nav icons, third-party insurance logos, stock photos, and the same photo at six different URLs.
The answer is a five-stage, in-memory pipeline, plus an owner-facing media library, a publishable media page, on-demand recrawl and a rolling . It’s also the first thing I planned before building: a parent ticket with sub-tickets, a design doc, and throwaway scripts run against real sites before any of it went into the app. That was a direct correction of an earlier ticket, where I started deleting code before I understood the pipeline it belonged to.
Extract
I didn’t design this. Real sites beat me into it.
The extractor runs a render and a plain fetch, and merges both. That isn’t redundancy. Some hosts, like GoDaddy, serve pages a renderer sees as empty, while other builders serve stub HTML specifically to automated browsers. Whichever one a site defeats, the other still returns something. It crawls up to 40 pages, down from an earlier 200 so heavy sites still fit the time budget, and runs the free filters first: skip URLs already in the library, and drop obvious stock photography by filename.
Every one of those decisions was forced by a real site. A plain HTML scan, the way logo extraction already worked, missed a restaurant’s hero photo because it was a CSS background, and found three of a grooming site’s eight or nine images because the rest on scroll. Rendering only when the scan came up short didn’t work either: you can’t tell a site with few images from one whose images haven’t loaded yet.
The first render kept anything served from the site’s image , and that CDN serves everything. Filtering on what each file actually is cut the result to five, because the render jumped to the bottom in one motion and every middle section’s lazy loads were skipped. Scrolling one viewport at a time, forty steps with a 600ms pause, brought it to sixteen.
First render: kept anything served from the site’s image CDN, which also serves scripts and fonts.
Filtered by what each file is, not where it’s hosted. But the page jumped to the bottom in one motion, so middle sections never lazy-loaded.
Scrolled one viewport at a time. The single highest-value line change in the pipeline.
“images” returned on one test site at each step
The rest came from sites picked deliberately to break it. A Next.js portfolio’s buried real URLs in a query string, and unwrapping them both rescued the images and collapsed size variants of one photo. and site-builder chrome are dropped by domain. Cart, checkout and account pages are skipped outright, and every page is isolated, so one failure is logged rather than losing the whole run.
| site | what broke | change it forced |
|---|---|---|
| Restaurant | Hero photo was a CSS background, invisible to an HTML scan | Render the page |
| Grooming site | Found 3 of 8 or 9 images: the rest lazy-load on scroll | Render and scroll |
| Next.js portfolio | Real URLs buried in the image optimiser’s query string | Unwrap before filtering |
| Ad-heavy WordPress | Tracking pixels end in an image extension | Drop ad and tracking domains |
| Online store | Cart page redirected mid-scroll and killed the whole run | Isolate every page |
| Wix | Empty shell to the renderer, full page to a plain fetch | Always union both |
Classify
Keep or drop, the same way every time.
GPT-4o-mini gives each image a keep-or-drop verdict at 0 and low detail, with . There are two ways to be dropped: junk, meaning spacers, icons, logos and decorative chrome, and not theirs, meaning a third party’s imagery embedded in the customer’s site.
The relevance check uses the business’s context, and when that context is missing it falls back to junk-only rather than guessing at relevance with no basis.
Dedup
Same photo, or just the same kind of photo?
A catches the same photo at a different size. It structurally can’t catch the same scene at a different crop, because cropping changes the low-frequency structure the hash is built from. So dedup is a two-pass cascade: a cheap, strict pHash first, then -small embeddings on whatever survives, clustered with .
Pixels go first, not a model, because comparing every image against every other with a model grows quadratically in cost, while hashing has no per-call price at all. Hashes are compared by . In the earlier separate-scripts version, a single image was effectively fetched four times, once per stage, which was slow, hammered the customer’s own site, and tripped hotlink protection.
Two details in the hash matter. The image is squashed to a square with a fill resize, so the whole image contributes; centre-cropping would throw away the edges that tell photos apart and merge unrelated shots with a similar middle. And the of the is dropped, with the other 63 coefficients hashed against their own median, because the DC term would otherwise be a bit that never changes.
Merging on embeddings needs two conditions: the closest variant pair must reach 0.92 , and the whole images must reach 0.82.
Variant-pair similarity merge threshold 0.92
The shaded band, roughly 0.85 to 0.95, is where DINOv2 scores two different images of the same kind of thing: two rings on bright stones, two head-on storefronts. A threshold that high still overlaps it.
Whole-image similarity floor 0.82
The second condition stops two different subjects merging just because they share a centred composition.
Memory needed care too. Keeper bytes are held across stages so describing and uploading never re-fetch someone else’s URL, while each collapsed duplicate’s buffer, up to 25 MB, is freed straight away. Fetching goes through the shared hardened fetch rather than my earlier hostname check, because it’s the DNS layer that actually blocks tricks like a hostname that quietly resolves to localhost. A last check removes images the business already has stored.
Describe and store
Descriptions written for another AI, then stored safely.
A vision model describes images six at a time, again at temperature 0, given both the business’s context and the site author’s alt text, so descriptions are grounded rather than generic. They lead with the subject, like “A Saint Bernard with a black-and-white coat…”, instead of narrating “the image,” because the text is context for another AI, not a caption for a person. The same call assigns a category and tags. Bytes are sent inline so the model doesn’t re-download an image we already hold, and formats it can’t read are converted first rather than failing silently.
Storing strips metadata, re-encodes to at up to 2048 pixels and quality 82, uploads to S3 and upserts the image row. Each image is keyed by its content fingerprint, so a re-run overwrites the same object instead of piling up copies, and storage errors are reduced to their message before logging, because the SDK attaches credentials to its own error objects.
Around the pipeline
Triggers, recrawls, and a page that stays generated.
Trigger. The job runs as a edge after onboarding ingestion, but only when storage is configured. A job that runs as a no-op still counts as a run, and would permanently poison the dedup baseline for that business.
Publishing is deliberately not a DAG edge. The job queues a republish only if images were actually stored and the media page is already live, and it records that media changed so a retry landing between storing and queueing doesn’t lose the publish. A recrawl that finds nothing new must not fan out republishes across every locale.
Fingerprints stored from day one. Every row keeps its exact-file hash, perceptual hash and embedding, with first-seen and last-seen times, before the recurring recrawl existed. The job already computes all three, so storing them was nearly free, and it turned the future cron into a database comparison instead of a that re-downloads every image ever stored. Pixel dimensions are kept for the same reason, so the gallery can size every tile without downloading a hundred full-size images to measure them.
Recrawls. A weekly cron picks up to 50 businesses per run that have just crossed a 90-day mark, skipping any with a run in flight, so the work rolls smoothly across weeks. Businesses from before the feature were backfilled by a script that dry-runs by default, refuses production without explicit confirmation, skips anything already crawled, and ran in small daily batches, partly for cost and partly so their 90-day dates don’t all fall due at once.
Images from anywhere else, like an emailed photo or a manual upload, come in through the same single entry point and the same sanitisation. Owners get the media library, a publishable media page, a recrawl button, and edit fields with limits.
The eval suite
Eve had no regression signal. Every change to it was a guess.
An is a test suite for a nondeterministic system: you can’t assert exact output, so you assert properties of it. Eve’s suite has three layers, from strictest to fuzziest.
- 1
Deterministic assertionsno model involved
Checks over the raw event stream: read the page before editing it, and never pass the canned no-answer fallback.
- 2
LLM-as-judgeclosedQA, threshold 0.7
Business-neutral rubrics, so the same case grades behaviour across an advisory firm and a restaurant.
- 3
Factualitythreshold 0.4
The reply compared with the business’s own stored pages, which catches a confident answer with wrong facts.
The first layer checks the agent’s , every tool call in order. Tool ordering, like reading a page before editing it, becomes a hard assertion rather than a rubric’s opinion, and any run that returns the canned no-answer fallback fails, because saying nothing otherwise passes every rubric by saying nothing wrong. The second layer is with rubrics. The third is against the business’s own stored pages.
There are nine buckets of cases, run against two real test businesses, with the agent given exactly the nine tools production has. I built on the eve framework’s own runner, which already ships evals, tool-call assertions and a judge, reading an old unmerged prototype for patterns and then starting fresh.
The setup was most of the actual time. Getting one case running locally took far longer than writing all of them, because of three configuration problems hidden behind the same vague error: an environment variable set to an empty string that the runtime read as “running on Vercel”; a test-only auth hook that disabled itself because pulling environment variables makes a laptop look like Vercel; and the app reading one database while the test businesses lived in another. None were logic bugs. A framework hiding its underlying error turned a twenty-minute problem into a multi-day one.
What the evals caught
Three real agent bugs, not test bugs.
Answering from the open web
The agent answered questions about the business’s own pages with a built-in web search tool, not the knowledge base. The framework enables default tools and nobody had turned them off.
fixed: defaults disabled
A tool that couldn’t serialise
A tool returned a raw Date the runtime couldn’t serialise, and crashed.
fixed: one line
Trying to edit read-only pages
A validator already blocks the write, but the agent shouldn’t try. It varied between the two businesses, so it’s a prompt-steering gap.
marked soft, logged
Answering questions from the open web instead of the knowledge base is a straight risk on a customer-facing product. Once the default tools were turned off, the failing cases went green.
It’s worth walking, because the two runs are indistinguishable from the reply alone. Same question, same business, same prompt:
Case passes on both layers: the tool calls are right and the answer is faithful to the page.
- input tokens per turn
- ~14k
- per case, depending on turns
- 2–20¢
- per full run across both businesses
- $8–12
Almost all of that is input. The system and tool schemas are re-sent every turn, and output is tiny, so cost scales with total turns rather than with the number of cases. That math is what settled how the suite should run.
Free analysis
Spending money on a stranger, safely.
A stranger texts the product’s number. They have no account and no business record, and every tool assumes both. The goal is to give them a free analysis of how visible their site is to AI assistants, then sign them up, all over SMS.
The analysis runs on Cloro, a paid third party that asks AI assistants questions about the business. So the first tool deliberately spends nothing, and its order is the cost control:
- 1
Pre-check the site for free
A plain HTTP fetch, capped at 2 MB while reading, catches parked or thin sites before anything paid runs. Not a headless browser, because that costs money too.
- 2
Claim the free analysis
A that only succeeds if no analysis has been claimed or completed, so two texts can’t both pay.
- 3
Identify the business
From the page’s first, with a small LLM call as the fallback.
- 4
Prepare the questions
Eight generated, plus up to five of the prospect’s own after vetting, with a hard cap of thirteen.
- 5
Park them in the conversation
In the agent’s , not a table, since they belong to one thread and are discarded after submitting.
A second tool is where money actually moves: vet the questions, claim the slots, submit the run.
The cost control the whole loop rests on. Without it, one unauthenticated message could trigger thirteen questions across five assistants: dozens of paid tasks. The guard is a single condition in the write’s WHERE clause: slots already used, plus the slots being requested, can’t exceed the allowance. However many requests race, that condition can never push the total past the allowance. It’s enforced in code as policy, never requested politely in a prompt.
the first version
Submit, then claim
- 1Both texts pass the free-run check
- 2Both submit to the paid provider: money spent twice
- 3Only one counter increment wins
The cap bounded a counter, after the money was gone.
the fix
Claim, then submit
- 1Both try a conditional claim in the database
- 2One update matches, the other returns zero rows
- 3Only the winner submits
The cap bounds the spend, which is the only thing a spend cap is for.
Claiming first has a cost: if something fails before submission, slots leak. A failure now carries an explicit safe-to-release flag, so slots can never be released once money might have moved. A failed run also keeps its ID instead of clearing it, because a cleared ID makes “spent but failed” look identical to “never ran,” and a retry would pay twice.
Closing an idempotency hole. Cloro recognises repeat requests by an built partly from a prompt ID, and the normal customer path mints a new prompt ID every time, so a replay is billed as new work. For prospects I derive that ID from a SHA-256 hash of the run and the question text, so the same question always produces the same key. It’s hashed rather than used raw because the question comes from a stranger, and raw text could smuggle in the key’s delimiter. back it up in the database.
The that reports finished runs learned that a prospect’s run is a separate reporting path from a customer’s, with two : one for stale runs, and one for results that finished but were never texted back. Whether to invite someone to sign up at all is a pure function with a fixed set of allowed moments, a six-hour cooldown, and a permanently honoured “no.” It’s code rather than prompt text because prompt-only rules leaked: the model writes the words, it doesn’t set the sales cadence.
Signup by text
Two texts, a tool that can’t wait between them, and the best bug of the term.
A tool can’t wait between “here’s my email” and “here’s the code.” It runs and exits. So all signup state lives on the phone’s row, and deliberately not in the field that means “this number has an account,” because during signup a user already exists in before their email is proven.
The first text makes a conditional claim on that row, and one condition is subtle: the row must not already point at a signup’s user, because that pointer is the only way back to the Clerk user holding the email. Losing it would lock the address forever. The user is recorded before the code is sent, so a failed send retries by resending the code rather than creating a second user.
fact 1, in the auth SDK
Creating a user marks every email you hand it as verified.
fact 2, in a different file
An “already verified” response counts as proof the code was entered.
togetherA naive signup could finish without anyone ever receiving a code.
That last distinction is the whole point. Clerk’s verified flag describes the email address, not anything this phone number did, so the flow keeps its own record of when the code was actually entered. That’s the difference between a retry-safe flow and an auth bypass.
On the second text the code is checked, and if our record already shows it verified, Clerk is skipped entirely: a verification attempt is single-use, so a retry would fail on a code that was already correct.
Provisioning and setup
Turning a verified phone number into a customer.
Provisioning creates or reuses the organisation, checks whether the site already has an owner while excluding the caller’s own organisation, then upserts the user, business and membership. That exclusion is the retry fix: without it, a second attempt finds the organisation it just created and decides the site is taken.
Finishing signup is a single that binds the phone row only if it still belongs to this exact signup, then writes the account and clears every signup field. Only a completed free analysis carries over to the new business. One still running stays the prospect’s, so its results are still texted back instead of vanishing into a business record they haven’t seen.
The texter is waiting on “your account is ready,” so account writes happen inside the request, and the knowledge base build runs afterwards with . A build failure doesn’t roll back the account; it texts them instead. That build runs through an onboarding function I split out of the browser flow, since SMS has no browser session to read, and the browser version is now a thin wrapper around it, which makes it load-bearing for both.
A reaper deletes abandoned signups’ reserved Clerk users, since otherwise a typed-and-abandoned email stays reserved forever and that person can never sign up. For connecting the site, a live DNS lookup is trusted over a stored flag, setup instructions can be emailed to whoever manages the website, and a stalled setup gets exactly one follow-up text to the owner and one email to their web person after three days.
Pay by text
Owners who only ever text still need a way to pay.
Paying through settings and Stripe still worked in the browser, but a texter never sees one. The agent now has exactly one way to send a billing link, and it only works in a text session, for a caller with write permission, on the owner’s own reply turn so a link never goes out on a proactive message, and for an account that isn’t comped.
the only billing link the agent may send, and only if
opening the link checks, in this order
Not subscribed yet
Checkout is created on tap
Subscribed since the link was sent
Opens the billing portal instead
The link is a signed with , expiring after 24 hours, with no database row behind it. Opening it checks the shape, then the signature, then the expiry, in that order: an expired but genuine link is a different fact from a forged one, and nothing inside the payload is trusted before the signature checks out. is created when the link is tapped, not when it’s sent, so an owner who subscribed in the meantime lands in the billing portal instead of a second checkout.
What I took away
Interactions are where bugs live. The advisory lock bug came from two individually correct changes I’d reasoned about in separate sittings.
Correct code isn’t a correct system. The Bing tag was provably stored and still not served, and the ONNX engine worked everywhere except the deployed bundle.
Plan before building. The image pipeline was the first thing I designed up front, with a doc and throwaway scripts against real sites, after learning what deleting code I didn’t understand costs.
Put limits where money moves. Claiming before spending bounds the spend. Claiming afterwards only bounds a counter.
Hidden errors are the expensive ones. Every eval setup problem was configuration, and a framework swallowing its real error is what made them take days.
Stack
| App | TypeScript, Next.js, Node.js on Vercel, Vercel Queues |
|---|---|
| Database | PostgreSQL (Supabase) |
| Hosting | AWS S3, CloudFront, ACM |
| Auth and billing | Clerk, Stripe |
| Search and AI providers | Google, Bing, IndexNow, Brave, Parallel, Firecrawl, Exa |
| Image pipeline | Browserbase, GPT-4o-mini, DINOv2-small on ONNX Runtime |
| Agent and evals | Vercel eve, autoevals |
| Visibility analysis | Cloro |
