Mado (窓, "window") turns an old Android photo frame into a display your other machines and agents can send things to. It is not a browser kiosk: the frame runs a small native app, so it stays smooth on very modest hardware.
- A dashboard that is a painting. Each day the hub picks a public-domain painting from the Art Institute of Chicago, or from the other sources and kinds of work you tick. The frame shows it with the time, the weather and your projects in one of four layouts, all coloured from the painting itself. Tap the label to read about it and get a QR code for the museum's page; tap elsewhere for the next layout. You choose what it draws from: a period, a subject, and what to leave out.
- Weather in a sentence. "Rain easing by three", with the temperature and sunset. Other places you care about get a line each, with their local time. The frame fetches forecasts itself, so weather keeps working when the hub is down. Tap the weather for a page with the place's sky, the next 24 hours, the week, and a tab for each place.
- Project status from your agents. An agent reports where a project stands and the dashboard keeps the latest word on each, beside its icon (or its initials, if it has none). Tap them for a page of every project with its whole note.
- Liquid alerts. Anything on your network can put a message on the glass: a whisper (a bead at the edge), a nudge (one line that drains away) or urgent (stays until someone taps it). The frame keeps the last 200, and the rail's Messages page lists them with when each came and whether it was read.
- Now playing. The cover of whatever Apple Music or Spotify is playing on your Mac takes over the screen while music plays. A small menu bar app on the Mac does the reporting. Tap the cover to fill the screen with it. Under the progress bar are previous, play or pause, and next, which work the player on the Mac; previous starts the track again unless it has only just begun. "Close" in the corner goes back to the dashboard. While a track is on, the dashboard names it on a line by the date; tap that to come back.
- MCP and HTTP. Agents get tools; scripts get plain endpoints.
- The rail. Tap or swipe the left edge for scenes, the weather, projects and messages pages, brightness and the way back to the launcher.
![]() |
![]() |
![]() |
![]() |
Tama, a small creature, lives on the glass above every scene. It wanders, amuses itself (draws in fogged glass, stacks pebbles, chases dust, naps), dresses for the weather, bops to music, frets beside urgent alerts, and can be poked, carried and tilted. The rail has switches to hide it and to mute it.
Tama is also a small pet. It gets hungry and holds out its bowl; it catches the sniffles, the hiccups, a tummy ache or, from too long in the arcade, a glitch. A tap on it feeds it or gives it its medicine. Nothing ever goes worse than a mope: it gets better by itself, and on its quests it forages when nobody feeds it.
Those quests are Tama's Quest, a page on the rail: an RPG that plays itself in big pixels. Every quest is set in a realm made of two genres mashed together (a Hardboiled Pantry, a Haunted Space Station, a Cosmic Karaoke Box), with creatures made up on the spot, loot, riddles, shops, friends who join the party, and a boss. Nobody dies; foes are befriended, and Tama faints and is carried back to camp. It goes on whether or not anyone watches, slowly, and faster while the page is open; tap the scene to cheer Tama on in a fight. The two worlds leak into each other: Tama wears its best hat on the glass, celebrates its level-ups there, and now and then a quest creature wanders out onto the dashboard and has to be shooed back in. The real night, weather, music and painting of the day turn up in the quest, and a nap on the glass is a nap in the quest.
At night the frame sleeps: a deep amber clock on black with the backlight turned right down, and Tama asleep. Whispers and nudges wait for the morning; urgent alerts still show. Touch it or knock twice on the desk and it comes back for a moment. The hours are set on the hub's page (23:00 to 07:00 unless you change them), the rail has a Sleep switch, and the glass also turns itself down between sunset and sunrise.
Planned: canvases that agents can draw on.
senders hub frame
─────── ─── ─────
Mac menu bar app ─────────┐
agents over MCP ──────────┼──▶ mado-hub ◀── one socket ── Mado app
curl, scripts, cron ──────┘ (Rust) frame dials out (Kotlin)
| Folder | What it is |
|---|---|
hub/ |
mado-hub, the brain. Rust. Serves the frame's WebSocket, an HTTP API, MCP, and a web page where all configuration lives. Runs on a home server (Docker or not) or on a Mac. |
frame-app/ |
The Android app that draws everything. Kotlin, plain Canvas views, minSdk 23. |
mac/ |
Mado for Mac, a menu bar app that reports what Apple Music or Spotify is playing. Swift. |
The frame holds no secrets and listens on no ports: it opens one outbound connection to the hub and draws what it is told. Its clock and weather keep working when the hub is down.
With an agent: point your coding agent at docs/agent-setup.md. It covers checking the device, building, installing, starting at power-on and wiring up MCP, and it says what the agent must ask you before doing. docs/boot-splash.md replaces the vendor's boot logo and animation with Mado's, if you want the frame to be Mado from the moment it gets power.
By hand, the short version:
-
The hub, on any machine the frame can reach:
docker compose up -d --build # or: cd hub && cargo run --releaseIt listens on port 7878 (
MADO_BIND) and keeps its settings in./data(MADO_DATA). Openhttp://HOST:7878/for the web page. -
The frame app. With the frame connected over USB and
adbworking:cd frame-app && ./run.sh
A frame that has no hub looks for one on its network and connects by itself (see "Finding the hub" below). To name the hub yourself instead, run
./run.sh ws://HOST:7878/frame; either way the address is remembered. Put machine-specific setup such asJAVA_HOMEorANDROID_SERIALinframe-app/env.local.sh. -
The menu bar app (optional), on the Mac with the music. It needs Xcode and XcodeGen:
cd mac && scripts/build.sh install
Or download the notarized
Mado.zipfrom the latest release and drag Mado to Applications.Choose "Hub Address…" from its menu and enter the hub's address. macOS asks once for permission to control Music or Spotify; the app never launches either. The menu also has a switch for each player, one to pause sharing, and "Start at Login".
You need adb access to the frame. For most photo frames that means rooting it first, which
depends on the device and is not covered here.
The hub speaks both plain HTTP and MCP; they do the same things.
curl -X POST http://HOST:7878/api/notify \
-H 'content-type: application/json' \
-d '{"text": "Backup finished", "level": "nudge", "source": "nas"}'| HTTP | |
|---|---|
POST /api/notify |
text, optional level (whisper, nudge, urgent), source, ttl_s (urgent ignores it and stays until tapped) |
POST /api/sleep, POST /api/wake |
Night mode from now, or day from now, until the frame's hours next change over. Optional source |
POST /api/companion |
action (as for frame_companion below), optional ttl_s for a mood or hiding, source |
POST /api/project |
name, status (on_track, needs_you, blocked, done, archived), optional note, progress 0 to 1, and icon (the URL of a PNG or JPEG, or the image in base64). Send the icon once: later reports keep it, and "" removes it. |
GET /api/look |
JPEG of what the frame is showing |
GET /api/state |
Connected frames, now playing, today's painting, projects, config, recent activity |
POST /api/now_playing |
What a player is doing: state (playing, paused, stopped), title, artist, album, source, position_s, duration_s, and art_b64 (the cover, JPEG or PNG) when the track changes. Repeat it at least every 30 seconds while playing. |
| MCP tool | |
|---|---|
frame_notify |
Show an alert |
frame_sleep, frame_wake |
Put the frame into night mode, or end it |
frame_companion |
Ask Tama for a gesture (wave, nod, celebrate), a mood (happy, worried, calm), to hide, a hobby, or care (feed, medicine) |
frame_project |
Report where a project stands |
frame_look |
Get a picture of the frame's screen, so an agent can see its own work |
frame_state |
The same as GET /api/state |
The MCP endpoint is http://HOST:7878/mcp (streamable HTTP). For Claude Code:
claude mcp add --transport http mado http://HOST:7878/mcpThe hub has no authentication. Run it where only your own devices can reach it, for example bound to a Tailscale address, and never expose it to the internet. The frame does not need to be on the tailnet; it only needs to reach the hub.
The hub announces itself on the local network over mDNS (as a _mado._tcp service, the way
printers and speakers do), and a frame with no hub looks for that announcement. One hub
found: the frame connects to it. Several: it asks which, on its own screen. A hub found this
way is remembered by name as well as address, so the frame finds it again if the address
changes. MADO_NAME sets the name people see (default "Mado on" and the machine's name).
Tap the hub's corner at the bottom of the rail to choose another hub or to type an address
(192.168.1.20, hub.local:7878); adb shell am start -n jp.co.xenocode.mado/.MainActivity --es hub ws://HOST:7878/frame still works, and --es hub none makes the frame forget its
hub and look again.
A hub with more than one address (a server on both a cable and Wi-Fi, say) can be given all
of them, separated by commas, typed or with --es hub. The frame moves to the next address
whenever its link drops, so it follows the server from one to the other.
The announcement does not leave a container on Docker's default network, so the Docker image
keeps quiet (MADO_ADVERTISE=off) and a frame is given the address by hand. To have
discovery with Docker on Linux, run the container with network_mode: host and
MADO_ADVERTISE=on; compose.yaml shows how. Some networks, guest Wi-Fi especially, block
mDNS altogether; then type the address.
If the frame sits on a network you would rather not open the whole hub to, set
MADO_FRAME_BIND (for example 0.0.0.0:7879). The hub then opens a second port that serves
only the frame's socket and the images it fetches, and you can keep the main port, with the
web page, API and MCP, somewhere private. Point the frame at the second port (it is the one
the hub announces). Anything that
can reach that port can pose as a frame and read what a frame is sent (alerts, projects,
weather places), but cannot send or change anything.
Developed on a Uhale 102KZ: Rockchip RK3126, Mali-400, 1 GB RAM, Android 6.0.1, 1280×800. The app targets that class of device: no Compose, no large frameworks, and images are resized on the hub so the frame only has to draw them. Layouts currently assume a 1280×800 landscape screen.
cd hub && cargo test # hub
cd frame-app && ./gradlew testDebugUnitTest # forecast wording
cd mac && scripts/build.sh test # menu bar appcargo fmt and cargo clippy are expected to be clean. To sign the Mac app with your own
Developer ID and notarize it, see mac/Signing.xcconfig and mac/scripts/notarize.sh. See CHANGELOG.md
for what changed.
Paintings and their descriptions come from the Art Institute of Chicago's public API; only works it marks as public domain are used unless you choose otherwise on the hub's page. The other sources there are the Cleveland Museum of Art's Open Access API and WikiArt, whose most viewed paintings include many still in copyright: fine on a frame at home, not for showing in public. Forecasts and city search come from Open-Meteo. Album artwork that Music does not have locally is looked up in the iTunes catalogue.
MIT. The paintings shown by default are in the public domain; Mado's licence covers the software, not the artwork or the museum's descriptions.





