Building an Extension

This guide builds the official weather Extension, step by step. Its one tool, get_weather, finds a location with the Google Maps Geocoding API, gets the forecast from the Open-Meteo API, and returns the current weather and the next 24 hours. It’s a realistic example: it has a secret setting, an ordinary setting with a default, input that the LLM may get wrong, two third-party services that may fail, and a result that is too big to return unstructured.

What an Extension Can Use

An Extension runs inside the platform, so it can import:

  • the Python standard library;

  • Smarter itself: smarter.common, smarter.lib and smarter.apps;

  • every library that the platform already installs, such as Pydantic, requests, pandas, NumPy, Pint, googlemaps and openmeteo_requests.

An Extension cannot install libraries. It declares the ones it needs in its pyproject.toml’s dependencies, and the platform refuses to install an Extension whose dependencies it doesn’t satisfy. To find out what a platform has, read its requirements files, or run pip list in its container.

An Extension does not define Django models, migrations, URL routes or Celery tasks. To store data, it can create SAM resources, such as Secrets, from its lifecycle hooks: see The Extension Lifecycle.

1. The Layout

Create a repository with a pyproject.toml and a Python package beside it:

smarter-extension-weather/
├── pyproject.toml
├── README.md
├── LICENSE
└── weather/
    ├── __init__.py
    ├── models.py
    ├── services.py
    └── tools.py

The package’s modules import each other with relative imports, from .models import WeatherArguments, never from weather.models import .... The platform imports each installed version under a name of its own, so that two versions can run side by side during an update, and the package’s own name is not importable.

2. The pyproject.toml

pyproject.toml is a standard Python project file. Smarter reads the [project] table, which any Python package declares, and its own [tool.smarter] table:

[project]
name = "smarter-extension-weather"
version = "1.0.0"
description = "Current weather and a 24-hour forecast for any location."
authors = [{ name = "The Smarter Project" }]
license = "AGPL-3.0"
requires-python = ">=3.12"
dependencies = [
    "googlemaps>=4.10",
    "openmeteo-requests>=1.5",
]

[project.urls]
Homepage = "https://github.com/smarter-sh/smarter-extension-weather"

[tool.smarter]
slug = "weather"
package = "weather"
display-name = "Weather"
requires-smarter = ">=0.27"
tools = ["weather.tools:GetWeather"]
timeout = 30

[[tool.smarter.settings]]
name = "google_maps_api_key"
type = "secret"
required = true
description = "A Google Maps Geocoding API key, which finds the latitude and longitude of a location."

[[tool.smarter.settings]]
name = "weather_api_url"
type = "url"
default = "https://api.open-meteo.com/v1/forecast"
description = "The Open-Meteo forecast api."
  • slug is the Extension’s permanent identifier. LLMClients list it in spec.extensions, and its tools are offered to LLMs as ext_<slug>_<tool name>. Choose it once.

  • version is the Extension’s version, a PEP 440 version, which should follow semantic versioning. A released version never changes: release a new one instead.

  • requires-smarter is the range of Smarter versions that the Extension works with.

  • tools lists the tools’ classes, as <module>:<class>.

  • settings are what the administrator configures. A secret setting’s value is stored in a Smarter Secret, encrypted at rest.

Every field is described in The Extension’s pyproject.toml.

3. The Documentation

The package’s module docstring is the Extension’s documentation. The web console shows it on the Extension’s page, and the registry shows it to administrators before they install it. Write it in reStructuredText, for the administrator who decides whether to install the Extension and then configures it:

weather/__init__.py
"""
Weather
=======

Current weather and a 24-hour forecast for any location, from the Open-Meteo api, with
locations found by the Google Maps Geocoding api.

Settings
--------

``google_maps_api_key``
    Required. A Google Maps API key with the Geocoding API enabled. See
    https://developers.google.com/maps/documentation/geocoding/get-api-key.

``weather_api_url``
    The Open-Meteo forecast api. Change it only to use a self-hosted Open-Meteo.

Tools
-----

``get_weather(location, unit)``
    The current temperature, precipitation, wind speed and cloud cover, and the forecast for each
    of the next 24 hours, starting this hour, in metric or US customary units. Times are in the
    location's own timezone, and the result names the unit of every measurement.
"""

4. The Arguments and the Result

The LLM calls a tool with JSON arguments, which may be wrong: a missing location, a unit that doesn’t exist. Model them with Pydantic. The model validates the arguments, and its JSON Schema is what the LLM is told the tool accepts, so the two can never disagree. Describe every field: the LLM reads the descriptions.

Model the result too. The LLM can only interpret what the result says, so say everything it needs: the weather’s timezone, so that it knows what “14:00” means, and the unit of every measurement, so that it never guesses whether the wind is in km/h or mph.

weather/models.py
"""The arguments and result of the get_weather tool."""

from typing import Literal

from pydantic import BaseModel, Field

