You can actually check whether a large language model knows MiCode exists — and whether it says so to whoever happens to ask. What we built to do that is simpler than it sounds: a script that asks Gemini a fixed list of questions once a day, phrased the way a real prospect would ask them, turns on Google Search grounding, and records which sources the model cited back. The idea itself is unremarkable. The rest of this article is about how we fit 54 model calls into a free tier that allows 20 a day — and why almost every small mechanism along the way exists because of a specific failure, not because of precaution for its own sake.

An AI-citation monitor for our own name

More and more questions that used to go to a search engine now go straight to ChatGPT, Perplexity, or Gemini — and ranking well on Google says nothing about whether any of those models even knows we exist. The answer is an automated monitor: once a day, with no human involved, it asks Gemini a fixed list of 27 prompts — brand questions, category questions, questions about specific products — with Google Search grounding turned on, and checks whether mi-code.pl shows up as a cited source in the answer. Every prompt is asked twice, so one lucky hit or one unlucky miss doesn't decide the result on its own — that is 54 model calls for a full sweep.

That is only half the system. Gemini has a free API with grounding, so this half can run unattended; ChatGPT and Perplexity don't, so the same project also keeps a separate, manual pass once a month — logging in to both by hand, asking the same questions from the keyboard, and recording the results alongside site traffic and AI-crawler traffic from Cloudflare. This article is only about the automated, daily half — the one that has to fit inside the free API's quota.

A quota that isn't the one in the pricing table

The first version of this project sized its budget off Gemini's pricing page, which advertises "up to 500 requests a day" — and got it wrong. That figure is for the search-grounding tool, not for calls to the model itself. The real limit for generateContent on the free tier is ten times lower, and the API says so directly, in the body of its own error:

Twenty model calls a day, per project, per model. Conflating those two numbers cost the team an entire design, before anyone read the error's own detail instead of the pricing table.

54 calls, a 20-call cap

Twenty calls a day against 54 needed for a full sweep means the sweep physically does not fit into one day. The fix isn't trimming the prompt list — it's spreading the sweep across several days. The code keeps its daily budget as a constant set to 18, not 20 — deliberate headroom under the cap, not a rounding-down. At a budget of 18, a full 54-call sweep closes in exactly three days: 54 divided by 18 is a clean 3, with no remainder and no half-finished fourth day left hanging.

Laid out as a constraint against a demand, the mismatch — and the margin the daily budget leaves on top of the hard cap — looks like this:

Free-tier constraintValue
Model calls allowed per day (per project, per model)20
Daily budget actually used18 (headroom under the cap)
Calls for one full sweep54 (27 prompts × 2 repeats)
Days needed to close one sweep3
Pacing gap between calls (per-minute limit)6.5 seconds

A cursor that remembers where the sweep stopped

Each day the job measures the next slice of a stable work list and saves its place — the cursor position and the attempts gathered so far — into a single file, partial.json. Only the run that reaches the end of that list folds everything into one whole: it writes the full sweep's result to a dated file, rebuilds the report, and compares it against the previous sweep. A Telegram alert fires only from that closing run — and only if the comparison shows the set of prompts that actually cite us has changed. An in-between day that only advances the cursor sends no notification at all, because there is nothing to say yet.

The dated file from a closed sweep lands in a directory that keeps one file per completed sweep, so the history of what the model cited a month or two ago survives — not just whatever the latest report happens to show. The three-day rhythm, and where a mid-sweep retry re-enters instead of skipping ahead, looks like this:

The three-day sweep: a cursor over a 54-item work list, closing on day three

The work list itself is nothing more than 27 prompts repeated twice, laid out in a fixed order — every repeat of a prompt sits right after its first occurrence, not bunched at the end of the list. That ordering is what makes the slicing predictable: a nextSlice helper takes a cursor and a budget and returns the next chunk plus a new cursor, capped at the list's length, and a boolean saying whether that new cursor reached the end. Run it by hand on paper and the three days fall out exactly as the constant promises. Day one starts at cursor 0, takes items 0 through 17, and leaves a new cursor at 18 — not yet complete, since 18 is less than 54. Day two starts where day one stopped, takes items 18 through 35, and leaves the cursor at 36 — still not complete. Day three takes the last slice, items 36 through 53, and the new cursor lands on 54, which is not less than the list's length of 54 — so this is the run that closes the sweep. None of that is a hypothetical: the partial.json file this project keeps in its own repository currently shows exactly cursor: 18, calls: 18 after one real day's run, which is the day-one outcome above, captured live rather than reasoned about after the fact.

A sweep can also stretch across more than the same three prompts landing on the same day twice. Because repeats of one prompt aren't adjacent to repeats of a different prompt in a way that respects daily boundaries — the list is prompt-major, not day-major — a given prompt's two attempts can land on different daily slices entirely, one in Monday's batch and its repeat in Wednesday's. The folding step that closes a sweep groups every attempt by prompt id regardless of which day it was measured on, and keeps the best status seen across however many attempts that prompt collected — so a citation found once, even on a different day than its sibling attempt, still counts.

