> For the complete documentation index, see [llms.txt](https://docs.mapbox.com/help/ja/llms.txt)

# Build your own agent with Mapbox MCP Server

### Introduction

In this tutorial, you will build an agent capable of answering prompts with the help of the [Mapbox MCP Server](https://docs.mapbox.com/api/guides/mcp-server/). You'll stand up a web app that will contain a chat user interface and will allow a user to interact with the agent. You will also integrate a [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/) map to make the experience more interactive. The agent will be able to render GeoJSON responses on the map, adjust camera position, weather, lighting.

![A screenshot of a webpage with chat interface. The agent responds to a user and shows a close up of Boston financial district with the dawn lighting.](https://docs.mapbox.com/help/ja/assets/ideal-img/byoa-boston.bf7fed7.480.png)

### What we'll cover

By the end of this tutorial you will have completed the following

-   Setup a new python project and install dependencies with [UV](https://docs.astral.sh/uv/) package manager
-   Create a basic agent with CrewAI
-   Setup `env` variables to manage API keys for the project
-   Connect the agent with **Mapbox MCP Server**
-   Integrate a **Mapbox GL JS** map by setting up a [Flask](https://flask.palletsprojects.com/en/stable/) web app and creating a new agent to return GeoJSON responses
-   Create a third agent to manage camera views, lighting presets & weather.

This tutorial is intended to be completed in about 1 hour. Time to get coding!

## Getting started

There are a few resources you will need to follow along with this tutorial:

-   **A Mapbox access token**: Find yours on the [Access token page](https://console.mapbox.com/account/access-tokens/) of your Developer Console.
-   **Development environment**. This tutorial requires writing code. For tips on how to get started, see the "Get ready to write code" call out below.
-   **LLM provider access token**. This tutorial requires access to an LLM. You can use OpenAI, Anthropic, Google or one of many other providers. You can find examples of how to find and specify tokens for different providers on the [CrewAI documentation page](https://docs.crewai.com/en/concepts/llms#provider-configuration-examples).

> **Note: Get ready to write code**
> 
> You will use configuration files, Python, HTML, CSS, and JavaScript code, so you will need a development environment, including a text or code editor. We recommend downloading and installing [Sublime Text](https://www.sublimetext.com/) or [Visual Studio Code](https://code.visualstudio.com/download). To use [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/api/), our JavaScript library, you will also need some familiarity with [JavaScript](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/First_steps/What_is_JavaScript) and front-end development concepts. If you are new to writing code, you may want to explore JavaScript learning resources before you begin building with Mapbox GL JS.

## Create Python project and install dependencies

In this section you will setup your environment and install dependencies that will be used later on.

### Setup Python project

1.  Create a blank directory, this will be the root project directory.
2.  [Install UV](https://docs.astral.sh/uv/getting-started/installation/) package manager if you do not have it yet.
3.  Create a new Python 3.13 environment: `uv venv --python 3.13` and then `uv init`
4.  Install required packages: `uv add 'crewai-tools[mcp]' mcp crewai flask` and then `uv lock`
5.  At this point, you should have a few files created, most of which are related to the package manager. Run `main.py` to make sure you have the "Hello world" set up correctly: `uv run main.py`. You should see a Hello message. If you do not see the Hello message, check that you are inside the initialized uv environment and all packages were installed successfully.

## Create a Basic Agent with CrewAI

In this section, you will create a basic agent that uses its own general knowledge to answer user prompts in a helpful way.

### Create templates for agents and tasks

We need to create a template for agents and tasks. These templates will be used by [CrewAI](https://docs.crewai.com/) to create a crew of agents.

1.  Create a `config/` directory. You will use this directory to store agent and task templates.
2.  Create `config/agents.yaml`. For now, you will only have one agent:

```yml
helpful_agent:
  role: >
    A helpful assistant.
  goal: >
    Try to help a user who provided this prompt: `{prompt}`
  backstory: >
    You're a powerful LLM willing to help users with their prompts.
```

3.  Create `config/tasks.yaml`. For now, this will also have only one task:

```yml
help_task:
  description: >
    Use tools and your own general knowledge to answer this prompt: `{prompt}`
    Current year is {current_year}.
  expected_output: >
    A natural response in your own words.
  agent: helpful_agent
```

4.  Now you need to create a crew class that will use the two templates above. Create a `crew.py` file:

```py
from crewai import Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
from crewai.agents.agent_builder.base_agent import BaseAgent
from typing import List, Optional, Dict, Any


@CrewBase
class DemoProject():
    """DemoProject crew"""

    agents: List[BaseAgent]
    tasks: List[Task]

    # Learn more about YAML configuration files here:
    # Agents: https://docs.crewai.com/concepts/agents#yaml-configuration-recommended
    # Tasks: https://docs.crewai.com/concepts/tasks#yaml-configuration-recommended
    
    # If you would like to add tools to your agents, you can learn more about it here:
    # https://docs.crewai.com/concepts/agents#agent-tools
    @agent
    def helpful_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['helpful_agent'], # type: ignore[index]
            verbose=True
        )

    # To learn more about structured task outputs,
    # task dependencies, and task callbacks, check out the documentation:
    # https://docs.crewai.com/concepts/tasks#overview-of-a-task
    @task
    def help_task(self) -> Task:
        return Task(
            config=self.tasks_config['help_task'], # type: ignore[index]
        )


    @crew
    def crew(self) -> Crew:
        """Creates the DemoProject crew"""
        # To learn how to add knowledge sources to your crew, check out the documentation:
        # https://docs.crewai.com/concepts/knowledge#what-is-knowledge

        return Crew(
            agents=self.agents, # Automatically created by the @agent decorator
            tasks=self.tasks, # Automatically created by the @task decorator
            process=Process.sequential,
            verbose=True,
            # process=Process.hierarchical, # In case you wanna use that instead https://docs.crewai.com/how-to/Hierarchical/
        )

```

5.  You will need an entry point to kick off the crew process. Update `main.py`, replacing the "hello world" content with:

```py
#!/usr/bin/env python
import warnings

from datetime import datetime

from crewai import CrewOutput
from crew import DemoProject
from dotenv import load_dotenv
load_dotenv()

warnings.filterwarnings("ignore", category=SyntaxWarning, module="pysbd")

# This main file is intended to be a way for you to run your
# crew locally, so refrain from adding unnecessary logic into this file.
# Replace with inputs you want to test with, it will automatically
# interpolate any tasks and agents information

def run(prompt) -> CrewOutput:
    """
    Run the crew.
    """
    inputs = {
        'prompt': prompt,
        'current_year': str(datetime.now().year)
    }
    
    try:
        return DemoProject().crew().kickoff(inputs=inputs)
    except Exception as e:
        raise Exception(f"An error occurred while running the crew: {e}")


def main():
    prompt = input('Enter your prompt: ')
    run(prompt)


if __name__ == '__main__':
    main()

```

You are almost ready to run this agent in the terminal. As the last step you need to specify environmental variables using an `.env` file.

### Create an `.env` file

Create a file named `.env` in the main project directory. This file will specify environmental variables and API keys. Here is an example `.env` file:

```
# We are using OpenAI with GPT 4.1 
MODEL=gpt-4.1
OPENAI_API_KEY=sk-proj-yZ....

# Alternatively you could use Anthropic with Claude sonnet 4
# MODEL=claude-sonnet-4-20250514
# ANTHROPIC_API_KEY=sk-ant-...

MAPBOX_ACCESS_TOKEN=YOUR_MAPBOX_ACCESS_TOKEN
```

Notice that this tutorial uses the `gpt-4.1` model from OpenAI. If you are using other models or providers, your `.env` will look different.

You can find examples of how to find and specify tokens for different providers on the [CrewAI documentation page](https://docs.crewai.com/en/concepts/llms#provider-configuration-examples).

You will need to specify `MAPBOX_ACCESS_TOKEN` to use Mapbox MCP Server. You can find your access tokens on your [Access tokens page](https://www.mapbox.com/account/access-tokens/).

> **Note: A note for the future**
> 
> If the underlying LLM provider deprecates their model, you'll need to update the `.env` file to reference a different model that is still available!

### Let's run the agent

Your file structure should look like this:

```
your-project-root/
├── config/
│   ├── agents.yaml
│   └── tasks.yaml
├── .env
├── crew.py
├── main.py
```

Try running the agent with `uv run main.py`. It should ask you to enter a prompt, which will be passed to the agent.

![A screenshot of an agent running in terminal](https://docs.mapbox.com/help/ja/assets/ideal-img/byoa-agent-in-terminal.bd54620.480.png)

> **Note: It's not using Mapbox MCP yet!**
> 
> The agent right now is not using Mapbox MCP; any responses are generated entirely by the LLM. In the next section, you will connect the agent to Mapbox MCP.

## Connect with Mapbox MCP

In this section, you will connect the agent with Mapbox MCP. This will allow the agent to use tools to handle prompts that require precise coordinates, navigation, or searching for places.

### Connect to Mapbox MCP server

1.  Update `crew.py` to add additional import statements at the top:

```py
from crewai import Agent, Crew, Process, Task, LLM
from crewai.project import CrewBase, agent, crew, task
from crewai.agents.agent_builder.base_agent import BaseAgent
from typing import List, Optional, Dict, Any
# highlight-start
from pydantic import BaseModel, Field
import os
# highlight-end

@CrewBase
class DemoProject():
...
```

2.  Update the `DemoProject` class to specify MCP server parameters and add the tools into the agent:

```py
...

@CrewBase
class DemoProject():
    """DemoProject crew"""

    agents: List[BaseAgent]
    tasks: List[Task]
    # highlight-start
    # Add mcp_server_params list, this is the first change we need to make to enable MCP
    mcp_server_params = [
        {
            "url": "https://mcp.mapbox.com/mcp",
            "transport": "streamable-http",
            "headers": {"authorization": f"Bearer {os.environ['MAPBOX_ACCESS_TOKEN']}"}
        }
    ]
    # highlight-end

    @agent
    def helpful_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['helpful_agent'], # type: ignore[index]
            verbose=True,
            # highlight-start
            tools=self.get_mcp_tools() # This is the second change we are making to enable tools
            # highlight-end
        )
```

Notice you are making only two changes in the `DemoProject` class: adding `mcp_server_params` and passing `tools` to the `Agent` constructor. The `mcp_server_params` is a list, so you could specify multiple MCP servers there if needed.

### Run the agent

Now try running the agent with `uv run main.py`. The agent should be using tools to answer prompts.

![A screenshot of an agent using POI search tool](https://docs.mapbox.com/help/ja/assets/ideal-img/byoa-tool-use.1ded9f2.480.png)

### Recap

We now have an agent that is able to answer prompts from the command line. It is using Mapbox MCP and can use tools to discover new information about places, compare routes, or plan short trips.

In the next section you will create a GL JS map and will add new agents that can visualize data on the map.

## Integrate a GL JS map

In this section, you will create a webpage with a GL JS map and explore how agents could interact with the map.

### An idea: what if the agent can return GeoJSON?

At the moment the agent is a terminal app, which limits its expressive power. A map would allow us to show routes or highlight different points of interest.

We can create a GL JS map, but the agent cannot directly interact with the map. What if the agent is able to return GeoJSON output as part of the response? Such an output could be added to the map as a new layer.

The implementation will require two separate components:

1.  An agent capable of returning GeoJSON
2.  A webpage with a GL JS map and front-end code that can send prompts to an API and parse GeoJSON objects from responses to add them as a new layer to the GL JS map.

We are going to implement both of these components next, starting with the webpage.

### Create a Flask template

We will use Flask to create a webpage and handle API endpoints to submit prompts and access the homepage.

1.  Create a `templates/` directory. You will use it to store an HTML template for our page.
2.  Create a template file `templates/index.html` with the following content:

Title: `templates/index.html`

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Location AI Demo</title>
    <script src="https://api.mapbox.com/mapbox-gl-js/v3.25.0/mapbox-gl.js"></script>
    <link
      href="https://api.mapbox.com/mapbox-gl-js/v3.25.0/mapbox-gl.css"
      rel="stylesheet"
    />
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        font-family:
          -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        height: 100vh;
        display: flex;
      }

      .map-container {
        flex: 1;
        position: relative;
      }

      #map {
        width: 100%;
        height: 100%;
      }

      .chat-container {
        width: 400px;
        background: #f8f9fa;
        border-left: 1px solid #dee2e6;
        display: flex;
        flex-direction: column;
      }

      .chat-header {
        padding: 20px;
        background: #343a40;
        color: white;
        font-size: 18px;
        font-weight: 600;
      }

      .chat-messages {
        flex: 1;
        padding: 20px;
        overflow-y: auto;
        min-height: 0;
      }

      .message {
        margin-bottom: 15px;
        padding: 12px;
        border-radius: 8px;
        max-width: 100%;
        word-wrap: break-word;
      }

      .user-message {
        background: #007bff;
        color: white;
        margin-left: 20px;
      }

      .ai-message {
        background: #e9ecef;
        color: #343a40;
        margin-right: 20px;
      }

      .chat-input {
        padding: 20px;
        border-top: 1px solid #dee2e6;
        background: white;
      }

      .input-group {
        display: flex;
        gap: 10px;
      }

      #message-input {
        flex: 1;
        padding: 12px;
        border: 1px solid #ced4da;
        border-radius: 4px;
        font-size: 14px;
      }

      #send-button {
        padding: 12px 20px;
        background: #007bff;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        font-weight: 500;
      }

      #send-button:hover {
        background: #0056b3;
      }

      #send-button:disabled {
        background: #6c757d;
        cursor: not-allowed;
      }

      .loading {
        color: #6c757d;
        font-style: italic;
      }
    </style>
  </head>
  <body>
    <div class="map-container">
      <div id="map"></div>
    </div>

    <div class="chat-container">
      <div class="chat-header">Location AI Assistant</div>

      <div class="chat-messages" id="chatMessages">
        <div class="message ai-message">
          Welcome! I'm your Location AI assistant. Ask me anything about
          geographic data, locations, or spatial analysis.
        </div>
      </div>

      <div class="chat-input">
        <div class="input-group">
          <input
            type="text"
            id="message-input"
            placeholder="Ask about locations, maps, or geographic data..."
            maxlength="500"
          />
          <button id="send-button">Send</button>
        </div>
      </div>
    </div>

    <script>
      // Initialize Mapbox map
      const map = new mapboxgl.Map({
        accessToken: 'YOUR_MAPBOX_ACCESS_TOKEN',
        container: 'map',
        style: 'mapbox://styles/mapbox/standard', // Latest Mapbox Standard style
        projection: 'globe',
        center: [-74.006, 40.7128], // NYC coordinates
        zoom: 10,
        config: {
          basemap: {
            showPointOfInterestLabels: false
          }
        }
      });

      // Add navigation control
      map.addControl(new mapboxgl.NavigationControl());

      // Set atmosphere style when the style loads
      map.on('style.load', () => {
        map.setFog({}); // Set default atmosphere style
      });

      // Chat functionality
      const messageInput = document.getElementById('message-input');
      const sendButton = document.getElementById('send-button');
      const chatMessages = document.getElementById('chatMessages');

      function addMessage(content, isUser = false) {
        const messageDiv = document.createElement('div');
        messageDiv.classList.add('message');
        messageDiv.classList.add(isUser ? 'user-message' : 'ai-message');
        messageDiv.textContent = content;

        chatMessages.appendChild(messageDiv);
        chatMessages.scrollTop = chatMessages.scrollHeight;
      }

      function addStreamingMessage(content) {
        const messageDiv = document.createElement('div');
        messageDiv.classList.add('message', 'ai-message');
        messageDiv.textContent = content;

        chatMessages.appendChild(messageDiv);
        chatMessages.scrollTop = chatMessages.scrollHeight;
        return messageDiv;
      }

      function clearMapLayers() {
        // Remove existing GeoJSON sources and layers
        if (map.getSource('geojson-data')) {
          if (map.getLayer('geojson-fill')) map.removeLayer('geojson-fill');
          if (map.getLayer('geojson-line')) map.removeLayer('geojson-line');
          if (map.getLayer('geojson-points')) map.removeLayer('geojson-points');
          map.removeSource('geojson-data');
        }
      }

      function addGeoJSONToMap(geojsonData) {
        if (
          !geojsonData ||
          !geojsonData.features ||
          geojsonData.features.length === 0
        ) {
          return;
        }

        try {
          // Clear existing layers first
          clearMapLayers();

          // Add the GeoJSON source
          map.addSource('geojson-data', {
            'type': 'geojson',
            'data': geojsonData
          });

          // Add layers for different geometry types

          // Polygons (fill)
          map.addLayer({
            'id': 'geojson-fill',
            'type': 'fill',
            'source': 'geojson-data',
            'slot': 'middle',
            'filter': ['==', ['geometry-type'], 'Polygon'],
            'paint': {
              'fill-color': '#007bff',
              'fill-opacity': 0.2
            }
          });

          // Lines and polygon outlines
          map.addLayer({
            'id': 'geojson-line',
            'type': 'line',
            'source': 'geojson-data',
            'slot': 'middle',
            'filter': [
              'in',
              ['geometry-type'],
              ['literal', ['LineString', 'Polygon']]
            ],
            'paint': {
              'line-color': '#007bff',
              'line-width': 2
            }
          });

          // Points
          map.addLayer({
            'id': 'geojson-points',
            'type': 'circle',
            'source': 'geojson-data',
            'filter': ['==', ['geometry-type'], 'Point'],
            'paint': {
              'circle-color': '#007bff',
              'circle-radius': 6,
              'circle-stroke-width': 2,
              'circle-stroke-color': '#ffffff'
            }
          });

          // Add click events for popups
          map.on('click', 'geojson-points', (e) => {
            const feature = e.features[0];
            const popup = new mapboxgl.Popup()
              .setLngLat(e.lngLat)
              .setHTML(
                `
                            <div>
                                <strong>${feature.properties.name || 'Location'}</strong>
                                ${feature.properties.description ? `<br>${feature.properties.description}` : ''}
                            </div>
                        `
              )
              .addTo(map);
          });

          map.on('click', 'geojson-fill', (e) => {
            const feature = e.features[0];
            const popup = new mapboxgl.Popup()
              .setLngLat(e.lngLat)
              .setHTML(
                `
                            <div>
                                <strong>${feature.properties.name || 'Area'}</strong>
                                ${feature.properties.description ? `<br>${feature.properties.description}` : ''}
                            </div>
                        `
              )
              .addTo(map);
          });

          // Change cursor on hover
          map.on('mouseenter', 'geojson-points', () => {
            map.getCanvas().style.cursor = 'pointer';
          });

          map.on('mouseleave', 'geojson-points', () => {
            map.getCanvas().style.cursor = '';
          });

          map.on('mouseenter', 'geojson-fill', () => {
            map.getCanvas().style.cursor = 'pointer';
          });

          map.on('mouseleave', 'geojson-fill', () => {
            map.getCanvas().style.cursor = '';
          });

          // Fit map to show all features
          const bounds = new mapboxgl.LngLatBounds();
          geojsonData.features.forEach((feature) => {
            if (feature.geometry.type === 'Point') {
              bounds.extend(feature.geometry.coordinates);
            } else if (feature.geometry.type === 'LineString') {
              feature.geometry.coordinates.forEach((coord) =>
                bounds.extend(coord)
              );
            } else if (feature.geometry.type === 'Polygon') {
              feature.geometry.coordinates[0].forEach((coord) =>
                bounds.extend(coord)
              );
            }
          });

          if (!bounds.isEmpty()) {
            map.fitBounds(bounds, { padding: 50 });
          }
        } catch (error) {
          console.error('Error adding GeoJSON to map:', error);
        }
      }

      async function sendMessage() {
        const message = messageInput.value.trim();
        if (!message) return;

        // Add user message to chat
        addMessage(message, true);

        // Clear input and disable button
        messageInput.value = '';
        sendButton.disabled = true;

        try {
          const response = await fetch('/submit_prompt', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ message: message })
          });

          if (!response.ok) {
            throw new Error('Network response was not ok');
          }

          const reader = response.body.getReader();
          const decoder = new TextDecoder();

          while (true) {
            const { done, value } = await reader.read();
            if (done) break;

            const chunk = decoder.decode(value);
            const lines = chunk.split('\n');

            for (const line of lines) {
              if (line.startsWith('data: ')) {
                const dataStr = line.slice(6);

                if (dataStr === '[DONE]') {
                  break;
                }

                try {
                  const data = JSON.parse(dataStr);
                  // Add each response as a separate message bubble
                  addStreamingMessage(data.text);

                  // If this is the final response and contains GeoJSON, add it to the map
                  if (data.final && data.geojson) {
                    console.log('Adding GeoJSON to map:', data.geojson);
                    addGeoJSONToMap(data.geojson);
                  }
                } catch (e) {
                  console.log('Skipping non-JSON line:', dataStr);
                }
              }
            }
          }
        } catch (error) {
          console.error('Error:', error);
          addMessage(
            'Sorry, there was an error processing your request. Please try again.'
          );
        } finally {
          sendButton.disabled = false;
          messageInput.focus();
        }
      }

      // Event listeners
      sendButton.addEventListener('click', sendMessage);

      messageInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault();
          sendMessage();
        }
      });

      // Focus on input when page loads
      messageInput.focus();
    </script>
  </body>
</html>
```

> **Note (warning): Note: Don't forget to update the token!**
> 
> If you are not logged into your Mapbox account you will need to update the code above to specify your own Mapbox token by replacing `YOUR_MAPBOX_ACCESS_TOKEN` in the `accessToken` option passed to `new mapboxgl.Map()`. If you are logged in you should see your token in the snippet above.

The page contains a chat interface and GL JS map with the Mapbox standard style. Additionally, it has some JavaScript to handle sending prompts to the Flask API. The responses from the API are expected to be JSON objects. Importantly, the response should contain a `text` attribute that JavaScript will put into a chat bubble as a response from the agent. Additionally, the response may contain a `geojson` attribute, which will be used to create a new layer on the map. See the `addGeoJSONToMap` function for implementation details.

### Create the Flask App

We need to create a Flask app to serve our page and handle the backend for running the agents.

Create `app.py` with the following content:

```py
from flask import Flask, render_template, request, Response
from main import run
import json

app = Flask(__name__)

@app.route('/')
def home():
    return render_template('index.html')

@app.route('/submit_prompt', methods=['POST'])
def submit_prompt():
    data = request.get_json()
    message = data.get('message', '')
    
    def generate_responses():
        # First response - processing indication
        yield f"data: {json.dumps({'text': 'Analyzing your location query...'})}\n\n"
        
        try:
            # Run the crew and get the output
            crew_output = run(message)
                            
            # Final response with text, geojson, and map_commands
            final_response = {
                'text': crew_output['text'],
                'geojson': crew_output['geojson'],
                'final': True
            }

        except Exception as e:
            print(f"Error running crew: {e}")
            final_response = {
                'text': 'Sorry, there was an error processing your request.',
                'geojson': None,
                'final': True
            }
        
        yield f"data: {json.dumps(final_response)}\n\n"
        
        # Signal end of stream
        yield f"data: [DONE]\n\n"
    
    return Response(
        generate_responses(),
        mimetype='text/plain',
        headers={
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Access-Control-Allow-Origin': '*'
        }
    )


def main():
    """Run the Location AI demo Flask application."""
    print("🗺️  Starting Location AI Demo...")
    print("📍 Open your browser to: http://localhost:5001")
    print("🛑 Press Ctrl+C to stop the server")
    print("-" * 50)
    
    app.run(
        host='0.0.0.0',
        port=5001,
        debug=True,
        use_reloader=True
    )

if __name__ == '__main__':
    main()
```

The `app.py` file has only two endpoints: one for the homepage and one to submit prompts from the chat. The prompts are handled similarly to `main.py`—we run the crew against a prompt and wait for the output. The crew output is a response and the response is a JSON object which has a `text` attribute and may also contain a `geojson` attribute.

Now that we've added `templates/index.html` and `app.py`, your file structure should look like this:

```
your-project-root/
├── config/
│   ├── agents.yaml
│   └── tasks.yaml
├── templates/
│   ├── index.html
├── .env
├── app.py
├── crew.py
├── main.py
```

At this point, you have a basic webpage, but the agent does not yet respond with a structured output. You will implement that next.

### Create a new agent to generate GeoJSON output

The current agent responds in a natural way, as unstructured text, not as JSON. The next steps will address this by adding a new agent with the expected output in the form of structured data.

1.  Update `config/agents.yaml` to match the code block below:

```yml
helpful_agent:
  role: >
    A helpful assistant.
  goal: >
    Try to help a user who provided this prompt: `{prompt}`
  backstory: >
    You're a powerful LLM willing to help users with their prompts.

# highlight-start
# new agent to handle geojson
geojson_enrichment_agent:
  role: >
    Senior GIS Data Specialist and Geographic Information Systems Expert
  goal: >
    Transform location-based responses into structured JSON format containing both textual content and valid GeoJSON geographic data for map visualization
  backstory: >
    You are a world-class Geographic Information Systems (GIS) specialist with over 15 years of experience in spatial data analysis, cartography, and geographic visualization. You have worked extensively with mapping platforms, spatial databases, and location intelligence systems for major tech companies and government agencies. Your expertise includes converting location references, addresses, coordinates, and geographic descriptions into precise GeoJSON format that can be visualized on interactive maps. You understand the nuances of different geographic coordinate systems, spatial relationships, and how to represent various geographic features including points (for specific locations), linestrings (for routes and paths), polygons (for areas and boundaries), and circles (for radius-based zones). You always make sure your GeoJSON output is valid and follows proper formatting standards while maintaining semantic accuracy of the geographic information.
# highlight-end
```

2.  Update `config/tasks.yaml` to match the code block below:

```yml
# Updated expected output
help_task:
  description: >
    Use tools and your own general knowledge to answer this prompt: `{prompt}`
    Current year is {current_year}.
  expected_output: >
    A natural response in your own words.
# highlight-start
    Bonus points if you add any relevant coordinates into the response.
    Try to include geojsons and coordinates from directions tool and any other tools into response too as it will help to visualize the data downstream.
    If geojsons get too big, try to retain top 10-20 points sampled from entire geometry as a simplification. We can still use that as crude approximation.
    Use proper coordinate format [longitude, latitude] in WGS84 (EPSG:4326)
# highlight-end
  agent: helpful_agent

# highlight-start
# New task for GeoJSON 
geojson_enrichment_task:
  description: >
    Take the response from the previous task about: `{prompt}` and transform it into a structured format.
    
    Extract the following information:
    1. "text": The textual response that will be displayed to the user (clean, user-friendly format)
    2. "geojson": Valid GeoJSON FeatureCollection representing any geographic elements mentioned in the response
    
    For the GeoJSON section:
    - Extract any locations, addresses, coordinates, routes, areas, or geographic references from the text
    - Convert them to appropriate GeoJSON features (Point, LineString, Polygon)
    - Do not try to represent polygons via lineString as it won't look very nice
    - Do not try to extrapolate coordinates more than 50 meters to ensure you always provide accurate responses
    - Use proper coordinate format [longitude, latitude] in WGS84 (EPSG:4326)
    - Include meaningful properties for each feature (name, description)
    - If no geographic data can be extracted, set geojson to null
    - if the route is too long to represent with a crude line string of 10-20 points, maybe it's best to visualize only points without line string. We do not want lines that cut through city blocks.
    
  expected_output: >
    Intermediate structured response with text and geojson fields for further processing.
  agent: geojson_enrichment_agent
  context:
    - help_task
# highlight-end
```

3.  Update `crew.py` with the highlighted snippets below to create the new agent and a task with structured output:

```py
from crewai import Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
from crewai.agents.agent_builder.base_agent import BaseAgent
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
import os

# highlight-start
class GeoJSONGeometry(BaseModel):
    """GeoJSON Geometry model"""
    type: str = Field(..., description="Geometry type (Point, LineString, Polygon)")
    coordinates: List[Any] = Field(..., description="Coordinate array")


class GeoJSONProperties(BaseModel):
    """GeoJSON Feature properties"""
    name: Optional[str] = Field(None, description="Name of the geographic feature")
    description: Optional[str] = Field(None, description="Description of the geographic feature")


class GeoJSONFeature(BaseModel):
    """GeoJSON Feature model"""
    type: str = Field(default="Feature", description="Feature type")
    geometry: GeoJSONGeometry = Field(..., description="Feature geometry")
    properties: GeoJSONProperties = Field(..., description="Feature properties")


class GeoJSONFeatureCollection(BaseModel):
    """GeoJSON FeatureCollection model"""
    type: str = Field(default="FeatureCollection", description="FeatureCollection type")
    features: List[GeoJSONFeature] = Field(..., description="List of features")


class LocationResponse(BaseModel):
    """Structured response model containing text and GeoJSON data"""
    text: str = Field(..., description="Textual response to display to the user")
    geojson: Optional[GeoJSONFeatureCollection] = Field(
        None, 
        description="GeoJSON FeatureCollection representing geographic elements, null if no geographic data"
    )
# highlight-end

@CrewBase
class DemoProject():
    """DemoProject crew"""

    agents: List[BaseAgent]
    tasks: List[Task]

    mcp_server_params = [
        {
            "url": "https://mcp.mapbox.com/mcp",
            "transport": "streamable-http",
            "headers": {"authorization": f"Bearer {os.environ['MAPBOX_ACCESS_TOKEN']}"}
        }
    ]

    @agent
    def helpful_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['helpful_agent'], # type: ignore[index]
            verbose=True,
            tools=self.get_mcp_tools()
        )
    # highlight-start
    @agent
    def geojson_enrichment_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['geojson_enrichment_agent'], # type: ignore[index]
            verbose=True
        )
    # highlight-end

    @task
    def help_task(self) -> Task:
        return Task(
            config=self.tasks_config['help_task'], # type: ignore[index]
        )
    # highlight-start
    @task
    def geojson_enrichment_task(self) -> Task:
        return Task(
            config=self.tasks_config['geojson_enrichment_task'], # type: ignore[index]
            output_json=LocationResponse
        )
    # highlight-end
    @crew
    def crew(self) -> Crew:
        """Creates the DemoProject crew"""
        # To learn how to add knowledge sources to your crew, check out the documentation:
        # https://docs.crewai.com/concepts/knowledge#what-is-knowledge

        return Crew(
            agents=self.agents, # Automatically created by the @agent decorator
            tasks=self.tasks, # Automatically created by the @task decorator
            process=Process.sequential,
            verbose=True,
            # process=Process.hierarchical, # In case you want to use that instead https://docs.crewai.com/how-to/Hierarchical/
        )
