This guide starts with a blank controller and Raspberry Pi and ends with a frame that returns after a reboot. Follow the sections in order. The first panel test uses an included plate, so you can prove the hardware before setting up Codex or bird observations.
Every application connection is client-initiated. The display node connects directly to the controller. A browser connects directly only from a trusted HTTP origin; otherwise, its application proxy or TLS terminator connects to the controller. The controller's configured port (8793 in the supplied example configuration) must remain on a trusted network and must not be forwarded from the public internet.
The controller and display node have different jobs. A capable Linux Raspberry Pi can run both, but the recommended framed build puts a small Pi behind the display and runs the controller elsewhere. The controller can also run on an existing Docker host; follow the Docker controller guide instead of either native controller section.
| Role | Recommended | Supported | Notes |
|---|---|---|---|
| Controller | Existing Apple silicon Mac or Ubuntu Server 24.04 LTS computer | macOS with launchd; 64-bit Ubuntu 24.04 with systemd; 64-bit Raspberry Pi OS Bookworm or later with systemd | A Raspberry Pi 4 with 4GB is the smallest recommended dedicated controller. Docker installation is documented separately. |
| Display | Raspberry Pi Zero 2 W with pre-soldered 40-pin header and Raspberry Pi OS Lite 64-bit | Raspberry Pi OS Bookworm or later on a 40-pin Raspberry Pi | Supports the 7.3-inch PIM773 at 800x480 and 13.3-inch PIM774 at 1600x1200. |
The setup command detects launchd or systemd, but detection does not mean every operating system has been tested. Other systems may work. Contributions that document and test them are welcome.
Pimoroni lists PIM773 and PIM774 as compatible with every 40-pin Raspberry Pi,
including Zero variants. A Zero without a header requires soldering. The Zero
2 W has a 64-bit processor and built-in 2.4 GHz Wi-Fi. See the
Inky Impression product page and
Raspberry Pi Zero 2 W specifications.
The application reads the panel geometry from Pimoroni's EEPROM; no display
model or resolution setting belongs in config.toml.
The supported topology remains one active display node per controller. Multiple nodes can read the same catalog, but the controller's fetch and success heartbeats describe the display role as a whole rather than identifying each physical panel independently.
You need:
- the framed-display parts in the hardware guide;
- one controller from the support table;
- a computer with a microSD reader;
- a GitHub connection to clone this public repository;
- a ChatGPT plan that includes Codex, or an OpenAI API key with separate API billing;
- coordinates, or a supported postal-code lookup, for location-based discovery; and
- administrative access on both computers.
The controller requires Python 3.11 or newer, git, rsync, uv, and Codex
CLI. The display requires git, rsync, and Pimoroni's Python environment.
| From | To | Purpose |
|---|---|---|
| Controller | Internet HTTPS (TCP 443) | Codex, internet-backed observation services and geocoder, licensed references, and configured research sources |
| Controller | BirdNET-Go HTTP(S) endpoint | Read-only summaries from an explicitly configured self-hosted station |
| Display node | Configured controller TCP port (8793 in the supplied example) | Health and catalog reads, image downloads, and narrow display-health reports |
| Trusted browser application | Same-origin proxy, HTTPS endpoint, or controller HTTP port for an HTTP application | Optional read-only catalog and image downloads; an exact allowed origin is required when the request is cross-origin |
| Application proxy or TLS terminator | Configured controller TCP port (8793 in the supplied example) | Server-side catalog and image requests; keep this hop on the trusted network |
| Setup computer | Display node SSH (TCP 22) | Installation, updates, and troubleshooting |
The two computers do not have to share a subnet. They must be routable to each other, and Wi-Fi client isolation must not block the display from reaching the controller. Give the controller a stable DNS name or DHCP reservation because the display stores its URL. The display itself may use ordinary DHCP because it initiates every application connection.
Do not expose the configured controller port to the public internet. The built-in server is an unauthenticated LAN service. It serves the active catalog and accepts only narrow display-health reports; it does not provide user authentication. Use a VPN or an authenticated TLS reverse proxy if traffic must cross an untrusted network.
The example configuration sets controller.bind_host = "0.0.0.0", which
listens on every interface of the controller host. On a multi-homed
controller, set bind_host to the specific LAN address the display uses so
the service is not offered on other networks.
Inky does not include a standalone browser application. This section is for an
operator-supplied application that consumes Inky's read-only catalog API or
frames its narrow featured-plate embed.
Both permissions are disabled by default. To let a trusted web application
fetch the active catalog and its images directly, list its exact origin under
[controller]:
[controller]
cors_allowed_origins = ["https://frame.example.test"]An origin contains only the scheme, host, and optional port. Inky rejects
wildcards, credentials, paths, queries, and fragments. Use an ASCII hostname
or the canonical form of an IP address. Internationalized and punycode
hostnames are not supported. A matching origin can read GET /v1/catalog and
the active catalog's portrait and display PNGs under GET /v1/assets/. Other
catalog files, inactive plates, health, the featured-plate embed, and display
telemetry do not receive cross-origin access. Requests from other origins
receive the normal response without an Access-Control-Allow-Origin header.
Framing is a separate permission. List each trusted parent origin explicitly:
[controller]
embed_allowed_origins = ["https://home.example.test"]The controller adds those origins to the embed's Content Security Policy
frame-ancestors directive. CSP does not reliably match IP-address sources, so
framing origins must use DNS hostnames. Underscores and trailing dots are also
rejected. Unlisted sites cannot frame the embed. Keeping CORS and framing
separate means an existing catalog reader does not silently gain permission to
present controller content inside its pages.
The browser catalog includes active species identity, approval time, image paths and checksums, and any available observation count and latest-detection time. It does not include provider names, raw observations, locations, credentials, or private provider state. Browser reads never refresh the physical display's health signals. The server also verifies an active image's catalog checksum before it sends the file.
This setting does not add authentication, TLS, or public-internet safety. Keep the controller private. Prefer a same-origin application proxy, which needs no CORS permission and can authenticate the browser before making a server-side request to Inky. Direct public-HTTPS-to-local-network behavior varies by browser: the browser may block mixed content, ask for local-network permission, or allow only specific local address forms. A VPN does not make those browser rules consistent. Terminate HTTPS for the controller or use the same-origin proxy when you need a portable path.
Direct cross-origin access through cookie, session, client-certificate, or HTTP
Authorization proxies is not compatible with this interface. Inky does not
permit credentialed CORS requests and does not implement CORS or Private
Network Access preflight. Standard catalog GET requests use no custom request
headers. The service logs each request locally with the source IP address,
request path and query, and HTTP status. It does not log request headers or send
browser analytics to an external service. Normal systemd journal or macOS log
retention applies.
Homepage can show Inky with its stock service
widgets; no Homepage fork, custom JavaScript, or Inky-specific plugin is
required. Its customapi widget fetches through the Homepage server, while its
iframe widget loads in the browser.
For a compact status card, add one customapi widget:
- Birds:
- Inky Bird Frame:
icon: mdi-bird
href: https://github.com/veteranbv/inky-bird-frame
description: Observation-driven field-journal plates
siteMonitor: http://inky-controller:8793/v1/catalog
widget:
type: customapi
url: http://inky-controller:8793/health
refreshInterval: 300000
mappings:
- field: active_species
label: Active
format: number
- field: approved_species
label: Plates
format: number
- field: version
label: Version
format: textFor the featured-plate card, use Homepage's multiple-widget form and add the
iframe after the same status widget:
- Birds:
- Inky Bird Frame:
icon: mdi-bird
href: https://github.com/veteranbv/inky-bird-frame
description: Observation-driven field-journal plates
siteMonitor: http://inky-controller:8793/v1/catalog
widgets:
- type: customapi
url: http://inky-controller:8793/health
refreshInterval: 300000
mappings:
- field: active_species
label: Active
format: number
- field: approved_species
label: Plates
format: number
- field: version
label: Version
format: text
- type: iframe
src: https://inky.example.test/v1/embed/featured-plate
classes: h-60
allowScrolling: no
referrerPolicy: no-referrerReplace the names and URLs with addresses reachable from the Homepage server
and browser. siteMonitor uses /v1/catalog, not /health, because the
catalog route fails closed when active catalog state is unavailable. Inky
answers Homepage's HEAD readiness probe without transferring the catalog.
The stats request is server-side and needs no CORS permission. Polling every
five minutes avoids Homepage's much noisier default while keeping the counts
useful.
The optional image needs a browser-reachable HTTPS route for the controller. A
true same-origin proxy beneath the Homepage origin works with the default
frame-ancestors 'self' policy. For a separate controller origin, as shown
above, add the Homepage origin to embed_allowed_origins. Make sure a reverse
proxy forwards both /v1/embed/featured-plate and /v1/assets/, and does not
replace the controller's CSP with a conflicting frame-denial header. The embed
uses a relative asset URL so those routes can share a direct origin or the same
proxy path prefix. Keep the route private; use authentication or a VPN before
traffic crosses an untrusted boundary. Allowing the origin does not secure the
controller. The iframe refreshes every 15 minutes and displays the upright
portrait, never the rotated hardware image. The embed refreshes itself, so do
not add Homepage's refreshInterval option. Homepage 2.1.2 accumulates iframe
timers when that option is enabled. That release also drops its documented
iframe name setting before rendering, so the example omits an ineffective
setting. This is an upstream accessibility limitation: Homepage 2.1.2 renders
the stock iframe without an accessible frame name. The embedded document itself
still has a descriptive title, and its image has alternative text.
Whenever any active entry has a valid detection time, the featured plate comes
from that set: newest detection wins, with approval time and taxon ID breaking
ties. Only when no active entry has a detection time does newest approval become
the fallback. BirdWeather, BirdNET-Go, and Bird Buddy are currently the only
sources that contribute those times, so this is not a universal
latest-observation view and does not claim to match the plate currently on the
frame. The embed exposes only the selected approved bird identity and image. A
valid empty catalog shows a quiet empty state; invalid or unavailable catalog
state returns 503.
Choose the macOS or Linux path below. Keep the source checkout. Native
installations use an explicit GitHub release tag; future updates fetch the
selected tag and run the same setup command again. Replace vX.Y.Z in the
examples with the exact release you intend to install.
Install prerequisites with Homebrew:
brew install git rsync uv
brew install --cask codexOpenAI also publishes npm packages and direct release binaries. See the official Codex CLI installation options.
Clone and prepare the project:
git clone https://github.com/veteranbv/inky-bird-frame.git
cd inky-bird-frame
INKY_VERSION=vX.Y.Z
git checkout --detach "$INKY_VERSION"
uv sync --extra controller --locked
mkdir -p "$HOME/Library/Application Support/Inky Bird Frame"
cp config.example.toml \
"$HOME/Library/Application Support/Inky Bird Frame/config.toml"
chmod 600 "$HOME/Library/Application Support/Inky Bird Frame/config.toml"Edit the private file:
open -t "$HOME/Library/Application Support/Inky Bird Frame/config.toml"At minimum, set:
- one discovery location from
zip_code,postal_codepluscountry_code, orlatitudepluslongitudewhen iNaturalist or eBird is enabled; controller.codex_pathto the output ofcommand -v codex;- optionally,
controller.codex_modelto a model you have validated for source-backed profile research, image generation, and review (omit it to use Codex's configured default, and run one foreground generation after changing the pin); display_node.controller_urlto a name or address the Pi can reach, such ashttp://bird-controller.local:8793; and- any paths that should live outside the configuration directory.
Sign in with the account that will run the LaunchAgents:
codex login
codex login statusOpenAI recommends ChatGPT sign-in to use Codex through a Plus, Pro, Business, Edu, or Enterprise plan. API-key login is supported but billed separately. See Codex authentication.
Validate, preview, install, and diagnose:
CONFIG="$HOME/Library/Application Support/Inky Bird Frame/config.toml"
uv run inky-bird-frame config validate --config "$CONFIG"
uv run inky-bird-frame setup controller --config "$CONFIG"
uv run inky-bird-frame setup controller --config "$CONFIG" --yes
uv run inky-bird-frame doctor controller --config "$CONFIG"The preview lists changes and performs no installation. --yes copies a managed
runtime to ~/Services/inky-bird-frame, installs its locked controller
environment, creates LaunchAgents, loads them, and verifies them with
launchctl.
LaunchAgents begin when this macOS user logs in. They survive application updates and restart after logout/login or reboot. For unattended recovery, the Mac must also power on after an outage and reach that user session. Newer supported desktop Macs expose System Settings > Energy > Start up when power is connected; Apple documents the hardware and OS requirements in Turn on a Mac without pressing its power button.
The commands below are for Debian-family systemd hosts. Start with a 64-bit Ubuntu Server 24.04 LTS or Raspberry Pi OS Bookworm-or-later installation.
sudo apt update
sudo apt install -y git rsync npm pipx
pipx ensurepath
source "$HOME/.profile"
pipx install uv
mkdir -p "$HOME/.local"
npm config set prefix "$HOME/.local"
npm install -g @openai/codex
export PATH="$HOME/.local/bin:$PATH"Add export PATH="$HOME/.local/bin:$PATH" to the account's shell profile if it
is not already present. The installer stores the absolute Codex path in service
configuration, so later service starts do not depend on the interactive PATH.
Clone and prepare the project:
git clone https://github.com/veteranbv/inky-bird-frame.git
cd inky-bird-frame
INKY_VERSION=vX.Y.Z
git checkout --detach "$INKY_VERSION"
uv sync --extra controller --locked
mkdir -p "$HOME/.config/inky-bird-frame"
cp config.example.toml "$HOME/.config/inky-bird-frame/config.toml"
chmod 600 "$HOME/.config/inky-bird-frame/config.toml"
nano "$HOME/.config/inky-bird-frame/config.toml"Set the same minimum fields listed in the macOS section. On a headless controller, use OpenAI's device-code flow:
codex login --device-auth
codex login statusDevice-code login must be permitted in the ChatGPT account or workspace. The official headless login guide documents that requirement and fallback methods.
Validate, preview, install, and diagnose:
CONFIG="$HOME/.config/inky-bird-frame/config.toml"
uv run inky-bird-frame config validate --config "$CONFIG"
uv run inky-bird-frame setup controller --config "$CONFIG"
uv run inky-bird-frame setup controller --config "$CONFIG" --yes
uv run inky-bird-frame doctor controller --config "$CONFIG"The installer creates a persistent inky-bird-frame-controller.service plus
refresh, generation, and optional notification/publication timers. It enables
them for boot and verifies their state through systemd. Setup performs one
successful refresh before installing the units, then each timer schedules its
first run relative to activation and continues at the configured interval.
Installer progress and any sudo prompt remain visible. See the official
systemd.timer manual.
Use the Docker controller guide on an AMD64 or ARM64 Docker host. The normal path pulls the published GHCR image; it does not build from source. After the controller health check passes, continue at Prepare the display Pi.
Run a read-only discovery, then refresh the active catalog:
IBF="$HOME/Services/inky-bird-frame/.venv/bin/inky-bird-frame"
"$IBF" discover --config "$CONFIG"
"$IBF" refresh --config "$CONFIG"
curl --fail --silent "http://127.0.0.1:8793/health"The health response must have "ok": true. version identifies the running
application. approved_species is the reusable catalog size. active_species
is the approved subset currently observed in the configured window and radius
or retained in the private collection. It may initially be zero in a region
whose birds have not been generated yet. The approved count comes from the last
built catalog index; the active count comes from current active-catalog state.
The endpoint does not rebuild or scan the catalog, so it stays fast as the
catalog grows. Run "$IBF" --version to identify the command-line installation
directly.
An existing controller can explicitly retain every currently approved plate
after upgrading by previewing and applying collection import-approved; see
the operations guide.
Use Raspberry Pi Imager to write Raspberry Pi OS Lite (64-bit) to the microSD card. In Imager's OS customization:
- choose a unique hostname;
- create a non-default username and password;
- configure the wall location's Wi-Fi country, SSID, and password;
- enable SSH with public-key authentication when possible; and
- set the time zone.
SSH is disabled by default unless it is enabled during imaging or later on the Pi. Raspberry Pi documents these settings in Remote access and recommends Raspberry Pi OS for most Pi use in Operating systems.
Boot the Pi, find its hostname or DHCP lease, and connect:
ssh YOUR_USER@YOUR_PI_HOSTNAME.localConfirm the network before attaching application behavior:
hostnamectl
ip address show
ip routeShut down and disconnect power before handling the boards:
sudo poweroffConceptual assembly sequence, not a pinout or dimensional drawing. Follow the Pimoroni hardware documentation supplied with the panel.
Hold the Inky board by its edges. Align the Pi's complete 40-pin header with the Inky connector, press it straight into place, and secure the Pi with the included standoffs. Do not press on the glass panel. Pimoroni ships both supported displays assembled with the required mounting hardware and documents that no soldering is needed when the Pi already has a 40-pin header.
Reconnect power and SSH back in.
Use Pimoroni's supported installer rather than modifying the system Python:
sudo apt update
sudo apt install -y git rsync
git clone https://github.com/pimoroni/inky.git "$HOME/inky"
cd "$HOME/inky"
./install.shThe installer creates ~/.virtualenvs/pimoroni and configures the required Pi
interfaces. Pimoroni recommends Raspberry Pi OS Bookworm or later and documents
the manual SPI, I2C, and dtoverlay=spi0-0cs requirements in the
Inky library installation guide.
Reboot if the installer asks.
Clone this project and install it into the same Pimoroni environment:
git clone https://github.com/veteranbv/inky-bird-frame.git
cd inky-bird-frame
INKY_VERSION=vX.Y.Z
git checkout --detach "$INKY_VERSION"
"$HOME/.virtualenvs/pimoroni/bin/python" -m pip install -e '.[inky]'
"$HOME/.virtualenvs/pimoroni/bin/inky-bird-frame" display-image \
catalog/species/12942-eastern-bluebird/display.pngThe included Eastern Bluebird should appear in portrait orientation. PIM774 uses the canonical image unchanged. PIM773 automatically contains the complete plate on its wider 800x480 aspect ratio with paper-colored margins, which appear above and below the plate when the panel is portrait-mounted. It does not crop or stretch the illustration or notes. A refresh normally takes tens of seconds and may take longer when cold. Do not continue until this test succeeds; it isolates the Pi, Python environment, 40-pin connection, and display from every controller dependency.
Create a private display configuration. Do not copy the controller's private file because it may contain credentials and a private discovery location.
cd "$HOME/inky-bird-frame"
mkdir -p "$HOME/.config/inky-bird-frame"
cp config.example.toml "$HOME/.config/inky-bird-frame/config.toml"
chmod 600 "$HOME/.config/inky-bird-frame/config.toml"
nano "$HOME/.config/inky-bird-frame/config.toml"Set display_node.controller_url to the stable controller URL and choose
display_node.rotation_mode. Set schedule.rotation_minutes to the desired
cadence. The display process uses only [display_node] and display schedule
values; leave other example values non-sensitive and disabled.
From the Pi, verify the exact controller URL before installation:
curl --fail --silent "http://YOUR_CONTROLLER:8793/health"Then validate, preview, install, and diagnose:
CONFIG="$HOME/.config/inky-bird-frame/config.toml"
INKY="$HOME/.virtualenvs/pimoroni/bin/inky-bird-frame"
"$INKY" config validate --config "$CONFIG"
"$INKY" setup display --config "$CONFIG" --source-dir "$PWD" \
--venv "$HOME/.virtualenvs/pimoroni"
"$INKY" setup display --config "$CONFIG" --source-dir "$PWD" \
--venv "$HOME/.virtualenvs/pimoroni" --yes
"$INKY" doctor display --config "$CONFIG"Setup installs and enables a systemd timer but does not force an immediate
panel update. The timer takes over after display_startup_delay_seconds. Start
the first live rotation explicitly when the controller is ready:
"$INKY" display-cycle --config "$CONFIG" --forceIf the controller reports active_species: 0, run a generation cycle on the
controller first:
# Run these on the controller with its private configuration.
"$HOME/Services/inky-bird-frame/.venv/bin/inky-bird-frame" generate \
--config /path/to/controller/config.toml
"$HOME/Services/inky-bird-frame/.venv/bin/inky-bird-frame" refresh \
--config /path/to/controller/config.tomlUse the controller's configuration path for those two controller commands, not the Pi's display configuration.
Do not call installation complete until both doctor commands report
"ready": true.
On a systemd controller:
systemctl is-enabled inky-bird-frame-controller.service
systemctl is-active inky-bird-frame-controller.service
systemctl list-timers 'inky-bird-frame-*'On the display:
systemctl is-enabled inky-bird-frame-display.timer
systemctl is-active inky-bird-frame-display.timer
systemctl list-timers inky-bird-frame-display.timerOn macOS:
launchctl print "gui/$(id -u)/com.inky-bird-frame.serve"
launchctl print "gui/$(id -u)/com.inky-bird-frame.refresh"
launchctl print "gui/$(id -u)/com.inky-bird-frame.generate"The Pi and Linux controller services return automatically after a reboot. The macOS LaunchAgents return when their owning user logs in. The e-paper panel keeps its last image without power, so a controller or network outage does not blank the frame.
| Section | Used by | Purpose |
|---|---|---|
[discovery] |
Controller | Providers, credentials, location, radius, observation window, and species cap |
[controller] |
Controller | Persistent paths, Codex executable, HTTP bind, and generation bounds |
[research] |
Controller | Bounded fallback research policy and approved domains |
[notifications] |
Controller | Optional Apprise destinations, events, retries, and noise controls |
[public_catalog] |
Controller | Optional owner-only publication of reusable approved plates |
[display_node] |
Display | Controller URL, local state path, and selection policy |
[schedule] |
Both | Refresh, generation, publication, notification, and rotation intervals |
config.example.toml contains comments, supported values, and recommended
defaults. Configuration is TOML everywhere. Keep real files outside the Git
checkout, mode 0600, and out of backups or support bundles that are shared
publicly.
Read the release notes and take a quiesced backup using the
backup and restore guide. Choose the exact target release, update
each source checkout to that tag, review the changes, and rerun the same setup
command. Setup is idempotent and replaces managed application code and native
service definitions without replacing the private configuration. Replace
vX.Y.Z below with that target tag.
INKY_VERSION=vX.Y.Z
git fetch --tags --prune
git checkout --detach "$INKY_VERSION"
uv sync --extra controller --locked # controller checkout
uv run inky-bird-frame setup controller --config "$CONFIG" --yes
uv run inky-bird-frame doctor controller --config "$CONFIG"On the display:
INKY_VERSION=vX.Y.Z
git fetch --tags --prune
git checkout --detach "$INKY_VERSION"
"$INKY" setup display --config "$CONFIG" --source-dir "$PWD" \
--venv "$HOME/.virtualenvs/pimoroni" --yes
"$INKY" doctor display --config "$CONFIG"--source-dir "$PWD" is required in the display update path because setup
points the Pimoroni environment at the managed runtime. The
explicit source directory ensures each update installs the checkout that was
just selected rather than the previous managed copy.
When the controller and display are updated separately, follow the release
notes for that upgrade. Ordinarily, update and validate the controller first.
When the release notes require a display-first update for a new catalog schema,
update display nodes before the controller. The active catalog wire format is
schema_version: 1, and a display node refuses a catalog with a newer schema
version: the cycle fails closed and the panel keeps its last image until the
display is updated. Private collection membership does not change this wire
schema.
Current display nodes report a parsed catalog fetch through POST /v1/display-fetch and a verified panel update through POST /v1/display-success. The reports contain only {"schema_version": 1}. If an
older controller rejects either POST, the display ignores the telemetry
failure and continues rotating plates. Controller and display releases before
v0.7.0 used state-changing GET telemetry that is no longer accepted. A direct
split upgrade from an older release cannot preserve heartbeat reporting: the
old role uses GET while the new role uses POST. Update the controller first,
then update the display promptly, and expect a temporary telemetry gap. If
uninterrupted heartbeat monitoring is required, update both roles to v0.7.0
first, verify POST telemetry, and then continue to the newer release.
Docker controller updates pull a published image and recreate the services. See the Docker update instructions for version pinning and rollback.
From the source checkout, run the uninstall script that matches the role and platform. Each script removes the installed services and timers but preserves private configuration, controller state, catalogs, and generated art:
./deploy/uninstall-controller.sh # macOS controller
./deploy/uninstall-controller-systemd.sh # Linux controller
./deploy/uninstall-display-local.sh # display nodeThe equivalent manual removal follows. On a systemd controller:
for unit in \
inky-bird-frame-controller.service \
inky-bird-frame-refresh.timer \
inky-bird-frame-generate.timer \
inky-bird-frame-catalog-publish.timer \
inky-bird-frame-notifications.timer; do
sudo systemctl disable --now "$unit" 2>/dev/null || true
done
sudo rm -f /etc/systemd/system/inky-bird-frame-{controller,refresh,generate,catalog-publish,notifications}.{service,timer}
sudo systemctl daemon-reloadMissing optional units are harmless. On the display:
sudo systemctl disable --now inky-bird-frame-display.timer
sudo rm -f /etc/systemd/system/inky-bird-frame-display.{service,timer}
sudo systemctl daemon-reloadOn macOS, unload each installed label and remove its matching plist from
~/Library/LaunchAgents:
for label in serve refresh generate catalog-publish notifications; do
launchctl bootout "gui/$(id -u)/com.inky-bird-frame.$label" 2>/dev/null || true
rm -f "$HOME/Library/LaunchAgents/com.inky-bird-frame.$label.plist"
doneAfter service removal, delete ~/Services/inky-bird-frame only if its managed
runtime is no longer needed. Delete the private support/configuration directory
only after saving any catalog or state you want to keep.
Start with the role's doctor command. Its JSON output gives every check a stable
ID, status, summary, and remediation. Continue with the focused runbook in
troubleshooting.md.
