DEV Community

Cover image for Multi-Agent AI Systems: Grounding with Google Maps in Genkit
Wayne Gakuo
Wayne Gakuo

Posted on Edited on

Multi-Agent AI Systems: Grounding with Google Maps in Genkit

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: true and a googleMapsWidgetContextToken to 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 read placeId / title / uri from groundingChunks and 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"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Go to the Google Cloud Console and select the project associated with your app.
  2. Navigate to APIs & Services > Library.
  3. Search for and enable the Maps JavaScript API.
  4. 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/* and http://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,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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};
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()};
  }
);
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Load maps, marker, and places. You no longer need the Maps JS alpha channel — that was for Contextual View.

Why this approach?

  1. Security: The API key isn't stored in environment.ts or hardcoded in index.html. It stays in Firebase Secrets.
  2. On-Demand Loading: The Maps JS SDK loads only when a grounded message is about to render.
  3. Consistency: Centralizing the loader ensures that all components use the same API version and configuration.
  4. 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:

  1. enableWidget / googleMapsWidgetContextToken are 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.

  2. The token was Web SDK only. Firebase AI Logic docs said googleMapsWidgetContextToken is 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.

  3. Genkit docs are stale. They still show { googleMaps: { enableWidget: true } }. The lookup path for metadata (response.custom / response.rawcandidates[0].groundingMetadata) is still correct, the field inside it changed. Look for groundingChunks, not googleMapsWidgetContextToken.

  4. Gemini Developer API grounds places, not routes. Gemini Enterprise Agent Platform (formerly Vertex AI) supports groundingTypes: { places, routing }. The googleAI() 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.

  5. Don't lose the tool payload in the orchestrator. Maps grounding happens on the sub-agent generate(). If the concierge only returns response.text, mapsPlaces never reaches our app.


Key Features & Benefits

  1. Grounded accuracy: The map shows the places Gemini actually used, via placeId, not a guessed search string.
  2. Interactive UX: Users can see ratings, hours, and photos without leaving your chat interface.
  3. ToS-friendly sources: Each card links back to Google Maps, which is required for Maps-grounded results.
  4. Modern Angular: Signal input(), viewChild(), and afterRenderEffect() 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

Visualizing grounded places

Angular, Genkit, and Maps loading

Related reading


Top comments (2)

Collapse
 
railsstudent profile image
Connie Leung

Genkit expert

Collapse
 
wayne_gakuo profile image
Wayne Gakuo

Trying my best...haha. Thank you, @railsstudent