In Part 3, I built the historical layer.
The system could persist AI visibility scans, brand observations, citations, and fan-out behavior in BigQuery. That gave me something I did not have in the earlier versions of this project: history.
I could ask questions such as:
- Which brands were mentioned?
- Which brands were recommended?
- Which citation domains appeared?
- Which queries found or missed a brand?
- How did visibility change across repeated scans?
But there was still friction.
Every time I wanted to investigate something new, I had to open the dashboard or write another SQL query.
GitHub Code: ai-search-journey-lab
That led to the next step:
What if I could ask an agent questions about the visibility history instead?
Not a generic chatbot.
Not an agent with unrestricted access.
I wanted a constrained analytical agent that could inspect the visibility data I had already collected, use explicit tools, stay read-only, and return an answer grounded in actual BigQuery history.
That became V4: the AI Visibility Agent.
What changed from V3 to V4
V3 gave me this:
Visibility Scans
↓
BigQuery
↓
Dashboard + SQL
V4 adds an agent between the user and that historical data:
User Question
↓
Gemini
↓
Google ADK Agent
↓
MCP Toolset
↓
Local MCP Server
↓
Read-Only BigQuery Analytics
↓
Grounded Answer
The important part is not simply that an LLM sits in front of BigQuery.
The important part is the boundary.
V4 does not execute new Places searches, does not run new web searches, and does not write new visibility data. It acts as an analytical layer over the history produced by V3.
That separation made the design much easier to reason about.
V3 creates evidence.
V4 investigates evidence.
Step 1: Define the agent boundary first
Before I built the agent, I decided what it should not be allowed to do.
That turned out to be one of the most important design choices in this version.
The V4 agent is intentionally read-only.
It should not:
- insert new BigQuery rows
- update visibility records
- delete historical data
- trigger new visibility scans
- call Google Places
- perform live web searches
- execute arbitrary user-supplied SQL
Instead, the agent should answer questions using a fixed set of analytical tools.
That gives me a much cleaner boundary:
V1 / V2
Live search journey
V3
Persisted visibility evidence
V4
Read-only analytics over V3
I find this pattern much more useful than giving the model broad database access and trying to control it only through prompting.
The capability boundary is architectural, not only instructional.
Step 2: Use Google ADK as the orchestration layer
The agent itself is built with Google ADK.
Its job is not to contain all of the analytical logic.
Instead, it handles the conversational layer:
User asks a question
↓
Gemini interprets the intent
↓
ADK selects an appropriate tool
↓
Tool returns structured data
↓
Gemini explains the result
In the implementation, the ADK agent connects to the MCP toolset over stdio, while the MCP server runs separately and performs the read-only BigQuery analytics.
That gives me two useful separations:
Reasoning
≠
Data access
and:
Agent orchestration
≠
Analytics implementation
The model decides what information it needs.
The tools decide how that information is retrieved.
Use a clean architecture diagram showing:
User Question
↓
Gemini
↓
Google ADK Agent
↓
MCP Toolset
↓
Local MCP Server
↓
Read-Only BigQuery
↓
V3 Visibility History
Step 3: Why I used MCP here
I could have connected the agent directly to Python functions.
But I wanted the data-access boundary to be more explicit.
That is where MCP became useful.
The analytical tools live behind a local MCP server that communicates with the ADK agent over JSON-RPC stdio.
Conceptually:
ADK Agent
│
│ MCP over stdio
▼
MCP Server
│
▼
Approved Analytics Tools
│
▼
BigQuery
That means the agent does not need direct knowledge of the underlying SQL implementation.
It sees a set of capabilities.
For example:
get_available_history
get_visibility_summary
get_brand_trend
compare_brands
analyze_citations
find_fanout_gaps
Those six tools form the analytical surface of V4.
That is a much more controlled interface than:
run_any_sql(query)
Step 4: Build tools around analytical questions
I did not want the tool names to mirror database tables.
I wanted them to mirror the questions I actually ask when analyzing visibility.
That resulted in six read-only tools.
get_available_history
Before asking for a trend or comparison, I first need to know what history actually exists.
This tool discovers things such as:
- persisted scan counts
- available date ranges
- known brand IDs
- recent scan windows
That makes it especially useful for broad prompts where the user does not specify a brand or date range.
This also supports a pattern I like:
Discover first, analyze second.
Instead of letting the model assume what history exists, the agent can inspect the available data.
get_visibility_summary
This tool provides an aggregated visibility view over a selected brand and time window.
It can work with metrics such as:
mention rate
recommendation rate
citation rate
Rather than making the model calculate those metrics itself, I keep them in the analytical layer.
That makes the agent responsible for explanation rather than arithmetic.
get_brand_trend
This tool turns historical scan records into a time-oriented visibility view.
Conceptually:
Scan 1
Scan 2
Scan 3
Scan 4
↓
Historical visibility trajectory
That gives the agent a way to answer questions such as:
Is this brand becoming more or less visible?
The trend logic remains deterministic.
Gemini interprets the result.
compare_brands
This tool gives the agent a controlled way to compare a target brand with competitors.
Instead of letting the model assemble arbitrary joins, the comparison is exposed as an explicit business operation.
The tool can compare signals such as:
mentions
recommendations
citations
between the target and competing brands.
analyze_citations
In V3, citations became independently queryable.
V4 turns that into an agent capability.
The agent can inspect citation-domain behavior across the stored search journeys instead of only answering questions about brand presence.
That lets me ask questions closer to:
Which domains repeatedly support this brand?
rather than only:
Was this brand mentioned?
find_fanout_gaps
This is one of the more interesting tools.
The purpose is to identify fan-out queries where a target brand was absent from observed results.
Conceptually:
User Prompt
↓
Fan-Out Query A → Brand found
Fan-Out Query B → Brand found
Fan-Out Query C → Brand missing
Fan-Out Query D → Brand missing
That means the agent can investigate where visibility breaks down, not only whether overall visibility is high or low.
Step 5: Keep the tools deterministic
A design principle I carried over from V3 was this:
Let the model reason about results, but do not make the model invent the measurements.
The MCP tools perform the analytical work.
Gemini receives structured results and explains them.
So instead of:
Gemini:
"I think the brand visibility is probably around 70%."
the flow becomes:
Tool:
mention_rate = 68.2
recommendation_rate = 41.7
citation_rate = 53.6
Gemini:
"Your brand appeared in roughly two-thirds of the observed journeys,
but it was recommended less frequently..."
The exact values come from data.
The language comes from the model.
That separation is important to me.
Step 6: Use discovery-first grounding
One problem with analytical agents is that users often ask incomplete questions.
For example:
Give me a visibility summary.
A model could easily make assumptions:
- which brand?
- what date range?
- which scans?
- what data actually exists?
Instead, V4 can discover the available history first using get_available_history. For broad questions or questions without an explicit brand/date window, that discovery step gives the agent actual scan coverage before it performs deeper analysis
That means:
Question
↓
Discover available history
↓
Choose valid scope
↓
Run analysis
↓
Explain answer
I prefer this to embedding assumed date ranges in the prompt.
Step 7: Run the agent locally
The V4 agent can be used through the Streamlit application or through a standalone CLI demo.
First activate the environment:
source .venv/bin/activate
Then launch the application:
streamlit run src/ai_search_journey/app.py
Step 8: Start with a broad-history question
One of the first prompts I use is:
Give me a visibility summary for the available history.
Use the most recent available scan window.
The important phrase is:
available history
I do not want the model to assume that a particular brand or date range exists.
The agent can inspect the historical coverage first and then use the appropriate read-only tools to produce the summary.
This gives me a much more grounded conversation.
The user does not need to know the dataset structure.
The agent does not need to guess the dataset structure.
The tools bridge that gap.
Step 9: Ask for a competitor comparison
The next test is more specific.
For example:
Compare "pullman_coffee" against its competitors for the most recent available scan window.
The important detail is that the brand ID needs to exist in the historical data. The project documentation explicitly treats pullman_coffee as an example only when it is present in the available scan history.
In practice, I can first discover the available brand IDs and then choose one.
The comparison tool can then return a structured view of:
mentions
recommendations
citations
between the target brand and competing brands.
The model then explains the comparison in natural language.
Step 10: Independently verify the agent with BigQuery
One thing I do not want is an agent that becomes the only interface to the data.
The underlying history should still be independently verifiable.
V4 never writes to BigQuery; the queries below are only for verifying the V3 data that the agent reads.
For example, I can inspect per-brand visibility metrics directly:
bq --project_id=ai-search-journey-lab query \
--use_legacy_sql=false \
--format=pretty \
'
SELECT
brand_id,
COUNT(DISTINCT scan_id) AS total_scans,
ROUND(100 * AVG(CAST(mentioned AS INT64)), 1) AS mention_rate_pct,
ROUND(100 * AVG(CAST(recommended AS INT64)), 1) AS recommendation_rate_pct,
ROUND(100 * AVG(CAST(cited AS INT64)), 1) AS citation_rate_pct,
MAX(started_at) AS latest_scan_at
FROM
`ai-search-journey-lab.ai_search_journey_v3.brand_observations`
WHERE
started_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 365 DAY)
GROUP BY
brand_id
ORDER BY
citation_rate_pct DESC,
mention_rate_pct DESC,
total_scans DESC
'
That query gives me an independent path to the same underlying evidence used by the agent.
I can also inspect recent persisted scans:
bq --project_id=ai-search-journey-lab query \
--use_legacy_sql=false \
--format=pretty \
'
SELECT
scan_id,
started_at,
brand_id,
brand_name_snapshot,
prompt_text_snapshot
FROM
`ai-search-journey-lab.ai_search_journey_v3.visibility_scans`
WHERE
started_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 365 DAY)
ORDER BY
started_at DESC
LIMIT 10
'
The partition filter matters here because the historical tables require filtering on started_at.
Step 11: Why I did not give the agent raw SQL
A natural question is:
Why not just let Gemini generate SQL?
That would certainly be simpler.
But it would also create a much broader capability surface.
With arbitrary SQL generation:
User
↓
LLM
↓
Generated SQL
↓
Database
the model has to understand:
- table names
- schemas
- joins
- partition requirements
- date handling
- security boundaries
- allowed operations
- analytical semantics
And then I have to validate all of that.
With MCP tools:
User
↓
Gemini + ADK
↓
compare_brands(...)
↓
Controlled analytics logic
↓
BigQuery
the capability is much narrower.
The model asks for an operation.
The tool owns the implementation.
That is a pattern I increasingly prefer for production-oriented agent systems.
Step 12: Separate reasoning from authority
This is probably the most important lesson from V4.
LLMs are very good at:
- interpreting a question
- selecting an analytical path
- comparing results
- summarizing findings
- explaining trade-offs
But that does not mean the model needs unrestricted authority over the underlying system.
I can keep the reasoning flexible while keeping the capabilities constrained.
Gemini
↓
Flexible reasoning
Google ADK
↓
Agent orchestration
MCP
↓
Explicit capability boundary
BigQuery
↓
Deterministic historical evidence
That architecture is much easier for me to understand and debug.
Step 13: Preserve unsupported-answer behavior
Another boundary matters just as much:
The agent should be able to say:
I do not have enough historical data to answer that.
If the user asks about:
- a brand that does not exist in the scan history
- a time period with no scans
- a metric the tools do not expose
- current live-search behavior
the right response is not to manufacture an answer.
The system should stay within the evidence available to V4.
That makes the agent less magical.
It also makes it much more useful.
Step 14: Validate the agent layer
As with the previous versions, I do not consider the feature complete just because the UI responds.
I run the repository checks:
./.venv/bin/ruff check . && \
./.venv/bin/mypy src && \
./.venv/bin/pytest -q
What V4 changed for me
V3 taught me to preserve the evidence before calculating the conclusion.
V4 added another lesson:
Give the model access to capabilities, not unrestricted infrastructure.
The final architecture is not:
Gemini
↓
Database
It is:
User Question
↓
Gemini
↓
Google ADK
↓
Approved MCP Tools
↓
Read-Only Analytics
↓
BigQuery History
↓
Structured Evidence
↓
Grounded Explanation
That distinction matters.
Gemini provides the reasoning.
Google ADK orchestrates the workflow.
MCP defines the tool boundary.
BigQuery remains the source of historical evidence.
The analytical tools remain deterministic and read-only.
What I learned building the AI Visibility Agent
The first lesson was that an analytical agent becomes more trustworthy when it knows less about the database.
That may sound backward.
But the model does not need to know every table, join, and partition rule.
It needs to know which analytical capability to use.
The second lesson was that discovery is a tool too.
get_available_history is not just convenience.
It helps prevent the model from reasoning over data that does not exist.
The third lesson was that agents become easier to evaluate when the tools reflect real business questions:
What history exists?
How visible is this brand?
How is visibility changing?
How does it compare with competitors?
Which sources are citing it?
Where does query fan-out miss it?
From agentic analytics to observability
Once the agent started working, another problem became much more obvious.
Now I had:
User Question
↓
Gemini
↓
ADK Agent
↓
MCP Tool Selection
↓
MCP Server
↓
BigQuery
↓
Tool Result
↓
Gemini Synthesis
But if something became slow or failed, I needed to know:
Which stage caused it?
Was Gemini slow?
Did the MCP process take too long?
Did the BigQuery tool fail?
Which tool did the agent call?
How much time did each stage take?
That moved the project into the next phase: observability.
Next: From Query Fan-Out to Trace — Observing AI Search Journeys with OpenTelemetry
In Part 5, I will instrument the AI Search Journey so I can trace what happens across the workflow.
The focus moves from:
What answer did the system produce?
to:
How did the system produce it?
Source code
The project is open source: GitHub Project







Top comments (1)
Working Code - github.com/hastimal/ai-search-jour...