Verifying a citation, not just counting one

Fitting 54 calls into a 20-call quota only matters if what comes back from each call is read correctly, and grounded answers don't hand back a clean source URL. Google's grounding metadata returns citations as redirect links through vertexaisearch.cloud.google.com — comparing that host against mi-code.pl would never match anything, no matter how many times we actually got cited.

Unwinding that redirect is its own small pipeline, ordered from cheapest to most expensive. If the grounding chunk already carries a domain field, that's trusted directly — no extra work needed. Failing that, the chunk's title field usually turns out to be the bare publisher domain rather than a page title — confirmed against a real captured response from a live call, sitting in this project's own fixtures — so a regex that asks "does this look like a domain" — letters, digits and hyphens, at least one dot — is enough to trust it. Only when neither the domain field nor a domain-shaped title is available does the code fall back to firing an actual HEAD request at the citation URL and following the redirect to see where it lands. That fallback is deliberately the last resort rather than the default: a full sweep can see a few hundred citations across its 54 calls, and turning every one of those into its own HTTP request would multiply the call volume against a quota that is already the entire constraint this whole project exists to work around.

Once a citation's real host is known, classification is a simple ranking, not a single yes/no. A brand can appear in the text of an answer without ever being linked as a source — the code tracks that separately as mentioned, and it is never allowed to count as a citation, however similar the two might look at a glance. Only a host that resolves to an owned domain earns cited; anything else that at least names the brand in the answer's text is mentioned; everything else is absent. Owned-domain matching itself has one deliberate guard: a candidate host counts as ours only if it equals one of the owned domains exactly or ends in that domain preceded by a dot — so a lookalike registration like noteksiegowyai.pl can never be mistaken for the real eksiegowyai.pl just because the string happens to appear inside it. And because each prompt is asked twice, the two attempts don't average against each other: the better of the two wins, cited outranking mentioned outranking absent, so one lucky hit out of two still counts as a real citation rather than being diluted by a miss.

Three kinds of failure, three different reactions

Not every error deserves the same reaction, so the code doesn't treat them the same.

A dropped connection — a fetch call that throws instead of answering at all — gets retried once, after five seconds, the same as a server error. That specific case exists because it once happened for real: one dropped connection that wasn't retried aborted a whole sweep mid-run and took the entire day's quota down with it — the remaining calls that day simply had nothing left to spend.

A rate limit, HTTP 429, gets different treatment: one retry too, but only after thirty seconds, not five. A rate limit clears on a clock — after a set interval it simply stops applying — while a server error clears on a whim. Thirty seconds is chosen to actually clear the per-minute window, instead of spending the one retry a few seconds too early.

A 4xx that isn't a 429 is never retried at all. It's a broken request that will fail identically the second time — retrying it fixes nothing, it just spends a second call from the same daily budget for no reason.

A 6.5-second gap that wasn't there at first

The daily cap isn't the only limit a burst of dozens of back-to-back calls can hit — there's also a per-minute one. The first real run fired its calls with no gap between them at all and tripped a 429 within seconds, even though a single call at rest went through fine. The fix is a 6.5-second pause between consecutive calls within a day's slice — not before the very first call of the day, since waiting before anything has started just wastes wall-clock time, only between the ones that follow.

A fingerprint that stops half a sweep being compared to a whole one

The prompt list in prompts.json isn't frozen — it changes whenever a new product or article shows up, and that's expected, because a single sweep spans several days. The problem is that a sweep measured against two different prompt lists means nothing: some prompts would be counted twice, and new ones would never get asked within that sweep at all. That is why partial.json stores a fingerprint — a sha1 hash computed from the prompt IDs and the repeat count. If the prompt list changes mid-sweep, the fingerprint stops matching on the next run, the unfinished sweep is abandoned, and a fresh one starts from zero with an empty cursor. It's the only way a mid-week prompt edit never corrupts the data — at the cost of throwing away whatever partial work was already done when that happens.

The list also keeps a fixed core: the brand prompts always stay in it, precisely because they're the canary. If even a question as simple as "what does MiCode do" stops finding us, that means indexing broke, not that content is missing — and no new article fixes that.

What closes a sweep, and what only guards it

A daily job that quietly does nothing and still exits with a green checkmark is worse than one that fails loudly, because nobody looks at green. That risk is concrete here, not theoretical: the daily budget is read from an environment variable, and a typo that makes AI_VIS_DAILY_BUDGET non-numeric would turn into NaN the moment it's parsed. A NaN budget would measure nothing, advance nothing, and the script would still exit 0 — the job going green every single day while REPORT.md quietly stops updating, for as long as it takes someone to notice the dates have stopped moving. The code guards against exactly this with a small helper that insists a configured value be a positive integer or throws, turning a silent zero-effort success into a loud, red CI failure the same day it happens — an unset variable falls back to the default rather than tripping the guard, since Actions renders an unset variable as an empty string rather than as genuinely undefined, and treating that as a bad value would make the guard fail on the common case instead of the broken one.