Unit = Literal["METRIC", "USCS"]
"""Metric, or US customary, units."""


class WeatherArguments(BaseModel):
    """The arguments of get_weather."""

    location: str = Field(..., min_length=1, description="The city and state or country, e.g. San Francisco, CA")
    unit: Unit = Field("METRIC", description="Metric, or US customary, units.")


class Units(BaseModel):
    """The units of the weather's measurements."""

    temperature: str = Field(..., description="e.g. °C or °F.")
    precipitation: str = Field(..., description="e.g. mm or inch.")
    wind_speed: str = Field(..., description="e.g. km/h or mph.")
    cloud_cover: str = Field("%", description="Percent of the sky.")


class Conditions(BaseModel):
    """The weather at one time."""

    time: str = Field(..., description="The location's local time, e.g. 2026-10-15 14:00.")
    temperature: float = Field(..., description="The temperature at 2 m.")
    precipitation: float = Field(..., description="The precipitation.")
    wind_speed: float = Field(..., description="The wind speed at 10 m.")
    cloud_cover: float = Field(..., description="The cloud cover.")


class WeatherResult(BaseModel):
    """What get_weather returns to the LLM."""

    location: str = Field(..., description="The location's address, as Google Maps found it.")
    latitude: float
    longitude: float
    timezone: str = Field(..., description="The location's timezone, e.g. Europe/Paris, in which times are given.")
    units: Units = Field(..., description="The units of every measurement.")
    current: Conditions = Field(..., description="The weather now.")
    forecast: list[Conditions] = Field(..., description="The weather in each of the next 24 hours.")

Returning a model’s model_dump(mode="json"), rather than a dict assembled by hand, keeps the result’s shape stable and documented.

5. The Tool

A tool subclasses SmarterExtensionTool:

weather/tools.py
"""The weather extension's tools."""

from pydantic import JsonValue
from smarter.apps.llmtool.extension import ExtensionContext, ExtensionToolError, SmarterExtensionTool

from .models import WeatherArguments, WeatherResult
from .services import UNITS, ForecastError, GeocodingError, geocode, weather


class GetWeather(SmarterExtensionTool):
    """Get the current weather, and the forecast for the next 24 hours, for a location."""

    name = "get_weather"
    arguments_model = WeatherArguments

    def call(self, arguments: WeatherArguments, context: ExtensionContext) -> JsonValue:
        settings = context.settings
        try:
            place = geocode(arguments.location, settings.google_maps_api_key.get_secret_value())
            found = weather(place, arguments.unit, settings.weather_api_url)
        except GeocodingError as e:
            raise ExtensionToolError(f"Could not find {arguments.location}: {e}") from e
        except ForecastError as e:
            raise ExtensionToolError(f"The weather service is unavailable: {e}") from e
        context.logger.info("get_weather found %s", place.address)
        return WeatherResult(
            location=place.address,
            latitude=place.latitude,
            longitude=place.longitude,
            timezone=found.timezone,
            units=UNITS[arguments.unit],
            current=found.current,
            forecast=found.forecast,
        ).model_dump(mode="json")
  • name is the tool’s name, unique within the Extension. The LLM sees it as ext_weather_get_weather.

  • The class docstring is the tool’s description for the LLM. Say what it does and when to use it, in a sentence or two.

  • call() receives the validated arguments, as an instance of arguments_model, and an ExtensionContext. It returns any JSON value.

  • context.settings has one attribute per setting in pyproject.toml. A secret setting is a pydantic.SecretStr: call get_secret_value() only where you use it, and never log it.

  • context.account, context.user_profile and context.llmclient say who called the tool.

  • context.logger logs as smarter_extensions.<slug>.

Errors

Raise ExtensionToolError when the tool can’t do what was asked. Its message is returned to the LLM as {"error": "..."}, so write it for the LLM, which can then ask the user for a better location, or apologize. Any other exception is logged with its traceback, and the LLM is told only that the tool failed. Invalid arguments never reach call(): the LLM is told what was wrong with them.

A call that runs longer than the timeout in pyproject.toml is abandoned, and the LLM is told so.

6. The Services

Keep the calls to third-party services out of the tool, so that each can be tested alone, and do any setup when it’s first needed, not when the module is imported. Importing happens when the platform loads the Extension, in every process, and an import that fails or blocks makes the Extension fail to load.

weather/services.py
"""Geocoding with Google Maps, and current conditions and forecasts from Open-Meteo."""

from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone

import googlemaps
import openmeteo_requests

from .models import Conditions, Unit, Units

