<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Florian Schaal</title><description>Technical articles, tutorials, and notes</description><link>https://florianschaal.com/</link><language>en-us</language><item><title>Building a Stable Zigbee Network: Lessons from 150 Devices</title><link>https://florianschaal.com/blog/stable-zigbee-network-150-devices/</link><guid isPermaLink="true">https://florianschaal.com/blog/stable-zigbee-network-150-devices/</guid><description>What I learned running a Zigbee network with 150 devices in Home Assistant: coordinator placement, routers, channels, and update discipline.</description><pubDate>Sat, 09 May 2026 00:00:00 GMT</pubDate><content:encoded>Our home runs around 150 Zigbee devices. Lights, sensors, switches, plugs, blinds, leak detectors, climate sensors, you name it. Most of the time it just works, but getting there took a few painful nights, an unhappy wife, and more re-pairing than I want to admit.

This post is what I&apos;d tell myself if I was starting over. Nine things that actually broke or saved my Zigbee network, grouped into three areas: the foundation you set once, the mesh you grow over time, and the operations you do (or skip) every week.

I&apos;m using [Zigbee2MQTT](https://www.zigbee2mqtt.io/) with a [SMLIGHT SLZB-06p7](https://smlight.tech/global/slzb06p7) PoE coordinator. If you&apos;re on ZHA or a different stick, most of this still applies. Just translate the tooling.

## The Foundation

These are the decisions you make early and rarely change. Get them wrong and everything downstream feels broken.

### Put the coordinator in the middle of the house

About a year ago, my Home Assistant instance lived on the second floor of the house. Then we had a kid, that room became the nursery, and I moved everything down to the ground floor.

Within a few weeks, lights started taking longer to respond. Scene switches felt sluggish. Nothing was completely broken, but the snappy feel was gone. When I finally opened the Zigbee2MQTT network map, almost 50 devices were sitting on a low LQI score (the link quality between a device and its parent).

I tried the obvious things first. Firmware updates on the worst devices. Swapping a few problem switches for known-good ones. I even flashed a couple of spare Sonoff sticks as repeaters and plugged them in around the house. None of it really helped.

What actually fixed it was moving the coordinator back to the middle of the house. I switched from a USB stick to a SMLIGHT SLZB-06p7 (a PoE coordinator with Ethernet), put it in a central spot on the second floor, and ran my Home Assistant instance from wherever was convenient. LQI scores recovered within a day.

The non-obvious bit: even though I had repeaters scattered around, devices kept choosing the strongest signal, which was the coordinator downstairs. So they&apos;d route through one giant weak hop instead of using the closer router. Adding more routers didn&apos;t help when the coordinator was loud enough to outshout them all.

If your coordinator is sitting in your meter cabinet, that&apos;s almost always the wrong spot. Modern houses have heavy insulation, sometimes three layers between the cabinet and the rooms above. Zigbee at 2.4 GHz doesn&apos;t love that. PoE coordinators solve it cleanly: the coordinator goes where the radio coverage needs to be, and Home Assistant goes wherever you have a network port.

### Pick a Zigbee channel that isn&apos;t fighting your Wi-Fi

Early on, when our network was still small, devices would randomly drop and refuse to come back. I went through the usual fix-it-in-software cycle. Read GitHub issues, run updates, restore old configs, swap firmware. Devices kept disappearing.

The actual problem was the channel. I scanned my Wi-Fi environment with [NetSpot](https://www.netspotapp.com/) (free version, Mac and Windows), and channel 11 was completely saturated. My Zigbee network was on the default, which happened to overlap channel 11. I was running both technologies on the same slice of 2.4 GHz spectrum.

I switched my Zigbee network to channel 25 and stability came back immediately.

The painful part: I didn&apos;t know how to migrate devices to a new channel cleanly, so I re-paired almost everything. That&apos;s the warning I want to give you upfront. Changing the channel on a live network is a near-one-way door. Devices don&apos;t follow the coordinator, you have to re-pair them. Pick your channel before you pair 100 devices, not after.

Channel 25 is usually the safest bet. It sits above Wi-Fi channel 11, which means you&apos;re outside the busy 2.4 GHz Wi-Fi range (channels 1, 6, and 11). Channel 26 is restricted to lower transmit power in the US under FCC rules. I&apos;m in the Netherlands so it&apos;s not an issue, but worth knowing if you&apos;re elsewhere.

### Use random PAN IDs and back them up

I don&apos;t have a war story here, which is exactly why it&apos;s worth mentioning. The PAN ID and Extended PAN ID identify your Zigbee network. If two networks in radio range share the same values, things get messy.

Older Zigbee2MQTT installs used a static default PAN ID (`0x1a62`), which meant any two neighbors who never touched their config could clash. Modern Z2M ships with `pan_id: GENERATE` and `ext_pan_id: GENERATE` in the [example config](https://github.com/Koenkk/zigbee2mqtt/blob/master/data/configuration.example.yaml), so a fresh install picks random values for you. If you set up Z2M years ago, check your `configuration.yaml` and make sure those values aren&apos;t still on the static default.

Once Z2M generates the values, write down the `pan_id`, `ext_pan_id`, `network_key`, and `channel`. You&apos;ll need the exact values if you ever migrate to a new coordinator without re-pairing every device.

Same caveat as the channel. Changing any of these on a live network forces re-pairing.

## The Mesh

These are the things that take time and money to build. You add to them as your network grows.

### Add routers as you grow

When we first moved into our home, the network started small. Just a handful of devices on the ground floor. As we expanded, we added a sensor to the attic. It performed terribly. Constant timeouts, dropped events, going offline for hours.

That&apos;s the symptom of not having enough routers. Zigbee is a mesh, but only mains-powered devices act as routers. Battery devices are leaves on the tree. If there&apos;s no router between your far-away sensor and the coordinator, the sensor either has to make the full hop (often impossible in a normal house) or just doesn&apos;t work.

I temporarily fixed it with an IKEA Tradfri Zigbee repeater, a USB stick you plug into a wall outlet that does nothing but act as a router. I think IKEA stopped selling those, but any decent mains-powered Zigbee device works the same way.

Today, out of about 150 devices, around 90 are routers. I don&apos;t need the IKEA repeaters anymore because we built the rest of the network out so heavily that there&apos;s always a router nearby.

### Make Hue lights your backbone

When we built our home, we made one big architectural decision. Every ceiling light, wall light, and bulb would be Philips Hue. We&apos;d already used Hue in our previous home and trusted them, and they happen to be excellent Zigbee routers.

That decision alone gave us router coverage in every room before we paired a single sensor. Lights are everywhere in a house, they&apos;re always powered, and Hue&apos;s mesh implementation is rock solid.

[Innr](https://www.innr.com/) lights work just as well if you want a cheaper alternative. Both brands are mainstream, well-supported in Zigbee2MQTT, and behave properly as routers. Pick based on color rendering and price, not Zigbee compatibility.

If you&apos;re building or renovating, this is the easiest decision you&apos;ll ever make. Pre-plan your lights as Zigbee, and your mesh problem mostly solves itself.

### Beware cheap router devices

A few years in, I wanted to measure power on a few specific outlets. The oven, some entertainment gear, that kind of thing. I bought a batch of Tuya smart plugs from AliExpress, around eight to ten euros each, and put them everywhere I had a measurable load.

They worked. Sort of. The plugs themselves did what I bought them for. But devices that ended up routing through them started behaving strangely. Lag on actions. Random disconnects. Some sensors going offline at night and reconnecting in the morning.

I figured it out by opening the Zigbee2MQTT network map. Any device whose route passed through a Tuya plug, directly or indirectly, performed badly. Devices routing through a Hue bulb were fine. The plugs were technically routers, but they were dropping their children&apos;s traffic.

I replaced all the Tuya plugs with better-known brands and the network healed within a few hours.

This isn&apos;t just an AliExpress problem either. I&apos;ve seen the same pattern with Aqara plugs, even though Aqara is a mainstream brand most people trust. The lesson: a cheap or poorly-implemented plug can be a bad neighbor for your entire network. If you must use them, keep them out of the routing path or check the [Zigbee2MQTT supported devices list](https://www.zigbee2mqtt.io/supported-devices/) for known-good alternatives.

### USB 3.0 interference (the trap I dodged)

Not really a war story, but worth a note. Before the SLZB-06p7, I was running a Sonoff stick plugged into a Raspberry Pi.

USB 3.0 ports and devices radiate noise around 2.4 GHz. If you plug your coordinator stick directly into a host that also has USB 3 SSDs or hubs nearby, your Zigbee range collapses. The fix is a one to two meter USB 2.0 extension cable that puts the stick somewhere away from the noise.

I happened to read about this before I set up my first network, so I always used an extension cable. If you&apos;re starting fresh with a USB stick coordinator, this is the cheapest single thing you can do to improve range.

## The Operations

These are the things you do (or fail to do) every week. They&apos;re boring, but they&apos;re the difference between a network that runs for years and one that breaks at 6am on a Tuesday.

### Don&apos;t update without a reason

This was the one that taught me the hardest lesson.

About two years into the network, sitting around 100 devices, I was clicking &quot;update&quot; every time Zigbee2MQTT or my coordinator firmware had a new release. I didn&apos;t read the changelogs. I just trusted that newer was better.

One update broke almost everything. Devices started dropping out within hours. By the morning, half the lights wouldn&apos;t turn on. My wife was very, very unhappy.

The cherry on top: I didn&apos;t have Home Assistant backups configured at the time. Restoring took forever. I had to figure out how to roll Zigbee2MQTT back to the previous version manually, which is not the documented happy path. Then, because I assumed a coordinator firmware update might &quot;stabilize things,&quot; I updated that too. It made things worse.

After that, my approach changed completely:

- Daily Home Assistant backups, automated. If anything breaks tomorrow, I restore yesterday and life goes on.
- I read the [Zigbee2MQTT changelog](https://github.com/Koenkk/zigbee2mqtt/releases) before any update. Most releases are device support I don&apos;t need or fixes for things I&apos;m not using. So I skip them.
- I do not touch the coordinator firmware unless I have a specific reason. If my current version is stable, that&apos;s the version I run.

The principle: don&apos;t update for the sake of updating. Read the changelog. If nothing in there matters to you, the safest move is to do nothing.

### Back up before migrations

A separate thing from Home Assistant backups: when you change coordinator hardware, you can export a Zigbee network backup that captures the network key, channel, PAN ID, and device list. Without it, a coordinator swap means re-pairing every device.

I don&apos;t run regular backups of this file. But when I migrated from the Sonoff USB stick to the SLZB-06p7, I exported one. If the migration had gone sideways, I could have restored it onto the Sonoff and tried again. The migration went fine, but that backup was the safety net that let me even attempt it.

If you&apos;re planning a coordinator change, do the export first. The [Zigbee2MQTT FAQ](https://www.zigbee2mqtt.io/guide/faq/) walks through which files to back up and the migration steps.

### Use the network map when something feels off

The Zigbee2MQTT map (the Map tab in the Z2M frontend) is the most underused diagnostic tool in Home Assistant.

I don&apos;t look at it on a schedule. I open it when something feels wrong. It tells me three useful things:

- Which devices have low LQI (anything under 50 is a yellow flag).
- Which router each device is talking to.
- Whether any router has a suspiciously large fan-out, which usually means other routers nearby aren&apos;t working.

A useful detail: if you&apos;ve recently lost power or rebooted your coordinator, devices spend hours rediscovering routes. The map will look messy in the meantime, and devices may temporarily perform worse than usual. That&apos;s normal. Give it a day before assuming something is broken.

## A 5-minute self-audit

If you read this far, here&apos;s what to check on your network before you close this tab:

1. Where&apos;s your coordinator physically? Is it in a meter cabinet or basement? Could you move it to a more central spot, or put a PoE one there?
2. What channel is your Zigbee network on? Is it set explicitly, or still on the default? Run a Wi-Fi scan with NetSpot or similar and see if your channel overlaps a busy Wi-Fi.
3. Open the Zigbee2MQTT network map. How many devices are below LQI 50? Are any of them routing through cheap plugs?
4. When did you last back up Home Assistant? Is the backup actually scheduled, or did you do it once last year and forget?
5. Do you have any AliExpress smart plugs or Aqara plugs acting as routers? If yes, watch them in the map. If devices are bunching behind them, that&apos;s your next afternoon project.

A solid Zigbee network is the foundation for everything else. My [iPhone presence detection setup](/blog/iphone-presence-detection-home-assistant) wouldn&apos;t survive a flaky mesh, and neither would any of the lighting automations my family relies on. So pick the worst item from the audit and fix that this weekend. You don&apos;t have to do everything at once.</content:encoded></item><item><title>Reliable Presence Detection with iPhones and Node-RED</title><link>https://florianschaal.com/blog/iphone-presence-detection-home-assistant/</link><guid isPermaLink="true">https://florianschaal.com/blog/iphone-presence-detection-home-assistant/</guid><description>How I built a presence detection system using Node-RED and Home Assistant that works around iPhone Wi-Fi sleep behavior.</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>Every smart home needs to know one basic thing: is anyone home? Door locks, lights, heating, alarms. They all depend on whether the house is occupied or empty. Getting this right with iPhones is trickier than you&apos;d expect.

I&apos;ve been running a presence detection system in [Node-RED](https://nodered.org/) and [Home Assistant](https://www.home-assistant.io/) for a while now. It tracks who&apos;s home, counts how many people are in the house, and triggers automations based on that. Here&apos;s how it works and why I built it the way I did.

## The iPhone Problem

Home Assistant tracks devices using a `device_tracker` entity. For phones, this usually means checking if the device is connected to your Wi-Fi network. When the phone is on Wi-Fi, it&apos;s `home`. When it disconnects, it&apos;s `not_home`. Simple enough.

Except iPhones don&apos;t play nice with this approach.

When an iPhone&apos;s screen turns off, iOS aggressively manages the Wi-Fi radio to save battery. The phone enters a doze state where it stops responding to network pings and broadcasts. It&apos;s still technically associated with your access point, but your router (or any ping-based tracker) can&apos;t reach it. This is part of the 802.11 power save mechanism, and Apple leans into it hard to preserve battery life.

This means your phone can appear to &quot;leave&quot; your network for several minutes while you&apos;re sitting on the couch. Without a grace period, your smart home would think you left, turn off all the lights, and lock the door. Not great.

The Home Assistant community has [dealt with this extensively](https://community.home-assistant.io/t/resolving-wifi-sleep-issue-for-tracked-phones/55013). The consensus is a **15-minute grace period** before marking someone as away. That&apos;s long enough to cover the worst-case iPhone sleep cycle, but short enough that departure detection doesn&apos;t feel sluggish. Home Assistant&apos;s own [`device_tracker` documentation](https://www.home-assistant.io/integrations/device_tracker/) has the `consider_home` setting for exactly this reason.

## My Setup

I&apos;m using [UniFi](https://ui.com/) networking gear, and the [UniFi integration](https://www.home-assistant.io/integrations/unifi/) in Home Assistant provides device tracker entities for every connected client. So each iPhone in the household gets a `device_tracker` entity that reflects whether it&apos;s connected to the Wi-Fi network.

The UniFi integration is a step up from ping-based tracking because it checks the device&apos;s association state at the controller level. It knows when a device is connected even if it doesn&apos;t respond to pings. But the iPhone sleep issue still applies. The phone can disassociate from the access point entirely during deep sleep, so the 15-minute grace period is still necessary.

If you don&apos;t have UniFi gear, don&apos;t worry. You can get the same result with a simple [ping-based device tracker](https://www.home-assistant.io/integrations/ping/) or the [nmap tracker](https://www.home-assistant.io/integrations/nmap_tracker/). The only difference is that ping and nmap rely on the phone responding to network requests, which makes them slightly less reliable during iPhone sleep cycles. The 15-minute grace period handles that just fine though.

## How the System Works

The approach is pretty straightforward. For each person in the household, the system:

1. Watches their iPhone&apos;s device tracker entity from the UniFi integration
2. Waits 15 minutes after a `not_home` state before acting
3. Toggles an `input_boolean` to track their presence
4. Updates a counter that tracks how many people are home

The counter is the key piece. It lets me trigger different automations based on whether someone arrived, someone left, the last person left, or the first person came home.

Here&apos;s the full architecture:

```
┌──────────────────────────────┐
│        Per-Person Flow       │
│                              │
│  device_tracker.iphone       │
│  (from UniFi integration)    │
│         │                    │
│    ┌────┴────┐               │
│    │         │               │
│  not_home  home              │
│  (15 min)    │               │
│    │         ▼               │
│    │    Was person away?     │
│    │     │          │        │
│    │    Yes         No       │
│    │     │       (ignore)    │
│    ▼     ▼                   │
│  Turn   Turn                 │
│  OFF    ON                   │
│  bool   bool                 │
│    │     │                   │
│    ▼     ▼                   │
│  Counter  Counter            │
│  -1       +1                 │
└──────────────────────────────┘
         │
         ▼
┌──────────────────────────────┐
│     Counter-Based Events     │
│                              │
│  counter.personen_thuis      │
│         │                    │
│    ┌────┼────────┐           │
│    │    │        │           │
│    ▼    ▼        ▼           │
│  First  Someone  Last        │
│  person left    person       │
│  home           left         │
│    │    │        │           │
│    ▼    ▼        ▼           │
│  (link) (link)  Lights off   │
│                 Lock door    │
│                 (links)      │
└──────────────────────────────┘
```

## The Departure Flow

Each person has a `server-state-changed` node watching their device tracker. This node has two outputs:

- **Output 1**: The phone has been `not_home` for 15 minutes. This is a confirmed departure. The system turns off their `input_boolean` (e.g., `input_boolean.presence_florian`) and decrements the people counter.
- **Output 2**: The state changed but the 15-minute condition wasn&apos;t met. This could mean the phone reconnected to Wi-Fi within the grace period.

That second output is where the arrival detection lives.

## The Arrival Flow

When the device tracker changes state but doesn&apos;t meet the &quot;not_home for 15 minutes&quot; condition, it means the phone came back online. But I only want to count this as an arrival if the person was actually marked as away.

So the flow checks the `input_boolean` first. If it&apos;s `off` (person was marked away), it turns it back `on` and increments the counter. If it&apos;s already `on`, the person never left in the first place. It was just the iPhone doing its sleep cycle thing. No action needed.

This is important. Without this check, you&apos;d get phantom arrivals every time an iPhone reconnects after a Wi-Fi nap.

## Counting People

The `counter.personen_thuis` entity tracks how many people are home. Every departure decrements it. Every arrival increments it. A separate flow watches this counter and categorizes the change into four events:

| Event | Condition | Example use |
|---|---|---|
| Someone came home | Counter increased | Welcome home notification |
| Someone left | Counter decreased | (Available for future use) |
| Last person left | Counter hit 0 | Turn off all lights, lock door |
| First person home | Counter went from 0 to 1+ | Turn on hallway light |

The &quot;last person left&quot; event is the most useful one. When the counter hits zero, the system turns off every light in the house through a `light.alle_lampen` group entity and locks the front door. These are the two things I always want to happen when nobody&apos;s home.

The &quot;first person home&quot; and &quot;someone came home&quot; events are available as link nodes in Node-RED, so I can easily hook up new automations to them later without touching the presence flow itself.

## Why Input Booleans Instead of Just the Device Tracker

You might wonder why I use `input_boolean` entities as an intermediate step instead of working directly with the device tracker states. A few reasons:

**The grace period creates ambiguity.** During those 15 minutes, the device tracker might flip between `home` and `not_home` multiple times. The input boolean gives me a clean, debounced state. It&apos;s either on or off, no in-between.

**It decouples presence from the detection method.** If I ever switch from UniFi to something else (like the [Home Assistant Companion App](https://companion.home-assistant.io/docs/core/location) or Bluetooth-based tracking), I only need to change what drives the input boolean. Everything downstream that reads presence state keeps working.

**Manual override.** Sometimes the system gets it wrong. With an input boolean, I can manually toggle someone&apos;s presence from the Home Assistant dashboard. Can&apos;t do that with a device tracker.

## Potential Improvements

This system has been running reliably for a while, but there are a few things that could make it better:

**Faster arrival detection.** Right now, arrival is detected when the device tracker flips back to `home`. The [Home Assistant Companion App](https://companion.home-assistant.io/) on iOS can detect arrivals faster using GPS and significant location changes. Combining both sources would reduce the delay.

**Bluetooth-based tracking.** Projects like [ESPresense](https://espresense.com/) use ESP32 boards to detect Bluetooth advertisements from phones. iPhones broadcast BLE beacons more consistently than they respond to Wi-Fi pings, so this could be more reliable for detecting someone&apos;s presence without the 15-minute grace period.

**Guest handling.** The current system only tracks known household members. A simple addition would be a guest mode toggle that prevents the &quot;last person left&quot; automations from firing when guests are over.

## Getting Started

If you want to build something similar, here&apos;s what you need:

- **Home Assistant** with device tracker entities for each phone (I use the UniFi integration, but ping or nmap work too)
- **Node-RED** running as a Home Assistant add-on, or just use native Home Assistant automations. I like Node-RED for visual flows, but everything in this post can be done with regular HA automations too.
- An `input_boolean` entity per person (create these in Home Assistant under Settings &gt; Devices &amp; Services &gt; Helpers)
- A `counter` helper entity for tracking the number of people at home

Start with one person. Get the departure and arrival flow working correctly, then duplicate it for other household members. The counter and event-based automations can be added once the basic tracking is solid.

If you&apos;re into Node-RED and Home Assistant automations, you might also like my post on [smart garden watering](/blog/smart-garden-watering-node-red), which uses a similar approach for weather-based irrigation scheduling.</content:encoded></item><item><title>15 Claude Code Features You&apos;re Probably Not Using</title><link>https://florianschaal.com/blog/claude-code-hidden-features/</link><guid isPermaLink="true">https://florianschaal.com/blog/claude-code-hidden-features/</guid><description>A look at the most overlooked Claude Code features, from session teleporting and voice mode to /batch migrations and the /btw side-query trick.</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Boris Cherny, one of the engineers behind Claude Code at Anthropic, recently shared a list of features he considers hidden or underused. I went through all of them, tested a few, and was surprised how many I&apos;d never tried. Some of these are genuine workflow changers.

Here&apos;s my take on each one, with practical context on when they actually matter.

## 1. The Mobile App Has a Code Tab

Claude Code isn&apos;t just a terminal tool. The Claude app on [iOS](https://apps.apple.com/us/app/claude-by-anthropic/id6473753684) and [Android](https://play.google.com/store/apps/details?id=com.anthropic.claude) has a Code tab that gives you full Claude Code sessions from your phone. You can start a task on your laptop and pick it up from your couch.

This pairs well with Remote Control (more on that below). It&apos;s not a gimmick. I&apos;ve used it to approve permission prompts and check on long-running tasks when I&apos;m away from my desk.

## 2. Teleport Sessions Between Devices

Start a session on the web at [claude.ai](https://claude.ai), then pull it down to your local terminal with `claude --teleport`. Or use `/teleport` inside a running session to see all your active web sessions.

There&apos;s one important catch: you can pull a web session down to your terminal, but you can&apos;t push a local session up to the web. If you think you might want to move a session later, start it on the web first.

Before teleporting, Claude checks for clean git state, the correct repo, and branch availability. It&apos;s not magic, but it works reliably when those conditions are met. The [official docs](https://code.claude.com/docs/en/remote-control) cover the full details.

## 3. /loop and /schedule for Recurring Tasks

These are probably the most powerful features on this list, and I barely see anyone talk about them.

`/loop 5m check if the deployment finished` sets up a recurring task that runs every 5 minutes. It uses cron under the hood, so the minimum interval is one minute. Tasks auto-expire after 3 days, which is a nice safety net.

`/schedule` goes further. It creates cloud-based recurring jobs that keep running even after you close your laptop. Think daily code review summaries, deploy monitoring, or Slack digest generation.

A session can hold up to 50 scheduled tasks. Between `/loop` for short-lived monitoring and `/schedule` for persistent automation, there&apos;s a lot of untapped potential here.

## 4. Hooks for Lifecycle Control

[Hooks](https://code.claude.com/docs/en/hooks) let you run shell commands at specific points in Claude&apos;s lifecycle. The key events are:

- **SessionStart**: set up your environment, check prerequisites, inject context
- **PreToolUse**: inspect and block tool calls before they execute
- **PermissionRequest**: auto-approve or auto-deny permission prompts programmatically
- **Stop**: run cleanup when a session or subagent finishes

I wrote about using hooks for [physical notifications with ESPTimeCast](/blog/esptimecast-claude-code-notifications) a few days ago. That used a Stop hook to trigger an LED notification when Claude finishes a task. But hooks go much deeper than that. You can auto-format code after every file write, enforce safety policies, or route permission decisions through custom logic.

As of March 2026, Claude Code supports 21 lifecycle events with 4 handler types. The [hooks reference](https://code.claude.com/docs/en/hooks) has the full list.

## 5. Cowork Dispatch from Your Phone

If you use Claude Desktop, Dispatch lets you message a task from the Claude mobile app and have it spawn a Desktop session to handle it. It&apos;s remote control for Claude Desktop when you&apos;re away from your computer.

The setup is straightforward: [pair your mobile app with Desktop](https://support.claude.com/en/articles/13947068), then send tasks from your phone. Claude runs locally on your machine using your full environment, MCP servers, and project config.

## 6. Chrome Extension for Frontend Work

Boris calls this &quot;the most important tip&quot; for frontend development: give Claude a way to verify its output. The [Chrome extension](https://code.claude.com/docs/en/chrome) connects Claude Code to a real browser window so it can see what it built.

It works with Google Chrome and Microsoft Edge (beta). Claude can click, type, navigate, and screenshot. For frontend verification, this means Claude can build a UI, open it in the browser, check if it looks right, and fix issues without you doing anything.

You need Claude Code v2.0.73+ and the Chrome extension v1.0.36+. It&apos;s more reliable than browser MCPs, according to Boris, because it uses the native extension API rather than going through a proxy.

## 7. Desktop App with Built-in Browser

The Claude Code Desktop app can auto-start web servers and test them with its built-in browser. If you&apos;re doing frontend work and don&apos;t want to set up the Chrome extension, this is the simpler path.

It&apos;s especially useful for quick prototyping where you want Claude to spin up a dev server, make changes, and verify them visually in one flow.

## 8. Fork Sessions with /branch

Ever been deep in a Claude session and wanted to try a different approach without losing your current progress? Use `claude --resume &lt;session-id&gt; --fork-session` to create a branch of your conversation.

The fork gets its own session ID but starts with the full history of the original. The original stays untouched. It&apos;s like git branching, but for your conversations.

## 9. /btw for Side Queries

This one is clever. While Claude is working on a task, type `/btw` followed by a question. It runs a side-chain query that sees your full conversation context but doesn&apos;t interrupt the main task. The answer shows up in an overlay, and it doesn&apos;t pollute your conversation history.

The [/btw command](https://code.claude.com/docs/en/interactive-mode) is the inverse of a subagent: it sees everything Claude knows from the current session but has no tool access. Use it for quick &quot;what was that variable name again?&quot; questions while Claude is mid-refactor.

It reuses the parent conversation&apos;s prompt cache, so the cost is minimal.

## 10. Git Worktrees for Parallel Work

Running multiple Claude sessions on the same repo causes conflicts. Git worktrees solve this. Use `claude -w` or check the worktree checkbox in Claude Desktop to give each session its own isolated copy of your repo.

Each worktree gets its own branch and working directory, so multiple Claudes can edit files in parallel without stepping on each other. This is what makes `/batch` possible (see below).

I covered [skills for Claude Code](/blog/building-skills-for-claude-code) before, and worktrees pair well with custom skills that dispatch parallel work.

## 11. /batch for Parallel Migrations

`/batch` is the big one. Describe a codebase-wide change, and Claude breaks it into units, spins up one agent per unit in isolated worktrees, and runs them all in parallel.

The flow has three phases. First, Claude launches Explore agents to research the codebase and decompose the work into 5-30 self-contained units. After you approve the plan, it launches one background agent per unit. Each agent gets worktree isolation, implements its changes, runs `/simplify`, executes tests, commits, pushes, and opens a PR.

You get a status table that updates as agents complete, showing PR URLs for each unit. Boris mentions results like &quot;22/24 units landed as PRs&quot; for large migrations.

## 12. --bare for Faster SDK Startup

If you&apos;re using Claude Code programmatically (CI, scripts, SDK calls), add `--bare` to skip hooks, LSP, plugin sync, skill discovery, and auto-memory loading.

The speedup is about 14% to the first API request. More importantly, it gives you deterministic behavior. No teammate&apos;s hooks or project MCP servers will interfere with your script. The `--bare` flag is expected to become the default for `-p` calls in a future release.

Requires `ANTHROPIC_API_KEY` or an `apiKeyHelper` via `--settings`, since OAuth and keychain auth are disabled in bare mode.

## 13. --add-dir for Multi-Repo Work

When working across multiple repositories, use `--add-dir` at startup or `/add-dir` during a session to give Claude access to additional folders. This grants both visibility and write permissions.

```bash
claude --add-dir ../backend-api --add-dir ../shared-lib
```

You can also add `additionalDirectories` to your team&apos;s `settings.json` so extra folders load automatically for everyone. Since v2.1.20, Claude can even load CLAUDE.md files from additional directories when you set the `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` environment variable.

## 14. --agent for Custom Personas

Drop a `.md` file in `.claude/agents/` with YAML frontmatter defining a role, and launch it with `claude --agent &lt;name&gt;`. Your entire session becomes that specialized agent, with its own system prompt, restricted tools, and model choice.

This isn&apos;t a subagent. It replaces the default Claude Code system prompt entirely. Think of it as creating different &quot;modes&quot; for Claude: a security reviewer, a documentation writer, a migration specialist.

The [subagents docs](https://code.claude.com/docs/en/sub-agents) cover the full setup, including how to restrict which tools an agent can access.

## 15. /voice for Hands-Free Coding

Type `/voice` to toggle voice mode, then hold spacebar and speak. Claude transcribes your speech into the prompt input. You can mix voice and typing in the same message.

It works in the CLI (hold spacebar), Desktop (voice button), and iOS (enable dictation). As of March 2026, it supports 20 languages. The feature requires v2.1.69+ and a Claude.ai account, since it uses a streaming speech-to-text service that isn&apos;t available with API keys or third-party providers.

The default push-to-talk key is spacebar, but you can rebind it to a modifier combination for faster activation.

## What I&apos;m Actually Using

Out of these 15, the ones that changed my daily workflow the most are hooks, `/btw`, and `--add-dir`. Hooks because I&apos;ve built a whole [notification system](/blog/esptimecast-claude-code-notifications) around them. `/btw` because it saves me from interrupting Claude constantly. And `--add-dir` because I work across multiple repos regularly.

`/batch` and `/loop` are the ones I want to invest more time in. The potential for automated migrations and recurring monitoring is huge, but they need more structured workflows to really shine.

Pick one or two from this list and try them this week. You don&apos;t need all 15 at once. Start with whatever matches your biggest friction point.</content:encoded></item><item><title>Physical Notifications for Claude Code with ESPTimeCast</title><link>https://florianschaal.com/blog/esptimecast-claude-code-notifications/</link><guid isPermaLink="true">https://florianschaal.com/blog/esptimecast-claude-code-notifications/</guid><description>How I connected an ESP32 LED matrix clock to Claude Code using hooks and the ESPTimeCast actions API, so I never miss when a task finishes.</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>I use Claude Code all day. It runs refactors, writes tests, scaffolds entire features, sometimes for minutes at a time. The problem: I&apos;d switch to something else while waiting, then forget to check back. Notifications on my laptop are easy to miss when I&apos;m deep in another task.

So I built a physical notification. A small LED matrix clock on my desk that scrolls a message whenever Claude finishes a task. No app switching, no checking the terminal. Just a glowing reminder sitting in my peripheral vision.

## The Hardware

[ESPTimeCast](https://github.com/mfactory-osaka/ESPTimeCast) is an open-source project that turns an ESP32 or ESP8266 and a MAX7219 LED matrix into a WiFi-connected clock with weather display. It supports the full ESP32 family including the S2, C3, and S3 variants. It has a clean web UI, NTP time sync, and most importantly for this project, an HTTP API.

My setup:

- AZ-Delivery ESP32 Dev Board
- 8x32 MAX7219 LED matrix module
- 3D-printed case from Printables (with a single-layer diffuser for the best results)
- Powered via USB

Flashing takes under a minute with their [web installer](https://esptimecast.github.io). No Arduino IDE needed. After connecting it to WiFi, the clock was running within five minutes.

A tip on the case: I printed the diffuser layer at just a single layer thickness. This gave the cleanest look, enough to soften the individual LEDs without dimming the display too much. Multiple layers made it too opaque.

## The API

ESPTimeCast exposes an `/action` endpoint that accepts both GET and POST requests. You can send messages, control brightness, set timers, and more, all over your local network.

Sending a message is as simple as:

```bash
curl -X POST -d &quot;message=HELLO WORLD&quot; &quot;http://esptimecast.local/action&quot;
```

The display only supports uppercase letters, numbers, and a handful of symbols. It scrolls the text and then returns to showing the time. You can control how long the message stays with `seconds` or how many times it scrolls with `scrolls` (which defaults to `0` for infinite):

```bash
# Show for 10 seconds
curl -X POST -d &quot;message=DONE!&amp;seconds=10&quot; &quot;http://esptimecast.local/action&quot;

# Scroll 3 times then disappear
curl -X POST -d &quot;message=TASK FINISHED&amp;scrolls=3&quot; &quot;http://esptimecast.local/action&quot;
```

Short messages (8 characters or fewer) display static and centered instead of scrolling. That works nicely for quick status indicators.

## Claude Code Hooks

Claude Code has a [hooks system](https://code.claude.com/docs/en/hooks) that lets you run shell commands at specific points in its lifecycle. The event I needed: `Stop`, which fires whenever Claude finishes responding.

Hooks are configured in `~/.claude/settings.json`:

```json
{
  &quot;hooks&quot;: {
    &quot;Stop&quot;: [
      {
        &quot;matcher&quot;: &quot;&quot;,
        &quot;hooks&quot;: [
          {
            &quot;type&quot;: &quot;command&quot;,
            &quot;command&quot;: &quot;curl -s -X POST -d &apos;message=CLAUDE DONE&amp;scrolls=3&apos; &apos;http://esptimecast.local/action&apos;&quot;
          }
        ]
      }
    ]
  }
}
```

That&apos;s the entire setup. Every time Claude finishes a task, it fires a POST request to the clock. The display scrolls &quot;CLAUDE DONE&quot; three times and then goes back to showing the time.

## The Flow

Here&apos;s what happens when I give Claude a task:

```
  ┌──────────────┐
  │ You: &quot;refactor│
  │ the auth      │
  │ module&quot;       │
  └──────┬───────┘
         │
         ▼
  ┌──────────────┐
  │ Claude Code   │
  │ working...    │
  │ (you go do    │
  │ something     │
  │ else)         │
  └──────┬───────┘
         │ Stop event fires
         ▼
  ┌──────────────┐    HTTP POST      ┌──────────────┐
  │ Stop hook     │ ──────────────►  │ ESPTimeCast   │
  │ runs curl     │                  │ shows message │
  └──────────────┘                   └──────┬───────┘
                                            │ after 3 scrolls
                                            ▼
                                     ┌──────────────┐
                                     │ Back to clock │
                                     └──────────────┘
```

## Going Further

Once you have a programmable display on your desk that responds to HTTP, it&apos;s hard to stop at just one notification. A few ideas:

**Different messages for different outcomes.** The `Stop` hook receives JSON on stdin with context about what just happened. You could parse it and send different messages based on whether Claude edited files, ran tests, or hit an error.

```json
{
  &quot;hooks&quot;: {
    &quot;Stop&quot;: [
      {
        &quot;matcher&quot;: &quot;&quot;,
        &quot;hooks&quot;: [
          {
            &quot;type&quot;: &quot;command&quot;,
            &quot;command&quot;: &quot;jq -r &apos;\&quot;CLAUDE: \&quot; + (.stop_reason // \&quot;DONE\&quot;) | ascii_upcase&apos; | xargs -I{} curl -s -X POST -d &apos;message={}&amp;scrolls=3&apos; &apos;http://esptimecast.local/action&apos;&quot;
          }
        ]
      }
    ]
  }
}
```

**Protected messages for failures.** ESPTimeCast supports an `interrupt=0` parameter that prevents other messages from overwriting the current one. By default, new messages can interrupt (`interrupt=1`). Setting it to `0` protects the message, and any other requests get a `409 Conflict` until it expires:

```bash
curl -X POST -d &quot;message=BUILD FAILED&amp;seconds=30&amp;interrupt=0&quot; &quot;http://esptimecast.local/action&quot;
```

**Combine with Home Assistant.** ESPTimeCast has native [Home Assistant support](https://github.com/mfactory-osaka/ESPTimeCast#home-assistant-setup). You could trigger automations alongside the display notification, like flashing a smart light or sending a push notification to your phone.

## What I Like About This Setup

It&apos;s simple. One curl command in a hook config. No daemon, no polling, no websockets. The clock sits on my desk doing its normal clock thing, and it becomes a notification display exactly when I need it.

The physical nature of it matters too. A scrolling LED matrix in your peripheral vision registers differently than yet another software notification competing for screen space. It&apos;s the kind of ambient awareness that screens can&apos;t replicate.

## Parts List

| Part | Notes |
|------|-------|
| AZ-Delivery ESP32 Dev Board | Any ESPTimeCast-compatible board works |
| MAX7219 8x32 LED matrix | The standard 4-module chain |
| USB cable + power adapter | For power |
| 3D printed case (optional) | [Printables](https://www.printables.com/model/1344276-esptimecast-wi-fi-clock-weather-display) or [Cults3D](https://cults3d.com/en/3d-model/gadget/wifi-connected-led-matrix-clock-and-weather-station-esp8266-and-max7219) (paid STL files) |

Total cost: around €10-15 if you already have a USB power source.

## Links

- [ESPTimeCast on GitHub](https://github.com/mfactory-osaka/ESPTimeCast)
- [ESPTimeCast Web Installer](https://esptimecast.github.io)
- [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)
- [Building Skills for Claude Code](/blog/building-skills-for-claude-code) (another way to customize Claude Code)</content:encoded></item><item><title>.NET 11 Preview 1: What Caught My Eye</title><link>https://florianschaal.com/blog/dotnet-11-preview-1/</link><guid isPermaLink="true">https://florianschaal.com/blog/dotnet-11-preview-1/</guid><description>A look at the highlights from .NET 11 Preview 1, covering runtime async, CoreCLR on WebAssembly, Zstandard compression, and SDK improvements.</description><pubDate>Sat, 14 Feb 2026 00:00:00 GMT</pubDate><content:encoded>Microsoft just shipped [.NET 11 Preview 1](https://devblogs.microsoft.com/dotnet/dotnet-11-preview-1/), and there&apos;s a lot to unpack. This is a preview release, not production-ready, but it gives a clear picture of where the platform is heading. Here&apos;s what stood out to me.

## Runtime: Async Gets a Rethink

The headline feature is **Runtime Async**, a fundamental change to how async/await works under the hood. Today, every `async` method allocates a state machine object on the heap. Runtime Async moves this into the runtime itself. That means fewer allocations and real performance gains for async-heavy workloads. If you&apos;re building services that do a lot of I/O (and in .NET, that&apos;s most of us), this is worth watching.

Alongside this, **CoreCLR now runs on WebAssembly**. Previously, Blazor WebAssembly used the Mono runtime. Moving to CoreCLR brings better performance and closer parity with server-side .NET behavior. For .NET MAUI, CoreCLR is now also the default runtime on Android.

## Libraries: Practical Additions

The libraries team delivered several additions that address real-world needs:

**Zstandard compression** (`System.IO.Compression`) is now built in. Zstandard offers better compression ratios than gzip at similar speeds. If you&apos;re compressing data for storage or transit, this is a welcome addition without needing third-party packages.

**BFloat16** is a new 16-bit floating-point type built for machine learning workloads. If you&apos;re doing ML inference in .NET, this gives you a native type that matches what most ML models use internally.

**FrozenDictionary collection expressions** let you create `FrozenDictionary` and `FrozenSet` using collection expression syntax, making immutable lookup tables cleaner to write:

```csharp
FrozenDictionary&lt;string, int&gt; statusCodes = [
    new(&quot;OK&quot;, 200),
    new(&quot;NotFound&quot;, 404),
    new(&quot;InternalServerError&quot;, 500)
];
```

Other notable additions:
- **MediaTypeMap** for MIME type lookups, so you can stop hardcoding content types
- **Hard link creation APIs** via `File.CreateHardLink`
- **Happy Eyeballs** support in `Socket.ConnectAsync` for faster dual-stack connections
- **HMAC/KMAC verification methods** for cleaner cryptographic validation

## SDK and Tooling

The developer experience improvements are subtle but appreciated:

`dotnet run` now supports **interactive target framework selection**. If your project multi-targets, it&apos;ll prompt you to choose instead of failing or picking one silently.

`dotnet watch` got **hot reload for reference changes** and **configurable ports**, making the inner dev loop smoother.

`dotnet test` now accepts **positional arguments**, so `dotnet test MyTests` works instead of requiring the full `--project` flag.

## ASP.NET Core and Blazor

Several quality-of-life improvements for web developers:

**EnvironmentBoundary component** lets you conditionally render Blazor components based on the hosting environment. Great for showing debug panels only in development.

**QuickGrid OnRowClick** adds row-level click handling to the built-in grid component, which was a common request.

**Blazor WebAssembly now supports `IHostedService`**, letting you run background tasks in the browser. Think periodic data refresh or WebSocket keep-alive.

On the API side, **OpenAPI gets binary file response schema support**, and there&apos;s a new **IOutputCachePolicyProvider** interface for more flexible output caching strategies.

## Entity Framework Core

EF Core gets some solid improvements:

**Complex types and JSON columns now work with TPT/TPC inheritance**. Previously you had to choose between these features, which was a frustrating limitation.

**Single-step migration creation and application** means `dotnet ef migrations add` can now also apply the migration in one command. No more create-then-update dance.

**Azure Cosmos DB transactional batches** and **bulk execution** bring better performance for Cosmos DB workloads.

## C# Language

C# picks up **collection expression arguments**, extending the collection expression syntax to support constructor parameters. This makes initializing collections with specific capacities or comparers more natural.

## F# Gets Faster

The F# compiler now has **parallel compilation enabled by default**, which should noticeably speed up build times for larger F# projects. There&apos;s also faster computation expression compilation and new FSI options for type-checking without execution.

## My Take

This preview is less about flashy new features and more about infrastructure improvements that make everyday .NET development better. Runtime Async and CoreCLR on WebAssembly are the big architectural bets, while the library and SDK additions address real friction points.

For those of us in the .NET ecosystem, the Zstandard support, EF Core inheritance fixes, and SDK quality-of-life improvements are the kind of things that reduce the number of workarounds in production code. That&apos;s always welcome.

You can try it out today by [downloading .NET 11 Preview 1](https://dotnet.microsoft.com/download/dotnet/11.0) or updating your global.json. Just don&apos;t deploy it to production yet.</content:encoded></item><item><title>Building Skills for Claude Code: A Practical Guide</title><link>https://florianschaal.com/blog/building-skills-for-claude-code/</link><guid isPermaLink="true">https://florianschaal.com/blog/building-skills-for-claude-code/</guid><description>How to build custom skills that teach Claude Code repeatable workflows, from folder structure and YAML frontmatter to testing and iteration.</description><pubDate>Sat, 14 Feb 2026 00:00:00 GMT</pubDate><content:encoded>If you&apos;ve used Claude Code for a while, you&apos;ve probably noticed a pattern: you keep explaining the same workflows over and over. &quot;When I say deploy, run these three commands in order.&quot; &quot;Always check for linting errors before committing.&quot; &quot;Use our team&apos;s PR template, not the default.&quot;

Skills solve this. A skill is a set of instructions, packaged as a simple folder, that teaches Claude how to handle specific tasks or workflows. Instead of re-explaining your preferences in every conversation, you teach Claude once and benefit every time.

Anthropic recently published [The Complete Guide to Building Skills for Claude](https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf?hsLang=en), and I&apos;ve been building skills for my own workflows for a while now. This post walks through the process of creating a skill from scratch, covering the concepts that actually matter in practice.

## What Is a Skill?

At its core, a skill is a folder containing a `SKILL.md` file with YAML frontmatter and Markdown instructions. That&apos;s the minimum. Optionally, you can include scripts, reference documentation, and templates:

```
your-skill-name/
├── SKILL.md                    # Required - main skill file
├── scripts/                    # Optional - executable code
│   ├── process_data.py
│   └── validate.sh
├── references/                 # Optional - documentation
│   ├── api-guide.md
│   └── examples/
└── assets/                     # Optional - templates, etc.
    └── report-template.md
```

The key insight from Anthropic&apos;s guide is **progressive disclosure**. Skills use a three-level system:

1. **YAML frontmatter** is always loaded in Claude&apos;s system prompt. This is how Claude decides whether to use the skill at all.
2. **SKILL.md body** is loaded only when Claude thinks the skill is relevant. Contains the full instructions.
3. **Linked files** are additional files in the skill directory that Claude can discover and read as needed.

This matters because context window space is limited. A well-structured skill loads the minimum information needed and pulls in details on demand.

## Building a Skill: Step by Step

Let&apos;s build a practical skill: a **changelog generator** that creates consistent, well-formatted changelogs from git history. This is the kind of repetitive task that benefits most from a skill. It has a clear workflow, specific formatting requirements, and you want it done the same way every time.

### Step 1: Define the Use Case

Before writing any instructions, the guide recommends identifying 2-3 concrete use cases. For our changelog skill:

```
Use Case: Generate Release Changelog
Trigger: User says &quot;generate changelog&quot;, &quot;what changed since last
release&quot;, or &quot;prepare release notes&quot;
Steps:
1. Identify the last release tag
2. Collect commits since that tag
3. Categorize changes (features, fixes, breaking changes)
4. Format as markdown changelog
Result: Well-structured changelog ready for the release
```

Ask yourself:
- What does the user want to accomplish?
- What multi-step workflow does this require?
- Which tools are needed (built-in shell commands, git)?
- What domain knowledge or formatting rules should be embedded?

### Step 2: Write the Frontmatter

The YAML frontmatter is the most important part. It&apos;s the only thing Claude sees when deciding whether to load your skill. Get this wrong and your skill either never triggers or triggers on everything.

```yaml
---
name: changelog-generator
description: Generates well-formatted changelogs from git commit
  history. Use when user asks to &quot;generate changelog&quot;, &quot;prepare
  release notes&quot;, &quot;what changed since last release&quot;, or wants to
  summarize recent commits for a release.
---
```

A few rules to follow:

**`name`** must be kebab-case. No spaces, no capitals, no underscores.

```yaml
# Good
name: changelog-generator

# Bad - these will all fail
name: Changelog Generator
name: changelog_generator
name: ChangelogGenerator
```

**`description`** must include both **what the skill does** and **when to use it**. This is the pattern Anthropic recommends:

```
[What it does] + [When to use it] + [Key capabilities]
```

Here&apos;s the difference between descriptions that work and ones that don&apos;t:

```yaml
# Too vague - Claude won&apos;t know when to trigger this
description: Helps with changelogs.

# Missing triggers - Claude won&apos;t match user phrases
description: Creates sophisticated multi-format changelogs
  with categorization support.

# Good - specific about what AND when
description: Generates well-formatted changelogs from git commit
  history. Use when user asks to &quot;generate changelog&quot;, &quot;prepare
  release notes&quot;, or wants to summarize recent commits for a release.
```

The description has a 1024 character limit, and XML angle brackets (`&lt;` `&gt;`) are forbidden for security reasons.

### Step 3: Write the Instructions

After the frontmatter comes the actual instruction body in Markdown. Here&apos;s the complete `SKILL.md` for our changelog skill:

```markdown
---
name: changelog-generator
description: Generates well-formatted changelogs from git commit
  history. Use when user asks to &quot;generate changelog&quot;, &quot;prepare
  release notes&quot;, &quot;what changed since last release&quot;, or wants to
  summarize recent commits for a release.
---

# Changelog Generator

## Instructions

### Step 1: Identify the Version Range
Run `git tag --sort=-version:refname` to find the latest release
tag. If no tags exist, use the initial commit as the starting point.

Confirm the version range with the user before proceeding:
&quot;I&apos;ll generate the changelog from [last tag] to HEAD. OK?&quot;

### Step 2: Collect Commits
Run `git log [last-tag]..HEAD --oneline --no-merges` to get the
commit list.

### Step 3: Categorize Changes
Group commits into these categories based on conventional commit
prefixes:

- **Breaking Changes** - commits with `BREAKING CHANGE` in the
  body or `!` after the type
- **Features** - commits starting with `feat:`
- **Bug Fixes** - commits starting with `fix:`
- **Other Changes** - everything else (chore, docs, refactor, etc.)

If commits don&apos;t follow conventional commit format, categorize
by reading the commit message content.

### Step 4: Format the Changelog
Use this exact format:

## [version] - YYYY-MM-DD

### Breaking Changes
- Description of breaking change ([commit-hash])

### Features
- Description of feature ([commit-hash])

### Bug Fixes
- Description of fix ([commit-hash])

### Other Changes
- Description of change ([commit-hash])

Rules:
- Rewrite commit messages to be human-readable (not just the raw
  commit message)
- Include the short commit hash as a reference
- Omit empty categories
- Sort entries within each category alphabetically

### Step 5: Present and Confirm
Show the generated changelog to the user. Ask if they want to:
1. Save it to CHANGELOG.md (prepend to existing content)
2. Copy it for use elsewhere
3. Adjust any entries

## Common Issues

### No Git Tags Found
If `git tag` returns nothing:
1. Ask the user for a starting commit or date
2. Use `git log --after=&quot;YYYY-MM-DD&quot;` as an alternative

### Non-Standard Commit Messages
If commits don&apos;t follow any convention:
- Group by file paths changed (feature area)
- Use the commit message as-is but clean up formatting
```

Notice a few things about the instruction style:

**Commands are specific and actionable.** Instead of &quot;check the git history,&quot; the skill says exactly which command to run: `git log [last-tag]..HEAD --oneline --no-merges`.

**The format is explicit.** Rather than &quot;format it nicely,&quot; there&apos;s a literal template showing the expected output structure.

**Error handling is included.** The &quot;Common Issues&quot; section tells Claude what to do when things don&apos;t go as expected: no tags found, messy commit messages, etc.

**There&apos;s a confirmation step.** Step 1 asks the user to confirm before proceeding, and Step 5 gives options for what to do with the result. This keeps the user in control.

## The Three Skill Categories

Anthropic identifies three main categories of skills. Understanding which one you&apos;re building helps you choose the right patterns.

### Document and Asset Creation

Creating consistent output: documents, code, designs, reports. The changelog skill above falls into this category.

Key techniques:
- Embedded formatting standards
- Template structures for consistent output
- Quality checklists before finalizing

### Workflow Automation

Multi-step processes that benefit from consistent methodology, including coordination across multiple tools.

Example: a sprint planning skill that fetches project status, analyzes capacity, suggests priorities, and creates tasks. All in a specific sequence with validation at each step.

Key techniques:
- Step-by-step workflow with validation gates
- Templates for common structures
- Built-in review and improvement suggestions

### MCP Enhancement

If you&apos;re already using MCP servers, skills act as a knowledge layer on top. MCP provides the tools (connectivity to services like Linear, Notion, GitHub), while skills provide the recipes (how to use those tools effectively).

Think of it like a professional kitchen: MCP gives you the equipment and ingredients, skills give you the recipes.

## Writing Instructions That Actually Work

After iterating on several skills, here are the patterns that make the biggest difference:

### Be Specific, Not Vague

```markdown
# Good
Run `python scripts/validate.py --input {filename}` to check
data format.
If validation fails, common issues include:
- Missing required fields (add them to the CSV)
- Invalid date formats (use YYYY-MM-DD)

# Bad
Validate the data before proceeding.
```

The bad version gives Claude no actionable information. The good version tells it exactly what to run and what to expect.

### Use Progressive Disclosure

Keep the main `SKILL.md` focused on core instructions. Move detailed documentation into `references/` files and link to them:

```markdown
Before writing queries, consult `references/api-patterns.md` for:
- Rate limiting guidance
- Pagination patterns
- Error codes and handling
```

This prevents your skill from consuming excessive context window space. Anthropic recommends keeping `SKILL.md` under 5,000 words.

### Include Error Handling

```markdown
## Common Issues

### MCP Connection Failed
If you see &quot;Connection refused&quot;:
1. Verify MCP server is running: Check Settings &gt; Extensions
2. Confirm API key is valid
3. Try reconnecting: Settings &gt; Extensions &gt; [Service] &gt; Reconnect
```

Without error handling instructions, Claude will either guess at solutions or ask the user what to do. Both of which defeat the purpose of having a skill.

### Fight Model Laziness

Claude sometimes takes shortcuts on complex tasks. The guide suggests adding explicit encouragement:

```markdown
## Performance Notes
- Take your time to do this thoroughly
- Quality is more important than speed
- Do not skip validation steps
```

This might feel odd, but it genuinely helps with multi-step workflows where Claude might otherwise cut corners.

## Testing Your Skill

The guide recommends testing across three areas:

### 1. Triggering Tests

Does your skill load at the right times?

```
Should trigger:
- &quot;Generate a changelog for the release&quot;
- &quot;What changed since v2.1.0?&quot;
- &quot;Prepare release notes&quot;

Should NOT trigger:
- &quot;What&apos;s the weather?&quot;
- &quot;Help me write Python code&quot;
- &quot;Create a git branch&quot;
```

A useful debugging technique: ask Claude &quot;When would you use the changelog-generator skill?&quot; It&apos;ll quote the description back and explain its reasoning. If something is missing, adjust the description.

### 2. Functional Tests

Does the skill produce correct output? Run the same request 3-5 times and compare results for consistency. Check that:
- Outputs match the expected format
- All steps in the workflow execute
- Error handling works when things go wrong

### 3. Performance Comparison

Compare results with and without the skill enabled. A good skill should reduce the number of back-and-forth messages, eliminate failed tool calls, and produce more consistent output.

## Iteration Signals

Skills are living documents. Watch for these signals:

**Undertriggering** means the skill doesn&apos;t load when it should. Users have to manually invoke it. Fix: add more trigger phrases and keywords to the description.

**Overtriggering** means the skill loads for unrelated queries. Fix: add negative triggers to the description (&quot;Do NOT use for simple data exploration&quot;) and be more specific about scope.

**Instructions not followed** means Claude loads the skill but doesn&apos;t follow the instructions. Common causes:
- Instructions are too verbose (use bullet points, not paragraphs)
- Critical instructions are buried in the middle (put them at the top)
- Language is ambiguous (&quot;Make sure to validate things properly&quot; vs &quot;CRITICAL: Before proceeding, verify that the project name is non-empty&quot;)

## Distribution

Once your skill works, there are a few ways to share it:

**Personal use:** Place the skill folder in your Claude Code skills directory (`~/.claude/skills/`) or upload it via Claude.ai Settings &gt; Capabilities &gt; Skills.

**Team use:** Admins can deploy skills workspace-wide with automatic updates and centralized management.

**Open source:** Host on GitHub with a clear README, installation instructions, and example usage. Note that the README is for *human readers*, separate from the `SKILL.md` that Claude reads.

Skills are also available via the API for programmatic use cases, using the `/v1/skills` endpoint and `container.skills` parameter in Messages API requests.

## Quick Checklist

Before uploading your skill, run through this:

- [ ] Folder named in kebab-case
- [ ] `SKILL.md` file exists (exact spelling, case-sensitive)
- [ ] YAML frontmatter has `---` delimiters
- [ ] `name` field: kebab-case, no spaces, no capitals
- [ ] `description` includes WHAT and WHEN
- [ ] No XML tags (`&lt;` `&gt;`) anywhere in frontmatter
- [ ] Instructions are specific and actionable
- [ ] Error handling included
- [ ] Examples provided
- [ ] Tested triggering on obvious tasks
- [ ] Tested triggering on paraphrased requests
- [ ] Verified doesn&apos;t trigger on unrelated topics

## Getting Started

The investment in building a skill pays off quickly if you have workflows you repeat across conversations. The key principles are straightforward: make the frontmatter descriptive enough for Claude to know *when* to use the skill, make the instructions specific enough that Claude knows *how* to execute the workflow, and iterate based on real usage.

Start with your most repeated workflow, the one where you find yourself typing the same instructions every session. Build a skill for that, test it, and iterate. Once you have one working well, the pattern becomes second nature.

For the full guide with additional patterns and examples, check out [Anthropic&apos;s Complete Guide to Building Skills for Claude](https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf?hsLang=en). And for ready-made skills you can customize, browse the [public skills repository](https://github.com/anthropics/skills).</content:encoded></item><item><title>Smart Garden Watering with Node-RED and Home Assistant</title><link>https://florianschaal.com/blog/smart-garden-watering-node-red/</link><guid isPermaLink="true">https://florianschaal.com/blog/smart-garden-watering-node-red/</guid><description>How I built an automated irrigation system that adapts to temperature and rainfall using Node-RED, Gardena smart valves, and weather data.</description><pubDate>Sun, 25 Jan 2026 00:00:00 GMT</pubDate><content:encoded>After spending one too many summers manually turning on sprinklers during a heatwave (or worse, forgetting to), I decided it was time to automate my garden watering. The result is a Node-RED flow that evaluates weather conditions daily at sunrise and decides whether to water and for how long.

## What the System Does

The core idea is simple: water the garden based on three factors:

1. **Maximum temperature** since the last watering
2. **Days elapsed** since the last watering
3. **Total rainfall** (actual + expected) since the last watering

Every morning at sunrise, the system evaluates these conditions and either starts a watering cycle or skips the day. When it waters, it sequences through four zones one at a time to maintain adequate water pressure.

## Hardware Setup

My setup uses two types of [Gardena](https://www.gardena.com/int/products/smart-system/) smart irrigation products:

```
                         ┌─────────────────┐
                         │   Water Main    │
                         └────────┬────────┘
                                  │
              ┌───────────────────┼───────────────────┐
              │                                       │
              ▼                                       ▼
    ┌─────────────────┐                    ┌─────────────────┐
    │ Gardena Smart   │                    │  Outdoor Tap    │
    │ Gateway         │                    └────────┬────────┘
    └────────┬────────┘                             │
             │                                      ▼
    ┌────────▼────────┐                    ┌─────────────────┐
    │ 3-Valve Smart   │                    │ Bluetooth Valve │
    │ Distribution Box│                    │ (Gardena)       │
    └────────┬────────┘                    └────────┬────────┘
             │                                      │
     ┌───────┼───────┐                              ▼
     │       │       │                     ┌─────────────────┐
     ▼       ▼       ▼                     │ Gardena Water   │
   Lawn   Planter  Planter                 │ Stop + Spray    │
          Left     Right                   └─────────────────┘
          (Drip)   (Drip)                     Front Yard
```

**Backyard (via Smart Gateway):**
- **Lawn** - Standard sprinkler coverage
- **Left Planter** - Drip irrigation system
- **Right Planter** - Drip irrigation system

**Front yard (via Bluetooth valve):**
- **Front Yard** - Connected to a Gardena water stop with spray nozzle

The zones are watered sequentially rather than simultaneously because running multiple zones would drop the water pressure too much for effective coverage.

## The Decision Logic

Every day at sunrise, the system runs through this decision tree:

```
                    ┌─────────────┐
                    │   Sunrise   │
                    └──────┬──────┘
                           │
                           ▼
              ┌────────────────────────┐
              │ Days since watering &gt;4?│
              └────────────┬───────────┘
                     │           │
                    Yes          No
                     │           │
                     ▼           ▼
              ┌──────────┐ ┌─────────────────────┐
              │Reset all │ │Check max temperature│
              │counters  │ └──────────┬──────────┘
              └──────────┘            │
                                      ▼
                          ┌───────────────────────┐
                          │ Match temperature tier │
                          │ &amp; check days threshold │
                          └───────────┬───────────┘
                                      │
                                      ▼
                          ┌───────────────────────┐
                          │ Rain &lt; threshold for  │
                          │ this tier?            │
                          └───────────┬───────────┘
                                │           │
                               Yes          No
                                │           │
                                ▼           ▼
                        ┌────────────┐ ┌──────────┐
                        │Start cycle │ │Skip today│
                        └────────────┘ └──────────┘
```

## Temperature Tiers

The watering duration and conditions depend on how hot it&apos;s been:

| Max Temp | Days Since Last | Rain Threshold | Duration |
|----------|-----------------|----------------|----------|
| &gt; 31°C   | any             | &lt; 20.1mm       | 90 min   |
| 26-31°C  | ≥ 2 days        | &lt; 15.1mm       | 75 min   |
| 21-26°C  | ≥ 3 days        | &lt; 10.1mm       | 60 min   |
| 15-21°C  | ≥ 3 days        | &lt; 10.1mm       | 45 min   |
| Any      | &gt; 4 days        | - (safety)     | reset    |

**Visual representation:**

```
Duration (min)
    │
 90 ├────────────────────────────────────────────── &gt; 31°C (extreme heat)
    │
 75 ├──────────────────────────────────── 26-31°C (hot)
    │
 60 ├────────────────────────── 21-26°C (warm)
    │
 45 ├──────────────── 15-21°C (mild)
    │
  0 ├──── &lt; 15°C (skip watering)
    └────┬─────┬─────┬─────┬─────┬────► Temperature (°C)
        15    21    26    31    35
```

The logic is intentionally conservative: during extreme heat, water every day regardless of how recently you watered. During milder temperatures, wait a few days between cycles.

## Rain Tracking

Rain data comes from the [OpenWeatherMap](https://openweathermap.org/) integration in Home Assistant. The system tracks two values:

1. **Actual rainfall** - How much rain has fallen since the last watering
2. **Expected rainfall** - Today&apos;s forecast: `precipitation amount × probability`

For example, if the forecast shows 5mm with 60% probability, the expected rain is `5 × 0.6 = 3mm`.

Both values are accumulated between watering sessions and summed to determine if we&apos;ve had &quot;enough&quot; rain. This approach isn&apos;t perfect (sometimes forecasts are wrong), but it&apos;s been reliable enough in practice.

```javascript
// Simplified rain calculation
const actualRain = msg.payload; // from weather sensor
const currentTotal = parseFloat(global.get(&apos;rain_since_watering&apos;)) || 0;
const newTotal = currentTotal + actualRain;
global.set(&apos;rain_since_watering&apos;, newTotal);
```

## Temperature Tracking

Temperature tracking follows a similar pattern, but we only care about the **maximum** temperature since the last watering:

```javascript
// Temperature tracking logic
const currentTemp = msg.payload;
const storedMax = parseFloat(global.get(&apos;max_temp_since_watering&apos;)) || 0;

if (currentTemp &gt; storedMax) {
    global.set(&apos;max_temp_since_watering&apos;, currentTemp);
}
```

This runs every time the temperature sensor updates. After watering (or after 4+ days), the maximum is reset to start fresh.

## The Watering Sequence

When watering is triggered, each zone runs sequentially. Here&apos;s what a 90-minute cycle looks like:

```
Time ────────────────────────────────────────────────────────────────────►

Sunrise                                                              ~6h later
   │                                                                      │
   ▼                                                                      ▼
   ┌─────────┐
   │Evaluate │
   │Conditions│
   └────┬────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Notification: &quot;Watering started for 90 minutes&quot;                     │
   └─────────────────────────────────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Front Yard                                                          │
   │ ████████████████████████████████████████████████████████████████    │
   │ 0 ──────────────────────────────────────────────────────────► 90min │
   └─────────────────────────────────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Lawn                                                                │
   │ ████████████████████████████████████████████████████████████████    │
   │ 0 ──────────────────────────────────────────────────────────► 90min │
   └─────────────────────────────────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Left Planter (drip)                                                 │
   │ ████████████████████████████████████████████████████████████████    │
   │ 0 ──────────────────────────────────────────────────────────► 90min │
   └─────────────────────────────────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Right Planter (drip)                                                │
   │ ████████████████████████████████████████████████████████████████    │
   │ 0 ──────────────────────────────────────────────────────────► 90min │
   └─────────────────────────────────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Notification: &quot;Watering stopped&quot;                                    │
   └─────────────────────────────────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Reset: days counter, max temp, rain accumulator                     │
   └─────────────────────────────────────────────────────────────────────┘
```

A full 90-minute cycle across all four zones takes 6 hours to complete. The morning start time ensures everything is done by early afternoon.

## Node-RED Flow Overview

The flow uses these key node types:

**Triggers:**
- `state-change` nodes watching `sun.sun` for sunrise
- `state-change` nodes watching temperature and rain sensors

**Logic:**
- `function` nodes for calculations and threshold comparisons
- `switch` nodes for routing based on conditions
- `current-state` nodes to check sensor values

**Actions:**
- `call-service` nodes to turn valves on/off
- `delay` nodes for watering duration
- `api-call-service` for mobile notifications

**Flow control:**
- `link-in` and `link-out` nodes for the reset sequence

Here&apos;s a simplified version of the main evaluation function:

```javascript
const maxTemp = parseFloat(global.get(&apos;max_temp_since_watering&apos;)) || 0;
const daysSince = parseInt(global.get(&apos;days_since_watering&apos;)) || 0;
const totalRain = parseFloat(global.get(&apos;rain_since_watering&apos;)) || 0;

// Safety reset after 4+ days
if (daysSince &gt; 4) {
    return [null, null, null, null, { payload: &apos;reset&apos; }];
}

// Extreme heat: &gt;31°C, any days, &lt;20.1mm rain
if (maxTemp &gt; 31 &amp;&amp; totalRain &lt; 20.1) {
    return [{ payload: 90 }, null, null, null, null]; // 90 min
}

// Hot: 26-31°C, 2+ days, &lt;15.1mm rain
if (maxTemp &gt; 26 &amp;&amp; maxTemp &lt;= 31 &amp;&amp; daysSince &gt;= 2 &amp;&amp; totalRain &lt; 15.1) {
    return [null, { payload: 75 }, null, null, null]; // 75 min
}

// Warm: 21-26°C, 3+ days, &lt;10.1mm rain
if (maxTemp &gt; 21 &amp;&amp; maxTemp &lt;= 26 &amp;&amp; daysSince &gt;= 3 &amp;&amp; totalRain &lt; 10.1) {
    return [null, null, { payload: 60 }, null, null]; // 60 min
}

// Mild: 15-21°C, 3+ days, &lt;10.1mm rain
if (maxTemp &gt; 15 &amp;&amp; maxTemp &lt;= 21 &amp;&amp; daysSince &gt;= 3 &amp;&amp; totalRain &lt; 10.1) {
    return [null, null, null, { payload: 45 }, null]; // 45 min
}

// Skip watering
return null;
```

Each output connects to a different watering duration flow.

## Lessons Learned After One Season

After running this system through a full summer, I&apos;ve made several adjustments:

**Temperature thresholds needed tweaking.** My initial thresholds were too conservative. During a 35°C+ heatwave, even 90 minutes wasn&apos;t enough for some plants. I added a manual boost option for extreme conditions.

**Drip systems need longer.** The planters with drip irrigation needed roughly 50% longer run times compared to the lawn sprinklers to achieve equivalent saturation. I adjusted the per-zone durations accordingly.

**Rain prediction is imperfect.** Occasionally the system skips a day expecting rain that never comes, or waters just before an unexpected shower. It&apos;s still better than manual management, but there&apos;s room for improvement.

**Notifications are essential.** Getting a mobile notification when watering starts lets me catch any issues (forgotten hose still connected, etc.) before 6 hours of watering begins.

## Known Limitations

The current system has a few gaps I&apos;m aware of:

**No mid-cycle abort.** If it starts raining heavily while watering is in progress, the system will continue until the cycle completes. Adding a rain sensor trigger to stop early is on my to-do list.

**No soil moisture feedback.** The system uses weather data as a proxy for soil conditions, but actual soil moisture sensors would provide ground truth. The Gardena ecosystem doesn&apos;t include these, so it would require additional hardware.

**Manual intervention during heatwaves.** Extended heat periods sometimes need manual watering boosts beyond what the automation provides. A &quot;heatwave mode&quot; with more aggressive settings could help.

## Future Improvements

Some enhancements I&apos;m considering:

- **Rain sensor integration** - Abort watering if significant rain is detected during a cycle
- **Soil moisture sensors** - Replace weather-based guessing with actual measurements
- **Per-zone adjustments** - Different plants have different needs; the planters could use separate logic from the lawn
- **Seasonal presets** - Automatically adjust thresholds based on time of year

## Was It Worth It?

Automating garden watering with Node-RED and [Home Assistant](https://www.home-assistant.io/) has been one of my more satisfying home automation projects. The system handles 90% of watering decisions correctly, which is enough to keep the garden healthy while freeing me from the daily chore of manual irrigation.

If you&apos;re considering a similar setup, start simple. Begin with one zone and basic temperature-based logic, then add complexity as you learn what your garden actually needs. The beauty of [Node-RED](https://nodered.org/) is that you can iterate quickly. Just drag, connect, and deploy.

For questions or suggestions, feel free to reach out on [GitHub](https://github.com/fschaal).</content:encoded></item></channel></rss>