In my previous article, we explored how to build a multi-agent AI concierge using Angular and Google's Genkit. We discussed the architectural benefits of the multi-agent pattern and how to use Google Search Retrieval to ground our agents in real-world data.
Today, we're taking it a step further. We'll explore Grounding with Google Maps, a powerful feature in Genkit that allows your AI to not only talk about locations but to provide interactive, contextual map experiences directly within your application.
What changed: Earlier versions of this flow used
enableWidget: trueand agoogleMapsWidgetContextTokento render Google's experimental Contextual View (PlaceContextualElement). That widget path is deprecated. The token is no longer returned for server-side Genkit calls, and Genkit's own docs still mention it. The working approach is to readplaceId/title/urifromgroundingChunksand render places yourself with the Maps JavaScript API and Place Details UI Kit.
What is Grounding with Google Maps?
Grounding with Google Maps allows Gemini to access real-time place information, coordinates, and reviews.
A successful Maps-grounded response includes groundingMetadata. The part that matters for the UI is groundingChunks:
{
"groundingMetadata": {
"groundingChunks": [
{
"maps": {
"uri": "https://maps.google.com/?cid=...",
"title": "Central Park",
"placeId": "places/ChIJ4zGFAZpYwokRGUGph3Mf37k"
}
}
]
}
}
Each chunk is a source the model used. You must surface those sources in the UI (title + link). You can also take the placeId and render a map, markers, and Place Details cards so the user sees the same places the model talked about.
Instead of just getting a text description of a restaurant, your user gets an interactive card with photos, ratings, and a "Get Directions" button; all powered by the same data the model used to generate its response.
Enabling the Google Maps APIs
Before you can use these features, you must enable the necessary APIs in the Google Cloud Project associated with your application. This ensures that the Maps JavaScript API and the related Place services are available to your application.
- Go to the Google Cloud Console and select the project associated with your app.
- Navigate to APIs & Services > Library.
- Search for and enable the Maps JavaScript API.
- Once enabled, go to the APIs & Services tab within the Maps JavaScript API dashboard and ensure the following are enabled (these are sub-APIs under the main one):
- Places API
- Places API (New) - required for Place.fetchFields() and Place Details elements
- Maps Embed API
- Directions API
A Map ID is also required for Advanced Markers. For demos, Google's DEMO_MAP_ID is enough. For production, create a Map ID in Google Maps Platform > Map Management.
Obtaining and Restricting the API Key
Once the APIs are enabled, you need a way to authenticate your requests while keeping your project secure.
1. Create the Key
- In the Google Cloud Console, navigate to Google Maps Platform > Keys & Credentials.
- Click Create Credentials and select API Key.
- Copy your new API key (you'll need it for the next step).
2. Restrict the Key (Crucial!)
To prevent unauthorized use of your key, you must restrict it to only be usable by your specific websites.
- Click on the newly created key to edit its settings.
- Under Application restrictions, select Websites (HTTP referrers).
- Add your website URLs (e.g.,
https://your-app.web.app/*andhttp://localhost:4200/*for local development). - Under API restrictions, select Restrict key and choose the Maps and Places APIs you enabled earlier.
- Click Save.
Backend Implementation: The "Find & Navigate" Agent
In our concierge system, we have a specialized agent called the findAndNavigateAgentTool. This tool is specifically configured to use Google Maps grounding. The concierge orchestrator decides when to call it (for example: "How do I get from Times Square to Central Park?").
1. Enabling the Google Maps Tool
In Genkit, Maps grounding is a provider tool. You pass it in config.tools on ai.generate — not in the Genkit tools array used for your own agent functions.
export const _findAndNavigateAgentToolLogic = ai.defineTool(
{
name: 'findAndNavigateAgentTool',
description: 'Assists with finding the best routes and transportation options',
inputSchema: z.object({
input: z.string(),
history: z.array(conversationMessageSchema).optional(),
}),
outputSchema: z.object({
text: z.string(),
mapsPlaces: z.array(mapsPlaceSchema).optional(),
}),
},
async ({input, history}) => {
const response = await ai.generate({
system: TRANSPORT_AGENT_PROMPT,
messages: [
...toGenkitMessages(history ?? []),
{role: 'user', content: [{text: input}]},
],
config: {
tools: [{googleMaps: {}}],
},
});
const mapsPlaces = extractMapsPlacesFromResponse(response);
return {
text: response.text,
mapsPlaces: mapsPlaces.length ? mapsPlaces : undefined,
};
}
);
Do not set enableWidget: true. That flag is deprecated. The Gemini API no longer populates googleMapsWidgetContextToken for this server-side path, even though some Genkit docs still show it.
2. Extracting Place IDs from Grounding Metadata
Genkit keeps the raw Gemini payload on response.raw / response.custom. Walk candidates[0].groundingMetadata.groundingChunks and collect unique Maps sources. Place IDs often arrive as places/ChIJ.... Strip the places/ prefix so the Maps JavaScript API can use them.
function extractMapsPlacesFromResponse(response: {raw?: unknown; custom?: unknown}): MapsPlace[] {
const rawPayload = response.raw as GeminiGroundingPayload | undefined;
const customPayload = response.custom as GeminiGroundingPayload | undefined;
const chunks =
rawPayload?.candidates?.[0]?.groundingMetadata?.groundingChunks ??
customPayload?.candidates?.[0]?.groundingMetadata?.groundingChunks ??
[];
const places: MapsPlace[] = [];
const seen = new Set<string>();
for (const chunk of chunks) {
const maps = chunk?.maps;
if (!maps?.placeId) continue;
const placeId = String(maps.placeId).replace(/^places\//, '');
if (!placeId || seen.has(placeId)) continue;
seen.add(placeId);
places.push({
placeId,
title: maps.title,
uri: maps.uri,
});
}
return places;
}
3. Passing Places Through the Concierge Orchestrator
The transport tool returns { text, mapsPlaces }. The concierge agent then writes a final reply from that tool output. The place list lives on the tool message, not on the concierge's own generate() call, so copy it off response.messages before returning to the client.
let mapsPlaces: MapsPlace[] | undefined;
for (const msg of response.messages) {
if (msg.role !== 'tool') continue;
for (const part of msg.content) {
if (part.toolResponse?.name === 'findAndNavigateAgentTool') {
mapsPlaces = mapsPlacesFromToolOutput(part.toolResponse.output);
if (mapsPlaces) break;
}
}
if (mapsPlaces) break;
}
return {text: resultText, mapsPlaces};
Handle both a plain { mapsPlaces } object and Genkit's { name, content } wrapper. Otherwise the orchestrator silently drops the places even when grounding succeeded.
Frontend: Rendering Grounded Places
On the Angular side, the chat message carries mapsPlaces. The widget plots those IDs on a map and shows a Place Details card plus a Google Maps source link for each one.
The Message Model
export interface MapsPlace {
placeId: string;
title?: string;
uri?: string;
}
export interface ConciergeResponse {
text: string;
mapsPlaces?: MapsPlace[];
}
In the chat template:
@if (message.mapsPlaces?.length) {
<div class="maps-widget-container" role="region" aria-label="Google Maps grounded places">
<app-maps-widget [places]="message.mapsPlaces ?? []"></app-maps-widget>
</div>
}
The Maps Widget Component
We created a standalone MapsWidget component that handles the library loading and element creation. MapsWidget loads the maps, marker, and places libraries, resolves each placeId to a location, and draws Advanced Markers. Place Details Compact is a web component (gmp-place-details-compact), so the component needs CUSTOM_ELEMENTS_SCHEMA.
@Component({
selector: 'app-maps-widget',
templateUrl: './maps-widget.html',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MapsWidget {
readonly places = input<MapsPlace[]>([]);
readonly librariesReady = signal(false);
private readonly mapElement = viewChild<ElementRef<HTMLElement>>('mapElement');
constructor() {
afterRenderEffect({
write: () => {
const places = this.places();
const container = this.mapElement()?.nativeElement;
if (!this.librariesReady() || !places.length || !container) return;
void this.renderMap(container, places);
},
});
}
private async resolvePlaceLocations(googleMaps: any, places: MapsPlace[]) {
const Place = googleMaps.places?.Place;
const resolved = await Promise.all(
places.map(async (place) => {
const mapsPlace = new Place({ id: place.placeId });
await mapsPlace.fetchFields({ fields: ['displayName', 'location'] });
if (!mapsPlace.location) return null;
return {
position: mapsPlace.location,
title: "mapsPlace.displayName || place.title || 'Place',"
};
}),
);
return resolved.filter(Boolean);
}
}
Use afterRenderEffect (not a constructor effect) so the map is created after Angular has painted the container. Standard effect runs before the DOM update, which is the wrong time to attach a third-party map.
Place Details + Required Attribution
<div #mapElement class="map-element" role="img" aria-label="Map of grounded Google Maps places"></div>
@for (place of places(); track place.placeId) {
<gmp-place-details-compact orientation="horizontal" truncation-preferred>
<gmp-place-details-place-request [attr.place]="place.placeId"></gmp-place-details-place-request>
<gmp-place-content-config>
<gmp-place-media lightbox-preferred></gmp-place-media>
<gmp-place-rating></gmp-place-rating>
<gmp-place-type></gmp-place-type>
<gmp-place-open-now-status></gmp-place-open-now-status>
<gmp-place-attribution></gmp-place-attribution>
</gmp-place-content-config>
</gmp-place-details-compact>
@if (place.uri) {
<a [href]="place.uri" target="_blank" rel="noopener noreferrer">
View {{ place.title || 'this place' }} on Google Maps
</a>
}
}
The source link is not optional. Grounding with Google Maps requires that each grounded result is followed by its Google Maps source, viewable within one user interaction.
gmp-place-details-compact is the current Place Details UI Kit. It is not the deprecated Contextual View (PlaceContextualElement / <gmp-place-contextual>), which depended on the widget token.
Securely Loading the Maps API
Instead of hardcoding the API key in our frontend, we fetch it dynamically from a secure Firebase Function. This allows us to keep the key as a Firebase Secret and only expose it to authenticated users if needed.
1. The Backend: Providing the API Key
To ensure that the API keys are not committed to the repository, we store them as Firebase Secrets. This keeps them out of our source code and only injects them into the function's environment at runtime.
Step 1: Deploy the Secret
Use the Firebase CLI to securely upload your API key:
firebase functions:secrets:set MAPS_API_KEY
firebase functions:secrets:set MAPS_API_KEY_DEV
When prompted, paste the API key you generated in the Google Cloud Console. Use a separate dev key for localhost and PR preview channels so HTTP referrer restrictions stay tight.
Step 2: Access the Secret in your Code
In our functions/src/index.ts, we define the secret and then retrieve its value within our onCall function.
import { defineSecret } from 'firebase-functions/params';
// Define the secrets
const MAPS_API_KEY = defineSecret('MAPS_API_KEY');
const constMAPS_API_KEY_DEV = defineSecret('MAPS_API_KEY_DEV');
export const loadGoogleMaps = onCall(
{
...GENKIT_FUNCTION_CONFIG,
secrets: [MAPS_API_KEY, MAPS_API_KEY_DEV], // Explicitly grant the function access to these secrets
},
(request) => {
const origin = request.rawRequest.get('origin') || '';
const isPRPreview = /^https:\/\/agents-concierge--pr[a-z0-9-]+\.web\.app$/.test(origin);
const isLocalhost = origin.includes('localhost') || origin.includes('127.0.0.1');
// Access the values securely based on environment variables
if (isPRPreview || isLocalhost) {
return {key: MAPS_API_KEY_DEV.value()};
}
return {key: MAPS_API_KEY.value()};
}
);
2. The Frontend: The GoogleMapsLoaderService
We created a central service in Angular to handle the asynchronous loading of the Maps libraries. It uses the @googlemaps/js-api-loader package for a clean, Promise-based initialization.
@Injectable({ providedIn: 'root' })
export class GoogleMapsLoaderService {
private readonly functions = inject(Functions);
private initialized = false;
private async ensureInitialized() {
if (this.initialized) return;
// 1. Fetch the API key from our Firebase Function
const loadGoogleMaps = httpsCallable<unknown, { key: string }>(
this.functions,
'loadGoogleMaps'
);
const { data } = await loadGoogleMaps();
// 2. Configure the JS API Loader with the key
setOptions({
key: data.key,
libraries: ['maps', 'marker', 'places'],
});
this.initialized = true;
}
async importLibrary(library: string) {
await this.ensureInitialized();
return importLibrary(library);
}
}
Load maps, marker, and places. You no longer need the Maps JS alpha channel — that was for Contextual View.
Why this approach?
- Security: The API key isn't stored in
environment.tsor hardcoded inindex.html. It stays in Firebase Secrets. - On-Demand Loading: The Maps JS SDK loads only when a grounded message is about to render.
- Consistency: Centralizing the loader ensures that all components use the same API version and configuration.
- Environment-aware keys: Preview channels and localhost can use a less-restricted key without loosening production referrers.
Something to take note of
These are the reasons the old "set enableWidget: true and read the token" recipe stopped working:
enableWidget/googleMapsWidgetContextTokenare deprecated. The Gemini API documents that the field will no longer be populated. Google Maps Platform retired Contextual View (15 June 2026). The replacement visualization path is Place Details / the Maps Agentic UI Toolkit, driven by place IDs, not a context token.The token was Web SDK only. Firebase AI Logic docs said
googleMapsWidgetContextTokenis only returned when using the Web SDK. A Cloud Function + Genkit call is a server-side Gemini request, so the token was never a reliable contract for this architecture.Genkit docs are stale. They still show
{ googleMaps: { enableWidget: true } }. The lookup path for metadata (response.custom/response.raw→candidates[0].groundingMetadata) is still correct, the field inside it changed. Look forgroundingChunks, notgoogleMapsWidgetContextToken.Gemini Developer API grounds places, not routes. Gemini Enterprise Agent Platform (formerly Vertex AI) supports
groundingTypes: { places, routing }. ThegoogleAI()plugin path defaults to places. A directions question still works if Gemini grounds the origin and destination as places; you will get markers, not a turn-by-turn widget.Don't lose the tool payload in the orchestrator. Maps grounding happens on the sub-agent
generate(). If the concierge only returnsresponse.text,mapsPlacesnever reaches our app.
Key Features & Benefits
-
Grounded accuracy: The map shows the places Gemini actually used, via
placeId, not a guessed search string. - Interactive UX: Users can see ratings, hours, and photos without leaving your chat interface.
- ToS-friendly sources: Each card links back to Google Maps, which is required for Maps-grounded results.
-
Modern Angular: Signal
input(),viewChild(), andafterRenderEffect()keep the widget in sync with conversation state without fighting change detection.
Conclusion
Grounding with Google Maps transforms your AI from a text-based assistant into a rich, spatial guide. By combining Genkit's powerful tool-calling orchestration with Angular's component-based architecture, you can build concierge experiences that feel truly native to the physical world.
Check out the project, Concierge AI, here: https://agents-concierge.web.app/
GitHub Repo: https://github.com/waynegakuo/concierge
Happy Coding!
Resources
Start here if you want the primary sources behind this article. A few of these still mention enableWidget. Treat that as historical and use groundingChunks + place IDs as shown above.
Grounding with Google Maps
-
Grounding with Google Maps (Gemini
generateContentAPI): The contract that matters:groundingMetadata.groundingChunks[].maps(placeId,title,uri) and source-display requirements. -
Grounding with Google Maps (Firebase AI Logic): Same feature from the Firebase SDKs. Notes that
googleMapsWidgetContextTokenwas Web SDK only. -
Grounding with Google Maps (Gemini Enterprise Agent Platform): Agent Platform path, including
groundingTypesfor places vs routing (not available on the Gemini Developer API used in this app). -
Google Generative AI plugin (Genkit): How to pass
config.tools: [{ googleMaps: {} }]and readresponse.custom/response.raw. Still documentsenableWidget; the lookup path for metadata is current, the token field is not. -
GoogleMaps (
@google/genai) and GroundingMetadata: Official deprecation text forenableWidgetandgoogleMapsWidgetContextToken.
Visualizing grounded places
-
Place Details elements (Places UI Kit):
gmp-place-details-compactandgmp-place-details-place-request, the replacement for Contextual View in this chat UI. -
Place class:
Place.fetchFields()to resolve aplaceIdtolocation/displayNamefor markers. -
Advanced Markers and Map IDs: Required to plot grounded places on a JS map (
DEMO_MAP_IDis fine for demos). - Maps Agentic UI Toolkit: Longer-term visualization layer (inline maps, routes, place cards) if you outgrow Place Details Compact.
- Build smarter and faster (Maps Platform blog): Contextual View fully deprecated 15 June 2026; Agentic UI Toolkit is the stated replacement.
- Powering the next era of agentic experiences: Grounding GA in Firebase AI Logic and the Agentic UI Toolkit announcement.
Angular, Genkit, and Maps loading
-
Angular
afterRenderEffect: Correct hook for attaching a third-party map after the container is in the DOM. - Genkit agentic patterns: Tool-calling orchestrator pattern used by the concierge + specialist agents.
-
@googlemaps/js-api-loader: Dynamic Maps JS load (maps,marker,places). - API key best practices (Maps Platform): HTTP referrer and API restrictions for the browser key.
-
Firebase Functions secrets: How
MAPS_API_KEY/MAPS_API_KEY_DEVstay out of source control.
Related reading
- Multi-agent AI systems with Angular and Genkit - Previous article in this series (concierge architecture and Google Search grounding). Update this URL if your slug differs.
- Gemini forum: Maps grounding and widget token missing - Community reports of empty chunks / missing tokens; useful when debugging intermittent grounding.
Top comments (2)
Genkit expert
Trying my best...haha. Thank you, @railsstudent