Keeps houseplants alive when the person responsible for them has no idea what he is doing.
Planty watches soil moisture and cabinet humidity through Home Assistant, remembers what every plant is and who it belongs to, looks at photographs of them over time, and once a day says one short thing: water that one, ignore the rest. Most days it says nothing needs doing, which is the point.
It exists because an automatic waterer running on a timer is a drowning machine. It waters on a schedule and has no idea whether water reached soil, so a clogged line waters nothing, a stuck one floods, an already wet pot gets watered anyway, and all three report success.
- A soil sensor reads dry.
- Home Assistant runs the pump.
- The pump reports that it ran.
- The soil sensor confirms water actually reached the soil.
- If step 3 succeeded and step 4 did not, that dripper is clogged, and you are told.
Step 5 is the whole product. A timer cannot do it, and neither can a person who is not standing there at the time.
The Go service is the only thing that owns state. Everything else is a way of talking to it.
| Surface | What it is for |
|---|---|
| Service (Go, Kubernetes, Postgres) | Ingests readings, stores observations and photos, calls Claude for judgment, generates the daily verdict |
iOS app (SwiftUI, ios/) |
For when you are standing in front of the plant holding a watering can. Photograph it, log what you did, ask what is wrong |
| Dusk plugin (dusk-plugin-planty) | The same operations, for agents. Add a plant by describing it, log that you watered something, ask what needs doing |
The app and the plugin call the same API and have the same powers. Anything you can do by tapping, an agent can do by asking, and the other way round. The plugin stores nothing in Dusk: it hands everything to this service and reflects back what the service says.
export PLANTY_DATABASE_URL=postgres://planty:...@localhost:5432/planty
planty migrate # apply the schema
planty seed # load the sabbatical plants and their open questions
planty serve # the HTTP API on :8080Scheduled work, one command each, wired as CronJobs in deploy/:
| Command | Cadence | What it does |
|---|---|---|
planty ingest |
every 20 min | Pull current sensor values from Home Assistant |
planty daily |
08:00 | Judge every plant, sweep for autopsies, send one digest |
planty away |
08:30 | Pre-departure watering pass, or the briefing on return |
planty cold |
15:00 | Tonight's forecast: what comes in, and what can go back out |
planty remind |
hourly | The chores nothing measures: misting, feeding, hand watering |
planty autopsy <slug> |
on demand | Work out what killed a plant |
Every model call goes through one interface with two implementations, chosen by PLANTY_JUDGE.
PLANTY_JUDGE |
What answers | What it needs |
|---|---|---|
cli |
The Claude Code binary, against a subscription | claude on PATH and CLAUDE_CODE_OAUTH_TOKEN from claude setup-token |
api |
The Anthropic API, metered | ANTHROPIC_API_KEY |
Unset, an API key wins if there is one and the CLI is used otherwise. Neither being available is a supported state: the judge is nil, the watering line and the cold watch carry on, and only the parts that need an opinion report unavailable.
adr/0001 records why the CLI is the default here and what it costs, which is mostly image size and the seconds a photograph takes to reach the model.
Sensors measure water. They cannot see spider mites, root rot, light burn, or new growth.
Planty keeps a photo timeline per plant and reads it with a vision model, so the useful finding is not "soil is at 34%" but "the yellowing on the lower leaves has progressed since July 20th, and that is overwatering, not light". That comparison over time is the single most valuable thing in here and the reason a phone app exists at all.
"Nothing needs doing", "the data is stale", and "the judgment has never run" are three different answers and the API returns them as three different fields. Collapsing them is how this class of system kills things: silence that looks like reassurance.
It does not ask you to take readings. Sensors do that. If the design ever requires typing in a number, the design is wrong. Manual entry is for exceptions: repotted this, spotted mites, watered by hand.
It does not automate the mushroom kit.
Kits live four to eight weeks, the variable that matters is airflow rather than humidity, and the correct misting trigger is "the surface looks dry", which is not a number any sensor produces.
An RH threshold misting automation over mists, and over misting is precisely how bacterial blotch happens.
A daily reminder and a fan on a plug is the entire correct implementation, and it lives in docs/home-assistant.md.
It does not nag. One digest a day, usually saying nothing needs doing. The house speakers are reserved for a plant actually at risk, because a system that cries wolf gets ignored, and then it is worse than nothing.
It has no authentication, on purpose. Keep it on the LAN. The plant data is dull but the pod holds a Home Assistant token and an Anthropic key.
cmd/planty/ The binary: serve plus the scheduled jobs
internal/plant/ Domain types shared by every surface
internal/store/ Postgres, and the only thing that touches it
internal/api/ HTTP, 22 routes
internal/judge/ Claude: daily verdicts, vision diagnosis, autopsies
internal/job/ Scheduled work
internal/photos/ S3/MinIO object storage
internal/seed/ The sabbatical plants, as shipped data
docs/ The data model, the friend's care sheet, the HA side
adr/ Decisions worth not re-arguing
deploy/ Kubernetes manifests, not yet applied
design/ UI concept, screen specs, SwiftUI prototype, logo
ios/ The app
go test -race ./... # unit
PLANTY_TEST_DATABASE_URL=postgres://... go test ./internal/store/... # integrationThe integration tests exist because a digest query once compiled cleanly, passed every unit test, and was rejected outright by Postgres. Anything touching SQL gets an integration test.
TBD.