```

There are 3 changes in `crew.py`: a new agent, a new tool with `output_json=LocationResponse` and a definition of `LocationResponse`.

This creates a two-step process—first, Crew will run `help_task` and then `geojson_enrichment_task`. The output should be a JSON object that contains the `text` attribute and may optionally contain the `geojson` attribute. This is the structure of the response that will be passed from `app.py` into the webpage, where JavaScript will output text into a chat bubble and GeoJSON into a new layer.

### Run the app

Try running the app with: `uv run app.py`

You should be able to open the page in your browser. Try submitting a prompt. The agent should respond with a JSON object that will be interpreted by JavaScript on the frontend to update the map and chat interface.

![A screenshot of a webpage with chat interace. The agent reponds to a user and shows a point on a map that represents LGA.](https://docs.mapbox.com/help/ja/assets/ideal-img/byoa-geojson.e161b81.480.png)

### Next steps

You can continue adding agents and tasks to introduce new features.

For the next step you will add one more agent to coordinate camera movement, weather and lighting presets.

## Adding additional agents

In this section, you explore how agents can coordinate camera movement, weather, and lighting presets.

### Conceptual approach

Consider camera movements. To control the camera, you need to specify coordinates, pitch, bearing, and how you want the camera to transition from the current location to the desired point.

All these changes are relatively straightforward to hardcode in JavaScript on the webpage. But the challenge is to make an agent capable of performing such animations in the way that is most appropriate for the user's prompt.

In general, agents can gain new capabilities through tools. But because the GL JS map in our setup lives on the frontend rather than the backend, building a tool to interact with it becomes awkward. The tool would need to run in JavaScript on the frontend, while the backend agent would have to reach it in a secure way. Although this approach is possible, it adds unnecessary complexity for a tutorial.

There is a simpler approach—both easier to implement and to understand intuitively. Our frontend already expects `text` and `geojson` output in the response; what if it also expected a `map_commands` list, where each item describes how the camera angle and position should be adjusted? This approach would allow the agent to generate a sequence of camera positions, and the frontend would be able to replay them with a delay. The delay is also something the agent will specify. Moreover, this extends to lighting and weather presets too—the agent could specify a command to change lighting to dawn or night presets.

So, the approach you will implement will require that the agents generate a structured output containing a `map_commands` attribute as part of the JSON. Each map command needs to specify a type and some parameters specific to that type. The frontend will be able to loop through and execute each of those commands. The commands have to be based on the GL JS API, so that you can implement a parser on the frontend.

For example, the commands could look like this:

```js
[
  {"command": "flyTo", "params": {"center": [lng, lat], "zoom": 12, "duration": 2000, "bearing": 0, "pitch": 0}, "wait_for_completion": true},
  {"command": "setLights", "params": {"preset": "dawn"}, "wait_for_completion": false},
  ...
]
```

For the frontend you will have to implement a parser. The pseudocode would be something like this:

```js
foreach item in map_commands {
  if (item.command === "flyTo") {
    map.flyTo(item.params)
  }

  if (item.command === "setLights") {
    map.setLights(item.params)
  }

  ...
}
```

In fact, you will use a switch statement to make the implementation more readable.

### Implementation strategy

Let's create a new agent that will handle camera, weather, and lighting presets.

1.  Update `config/agents.yaml` by adding the new agent to the bottom of the file:

```yml
helpful_agent:
    role: >
        A helpful assistant.
    goal: >
        Try to help a user who provided this prompt: `{prompt}`
    backstory: >
        You're a powerful LLM willing to help users with their prompts.

