Docs menu

Tutorial

Slowapps in any language

A slowapp is any container that reads a few environment variables and writes PNGs and a JSON file. Here is one in Go, with no Slowboard library at all.

The Python SDK is a convenience. The hub only cares what your container reads and writes, which is set out in the container contract. This page builds a slowapp in Go that draws one card, a red square, and passes the same checks as a Python app.

A white card with a red square in the middle.

What the container has to do

For a render job, the hub starts your image with the command job render and:

  1. sets SLOWAPP_FEEDS to a JSON list of the feeds it wants, each with a width w and height h;
  2. sets SLOWAPP_FOR to the time the cards will be shown;
  3. expects, for each feed, /out/feeds/<feed>/deck.json and one PNG per card in /out/feeds/<feed>/cards/;
  4. reads your exit code: 0 means “store what I wrote”.

Each card PNG must be exactly the requested size and indexed, with a palette that contains only the four inks: black, white, yellow, red. The contract has the full rules.

main.go

// A slowapp in Go: one card with a red square, written straight to /out.
package main

import (
	"encoding/json"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"os"
	"path/filepath"
	"time"
)

// The four inks, in this order. A card's palette may hold only these colours.
var inks = color.Palette{
	color.RGBA{0, 0, 0, 255},       // 0 black
	color.RGBA{255, 255, 255, 255}, // 1 white
	color.RGBA{255, 255, 0, 255},   // 2 yellow
	color.RGBA{255, 0, 0, 255},     // 3 red
}

// One entry of SLOWAPP_FEEDS: a feed the hub wants drawn, and its size.
type feed struct {
	Feed string `json:"feed"`
	W    int    `json:"w"`
	H    int    `json:"h"`
}

func fail(code int, msg ...any) {
	fmt.Fprintln(os.Stderr, msg...)
	os.Exit(code)
}

func main() {
	if len(os.Args) != 3 || os.Args[1] != "job" || os.Args[2] != "render" {
		fail(64, "usage: job render")
	}
	var feeds []feed
	if err := json.Unmarshal([]byte(os.Getenv("SLOWAPP_FEEDS")), &feeds); err != nil {
		fail(64, "SLOWAPP_FEEDS:", err)
	}
	t, err := time.Parse(time.RFC3339, os.Getenv("SLOWAPP_FOR"))
	if err != nil {
		fail(64, "SLOWAPP_FOR:", err)
	}
	for _, f := range feeds {
		if f.Feed != "square" {
			continue
		}
		img := image.NewPaletted(image.Rect(0, 0, f.W, f.H), inks)
		for i := range img.Pix {
			img.Pix[i] = 1 // white
		}
		s := f.H / 3
		for y := f.H/2 - s/2; y < f.H/2+s/2; y++ {
			for x := f.W/2 - s/2; x < f.W/2+s/2; x++ {
				img.SetColorIndex(x, y, 3) // red
			}
		}
		dir := filepath.Join("/out/feeds", f.Feed)
		if err := os.MkdirAll(filepath.Join(dir, "cards"), 0o755); err != nil {
			fail(1, err)
		}
		out, err := os.Create(filepath.Join(dir, "cards", "home.png"))
		if err != nil {
			fail(1, err)
		}
		if err := png.Encode(out, img); err != nil {
			fail(1, err)
		}
		out.Close()
		deck, _ := json.Marshal(map[string]any{
			"cards":       []map[string]string{{"id": "home", "title": "A red square", "kind": "home"}},
			"valid_from":  t.Format(time.RFC3339),
			"valid_until": t.Add(time.Hour).Format(time.RFC3339),
		})
		if err := os.WriteFile(filepath.Join(dir, "deck.json"), deck, 0o644); err != nil {
			fail(1, err)
		}
	}
}

Go’s PNG encoder writes a Paletted image with a four-colour palette as a 2-bit indexed PNG, which is exactly what the contract asks for.

Dockerfile

FROM golang:1.23 AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go mod init square && go build -o /square .

FROM scratch
COPY slowapp.toml /app/slowapp.toml
COPY --from=build /square /square
ENTRYPOINT ["/square"]
CMD ["job", "render"]

The final image holds the binary and the manifest, and nothing else.

slowapp.toml

[app]
id = "square"
title = "Square"
contract = 3
image = "ghcr.io/you/slowapp-square"
memory = "32m"
tmp = "4m"
timeout_s = 30
network = "none"

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

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

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

Check it

The SDK’s checks don’t care what language the image is in. With the tools set up as in the quickstart:

mkdir -p fixtures/empty && touch fixtures/empty/.keep
docker build -t slowapp-square:dev .
PYTHONPATH=/tmp/slowapp ~/.slowapp-venv/bin/python -m slowapp_sdk validate
PYTHONPATH=/tmp/slowapp ~/.slowapp-venv/bin/python -m slowapp_sdk fixture --image slowapp-square:dev --repeat
ok    default             0.1 s  square: 1 cards
        square/home: bbadc33bbda906fb98a85d8468ebb3a59e4c502b5b1f2e436161bb87fb03c100
1/1 examples passed; previews in out/previews

What this app leaves out

The Python SDK handles several things this app skips. In another language you write them yourself, following the contract:

  • Exit codes. Exit 4 when a fetch fails (the hub retries later), 5 when a setting or secret is missing, 3 when nothing changed since the last run.
  • Fixture mode. In a test render SLOWAPP_MODE is fixture, there is no network, and test data is in /data.
  • Secrets are files under /run/secrets/.
  • The hub’s app card. Any job may write /out/summary.json with a status line for the hub’s web page.

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.