A small terminal weather assistant. It talks to a language model through
litellm → OpenRouter,
and gets its weather tools from an external
MCP server
(weather-mcp, backed by free public
data sources — NOAA, Open-Meteo, USGS — global, no
weather API key).
The chat interface shows each step the agent takes — every tool call, its arguments, the raw tool result, and the final answer — so you can watch it work. It's a Textual app, so the noisy panels are collapsed by default and expand when you click them.
you › what's the weather in Paris?
▶ reasoning # chain-of-thought, folded
→ call search_location(query='Paris') # resolve to lat/long first
▶ result · search_location
→ call get_current_conditions(latitude=48.85, longitude=2.35)
▶ result · get_current_conditions
▼ weather
It's partly cloudy in Paris, 20°C (68°F)… # model's final answer
2 tool calls · 412 tokens (350 in / 62 out, 18 reasoning) · $0.0002
Reasoning and tool-result panels start folded so the transcript stays scannable;
click a ▶ title to unfold one and inspect what the MCP server actually
returned. The final answer starts unfolded. Only one turn runs at a time — the
prompt is disabled while the agent works, so steps always appear in order.
After the final answer, a summary line shows the cost of that turn: how many tool calls were made and the token/cost breakdown (prompt vs. completion vs. reasoning tokens), using the provider-reported cost when available.
The agent loop lives in agent.py and is deliberately UI-agnostic: run_turn
is an async generator that yields a typed event per step, and the UI
(cli.py) renders each one as a widget. Adding the Textual front-end needed no
changes to agent.py at all. Tool definitions are discovered at runtime
from the MCP server (litellm.experimental_mcp_client.load_mcp_tools), not
hand-written.
The default model is a reasoning ("thinking") variant, so its chain-of-thought
(reasoning_content) is shown too, in a separate panel from tool calls and the
final answer.
The server's tool set is enabled via ENABLED_TOOLS=all,-summary (set in
config.py): current conditions, forecast, alerts, air quality, marine,
historical, lightning, wildfire, river conditions, imagery, and saved locations.
get_weather_summary is deliberately excluded — it's a composite tool that
bundles several of the above into one call, which would hide steps from the
per-step display. Excluding it forces the agent to make the individual calls.
Some sources are region-limited — e.g. NOAA weather alerts cover the US only,
so the agent will report when alerts aren't available for a foreign location.
The weather MCP server runs via npx, so you need Node.js (which provides
npx) on your PATH. The first run downloads and caches the server package.
Install Python dependencies:
uv sync
Set your OpenRouter API key:
export OPENROUTER_API_KEY=sk-or-...
Start the chat:
uv run weather-agent
Pick a different model (any OpenRouter model id that supports tool calling; pick a "thinking"/reasoning variant to see its chain-of-thought too):
uv run weather-agent --model openrouter/meta-llama/llama-3.3-70b-instruct
or set a default via the environment:
export WEATHER_AGENT_MODEL=openrouter/qwen/qwen3-235b-a22b-thinking-2507
Press Ctrl-C to quit.
| Variable | Default | Purpose |
|---|---|---|
OPENROUTER_API_KEY |
(required) | OpenRouter credential |
WEATHER_AGENT_MODEL |
openrouter/qwen/qwen3-235b-a22b-thinking-2507 |
Default model id |
The default is an affordable open-weights reasoning model with reliable tool calling.
Tracing to Langfuse and/or Arize is opt-in and turns on automatically once the relevant env vars are set — each backend is independent, so you can enable any combination.
Arize AX (space-based) and Arize Phoenix are separate products with separate litellm callbacks; use whichever env vars your Arize dashboard gave you.
Install the extra dependencies first:
uv sync --extra observability
Then set the env vars for whichever backend(s) you want:
| Backend | Required env vars | Optional |
|---|---|---|
| Langfuse | LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY |
LANGFUSE_HOST |
| Arize AX | ARIZE_SPACE_ID, ARIZE_API_KEY |
ARIZE_PROJECT_NAME |
| Arize Phoenix | PHOENIX_API_KEY, PHOENIX_COLLECTOR_ENDPOINT |
PHOENIX_PROJECT_NAME |
The header lines at the top of the transcript show which backend(s) are active
(observability: langfuse, none, etc.). This is wired up via
litellm.callbacks (config.ENABLED_CALLBACKS) — no changes to the agent
loop were needed, since litellm logs each completion call automatically.
Sessions: each CLI run generates one session_id (shown in the startup
transcript header as session: ... when observability is on) that's attached to every
completion call in the conversation, so a Langfuse/Arize session groups all of
that run's turns. Each turn additionally gets its own trace_id — litellm
defaults to using session_id as the trace id too when no trace_id is set,
which collapses every turn in the conversation into one trace instead of one
trace per turn, so we set it explicitly.
Note: the langfuse package is pinned to <3 — this litellm version's
integration targets the v2 SDK API and errors on import with v3+.
uv run pytest # tests
uv run black . # format
uv run isort . # sort imports