geojson_enrichment_agent:
    role: >
        Senior GIS Data Specialist and Geographic Information Systems Expert
    goal: >
        Transform location-based responses into structured JSON format containing both textual content and valid GeoJSON geographic data for map visualization
    backstory: >
        You are a world-class Geographic Information Systems (GIS) specialist with over 15 years of experience in spatial data analysis, cartography, and geographic visualization. You have worked extensively with mapping platforms, spatial databases, and location intelligence systems for major tech companies and government agencies. Your expertise includes converting location references, addresses, coordinates, and geographic descriptions into precise GeoJSON format that can be visualized on interactive maps. You understand the nuances of different geographic coordinate systems, spatial relationships, and how to represent various geographic features including points (for specific locations), linestrings (for routes and paths), polygons (for areas and boundaries), and circles (for radius-based zones). You always ensure your GeoJSON output is valid and follows proper formatting standards while maintaining semantic accuracy of the geographic information.

camera_choreographer_agent:
    role: >
        Interactive Map Camera, Animation, and Atmospheric Styling Specialist
    goal: >
        Create choreographed camera movements, atmospheric effects, and environmental styling that enhance geographic storytelling through immersive map experiences
    backstory: >
        You are a world-class expert in interactive map interfaces, camera choreography, and atmospheric styling with extensive experience in Mapbox GL JS and cutting-edge web mapping technologies. You specialize in creating cinematic, immersive map experiences that combine smooth camera movements with dynamic environmental effects. Your expertise spans camera controls (center, zoom, bearing, pitch), lighting systems (ambient, directional, and preset lighting), atmospheric effects (fog density, color, and range), and weather effects (rain intensity, snow coverage). You understand how to match atmospheric conditions to geographic contexts - using fog for coastal areas, dynamic lighting for urban exploration, and weather effects for environmental storytelling. You excel at creating cohesive visual narratives that guide users through spatial stories with both movement and mood, making geographic data not just informative but emotionally engaging through environmental immersion.
