Search
Search docs, blog posts, and ecosystem packages with citations.
Enter a query to see grounded citations.
We can't find the internet
Attempting to reconnect
Search docs, blog posts, and ecosystem packages with citations.
Build an end-to-end tool-calling agent that fetches live weather data with notebook-local Actions.
This notebook is self-contained. It defines the weather Actions locally, configures one provider key, starts the default runtime, and runs a tool-calling weather agent end to end. The weather data itself comes from free public APIs, so you do not need a separate weather API key.
Mix.install([
{:jido, "~> 2.1"},
{:jido_ai, "~> 2.0"},
{:req_llm, "~> 1.7"},
{:req, "~> 0.5"}
])
Logger.configure(level: :warning)
# Livebook imports can execute generated docs as doctests.
# Disable compiler docs until the current Jido Hex release drops the invalid signal_types/0 example.
Code.put_compiler_option(:docs, false)
In Livebook, store OPENAI_API_KEY as a secret. Livebook exposes it as LB_OPENAI_API_KEY, so the cell below checks both names.
openai_key = System.get_env("LB_OPENAI_API_KEY") || System.get_env("OPENAI_API_KEY")
configured? =
if is_binary(openai_key) do
ReqLLM.put_key(:openai_api_key, openai_key)
true
else
IO.puts("Set OPENAI_API_KEY or LB_OPENAI_API_KEY before running the request cells.")
false
end
In Jido, tools are Actions. This notebook defines its weather Actions locally so the example stays runnable in Livebook and in a fresh Mix project.
defmodule MyApp.WeatherHTTP do
@headers [
{"user-agent", "jido.run weather guide (https://jido.run)"},
{"accept", "application/geo+json, application/json"}
]
def get_json(url, opts \\ []) do
with {:ok, response} <-
Req.get(
url: url,
params: Keyword.get(opts, :params, []),
headers: @headers
) do
{:ok, response.body}
end
end
end
defmodule MyApp.WeatherGeocode do
use Jido.Action,
name: "weather_geocode",
description: "Convert a city or location string into latitude and longitude coordinates.",
schema: [
location: [type: :string, required: true, doc: "City, state, or other human-readable location"]
]
@impl true
def run(%{location: location}, _context) do
with {:ok, results} <-
MyApp.WeatherHTTP.get_json(
"https://nominatim.openstreetmap.org/search",
params: [q: location, format: "jsonv2", limit: 1]
),
[match | _] <- results,
{latitude, _} <- Float.parse(match["lat"]),
{longitude, _} <- Float.parse(match["lon"]) do
{:ok,
%{
location: location,
display_name: match["display_name"],
latitude: latitude,
longitude: longitude,
coordinates: "#{latitude},#{longitude}"
}}
else
[] -> {:error, :location_not_found}
:error -> {:error, :invalid_coordinates}
{:error, reason} -> {:error, reason}
end
end
end
defmodule MyApp.WeatherLocationToGrid do
use Jido.Action,
name: "weather_location_to_grid",
description: "Resolve a latitude/longitude pair into the National Weather Service forecast URLs.",
schema: [
coordinates: [
type: :string,
required: true,
doc: "Latitude and longitude as \"lat,lng\""
]
]
@impl true
def run(%{coordinates: coordinates}, _context) do
with [latitude, longitude] <- String.split(coordinates, ",", parts: 2),
latitude = String.trim(latitude),
longitude = String.trim(longitude),
{:ok, body} <- MyApp.WeatherHTTP.get_json("https://api.weather.gov/points/#{latitude},#{longitude}"),
properties when is_map(properties) <- body["properties"] do
{:ok,
%{
coordinates: coordinates,
forecast_url: properties["forecast"],
hourly_forecast_url: properties["forecastHourly"],
observation_stations_url: properties["observationStations"],
office: properties["gridId"],
grid_x: properties["gridX"],
grid_y: properties["gridY"]
}}
else
{:error, reason} -> {:error, reason}
_ -> {:error, :invalid_coordinates}
end
end
end
defmodule MyApp.WeatherForecast do
use Jido.Action,
name: "weather_forecast",
description: "Fetch the upcoming weather forecast from a National Weather Service forecast URL.",
schema: [
forecast_url: [type: :string, required: true, doc: "Forecast URL returned by weather_location_to_grid"]
]
@impl true
def run(%{forecast_url: forecast_url}, _context) do
with {:ok, body} <- MyApp.WeatherHTTP.get_json(forecast_url),
periods when is_list(periods) <- get_in(body, ["properties", "periods"]) do
preview =
periods
|> Enum.take(4)
|> Enum.map(fn period ->
%{
name: period["name"],
temperature: period["temperature"],
temperature_unit: period["temperatureUnit"],
short_forecast: period["shortForecast"],
detailed_forecast: period["detailedForecast"]
}
end)
summary =
Enum.map_join(preview, "\n", fn period ->
"#{period.name}: #{period.temperature}#{period.temperature_unit}, #{period.short_forecast}"
end)
{:ok, %{summary: summary, periods: preview}}
else
{:error, reason} -> {:error, reason}
_ -> {:error, :forecast_unavailable}
end
end
end
The weather Actions follow a simple chain:
weather_geocode turns a city into coordinates weather_location_to_grid turns those coordinates into NWS URLs weather_forecast fetches the forecast from the returned URL ToolAdapter converts Actions into the JSON Schema format that the LLM provider expects for tool calling:
tools = Jido.AI.ToolAdapter.from_actions([
MyApp.WeatherGeocode,
MyApp.WeatherLocationToGrid,
MyApp.WeatherForecast
])
IO.inspect(hd(tools).name)
IO.inspect(hd(tools).parameter_schema)
Any Jido.Action module can be exposed to the LLM as a tool. This example adds a simple temperature converter so the agent can answer unit-conversion questions too.
defmodule MyApp.TemperatureConverter do
use Jido.Action,
name: "convert_temperature",
description: "Convert between Fahrenheit and Celsius",
schema: [
value: [type: :float, required: true, doc: "Temperature value"],
from: [
type: {:in, [:fahrenheit, :celsius]},
required: true,
doc: "Source unit"
],
to: [
type: {:in, [:fahrenheit, :celsius]},
required: true,
doc: "Target unit"
]
]
@impl true
def run(%{value: value, from: :fahrenheit, to: :celsius}, _context) do
{:ok, %{result: Float.round((value - 32) * 5 / 9, 1), unit: "°C"}}
end
def run(%{value: value, from: :celsius, to: :fahrenheit}, _context) do
{:ok, %{result: Float.round(value * 9 / 5 + 32, 1), unit: "°F"}}
end
def run(%{value: value, from: same, to: same}, _context) do
unit = if same == :celsius, do: "°C", else: "°F"
{:ok, %{result: value, unit: unit}}
end
end
use Jido.AI.Agent wires in the ReAct reasoning strategy, which handles the tool-calling loop automatically.
defmodule MyApp.WeatherAgent do
use Jido.AI.Agent,
name: "weather_agent",
description: "Weather assistant with tool access",
tools: [
MyApp.WeatherGeocode,
MyApp.WeatherLocationToGrid,
MyApp.WeatherForecast,
MyApp.TemperatureConverter
],
model: :fast,
max_iterations: 6,
system_prompt: """
You are a helpful weather assistant.
For weather questions:
1. Call weather_geocode with the user's location.
2. Call weather_location_to_grid with the coordinates from weather_geocode.
3. Call weather_forecast with the forecast_url from weather_location_to_grid.
4. Summarize the forecast and give practical advice.
Use convert_temperature only when the user explicitly asks for unit conversion.
Keep answers concise and practical.
"""
@default_timeout 60_000
def get_forecast(pid, location, opts \\ []) do
ask_sync(
pid,
"What's the forecast in #{location}? Summarize the next few forecast periods.",
Keyword.put_new(opts, :timeout, @default_timeout)
)
end
def need_jacket?(pid, location, opts \\ []) do
ask_sync(
pid,
"Should I bring a jacket in #{location} today? Use the forecast and give practical advice.",
Keyword.put_new(opts, :timeout, @default_timeout)
)
end
end
The key options control agent behavior:
tools exposes Jido.Action modules to the LLM model: :fast uses the standard fast-model alias from jido_aimax_iterations caps the number of ReAct loops before the agent stops system_prompt teaches the model the required tool order {:ok, _} = Jido.start()
runtime = Jido.default_instance()
agent_id = "weather-demo-#{System.unique_integer([:positive])}"
{:ok, pid} = Jido.start_agent(runtime, MyApp.WeatherAgent, id: agent_id)
Show the success path first: ask one forecast question and print the answer.
forecast_answer =
if configured? do
{:ok, answer} = MyApp.WeatherAgent.get_forecast(pid, "Chicago, IL")
answer
else
"Skipped request. Set OPENAI_API_KEY or LB_OPENAI_API_KEY to run it."
end
IO.puts(forecast_answer)
ask/3 and await/2
For non-blocking usage, ask/3 returns a request handle you can await later:
jacket_answer =
if configured? do
{:ok, request} =
MyApp.WeatherAgent.ask(
pid,
"Should I bring a jacket in Denver, CO today?"
)
{:ok, answer} = MyApp.WeatherAgent.await(request, timeout: 60_000)
answer
else
"Skipped request. Set OPENAI_API_KEY or LB_OPENAI_API_KEY to run it."
end
IO.puts(jacket_answer)
The agent uses a ReAct loop to decide when tools are needed:
run/2max_iterations is reached For a weather question like “What’s the forecast in Chicago?”, the loop usually runs three tool steps: geocode, points lookup, and forecast retrieval.
ask_sync/3 returns {:error, reason} for provider timeouts, API failures, and max iteration exhaustion. Pattern match on the result so callers can handle failures explicitly.
query = "What's the forecast in Denver, CO? Do I need a jacket?"
case if(configured?, do: MyApp.WeatherAgent.ask_sync(pid, query, timeout: 60_000), else: {:skip, :no_openai_key}) do
{:ok, answer} ->
IO.puts(answer)
{:error, reason} ->
IO.puts("Failed: #{inspect(reason)}")
{:skip, :no_openai_key} ->
IO.puts("Skipped request. Set OPENAI_API_KEY or LB_OPENAI_API_KEY to run it.")
end
Common failure modes include:
max_iterations without reaching a final answer