METRICS = ["temperature_2m", "precipitation", "windspeed_10m", "cloudcover"]
"""The variables requested from Open-Meteo, in the order that they are returned."""
HOURS = 24
USCS_PARAMETERS = {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph", "precipitation_unit": "inch"}
UNITS = {
    "METRIC": Units(temperature="°C", precipitation="mm", wind_speed="km/h"),
    "USCS": Units(temperature="°F", precipitation="inch", wind_speed="mph"),
}
"""Open-Meteo's units: its defaults, or those that USCS_PARAMETERS request."""


class GeocodingError(Exception):
    """A location could not be found."""


class ForecastError(Exception):
    """The forecast api failed."""


@dataclass
class Place:
    """A location, as Google Maps found it."""

    address: str
    latitude: float
    longitude: float


@dataclass
class Weather:
    """A place's current conditions and forecast, in its local time."""

    timezone: str
    current: Conditions
    forecast: list[Conditions] = field(default_factory=list)


def geocode(location: str, api_key: str) -> Place:
    """
    Find a location's address, latitude and longitude.

    :param location: The location, e.g. ``Paris``.
    :param api_key: A Google Maps API key with the Geocoding API enabled.
    :returns: The place.
    :raises GeocodingError: If the location cannot be found, or the API key is not valid.
    """
    try:
        results = googlemaps.Client(key=api_key).geocode(location)
    except (ValueError, googlemaps.exceptions.ApiError, googlemaps.exceptions.TransportError) as e:
        raise GeocodingError(str(e)) from e
    if not results:
        raise GeocodingError("no such place")
    geometry = results[0]["geometry"]["location"]
    return Place(results[0].get("formatted_address", location), float(geometry["lat"]), float(geometry["lng"]))


def local_time(epoch: int, utc_offset: int) -> str:
    """Return a Unix time as a local time, e.g. ``2026-10-15 14:00``."""
    return datetime.fromtimestamp(epoch + utc_offset, timezone.utc).strftime("%Y-%m-%d %H:%M")


def conditions(time: str, values: list[float]) -> Conditions:
    """Return the conditions at a time, from the values of METRICS, in order."""
    return Conditions(
        time=time,
        temperature=round(values[0], 1),
        precipitation=round(values[1], 2),
        wind_speed=round(values[2], 1),
        cloud_cover=round(values[3], 0),
    )


def weather(place: Place, unit: Unit, api_url: str) -> Weather:
    """
    Return a place's current conditions, and its forecast for the next 24 hours, starting this hour.

    :param place: The place.
    :param unit: ``METRIC`` or ``USCS``.
    :param api_url: The Open-Meteo forecast api.
    :returns: The weather, with times in the place's timezone.
    :raises ForecastError: If the api fails, or its response is not as expected.
    """
    params: dict = {
        "latitude": place.latitude,
        "longitude": place.longitude,
        "current": METRICS,
        "hourly": METRICS,
        "forecast_hours": HOURS,
        "timezone": "auto",
    }
    if unit == "USCS":
        params.update(USCS_PARAMETERS)
    try:
        response = openmeteo_requests.Client().weather_api(api_url, params=params)[0]
        offset = response.UtcOffsetSeconds()
        zone = (response.Timezone() or b"UTC").decode()
        current = response.Current()
        now = conditions(
            local_time(current.Time(), offset), [float(current.Variables(i).Value()) for i in range(len(METRICS))]
        )
        hourly = response.Hourly()
        values = [hourly.Variables(i).ValuesAsNumpy() for i in range(len(METRICS))]
        start, interval = hourly.Time(), hourly.Interval()
        hours = (hourly.TimeEnd() - start) // interval
        forecast = [
            conditions(local_time(start + i * interval, offset), [float(values[m][i]) for m in range(len(METRICS))])
            for i in range(min(hours, HOURS))
        ]
    # the client and the flatbuffers response raise many kinds of exceptions.
    except Exception as e:  # pylint: disable=broad-exception-caught
        raise ForecastError(str(e)) from e
    return Weather(timezone=zone, current=now, forecast=forecast)


__all__ = ["ForecastError", "GeocodingError", "Place", "UNITS", "Weather", "geocode", "weather"]

7. Validate and Package

On a Smarter development platform, check the Extension as the installer and the registry will: its pyproject.toml, compatibility, that the code imports, and each tool’s definition:

python manage.py validate_extension ../smarter-extension-weather

It prints each tool’s definition, exactly as the LLM will see it. Then build the archive, which holds the pyproject.toml, the package, and the README, LICENSE and CHANGELOG, and leaves out caches and version control:

python manage.py package_extension ../smarter-extension-weather --output dist
dist/weather-1.0.0.zip
sha256: 6f1d...

The same source always builds the same archive, with the same sha256.

8. Install and Try It

Install it on your development platform, and configure its secret setting:

python manage.py install_extension dist/weather-1.0.0.zip

Then, in the web console, open Extensions › Weather, set google_maps_api_key on the Settings tab, and enable it. List it in an LLMClient:

spec:
  extensions:
    - weather

and ask the LLMClient about the weather in Paris. The chat’s Smarter messages show that the Extension’s tools were added, and the tool call and its result.

Next, write its tests (Testing an Extension), and publish it (Publishing an Extension).