```

2.  The new agent will need a new task. Update `config/tasks.yaml` with the new task at the bottom of the file:

```yml

help_task:
    description: >
      Use tools and your own general knowledge to answer this prompt: `{prompt}`
      Current year is {current_year}.
    expected_output: >
      A natural response in your own words.
      Bonus points if you add any relevant coordinates into the response.
      Try to include geojsons and coordinates from directions tool and any other tools into response too as it will help to visualize the data downstream.
      If geojsons get too big, try to retain top 10-20 points sampled from entire geometry as a simplification. We can stil use that as crude approximation.
      Use proper coordinate format [longitude, latitude] in WGS84 (EPSG:4326)
    agent: helpful_agent

geojson_enrichment_task:
    description: >
      Take the response from the previous task about: `{prompt}` and transform it into a structured format.
      
      Extract the following information:
      1. "text": The textual response that will be displayed to the user (clean, user-friendly format)
      2. "geojson": Valid GeoJSON FeatureCollection representing any geographic elements mentioned in the response
      
      For the GeoJSON section:
      - Extract any locations, addresses, coordinates, routes, areas, or geographic references from the text
      - Convert them to appropriate GeoJSON features (Point, LineString, Polygon)
      - Do not try to represent polygons via lineString as it won't look very nice
      - Do not try to extrapolate coordinates more than 50 meters to ensure you always provide accurate responses
      - Use proper coordinate format [longitude, latitude] in WGS84 (EPSG:4326)
      - Include meaningful properties for each feature (name, description)
      - If no geographic data can be extracted, set geojson to null
      - if the route is too long to represent with a crude line string of 10-20 points, maybe it's best to visualize only points without line string. We do not want lines that cut through city blocks.
      
    expected_output: >
      Intermediate structured response with text and geojson fields for further processing.
    agent: geojson_enrichment_agent
    context:
      - help_task

