I recently added an agent to my portfolio that can do more than answer questions. It can navigate to projects, move between pages, filter the tech stack, and spotlight the exact part of the site it is talking about.
That worked well until I changed the site structure.
Then I hit a strange bug: the agent could navigate to the correct URL and still highlight the wrong component.
The interesting part was that neither navigation nor spotlighting was individually broken. The bug was in the timing between them.
The setup
The agent had tools such as navigate and spotlight. A project card on the home page exposed a target like this:
<div data-agent-id="work:kapuru">
...
</div>The navigation tool could push a new route and optionally spotlight a target after navigation.
The simplified logic looked roughly like this:
router.push(input.path)
await poll(() =>
document.querySelector(
`[data-agent-id="${input.spotlightTarget}"]`,
),
)
spotlightTarget(input.spotlightTarget)At first glance this seems reasonable: navigate, wait for the target, then highlight it.
It was not enough.
The race condition
Imagine the agent is currently on / and I ask it to show the Kapuru project.
The old page already contains:
work:kapuruThe agent calls:
router.push('/works/kapuru')But router.push() does not synchronously replace the DOM. For a short period, the browser is still rendering the home page.
My polling condition only asked one question:
Does this target exist in the DOM?
The answer was immediately yes, because work:kapuru was still present on the old page.
So the navigation flow considered itself ready before the destination page had actually mounted.
That meant the agent could scroll to or spotlight a perfectly valid element from the wrong page.
This was one of those bugs where every individual line looked correct while the overall sequence was wrong.
The target names were also too ambiguous
The timing problem exposed another design issue.
I was using the same mental identity for a project across multiple surfaces:
- the project card on the home page
- the full project detail page
But those are not the same UI target.
I changed the target model so location became part of the contract:
work:kapurumeans the Kapuru card on /.
work-detail:kapurumeans the project content on /works/kapuru.
I applied the same idea to blog posts:
blog:<id> -> card on /blog
blog-detail:<id> -> article on /blog/<id>Now a target is not just an element name. It also has a page where it is valid.
The navigation sequence I use now
The corrected flow is intentionally more strict:
router.push()
↓
confirm exact pathname
↓
let destination render
↓
confirm target exists on destination DOM
↓
scroll / spotlightIn code, the important part is separating route readiness from element readiness:
router.push(input.path)
const routeArrived = await poll(
() => window.location.pathname === input.path,
4000,
)
if (!routeArrived) {
return { ok: false, error: 'Navigation timed out' }
}
await waitForNextPaint()
const targetAppeared = await poll(
() =>
window.location.pathname === input.path &&
Boolean(document.querySelector(selector)),
3000,
)I also reject invalid combinations before navigating.
For example:
/works/kapuru + work:kapuruis invalid because work:kapuru belongs to /.
The correct combination is:
/works/kapuru + work-detail:kapuruThat validation turned a timing assumption into an explicit interface contract.
I stopped maintaining project targets manually
There was another smaller problem hiding behind the first one: target registry drift.
If I added a new project to my portfolio data, I also had to remember to add its agent target manually. Eventually those two lists would disagree.
The project targets are now generated from the same WORKS_DATA used to render the site.
Conceptually:
Object.entries(WORKS_DATA).flatMap(([id, work]) => [
{
id: `work:${id}`,
page: '/',
},
{
id: `work-detail:${id}`,
page: `/works/${id}`,
},
])One source of truth removed an entire class of future agent errors.
The guided tour had the same bug in a different shape
My guided tour can be started from any page. If it starts from a project detail page, it first returns to the home page and then highlights the next section.
Originally I only waited until the pathname became /.
That was still slightly too early. The home route could be active while the exact target had not mounted yet.
The tour now waits for both:
pathname === '/'
AND
target existsThe same rule fixed both systems.
What I learned
The useful lesson for me was not "wait longer after router.push". Adding a random delay would only hide the race.
The real lesson was that a UI-driving agent needs a DOM contract in the same way a backend integration needs an API contract.
A target should answer:
- what component it identifies
- which route owns it
- when it is considered ready
- whether it still exists after navigation
I now think of agent navigation as a small state machine rather than a sequence of clicks.
The agent should not assume that because it requested a route, the route is ready. And it should not assume that because an element exists, it belongs to the page it intended to reach.
That distinction fixed a very specific portfolio bug, but it is a pattern I would reuse in any application where an AI agent is expected to control a real interface.