There's a second edge case worth naming, because it looks identical to the first from the outside but has to be handled the opposite way: a slice that comes back empty because the cursor has already run past the end of the work list. That isn't a misconfiguration — it's just the sweep being done — and it still has to close properly, writing the dated run and resetting the cursor, or the job would look exactly like the silent no-op above every day afterward. Only a slice that is empty and not yet complete is treated as the genuine nothing-to-do case, logged and returned early without touching any files.

Closing a sweep is the only path that does the expensive, visible work: the dated run file goes into a runs/ directory that accumulates one file per completed sweep, REPORT.md is rebuilt from scratch against that new run, the new run is diffed against whichever previous run is on record, and a Telegram summary fires — but only carrying the diff, never on a day that merely advanced the cursor. The very last step resets partial.json to a fresh cursor of zero for the next sweep, but it deliberately carries the same fingerprint forward rather than generating a new one; without that, the following day's run would read back a partial that matches nothing it expects and restart the sweep from scratch every single day, with the cursor forever stuck near zero.

Why it's still Gemini 2.5 Flash

Google Search grounding is free only on Gemini 2.5 Flash and Flash-Lite. A 3.x model would start billing for the search tool itself after 5,000 calls a month — and on a monitor that already lives right at the edge of a free quota, one casual model upgrade would turn a zero-cost project into a billed one. That's why the default model is hardcoded as gemini-2.5-flash instead of drifting toward whatever is newest the moment it ships.

What the automated half can't see

Gemini isn't ChatGPT, and a daily script that only talks to one model can't stand in for the other two. Because neither ChatGPT nor Perplexity offers a comparable free, automatable grounding API, the project keeps a second, manual pass once a month instead — the same prompts, typed by hand into both interfaces, with the results logged alongside site and AI-crawler traffic. The most recent logged pass, run on 2026-08-12, found ChatGPT citing mi-code.pl on 0 of 6 prompts tried, and Perplexity on 1 of 1.

The zero is more informative than it sounds. For one Polish prompt asking whether an AI assistant exists that connects to wFirma and answers VAT and PIT questions, ChatGPT cited exactly one source — wfirma.pl itself — and, finding nothing about such an assistant there, stated outright that no such product exists. That's not a ranking loss and it's not a quality problem with anything MiCode has published: for that question, ChatGPT goes to one specific site and reads only what's there. A blog post, however well written, was never going to be read for that question at all. Losing a citation, in cases like this one, turns out to be less about writing more and more often about not being present inside the one source an engine has already decided is the authority for that exact question — which is a different problem than the automated Gemini sweep is built to catch, and exactly why the manual pass stays in the project even though it can't run itself.

What it actually buys

Nothing about this monitor is flashy — it's a daily job that, most days, just nudges a cursor forward and tells nobody anything. All the real work went into things that will never show up in a screenshot: a retry that knows when to give up instead of trying forever; a pause that prevents a problem before it happens; a fingerprint that makes sure a sweep spread across several days keeps measuring the same thing the whole way through; a guard that turns a typo into a loud failure instead of a quiet, permanent no-op. That, not the fact that something asks an AI model once a day, is the difference between a job that runs unattended for months and one that needs a manual restart every time something goes sideways.

We build things like this — LLMOps automation that has to survive inside real API quotas, not just a demo — for clients too. If you're working through a similar constraint, write to us at development@mi-code.pl.

Frequently asked questions

What is MiCode's AI-citation monitor?
It's a daily, automated job that asks Gemini (gemini-2.5-flash) 27 prompts with Google Search grounding turned on and checks whether the answers cite mi-code.pl. Each prompt is asked twice, which makes 54 model calls for a full sweep.
Why don't 54 model calls fit into a single day?
Gemini's free tier allows 20 generateContent calls a day per project per model — the API states this directly in its own error detail (id = GenerateRequestsPerDayPerProjectPerModel-FreeTier, value = 20). That's a separate limit from the pricing page's "up to 500 requests a day," which covers the search-grounding tool, not calls to the model itself. The code keeps its daily budget at 18, so a full 54-call sweep spreads across exactly three days.
What happens when a call to Gemini fails?
It depends on the error type. A dropped connection and a 5xx server error are retried once, after five seconds. A rate limit, HTTP 429, is also retried once, but after thirty seconds, because a rate limit clears on a clock while a server error clears on a whim. A 4xx that isn't a 429 is never retried, because it's a broken request that will fail identically the second time.
Why does editing the prompt list mid-sweep reset progress?
Because a sweep measured against two different prompt lists means nothing — some prompts would get counted twice, and new ones would never get asked within that sweep. partial.json stores a fingerprint (a sha1 hash of the prompt IDs and repeat count); when prompts.json changes, the fingerprint stops matching on the next run, the unfinished sweep is abandoned, and a fresh one starts from zero.
Why does the monitor still use Gemini 2.5 Flash instead of a newer model?
Because Google Search grounding is free only on Gemini 2.5 Flash and Flash-Lite. A 3.x model would start billing for the search tool itself after 5,000 calls a month, so the default model is hardcoded as gemini-2.5-flash rather than being allowed to drift toward whatever is newest.