camera_choreography_task:
    description: >
        Take the structured response from the geojson_enrichment_task about: `{prompt}` and enhance it with choreographed map camera commands.
        
        Your job is to:
        1. Keep the existing "text" and "geojson" content from the previous task
        2. Add "map_commands": A list of MapCommand objects that will create a cinematic experience
        
        For the map_commands section:
        - Analyze the geographic content and create a logical sequence of camera movements and atmospheric effects
        - Use appropriate Mapbox GL JS commands: "flyTo", "easeTo", "jumpTo", "fitBounds", "setLights", "setFog", "setSnow", "setRain"
        - Start with atmospheric setup (lighting, weather) if contextually relevant
        - Then create camera movements with overview first if multiple locations involved
        - Progressive focus on specific areas of interest with appropriate environmental styling
        - Consider zoom levels: 1-5 (world), 6-9 (country/region), 10-14 (city), 14-19 (neighborhood), 20+ (building level)
        - Include smooth transitions with appropriate duration (3000ms for flyTo, 2000-6000ms for easeTo, for a slow tour even 15000+ can be great to keep it calm and smooth)
        - Set wait_for_completion: true for all commands to ensure sequential execution
        - Match atmospheric effects to geographic context (fog for coasts, weather for climate stories)
        - If no geographic content exists, set map_commands to null
        
        Example command structures:
        Camera: {"command": "flyTo", "params": {"center": [lng, lat], "zoom": 12, "duration": 2000, "bearing": 0, "pitch": 0}, "wait_for_completion": true}
        Lighting: {"command": "setLights", "params": {"preset": "dawn"}, "wait_for_completion": false}
        Fog: {"command": "setFog", "params": {"color": "rgb(186, 210, 235)", "high-color": "rgb(36, 92, 223)", "horizon-blend": 0.02, "range": [0.5, 10]}, "wait_for_completion": false}
        Weather: {"command": "setRain", "params": {"intensity": 0.5}, "wait_for_completion": false}
        
        Few notes on style:
        - Try to go slowly with camera movement, otherwise it is hard to admire the scene. I suggest durations of 3000ms and longer.
        - Try to be creative with combinations of commands, maybe even unpredictable!
        - Do not rely on directions requests to create a camera route as we can fly around without following the road network precisely.
        - Try to come up with key waypoints for any complex camera work, at each of them we could change pitch zoom too.
        - Try to end with a zoomed out view oriented to north, unless user's prompt implies they want something else
        - It is best not to draw polygons and zoom into them as the overlap between buildings and polygons close up does not look very clear, but user has a final say!

        The final output will use the LocationResponse Pydantic model.
    expected_output: >
        Final structured response using LocationResponse model containing text, geojson, and map_commands fields. The map_commands should create a choreographed viewing experience that enhances the geographic narrative.
    agent: camera_choreographer_agent
    context:
        - geojson_enrichment_task
