One morning I asked Antigravity's Browser Sub-Agent to read my AdMob console for me. The return value of get_page_text was just "Sign in" and "Loading…". The browser address bar had clearly moved on to the dashboard, but from the agent's point of view the body of the page was still empty. The instruction was "find the networks whose eCPM has dropped this week," and the agent — taking the empty text at face value — replied "none found."
I have been running a portfolio of mobile apps solo since 2014, now at about fifty million cumulative downloads, and Browser Sub-Agents reading SPA dashboards like AdMob, App Store Connect, and Stripe Dashboard have become a regular part of my workflow. The same misread happened to me several times a week at first, but once I broke the symptom apart it became clear that the problem was a mismatch in wait strategy. The same approach now powers the four sites I run under Dolice Labs, so I want to write down the diagnostic flow I have settled on and the three wait patterns that have proven reliable.
Three different states can look like "an empty page"
The first thing that matters is that "empty" is actually three different states. If you treat them as one and just turn up the wait time, the behaviour never quite stabilizes.
The first state is the SPA shell. The HTML contains only <div id="root"></div> and a handful of meta tags; the body content is injected by client JavaScript later. When the result of get_page_text is unusually short and contains only generic strings like "Loading…" or "Sign in to continue," you are in this state.
The second state is being bounced back to a login page by an auth guard. document.title may still read like the app's title, but the body is just a sign-in form. This often happens when last week's session cookie has expired — AdMob expires in about seven days, App Store Connect in roughly eight hours.
The third state is when the DOM is fully built but the data rows are still waiting on an API response. Table headers are visible, rows show "No data" or empty cells. This is purely network-bound and goes away if you wait a bit longer.
The first step in diagnosis is always to look at the HTML structure. Pairing get_page_text with get_page_html lets you tell the three apart in a few seconds.
The root cause is the gap between DOMContentLoaded and hydration
By default, a Browser Sub-Agent treats a page as "loaded" right after navigate returns. That judgment is roughly based on the load event, when the HTML and external resources have all been fetched. SPAs built with React, Vue, or the Next.js App Router only evaluate their JS bundle, run hydration, and finish the initial data fetch after load. The screen does not show meaningful text until that pipeline completes.
In other words, the moment the Sub-Agent says "ready to read" and the moment you, as a human, see the dashboard appear are separated by two to six seconds on an SPA. On the AdMob dashboard on my setup the median was about 3.2 seconds and the 95th percentile was 5.8 seconds. Covering 95% of cases with a fixed sleep would cost six seconds every time and burn execution time and tokens for nothing.
To avoid that, you need a structural way to detect that the page is actually ready. Here are the three patterns that stabilized for me, in order.
Pattern 1: Wait for a known selector to render
The most reliable and least false-positive pattern is to declare "the body is ready when this selector appears." Inside an Antigravity Browser Sub-Agent you can either install a MutationObserver via javascript_tool or, more simply, poll. Polling is usually enough.
// Run via javascript_tool from the Browser Sub-Agent
async function waitForSelector(selector, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const el = document.querySelector(selector);
if (el && el.offsetParent !== null) return true;
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Selector not visible: ${selector}`);
}
// Wait for the AdMob mediation table to render
await waitForSelector('table[data-test="mediation-waterfall"] tbody tr');The detail that matters is offsetParent !== null — it confirms not only that the element exists in the DOM but also that it is not display:none. SPAs often insert elements first and reveal them afterwards, so skipping this check will cause false-positive successes. For AdMob I look for tbody tr, but when I want to distinguish "render complete with zero rows" from "still rendering," I add an empty-state selector in an OR clause, such as tbody tr, [data-empty-state].
Pattern 2: Network idle plus structural verification
For pages where you cannot pin down a selector in advance — for example when the agent is given an open-ended instruction like "grab the latest error visible on the screen" — wait for the network to settle, then verify the structure.
// Wait until network has been idle for N milliseconds
async function waitForNetworkIdle(idleMs = 800, timeoutMs = 12000) {
return new Promise((resolve, reject) => {
const start = Date.now();
let lastActivity = Date.now();
let pending = 0;
const origFetch = window.fetch;
window.fetch = async function (...args) {
pending++;
lastActivity = Date.now();
try {
return await origFetch.apply(this, args);
} finally {
pending--;
lastActivity = Date.now();
}
};
const tick = setInterval(() => {
const idle = Date.now() - lastActivity;
if (pending === 0 && idle >= idleMs) {
clearInterval(tick);
window.fetch = origFetch;
resolve(true);
} else if (Date.now() - start > timeoutMs) {
clearInterval(tick);
window.fetch = origFetch;
reject(new Error("Network idle timeout"));
}
}, 100);
});
}
await waitForNetworkIdle(800);
// Structural verification: a heading and a table at minimum
const ok = document.querySelectorAll('h1, h2, h3').length > 0
&& document.querySelectorAll('table, [role="grid"]').length > 0;
if (!ok) throw new Error("Page structure not ready");Wrapping fetch will miss XHR traffic, so if the screen still relies on older XHR-based code, hook XMLHttpRequest.prototype.send in the same way. I added an XHR hook for a few App Store Connect screens, which is what finally made them reliable.
Pattern 3: A retry-first agent prompt
There is also the option of not over-engineering on the runtime side and instead baking the behaviour into the Sub-Agent's instructions: "if get_page_text returns something short or only contains keywords like 'Loading,' wait three seconds and retry. After three failures, summarize the situation and ask."
# Shared prompt fragment for the Sub-Agent
When reading a page, follow these rules.
1. Call get_page_text right after navigate.
2. If the result is shorter than 200 characters, or only contains "Loading" / "Sign in,"
wait 3 seconds and call get_page_text again. Up to three retries.
3. If all three retries fail, report "page may not have rendered" with the current
URL, title, and the first 500 characters, and ask for human judgment.
4. Treat the read as successful only when the expected keywords
(for example "Mediation", "Waterfall") appear.The implementation cost of this pattern is essentially zero, but token usage goes up. In practice I use Pattern 1 selector waits for high-frequency dashboards like AdMob, App Store Connect, and Stripe Dashboard, and Pattern 3 retries for admin screens I open only once a month.
Two habits that prevent this from coming back
Beyond the wait pattern itself, two operational habits raise the floor of stability noticeably. The first is to make the Sub-Agent itself responsible for "is this result obviously too short?" and "is the expected keyword completely missing?" Don't just look at the raw text length — define, per domain, a label that absolutely has to appear on this page, and key the success judgment off that. Misreads drop close to zero.
The second is to write session cookie lifetimes into your operational calendar. AdMob lasts about seven days, App Store Connect roughly eight hours, Stripe Dashboard via CSR about two weeks, and Cloudflare Dashboard around twelve hours with MFA enabled. Half of the "the Sub-Agent says it can't read it" complaints I get are really sessions, so reserving a single block of time on Monday to refresh the sessions you need that week makes a lot of mystery waits disappear.
That is the full picture of how I work the SPA-versus-Sub-Agent friction into my routine. Thank you for reading. As a concrete next step, pick the one dashboard you have the agent read most often and write down a single selector that would mean "this page is ready." Just that one line will visibly raise the trustworthiness of what the agent returns.