Docs menu

Tutorial

A networked app: the Weather app

How an app that fetches from the internet is set up: network and storage grants, test data saved from real fetches, failures the hub retries, and secrets.

Weather draws three cards for the Slowboard 1085: a forecast map, today’s conditions, and airport weather with upcoming events. It fetches from several weather services on every run, so it shows how a networked slowapp is put together. In this tutorial you render Weather from its saved data, read how it is set up, and then write a small networked app of your own with the Python SDK.

You need the tools from the quickstart.

1. Render Weather from saved data

git clone https://github.com/mike-anderson/slowapp-weather
cd slowapp-weather
docker build -t slowapp-weather:dev .
PYTHONPATH=/tmp/slowapp ~/.slowapp-venv/bin/python -m slowapp_sdk fixture --image slowapp-weather:dev
ok    live                0.9 s  left: 1 cards  middle: 2 cards  right: 2 cards
        left/home: a30f5353057dcd5878d869b120e14c24bd1f752e22c5de341041b83e82828593
        middle/home: c0fc1ca99d6639a1f5943e41b22d936a1527aa19876b836e9438a54e2409206f
        middle/hours: 3a859901a41000a4f69a035a11c6c6ff0e8a624c9a1ea475e5b939f7da02c5ad
        right/home: 7e8ec2210f78bf5be979d2f38a78df8f4f912340efa2de94dc838e95e0fbd026
        right/metar: 7f65a7c1b100dd90e7d9f687b627c200e19cdc2e53d49094fb3918c109ac86bc
1/1 examples passed; previews in out/previews

This ran without any network: the example uses data saved from a real run on 1 October 2026. out/previews/weather-live-frame.png is the whole screen:

The Weather app's three cards side by side: a forecast map, today's conditions, and upcoming events with airport weather.

2. Read the manifest

[app]
id = "weather"
title = "Weather"
contract = 3
image = "ghcr.io/mike-anderson/slowapp-weather"
memory = "256m"
tmp = "64m"
timeout_s = 300
network = "egress"
data = { max_mb = 8 }            # /data/geomet: HRDPS tiles, memoised per valid hour, pruned after 2 days
cache = { tmpfs_mb = 8 }         # /cache/weather: the rest of the API cache, in RAM
imports = ["calendar:agenda"]    # P4 transitional (R3): the right third's agenda until the P5 split

[[job]]
name = "render"
expected_s = 52

[[feed]]
name = "left"
size = "slot"

[[feed]]
name = "middle"
size = "slot"

[[feed]]
name = "right"
size = "slot"

[[example]]
name = "live"
caption = "1 Oct 2026 13:15: HRDPS GFA map, WPC fronts, Victoria conditions, airports, a made-up agenda"
fixture = "fixtures/live-2026-10-01"
now = "2026-10-01T13:15:00-07:00"
feeds = { left = "452x444.k4", middle = "456x444.k4", right = "452x444.k4" }

Compared with Reminders:

  • network = "egress" lets the app make outgoing connections. These reach your home network as well as the internet.
  • data = { max_mb = 8 } gives it a private folder, /data, that survives between runs. Weather keeps downloaded map tiles there so it doesn’t fetch the same hour twice.
  • cache = { tmpfs_mb = 8 } is a scratch folder in memory, /cache, for everything else it caches. It is emptied when the container stops, and it counts against memory.
  • memory = "256m" and timeout_s = 300: map rendering needs more room and time than a reminder.
  • Three feeds, one for each card position Weather was designed for. You can put any of them in any slot that has the right size.
  • imports is transitional, as in Reminders: until Calendar is its own slowapp, Weather reads its event list from the older system.

3. Failures and retries

Weather wraps an older renderer, and its render job turns that renderer’s result into one of the outcomes the hub understands:

  • If the renderer fails or runs out of time during a live run, the job raises Transient. The hub keeps the cards it has and tries again later. A failed fetch is almost always temporary.
  • In a test render there is no network to blame, so a failure is a real bug and fails the check.
  • On success it writes a summary for the hub’s app card with an Update now button:
ctx.write_summary("ok", titles, [{"label": "Update now", "job": "render"}])

The Python SDK reference lists the exceptions and the exit code each one becomes.

4. Write a networked app

Weather predates the SDK’s helpers for fetching, so a new app can be simpler. This one shows the current temperature from Open-Meteo, a free weather API that needs no key.

temp.py:

from datetime import timedelta

from PIL import ImageDraw

import slowapp_kit as kit
from slowapp_kit import BLACK, Card

URL = "https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current=temperature_2m"