```

3.  Update `crew.py` to add the new agent and task, with a new structured output, that should contain `map_commands` attribute:

```python
from crewai import Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
from crewai.agents.agent_builder.base_agent import BaseAgent
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
import os


class MapCommand(BaseModel):
    """Map camera manipulation and styling command"""
    command: str = Field(..., description="Command type (flyTo, easeTo, jumpTo, fitBounds, setLights, setFog, setSnow, setRain)")
    params: Dict[str, Any] = Field(..., description="Parameters for the command")
    wait_for_completion: bool = Field(default=True, description="Whether to wait for animation to complete")


class GeoJSONGeometry(BaseModel):
    """GeoJSON Geometry model"""
    type: str = Field(..., description="Geometry type (Point, LineString, Polygon)")
    coordinates: List[Any] = Field(..., description="Coordinate array")


class GeoJSONProperties(BaseModel):
    """GeoJSON Feature properties"""
    name: Optional[str] = Field(None, description="Name of the geographic feature")
    description: Optional[str] = Field(None, description="Description of the geographic feature")


class GeoJSONFeature(BaseModel):
    """GeoJSON Feature model"""
    type: str = Field(default="Feature", description="Feature type")
    geometry: GeoJSONGeometry = Field(..., description="Feature geometry")
    properties: GeoJSONProperties = Field(..., description="Feature properties")


class GeoJSONFeatureCollection(BaseModel):
    """GeoJSON FeatureCollection model"""
    type: str = Field(default="FeatureCollection", description="FeatureCollection type")
    features: List[GeoJSONFeature] = Field(..., description="List of features")


class LocationResponse(BaseModel):
    """Structured response model containing text and GeoJSON data"""
    text: str = Field(..., description="Textual response to display to the user")
    geojson: Optional[GeoJSONFeatureCollection] = Field(
        None, 
        description="GeoJSON FeatureCollection representing geographic elements, null if no geographic data"
    )
    map_commands: Optional[List[MapCommand]] = Field(
        None,
        description="Optional list of map camera commands to choreograph map movements"
    )


@CrewBase
class DemoProject():
    """DemoProject crew"""

    agents: List[BaseAgent]
    tasks: List[Task]

    mcp_server_params = [
        {
            "url": "https://mcp.mapbox.com/mcp",
            "transport": "streamable-http",
            "headers": {"authorization": f"Bearer {os.environ['MAPBOX_ACCESS_TOKEN']}"}
        }
    ]

    # Learn more about YAML configuration files here:
    # Agents: https://docs.crewai.com/concepts/agents#yaml-configuration-recommended
    # Tasks: https://docs.crewai.com/concepts/tasks#yaml-configuration-recommended
    
    # If you would like to add tools to your agents, you can learn more about it here:
    # https://docs.crewai.com/concepts/agents#agent-tools
    @agent
    def helpful_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['helpful_agent'], # type: ignore[index]
            verbose=True,
            tools=self.get_mcp_tools()
        )

    @agent
    def geojson_enrichment_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['geojson_enrichment_agent'], # type: ignore[index]
            verbose=True
        )

    @agent
    def camera_choreographer_agent(self) -> Agent:
        return Agent(
            config=self.agents_config['camera_choreographer_agent'], # type: ignore[index]
            verbose=True,
            tools=self.get_mcp_tools()
        )

    # To learn more about structured task outputs,
    # task dependencies, and task callbacks, check out the documentation:
    # https://docs.crewai.com/concepts/tasks#overview-of-a-task
    @task
    def help_task(self) -> Task:
        return Task(
            config=self.tasks_config['help_task'], # type: ignore[index]
        )

    @task
    def geojson_enrichment_task(self) -> Task:
        return Task(
            config=self.tasks_config['geojson_enrichment_task'], # type: ignore[index]
        )

    @task
    def camera_choreography_task(self) -> Task:
        return Task(
            config=self.tasks_config['camera_choreography_task'], # type: ignore[index]
            output_json=LocationResponse
        )

    @crew
    def crew(self) -> Crew:
        """Creates the DemoProject crew"""
        # To learn how to add knowledge sources to your crew, check out the documentation:
        # https://docs.crewai.com/concepts/knowledge#what-is-knowledge

        return Crew(
            agents=self.agents, # Automatically created by the @agent decorator
            tasks=self.tasks, # Automatically created by the @task decorator
            process=Process.sequential,
            verbose=True,
            # process=Process.hierarchical, # In case you wanna use that instead https://docs.crewai.com/how-to/Hierarchical/
        )
```

4.  Update `app.py` to return `map_commands` inside the `final_response` objects:

```py
...
    try:
        # Run the crew and get the output
        crew_output = run(message)
                        
        # Final response with text, geojson, and map_commands
        final_response = {
            'text': crew_output['text'],
            'geojson': crew_output['geojson'],
            # highlight-start
            'map_commands': crew_output['map_commands'],
            # highlight-end
            'final': True
        }

    except Exception as e:
        print(f"Error running crew: {e}")
        final_response = {
            'text': 'Sorry, there was an error processing your request.',
            'geojson': None,
             # highlight-start
            'map_commands': None,
             # highlight-end
            'final': True
        }

...

