Customizing Iris
/ 8 min read
One of the things I love about Iris’s architecture is how straightforward it is to extend. The local configuration system lets you add your own tools and prompts without touching the core codebase.
In this post, we’ll walk through building two weather features: a tool that Iris can invoke on-demand when you ask about the weather, and a custom prompt that automatically injects weather context into every conversation. By the end, Iris won’t just answer weather questions. She’ll know the current conditions and factor them into her responses naturally.
Let’s dive in…
The Local Configuration File
Before we get into building weather features, you need to understand how Iris handles customization. The magic happens in a file called config/iris-local.php. This file doesn’t exist by default, so you create it yourself.
<?php
return [ // Your customizations go here];What’s nice about this setup is the merge behavior. Tools you add here get appended to the built-in tools. You’re not replacing anything, just extending. But prompts work differently: they replace the entire prompt list. This means you need to include all the prompts you want, including the built-in ones.
Here’s the mental model:
- Tools: Your custom tools are added alongside memory tools, calendar tools, etc.
- Prompts: You control the entire prompt stack (order matters!)
- Other settings: Deep merge with sensible defaults
For the full reference on what you can customize, check out the Iris customization docs.
The Meteorologist Service
Before we build the tool and prompt, let’s create a service to handle all the weather fetching logic. This way both components can share the same code.
First, an enum to map Tomorrow.io’s weather codes to human-readable conditions. Create app/Enums/WeatherCondition.php:
<?php
declare(strict_types=1);
namespace App\Enums;
enum WeatherCondition: int{ case Clear = 1000; case MostlyClear = 1100; case PartlyCloudy = 1101; case MostlyCloudy = 1102; case Cloudy = 1001; case Fog = 2000; case LightFog = 2100; case Drizzle = 4000; case Rain = 4001; case LightRain = 4200; case HeavyRain = 4201; case Snow = 5000; case Flurries = 5001; case LightSnow = 5100; case HeavySnow = 5101; case FreezingDrizzle = 6000; case FreezingRain = 6001; case LightFreezingRain = 6200; case HeavyFreezingRain = 6201; case IcePellets = 7000; case HeavyIcePellets = 7101; case LightIcePellets = 7102; case Thunderstorm = 8000;
public function label(): string { return match ($this) { self::Clear => 'Clear', self::MostlyClear => 'Mostly Clear', self::PartlyCloudy => 'Partly Cloudy', self::MostlyCloudy => 'Mostly Cloudy', self::Cloudy => 'Cloudy', self::Fog => 'Fog', self::LightFog => 'Light Fog', self::Drizzle => 'Drizzle', self::Rain => 'Rain', self::LightRain => 'Light Rain', self::HeavyRain => 'Heavy Rain', self::Snow => 'Snow', self::Flurries => 'Flurries', self::LightSnow => 'Light Snow', self::HeavySnow => 'Heavy Snow', self::FreezingDrizzle => 'Freezing Drizzle', self::FreezingRain => 'Freezing Rain', self::LightFreezingRain => 'Light Freezing Rain', self::HeavyFreezingRain => 'Heavy Freezing Rain', self::IcePellets => 'Ice Pellets', self::HeavyIcePellets => 'Heavy Ice Pellets', self::LightIcePellets => 'Light Ice Pellets', self::Thunderstorm => 'Thunderstorm', }; }}Now create the service at app/Services/Meteorologist.php:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\WeatherCondition;use Illuminate\Support\Facades\Cache;use Illuminate\Support\Facades\Http;
class Meteorologist{ public function current(string $location): ?array { return Cache::remember( "weather:{$location}", now()->addMinutes(30), fn () => $this->fetch($location) ); }
protected function fetch(string $location): ?array { $apiKey = config('services.tomorrow.key');
if (! $apiKey) { return null; }
$response = Http::get('https://api.tomorrow.io/v4/weather/realtime', [ 'location' => $location, 'apikey' => $apiKey, 'units' => 'imperial', ]);
if ($response->failed()) { return null; }
$data = $response->json();
return [ 'temperature' => (int) round(data_get($data, 'data.values.temperature', 0)), 'conditions' => WeatherCondition::tryFrom((int) data_get($data, 'data.values.weatherCode', 0))?->label() ?? 'Unknown', 'humidity' => (int) round(data_get($data, 'data.values.humidity', 0)), 'wind' => (int) round(data_get($data, 'data.values.windSpeed', 0)), ]; }}The service handles caching internally, so consumers don’t need to worry about it. Weather data is cached for 30 minutes per location.
Part 1: Building the Weather Tool
Now let’s create a tool that Iris can invoke when you explicitly ask about the weather. Tools in Iris extend Prism\Prism\Tool and use a fluent interface to define their capabilities.
Create app/Tools/WeatherTool.php:
<?php
declare(strict_types=1);
namespace App\Tools;
use App\Services\Meteorologist;use Prism\Prism\Tool;
class WeatherTool extends Tool{ public function __construct( protected Meteorologist $meteorologist, ) { $this ->as('get_weather') ->for('Get current weather conditions for a specified location. Use this when the user asks about weather, temperature, or outdoor conditions.') ->withStringParameter( 'location', 'The city name or location to get weather for (e.g., "New York", "London, UK")', required: true ) ->using($this); }
public function __invoke(string $location): string { $weather = $this->meteorologist->current($location);
if (! $weather) { return "Unable to fetch weather for {$location}."; }
return view('tools.weather.current', [ 'location' => $location, 'temperature' => $weather['temperature'], 'conditions' => $weather['conditions'], 'humidity' => $weather['humidity'], 'wind' => $weather['wind'], ])->render(); }}The tool injects the Meteorologist service and delegates all the weather fetching to it. Clean and simple.
The as() method defines the tool name that the LLM sees. The for() method is crucial. This is how Claude knows when to use your tool. Be descriptive here.
Now we need a Blade template for the response. Create resources/views/tools/weather/current.blade.php:
Current weather for {{ $location }}:- Temperature: {{ $temperature }}°F- Conditions: {{ ucfirst($conditions) }}- Humidity: {{ $humidity }}%- Wind: {{ $wind }} mphRegistering the Tool
Here’s where the local config comes in. Create (or update) your config/iris-local.php:
<?php
return [ 'tools' => [ App\Tools\WeatherTool::class, ],];That’s it. Iris will now have access to your weather tool alongside all the built-in tools.
Part 2: Automatic Weather Context
Now for the fun part. What if Iris just knew the current weather without being asked? This is where custom prompts come in.
Prompts in Iris extend App\Prompts\Prompt and return content that gets injected into the system prompt. Create app/Prompts/WeatherContextPrompt.php:
<?php
declare(strict_types=1);
namespace App\Prompts;
use App\Services\Meteorologist;use App\ValueObjects\RequestContext;use Illuminate\View\View;
class WeatherContextPrompt extends Prompt{ public function __construct( protected RequestContext $requestContext, protected Meteorologist $meteorologist, ) {}
public function content(): View { $user = $this->requestContext->user(); $location = $user?->settings['location'] ?? null;
if (! $location) { return view('prompts.weather-context', [ 'weather' => null, ]); }
return view('prompts.weather-context', [ 'weather' => $this->meteorologist->current($location), 'location' => $location, ]); }}The RequestContext gives you access to the current user (and their message, attached images, etc.). Here we’re pulling the user’s location from a settings JSON column on the User model. You could store this however makes sense for your setup.
Since the Meteorologist service handles caching internally, the prompt doesn’t need to worry about it.
Now create the Blade template at resources/views/prompts/weather-context.blade.php:
@if ($weather)## Current Weather Context
The user is located in {{ $location }}. Current conditions:- Temperature: {{ $weather['temperature'] }}°F- Conditions: {{ ucfirst($weather['conditions']) }}- Humidity: {{ $weather['humidity'] }}%
Use this context naturally in conversation when relevant (e.g., suggesting indoor activities on rainy days, mentioning nice weather, etc.). Don't force weather references where they don't belong.@endifThat last instruction is important. You want Iris to be aware of the weather, not constantly talking about it. The prompt tells her when it’s appropriate to use this context.
Registering the Prompt
Here’s where things get a bit different from tools. Remember, prompts replace the entire list. You need to include the built-in prompts too, in the order you want them:
<?php
return [ 'prompts' => [ App\Prompts\IrisStaticPrompt::class, App\Prompts\WeatherContextPrompt::class, App\Prompts\IrisDynamicPrompt::class, ],
'tools' => [ App\Tools\WeatherTool::class, ],];The weather prompt is sandwiched between the static and dynamic prompts. The static prompt contains Iris’s core personality (and gets cached for efficiency). The dynamic prompt includes recalled memories, calendar events, and other per-request context. Weather fits nicely in the middle.
Setting Up the API Key
You’ll need a Tomorrow.io API key. They have a free tier that’s plenty for personal use. Add it to your .env:
TOMORROW_API_KEY=your_key_hereAnd create a config entry in config/services.php:
'tomorrow' => [ 'key' => env('TOMORROW_API_KEY'),],What This Looks Like in Practice
When you ask “What’s the weather like?”, Iris invokes the get_weather tool with your location and gives you a detailed response.
But here’s the cooler part: when you’re just chatting about what to do this afternoon, Iris already knows it’s 45°F and drizzling. She might suggest a coffee shop instead of a walk in the park. That context flows naturally into the conversation without you having to ask.
A Few Tips
Keep prompts focused. Each prompt should have a single responsibility. Weather context, user preferences, domain knowledge. Separate prompts for each.
Cache external API calls. Your prompts run on every message. Don’t make expensive API calls without caching.
Be thoughtful about prompt order. The static prompt should come first (it gets cached). Dynamic, per-request context comes last.
Test your tools with edge cases. What happens when the API is down? When the location isn’t found? Handle these gracefully.
The local config system gives you clean extension points without being opinionated about what you add. Weather is a simple example, but the same patterns work for stock prices, home automation status, calendar-aware scheduling, or whatever context would make your assistant more useful.