@kit.job("render")
def render(ctx):
    surface = ctx.feed("now")
    if surface is None:
        return
    url = URL.format(lat=ctx.settings["latitude"], lon=ctx.settings["longitude"])
    data = ctx.fetch("forecast.json", lambda: ctx.http_json(url))   # 1. fetch every input first
    ctx.exit_if_unchanged()                                          # 2. stop here if nothing moved
    temp = data["current"]["temperature_2m"]                         # 3. then draw
    img = ctx.canvas(surface)
    d = ImageDraw.Draw(img)
    d.fontmode = "1"
    d.text((surface.w // 2, surface.h // 2), f"{temp:.0f}°", font=kit.font("DejaVuSans-Bold.ttf", 120),
           fill=BLACK, anchor="mm")
    ctx.write_feed("now", [Card("home", img, "Temperature", peek=f"{temp:.0f}° now")],
                   valid_from=ctx.for_, valid_until=ctx.for_ + timedelta(hours=1))
    ctx.write_summary("ok", [f"{temp:.0f}° now"], [{"label": "Update now", "job": "render"}])


kit.main()

slowapp.toml:

[app]
id = "temp"
title = "Temperature"
contract = 3
image = "ghcr.io/you/slowapp-temp"
memory = "48m"
tmp = "8m"
timeout_s = 60
network = "egress"
data = { max_mb = 1 }

[settings]
latitude = { type = "number", default = 48.43, label = "Latitude" }
longitude = { type = "number", default = -123.37, label = "Longitude" }

[[job]]
name = "render"
expected_s = 5

[[feed]]
name = "now"
size = "slot"

[[example]]
name = "victoria"
fixture = "fixtures/victoria"
now = "2026-10-07T09:00:00-07:00"
feeds = { now = "452x444.k4" }

The Dockerfile is the quickstart’s, with temp.py in place of hello.py.

Fetch first, then draw

ctx.fetch(name, fn) is the key to testing a networked app:

  • In a live run it calls fn(), saves the result to /data/snapshots/<name>, and returns it.
  • In a test run it never calls fn(). It reads the saved copy from the example’s fixture instead.

So fetch every input through ctx.fetch before you draw anything. Then a live run’s /data/snapshots/ folder is, as it stands, a fixture.

ctx.http_json and ctx.http_get fit their timeout to the job’s deadline. A network error becomes Transient (the hub retries later), and HTTP 401 or 403 becomes NeedsConfig (the hub waits until a setting or secret changes).

ctx.exit_if_unchanged() ends the job with “unchanged” when every fetched input is byte-for-byte the same as last time, so the hub keeps the old cards and nothing is redrawn. Open-Meteo puts a fresh timestamp in every response, so for this API it rarely triggers.

Make the fixture

Run the job once for real on your computer, outside a container, so it saves its snapshot. Test without hardware explains the variables:

SLOWAPP_FONTS=/tmp/slowapp/fonts SLOWAPP_MANIFEST=slowapp.toml \
SLOWAPP_STATE=./data SLOWAPP_OUT=./out SLOWAPP_IN=./in SLOWAPP_JOB_LOCK=./job.lock \
SLOWAPP_FEEDS='[{"feed":"now","w":452,"h":444}]' \
PYTHONPATH=/tmp/slowapp ~/.slowapp-venv/bin/python temp.py job render

Then copy the snapshots into the fixture and render the example:

mkdir -p fixtures/victoria && cp -r data/snapshots fixtures/victoria/
docker build -t slowapp-temp:dev .
PYTHONPATH=/tmp/slowapp ~/.slowapp-venv/bin/python -m slowapp_sdk fixture --image slowapp-temp:dev --repeat
ok    victoria            0.2 s  now: 1 cards
        now/home: ca59fbe7…
1/1 examples passed; previews in out/previews

Your card’s id will differ: it depends on the temperature you fetched. Add an example for every state worth seeing: a hot day, a cold one, an API error.

5. Secrets

If an API needs a key, list it in the manifest and read it with ctx.secret:

[app]
secrets = ["api_key"]
key = ctx.secret("api_key")

In a live run this reads /run/secrets/api_key. If it is missing, the job stops with “needs config” and the hub doesn’t retry until the secret is set. In a test render there are no secrets: ctx.secret("api_key") returns the placeholder fixture-api_key, which is one more reason to fetch through ctx.fetch.

TODO(owner): how does someone running a hub set a secret for an app today? (The docs used to say slowhub app secret set, which the hub doesn’t have yet.)

Next

Updates, now and then

A short note when there is a new board, a new slowapp, or kit news. Rarely more than one a month.