```

5.  Finally, you need to update `templates/index.html` to account for `map_commands` in the response. This will require JavaScript to parse commands and run them with appropriate delay. Add the `executeMapCommands` function before the `sendMessage` function, then add the handler to call `executeMapCommands` inside the second `try` block of the `sendMessage` function.

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Location AI Demo</title>
    <script src="https://api.mapbox.com/mapbox-gl-js/v3.25.0/mapbox-gl.js"></script>
    <link
      href="https://api.mapbox.com/mapbox-gl-js/v3.25.0/mapbox-gl.css"
      rel="stylesheet"
    />
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        font-family:
          -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        height: 100vh;
        display: flex;
      }

      .map-container {
        flex: 1;
        position: relative;
      }

      #map {
        width: 100%;
        height: 100%;
      }

      .chat-container {
        width: 400px;
        background: #f8f9fa;
        border-left: 1px solid #dee2e6;
        display: flex;
        flex-direction: column;
      }

      .chat-header {
        padding: 20px;
        background: #343a40;
        color: white;
        font-size: 18px;
        font-weight: 600;
      }

      .chat-messages {
        flex: 1;
        padding: 20px;
        overflow-y: auto;
        min-height: 0;
      }

      .message {
        margin-bottom: 15px;
        padding: 12px;
        border-radius: 8px;
        max-width: 100%;
        word-wrap: break-word;
      }

      .user-message {
        background: #007bff;
        color: white;
        margin-left: 20px;
      }

      .ai-message {
        background: #e9ecef;
        color: #343a40;
        margin-right: 20px;
      }

      .chat-input {
        padding: 20px;
        border-top: 1px solid #dee2e6;
        background: white;
      }

      .input-group {
        display: flex;
        gap: 10px;
      }

      #message-input {
        flex: 1;
        padding: 12px;
        border: 1px solid #ced4da;
        border-radius: 4px;
        font-size: 14px;
      }

      #send-button {
        padding: 12px 20px;
        background: #007bff;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        font-weight: 500;
      }

      #send-button:hover {
        background: #0056b3;
      }

      #send-button:disabled {
        background: #6c757d;
        cursor: not-allowed;
      }

      .loading {
        color: #6c757d;
        font-style: italic;
      }
    </style>
  </head>
  <body>
    <div class="map-container">
      <div id="map"></div>
    </div>

    <div class="chat-container">
      <div class="chat-header">Location AI Assistant</div>

      <div class="chat-messages" id="chatMessages">
        <div class="message ai-message">
          Welcome! I'm your Location AI assistant. Ask me anything about
          geographic data, locations, or spatial analysis.
        </div>
      </div>

      <div class="chat-input">
        <div class="input-group">
          <input
            type="text"
            id="message-input"
            placeholder="Ask about locations, maps, or geographic data..."
            maxlength="500"
          />
          <button id="send-button">Send</button>
        </div>
      </div>
    </div>

    <script>
      // Initialize Mapbox map
      const map = new mapboxgl.Map({
        accessToken: 'YOUR_MAPBOX_ACCESS_TOKEN',
        container: 'map',
        style: 'mapbox://styles/mapbox/standard', // Latest Mapbox Standard style
        projection: 'globe',
        center: [-74.006, 40.7128], // NYC coordinates
        zoom: 10,
        config: {
          basemap: {
            showPointOfInterestLabels: false
          }
        }
      });

      // Add navigation control
      map.addControl(new mapboxgl.NavigationControl());

      // Set atmosphere style when the style loads
      map.on('style.load', () => {
        map.setFog({}); // Set default atmosphere style
      });

      // Chat functionality
      const messageInput = document.getElementById('message-input');
      const sendButton = document.getElementById('send-button');
      const chatMessages = document.getElementById('chatMessages');

      function addMessage(content, isUser = false) {
        const messageDiv = document.createElement('div');
        messageDiv.classList.add('message');
        messageDiv.classList.add(isUser ? 'user-message' : 'ai-message');
        messageDiv.textContent = content;

        chatMessages.appendChild(messageDiv);
        chatMessages.scrollTop = chatMessages.scrollHeight;
      }

      function addStreamingMessage(content) {
        const messageDiv = document.createElement('div');
        messageDiv.classList.add('message', 'ai-message');
        messageDiv.textContent = content;

        chatMessages.appendChild(messageDiv);
        chatMessages.scrollTop = chatMessages.scrollHeight;
        return messageDiv;
      }

      function clearMapLayers() {
        // Remove existing GeoJSON sources and layers
        if (map.getSource('geojson-data')) {
          if (map.getLayer('geojson-fill')) map.removeLayer('geojson-fill');
          if (map.getLayer('geojson-line')) map.removeLayer('geojson-line');
          if (map.getLayer('geojson-points')) map.removeLayer('geojson-points');
          map.removeSource('geojson-data');
        }
      }

      function addGeoJSONToMap(geojsonData) {
        if (
          !geojsonData ||
          !geojsonData.features ||
          geojsonData.features.length === 0
        ) {
          return;
        }

        try {
          // Clear existing layers first
          clearMapLayers();

          // Add the GeoJSON source
          map.addSource('geojson-data', {
            'type': 'geojson',
            'data': geojsonData
          });

          // Add layers for different geometry types

          // Polygons (fill)
          map.addLayer({
            'id': 'geojson-fill',
            'type': 'fill',
            'source': 'geojson-data',
            'slot': 'middle',
            'filter': ['==', ['geometry-type'], 'Polygon'],
            'paint': {
              'fill-color': '#007bff',
              'fill-opacity': 0.2
            }
          });

          // Lines and polygon outlines
          map.addLayer({
            'id': 'geojson-line',
            'type': 'line',
            'source': 'geojson-data',
            'slot': 'middle',
            'filter': [
              'in',
              ['geometry-type'],
              ['literal', ['LineString', 'Polygon']]
            ],
            'paint': {
              'line-color': '#007bff',
              'line-width': 2
            }
          });

          // Points
          map.addLayer({
            'id': 'geojson-points',
            'type': 'circle',
            'source': 'geojson-data',
            'filter': ['==', ['geometry-type'], 'Point'],
            'paint': {
              'circle-color': '#007bff',
              'circle-radius': 6,
              'circle-stroke-width': 2,
              'circle-stroke-color': '#ffffff'
            }
          });

          // Add click events for popups
          map.on('click', 'geojson-points', (e) => {
            const feature = e.features[0];
            const popup = new mapboxgl.Popup()
              .setLngLat(e.lngLat)
              .setHTML(
                `
                            <div>
                                <strong>${feature.properties.name || 'Location'}</strong>
                                ${feature.properties.description ? `<br>${feature.properties.description}` : ''}
                            </div>
                        `
              )
              .addTo(map);
          });

          map.on('click', 'geojson-fill', (e) => {
            const feature = e.features[0];
            const popup = new mapboxgl.Popup()
              .setLngLat(e.lngLat)
              .setHTML(
                `
                            <div>
                                <strong>${feature.properties.name || 'Area'}</strong>
                                ${feature.properties.description ? `<br>${feature.properties.description}` : ''}
                            </div>
                        `
              )
              .addTo(map);
          });

          // Change cursor on hover
          map.on('mouseenter', 'geojson-points', () => {
            map.getCanvas().style.cursor = 'pointer';
          });

          map.on('mouseleave', 'geojson-points', () => {
            map.getCanvas().style.cursor = '';
          });

          map.on('mouseenter', 'geojson-fill', () => {
            map.getCanvas().style.cursor = 'pointer';
          });

          map.on('mouseleave', 'geojson-fill', () => {
            map.getCanvas().style.cursor = '';
          });

          // Fit map to show all features
          const bounds = new mapboxgl.LngLatBounds();
          geojsonData.features.forEach((feature) => {
            if (feature.geometry.type === 'Point') {
              bounds.extend(feature.geometry.coordinates);
            } else if (feature.geometry.type === 'LineString') {
              feature.geometry.coordinates.forEach((coord) =>
                bounds.extend(coord)
              );
            } else if (feature.geometry.type === 'Polygon') {
              feature.geometry.coordinates[0].forEach((coord) =>
                bounds.extend(coord)
              );
            }
          });

          if (!bounds.isEmpty()) {
            map.fitBounds(bounds, { padding: 50 });
          }
        } catch (error) {
          console.error('Error adding GeoJSON to map:', error);
        }
      }

      async function executeMapCommands(commands) {
        if (!commands || !Array.isArray(commands)) {
          return;
        }

        console.log('Starting choreographed animations...');

        // Second pass: Execute all commands with animations
        for (const commandData of commands) {
          try {
            console.log(
              `Executing ${commandData.command} with params:`,
              commandData.params
            );

            const { command, params, wait_for_completion = true } = commandData;

            switch (command) {
              case 'flyTo':
                if (wait_for_completion) {
                  await new Promise((resolve) => {
                    map.flyTo({
                      ...params,
                      // Add a callback when the animation completes
                      callback: resolve
                    });
                    // Fallback timeout in case callback doesn't fire
                    setTimeout(resolve, params.duration + 500 || 3500);
                  });
                } else {
                  map.flyTo(params);
                }
                break;

              case 'easeTo':
                if (wait_for_completion) {
                  await new Promise((resolve) => {
                    map.easeTo({
                      ...params,
                      callback: resolve
                    });
                    // Fallback timeout
                    setTimeout(resolve, params.duration + 500 || 2000);
                  });
                } else {
                  map.easeTo(params);
                }
                break;

              case 'jumpTo':
                map.jumpTo(params);
                // jumpTo is instantaneous, but add small delay for visual effect
                if (wait_for_completion) {
                  await new Promise((resolve) => setTimeout(resolve, 100));
                }
                break;

              case 'fitBounds':
                if (params.bounds) {
                  if (wait_for_completion) {
                    await new Promise((resolve) => {
                      map.fitBounds(params.bounds, {
                        ...params,
                        callback: resolve
                      });
                      // Fallback timeout
                      setTimeout(resolve, params.duration + 500 || 2000);
                    });
                  } else {
                    map.fitBounds(params.bounds, params);
                  }
                }
                break;

              case 'setLights':
                if (params.preset) {
                  // Use light preset
                  map.setConfigProperty(
                    'basemap',
                    'lightPreset',
                    params.preset
                  );
                } else if (params.lights) {
                  // Custom light configuration
                  map.setLights(params.lights);
                }
                // Lighting changes are immediate
                if (wait_for_completion) {
                  await new Promise((resolve) => setTimeout(resolve, 100));
                }
                break;

              case 'setFog':
                map.setFog({
                  'color': params.color || 'rgb(186, 210, 235)',
                  'high-color': params['high-color'] || 'rgb(36, 92, 223)',
                  'horizon-blend': params['horizon-blend'] || 0.02,
                  'space-color': params['space-color'] || 'rgb(11, 11, 25)',
                  'star-intensity': params['star-intensity'] || 0.6,
                  'range': params.range || [0.5, 10]
                });
                // Fog changes are immediate
                if (wait_for_completion) {
                  await new Promise((resolve) => setTimeout(resolve, 100));
                }
                break;

              case 'setRain':
                map.setConfigProperty('basemap', 'showWeatherData', true);
                map.setConfigProperty('basemap', 'weather', {
                  'type': 'rain',
                  'intensity': params.intensity || 0.5
                });
                // Weather changes are immediate
                if (wait_for_completion) {
                  await new Promise((resolve) => setTimeout(resolve, 100));
                }
                break;

              case 'setSnow':
                map.setConfigProperty('basemap', 'showWeatherData', true);
                map.setConfigProperty('basemap', 'weather', {
                  'type': 'snow',
                  'intensity': params.intensity || 0.5
                });
                // Weather changes are immediate
                if (wait_for_completion) {
                  await new Promise((resolve) => setTimeout(resolve, 100));
                }
                break;

              default:
                console.warn(`Unknown map command: ${command}`);
            }
          } catch (error) {
            console.error(
              `Error executing map command ${commandData.command}:`,
              error
            );
          }
        }
      }

      async function sendMessage() {
        const message = messageInput.value.trim();
        if (!message) return;

        // Add user message to chat
        addMessage(message, true);

        // Clear input and disable button
        messageInput.value = '';
        sendButton.disabled = true;

        try {
          const response = await fetch('/submit_prompt', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ message: message })
          });

          if (!response.ok) {
            throw new Error('Network response was not ok');
          }

          const reader = response.body.getReader();
          const decoder = new TextDecoder();

          while (true) {
            const { done, value } = await reader.read();
            if (done) break;

            const chunk = decoder.decode(value);
            const lines = chunk.split('\n');

            for (const line of lines) {
              if (line.startsWith('data: ')) {
                const dataStr = line.slice(6);

                if (dataStr === '[DONE]') {
                  break;
                }

                try {
                  const data = JSON.parse(dataStr);
                  // Add each response as a separate message bubble
                  addStreamingMessage(data.text);

                  // If this is the final response and contains GeoJSON, add it to the map
                  if (data.final && data.geojson) {
                    console.log('Adding GeoJSON to map:', data.geojson);
                    addGeoJSONToMap(data.geojson);
                  }

                  // If this response contains map commands, execute them
                  if (data.final && data.map_commands) {
                    console.log('Executing map commands:', data.map_commands);
                    executeMapCommands(data.map_commands);
                  }
                } catch (e) {
                  console.log('Skipping non-JSON line:', dataStr);
                }
              }
            }
          }
        } catch (error) {
          console.error('Error:', error);
          addMessage(
            'Sorry, there was an error processing your request. Please try again.'
          );
        } finally {
          sendButton.disabled = false;
          messageInput.focus();
        }
      }

      // Event listeners
      sendButton.addEventListener('click', sendMessage);

      messageInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault();
          sendMessage();
        }
      });

      // Focus on input when page loads
      messageInput.focus();
    </script>
  </body>
</html>
```

### Run the agent

Let's run the agent: `uv run app.py`

This time, the agent should be able to change lighting presets and create sequences of camera moves.

Your browser doesn't support HTML5 video. Open [link to the video](https://docs.mapbox.com/help/ja/help/ja/assets/medias/tutorials--build-your-own-agent-a19a07a740f0a6fece69ed3c7c9fdf9d.mp4).

## Next Steps

**Congratulations!** 🎉 You've successfully built your own crew of AI agents to handle location specific requests utilizing the **Mapbox MCP Server** and integrated your agents with a **Mapbox GL JS** map.

### What we covered

-   Setup a new python project and install dependencies with UV package manager
-   Create a basic agent with CrewAI
-   Setup `env` variables to manage API keys for the project
-   Connect the agent with **Mapbox MCP Server**
-   Integrate a **Mapbox GL JS** map by setting up a [Flask](https://flask.palletsprojects.com/en/stable/) web app and creating a new agent to return GeoJSON responses
-   Create a third agent to manage camera views, lighting presets & weather.

This tutorial is intended to serve as an example and inspiration for what is possible.

Additionally, you might be interested in exploring our [Mapbox MCP Devkit](https://www.mapbox.com/blog/the-mapbox-mcp-devkit-equip-ai-coding-tools-with-geospatial-skills-for-mapbox-development) server.

Get in touch if you have any issues:

### For MCP Server Issues

-   Email: [mcp-feedback@mapbox.com](mailto:mcp-feedback@mapbox.com)
-   GitHub Issues: Report bugs and feature requests

### For Mapbox API Questions

-   Mapbox Support: [https://support.mapbox.com/](https://support.mapbox.com/)
-   Documentation: [https://docs.mapbox.com/api/](https://docs.mapbox.com/api/)
-   API Status: [https://status.mapbox.com/](https://status.mapbox.com/)