Notes · Jul 2026 · Post

Sage: an AI farmhand that only knows my garden.

A build log, in the order it happened: the beds, the knowledge, the interface, the advisor inside it, and the one part that runs entirely on its own.

This is the story of a vegetable garden that grew a piece of software, one layer at a time. It started as six raised beds in my backyard and ended up with a pixel-art dashboard and an AI advisor that knows my soil better than most gardening books know gardening.

None of it started as a plan. Each layer showed up to solve a problem the previous one created.

The six raised beds in summer, full of vegetables, seen from the house.
The six beds, July 2026.

01 · Beds

The garden came first

I did this because of nature. My parents and grandparents always grew things, and I wanted to grow my own vegetables. The bonus was my daughters getting to eat straight from the ground. I gotta say, they'll walk right up to a bed and eat a carrot, a tomato, sometimes a raw onion, like it's a snack.

I had a new house and a plot out back with no purpose.

The bare plot with its sand-and-gravel base and retaining wall, before any beds went in.
The plot, before.

So I built raised beds. RB-12 was the first, a small box packed with organic matter (dry branches, fallen wood, soil). I planted it right away and it worked. Then RB-13, then the rest. Today there are six.

The wooden raised beds newly built on the gravel base, some already filled with soil.
The beds going in.

That's where the software problem starts. Six beds, each with its own crops, rotations, and history, add up to more than I can hold in my head, and in a raised bed the history matters more than usual. That's what pushed me to write things down, and eventually to let a model read what I wrote.

02 · Knowledge

From “a gardener” to “my garden”

The first version wasn't an app at all. It was a Claude project wired to a Notion database, where I kept every bed, crop, and plan. That setup already knew gardening: it could plan rotations, pick companions, respect the season. Useful, but it was advice for a garden, not mine.

The advisor that lives in the app now, Sage, is different in one way that matters: it carries history. It knows what went into each bed and how each crop did. That RB-21 is on tomatoes in a sawdust-heavy mix that locks up nitrogen. That RB-13 lost its brassicas to cabbage-root fly and is closed to them until 2027.

In a raised bed, that history isn't trivia. My beds are lined, sitting on a thick layer of gravel for drainage, so they never touch open ground. Nothing comes up from below to top them off. What's in the box is all the plants get, so what I plant next depends entirely on what came before. A generic gardener can tell me tomatoes like sun. Only a record of this bed can tell me what RB-21 is allowed to grow next season.

system-prompt.v1.md · Notionarchived
You are an expert vegetable-garden
advisor. The Notion database describes
the beds, crops and current plans.

Help with rotations, companion
planting and seasonal timing for a
home garden in Coimbra, PT.
system-prompt.v2.md · Sagelive
You are Sage, the advisor of one
specific garden: six lined raised
beds on gravel, closed systems.

<history> is what each bed has grown
and how it went. It is binding:
- RB-21: sawdust-heavy mix, locks up
  nitrogen. Feed before fruiting crops.
- RB-13: lost brassicas to cabbage-root
  fly. Closed to Brassicaceae until 2027.

What a bed grows next depends on what
came before. Never advise without
the bed's record.

The difference between them is the history.

That knowledge had to live somewhere I'd look at willingly. Notion worked, but I never opened it for fun.

03 · Interface

The dashboard that started as a toy

I didn't set out to build an interface. Claude Design showed up. I wanted to see what it could do, so I threw a garden dashboard at it, expecting a throwaway. What came back was a static mock-up in 8-bit pixel art: chunky beds, a little farmer sprite, the whole thing looking like a Pokémon game about vegetables.

The pixel-art dashboard: the six beds, a farmer sprite walking the plot, with weather, tasks and Sage live around it.
The dashboard: chunky beds, a little farmer, live data underneath.

I'm a 90s kid, and this hit every one of those buttons. It was just for fun. But looking at it, I realised the toy did something Notion never did: it made me want to open it.

So I built it for real. The mock-up became a SvelteKit app, the pixel art rebuilt in CSS, the beds now backed by a live database instead of a screenshot. That was the jump from Claude and Notion into a UI that was pleasant to use, an extension of my veggie-garden logbook. The look is still deliberately a game: a farmer you walk across the plots, a Hortidex styled like a Pokédex, tasks the beds generate for themselves.

The Hortidex, a Pokédex-style catalogue of every plant in the garden.
The Hortidex.
The app on a phone: the pixel farm map with the beds and weather, and a prompt to talk to Sage.
Sage, on the phone.

Underneath the sprites, it's tracking six real beds. And once the beds were in a live database, the advisor could finally read the garden as it actually was, not as I'd last remembered to write it down.

04 · Advisor

Inside Sage

Sage is Claude, the claude-sonnet-4-6 model, called through the Anthropic SDK from a SvelteKit endpoint. No agent framework, no MCP server behind it. The tools are passed straight into the API call and run in the same endpoint against a local SQLite database. MCP is future work, if this ever proves worth it. It's a home-lab v1, and it's built like one.

Every message carries two things the server puts together on the spot. First, a fixed system prompt: who Sage is, the growing principles, the physical layout of the six beds, the known problems, the seasonal calendar for my zone, and the voice it answers in: European Portuguese, blunt and practical, the way an old hand at an allotment talks.

system-prompt.md · known problems, per bedlive
## Known problems, per bed

- RB-21: tomatoes on a sawdust-heavy mix
  that locks up nitrogen. Expect hungry
  plants; feed before fruit set. Medfly
  (Ceratitis capitata) confirmed: yellow
  sticky traps + cider-vinegar bottle traps.
- RB-13 & RB-22: lost their brassicas to
  cabbage-root fly (Delia radicum), from eggs
  on shop-bought seedlings. Closed to
  Brassicaceae until 2027; Steinernema feltiae
  to break the cycle.
- Earwigs (Forficula) tied to the sawdust mix.
- Powdery mildew: diluted milk (1:10) in
  summer, since sulphur is out above 28°C.
- RB-22: 2.4 m², too small for sprawling
  cucurbits.

Second, a JSON snapshot of the garden built fresh for that message, with every rotation each bed has had, including the ones that failed, the watering data from Home Assistant, the composter level, the weather, and the last five diary entries. So before Sage reads my question, it already has the current state of the garden in front of it.

context.json · one bed, trimmed (RB-21)per message
{
  "data": "2026-07-16",
  "compostor": "62%",
  "clima": { "tempC": 34, "chuva_12h_mm": 0 },
  "canteiros": [
    {
      "id": "RB-21",
      "area_m2": 4.8,
      "rega": {
        "valvula": "off",
        "ultimaRega": "2026-07-16T06:41+01:00",
        "duracaoMin": 38,
        "horasDesdeRega": 2
      },
      "rotacoes": [
        {
          "titulo": "RB-21 · Tomates (3 variedades)",
          "estado": "Plantado",
          "rotacao": "3 - Fruto",
          "plantado": "2026-04-26",
          "culturas": ["coração de boi", "redondo roxo", "cherry"],
          "notas": "serrim, risco de imobilização de azoto; reforço quinzenal. 2027: evitar Solanaceae.",
          "pragas": "manjericão contra pulgões; vigiar oídio em agosto"
        }
      ]
    },
    "…5 canteiros omitidos…"
  ],
  "acoes_recentes": [ "…" ]
}

I didn't build memory management for this. Reassembling the snapshot every message was the simplest thing that worked with the SDK, and I wanted it running so I could use it, not a hand-rolled agent memory layer. For six beds, “just send the current picture every time” turned out to be enough.

What doesn't travel in every message sits behind tools. Sage has eight tools against my database, two that read (pull more diary history than the five in the snapshot, look a species up in the catalogue) and six that write (log a note, update a rotation's notes or its state, update a bed, create a task, register a new species), plus web search. It only reaches for them when it needs to.

That split is the point, and it's the one design decision I'd actually defend. What Sage almost always needs, the state of the beds right now, rides in the request. What's occasional or bulky, the deep action history and the plant catalogue, stays behind a tool it can call on demand. It's less about what the model knows and more about what I put in front of it, and when.

That last tool, the one that registers a new species, means Sage can go and learn a plant it's never seen.

05 · Research

Species research

Not every plant is in the catalogue, and that's fine, because I don't always arrive knowing what I want. Sometimes I have a hunch from family habit or something I've seen. Sometimes I just ask what a bed should take next and have no idea myself.

Either way, if Sage doesn't know a plant, it looks it up on the web and uses what it finds to answer the actual question, which was never “register this plant.” It was “should I grow it, here, now?” If the plant earns a place, it saves it to the catalogue, but only when I say so: it offers first, and writes on a yes.

Here's a real exchange, Portuguese and all. I asked about sowing Hokkaido squash in RB-22:

Me

posso semear abobora hokaido em rb22?

Sage

Curta resposta: não agora, mas no outono sim. RB-22 tem 2,4m² e está ocupada com pepinos e o bloco de feijão/tagetes, com sucessão de solanáceas planeada até agosto. Não há espaço nem timing para a Hokkaido agora. Mas há um problema maior: a abóbora Hokkaido é Cucurbitaceae, e em julho já é tarde para semear — o ciclo é longo (90-110 dias) e em Coimbra a sementeira vai de maio a meados de junho no máximo para colheita antes das primeiras frescas de outubro/novembro. Para 2027, RB-22 vai estar bloqueada para Solanaceae, então Cucurbitaceae encaixa bem na rotação. Mas precisas de um canteiro maior — RB-22 com 2,4m² é pequena para abóbora Hokkaido, que precisa de espaço para espalhar as guias. RB-11 ou RB-13 seriam melhores candidatos. Queres que planeie isso para a próxima época?

In short: not now, autumn yes, but not that bed. Too late in the year to sow, the bed is full and too small for the vines, try a bigger one.

Look at what's in that answer. The web research it just did, that Hokkaido is a Cucurbitaceae with a long cycle and a sowing window that closes mid-June. The live state of the bed from the snapshot, that RB-22 is 2.4m², already full, with a Solanaceae succession booked through August. And a rotation rule from its own context, that the bed opens up to cucurbits in 2027. Three sources folded into one practical answer none of them could give alone.

I trust what it saves. It can be wrong, since it's model-read research and not a verified database, but in practice it rarely is, and the point was never a perfect catalogue anyway. The catalogue is a side effect; the decision is the product.

Everything so far is Sage waiting for me to ask. There's one part of the setup that doesn't wait at all.

06 · Water

A side note on water

This one isn't Sage. It's a separate system that runs itself, and it earns a mention precisely because it's the part of the whole setup I think about least.

A sustainable vegetable garden isn't sustainable if I'm standing there with a hose every evening. Hand-watering ate time and wasted water, the two things the whole project is meant to save. I also have a soft spot for home automation, so fixing it properly felt like the natural upgrade rather than a chore.

I started small, with drip lines on RB-11 and RB-21 driven by Sonoff Zigbee valves. It worked, so I extended it bed by bed. The logic runs in Home Assistant, and I wrote the watering algorithm with Claude. It isn't a timer. Every morning it pulls the hourly forecast and skips the run if any of the next twelve hours is forecast to rain, or if the total comes to 5mm or more (and it won't start at all if it's already raining at 06:30). Otherwise it sizes the watering time from the day's peak heat, a seasonal daylight factor, and how long it's been since the last run, clamped between ten and fifty-five minutes a valve.

watering.yaml · 4b - Smart Raised Bed WateringHome Assistant
alias: 4b - Smart Raised Bed Watering
description: >-
  Rega dos canteiros (raised beds) às 06:30 diariamente. Usa previsão de pico
  (13-15h) para ajustar duração. Base 40 min/válvula, fator sazonal, skip se
  chuva (com notificação). continue_on_error em todos os valves — falha de um
  não para os outros. SWV 1 = RB-21 (tomates), SWV 2 = RB-11
  (courgettes+solanáceas), SWV RB-12 = RB-12, SWV RB-13 = RB-13, SWV RB-23 =
  RB-23.
triggers:
  - trigger: time
    at: "06:30:00"
conditions:
  - condition: template
    value_template: |
      {{ states('weather.forecast_home') not in
         ['rainy', 'pouring', 'lightning-rainy', 'hail'] }}
actions:
  - action: weather.get_forecasts
    target:
      entity_id: weather.forecast_home
    data:
      type: hourly
    response_variable: forecast
  - variables:
      rain_skip: >
        {% set forecasts = forecast.get('weather.forecast_home',
        {}).get('forecast', []) %} {% if not forecasts %}
          false
        {% else %}
          {% set ns = namespace(rain=false, precip=0) %}
          {% for f in forecasts[:12] %}
            {% if f.condition in ['rainy', 'pouring', 'lightning-rainy', 'hail'] %}
              {% set ns.rain = true %}
            {% endif %}
            {% if f.precipitation is defined %}
              {% set ns.precip = ns.precip + f.precipitation | float(0) %}
            {% endif %}
          {% endfor %}
          {{ ns.rain or ns.precip >= 5 }}
        {% endif %}
      rain_detail: >
        {% set forecasts = forecast.get('weather.forecast_home',
        {}).get('forecast', []) %} {% if not forecasts %}
          Previsão indisponível — a regar na mesma.
        {% else %}
          {% set ns = namespace(precip=0, cond='') %}
          {% for f in forecasts[:12] %}
            {% if f.precipitation is defined %}
              {% set ns.precip = ns.precip + f.precipitation | float(0) %}
            {% endif %}
            {% if f.condition in ['rainy', 'pouring', 'lightning-rainy', 'hail'] and not ns.cond %}
              {% set ns.cond = f.condition + ' às ' + f.datetime[11:16] %}
            {% endif %}
          {% endfor %}
          Precipitação 12h: {{ ns.precip | round(1) }}mm{% if ns.cond %}, chuva prevista: {{ ns.cond }}{% endif %}.
        {% endif %}
  - if:
      - condition: template
        value_template: "{{ rain_skip }}"
    then:
      - action: notify.notify
        data:
          title: 🌧️ Rega canteiros saltada — chuva
          message: |
            Rega das 06:30 não correu. {{ rain_detail }}
      - stop: Chuva prevista nas próximas 12h
  - variables:
      peak_temp: >
        {% set ns = namespace(temp=20) %} {% set forecasts =
        forecast.get('weather.forecast_home', {}).get('forecast', []) %} {% for
        f in forecasts %}
          {% set hour = as_timestamp(f.datetime) | timestamp_custom('%H') | int %}
          {% if hour >= 13 and hour <= 15 %}
            {% if f.temperature | float(0) > ns.temp %}
              {% set ns.temp = f.temperature | float(0) %}
            {% endif %}
          {% endif %}
        {% endfor %} {{ ns.temp }}
      peak_hum: >
        {% set ns = namespace(hum=50) %} {% set forecasts =
        forecast.get('weather.forecast_home', {}).get('forecast', []) %} {% for
        f in forecasts %}
          {% set hour = as_timestamp(f.datetime) | timestamp_custom('%H') | int %}
          {% if hour >= 13 and hour <= 15 %}
            {% set ns.hum = f.humidity | float(50) %}
          {% endif %}
        {% endfor %} {{ ns.hum }}
      days_since: >
        {% set last = states('input_datetime.last_watering_canteiros') %} {% if
        last not in ['unknown', 'unavailable'] %}
          {{ ((now().timestamp() - as_timestamp(last, 0)) / 86400) | round(1) }}
        {% else %}
          999
        {% endif %}
      daylight_hours: >
        {% set sunrise = as_timestamp(states('sensor.sun_next_rising')) %} {%
        set sunset = as_timestamp(states('sensor.sun_next_setting')) %} {% if
        sunrise > sunset %}{% set sunrise = sunrise - 86400 %}{% endif %} {{
        ((sunset - sunrise) / 3600) | round(1) }}
      seasonal_factor: |
        {% set h = daylight_hours | float(12) %} {% if h < 10 %}
          0.5
        {% elif h < 11 %}
          0.65
        {% elif h < 13 %}
          0.85
        {% else %}
          1.0
        {% endif %}
      base_minutes: >
        {% set base = 40 %} {% set t = peak_temp | float(20) %} {% set h =
        peak_hum | float(50) %} {% set d = days_since | float(0) %} {% if t > 30
        and h < 40 %}
          {% set base = base + 5 %}
        {% elif t > 25 and h < 60 %}
          {% set base = base + 3 %}
        {% elif t < 15 %}
          {% set base = base - 5 %}
        {% endif %} {% if d > 2 %}
          {% set base = base + 3 %}
        {% endif %} {{ base }}
      duration_min: >
        {% set raw = (base_minutes | float(40) * seasonal_factor | float(1)) |
        round(0) | int %} {{ [10, [raw, 55] | min] | max }}
      duration: |
        {{ '{:02d}:{:02d}:{:02d}'.format(0, duration_min | int, 0) }}
  - action: notify.notify
    data:
      title: 🌱 Rega canteiros iniciada
      message: >
        Duração: {{ duration_min }} min/válvula (base {{ base_minutes }} min ×
        fator sazonal {{ seasonal_factor }}). Pico previsto: {{ peak_temp }}°C,
        {{ peak_hum }}% hum. Luz solar: {{ daylight_hours }}h. Dias sem rega: {{
        days_since }}.
  - target:
      entity_id: switch.sonoff_swv_rb_21
    action: switch.turn_on
    continue_on_error: true
  - delay: "{{ duration }}"
  - target:
      entity_id: switch.sonoff_swv_rb_21
    action: switch.turn_off
    continue_on_error: true
  - target:
      entity_id: switch.sonoff_swv_rb_11
    action: switch.turn_on
    continue_on_error: true
  - delay: "{{ duration }}"
  - target:
      entity_id: switch.sonoff_swv_rb_11
    action: switch.turn_off
    continue_on_error: true
  - target:
      entity_id: switch.sonoff_swv_rb_12
    action: switch.turn_on
    continue_on_error: true
  - delay: "{{ duration }}"
  - target:
      entity_id: switch.sonoff_swv_rb_12
    action: switch.turn_off
    continue_on_error: true
  - target:
      entity_id: switch.sonoff_swv_rb_13
    action: switch.turn_on
    continue_on_error: true
  - delay: "{{ duration }}"
  - target:
      entity_id: switch.sonoff_swv_rb_13
    action: switch.turn_off
    continue_on_error: true
  - target:
      entity_id: switch.sonoff_swv_rb_23
    action: switch.turn_on
    continue_on_error: true
  - delay: "{{ duration }}"
  - target:
      entity_id: switch.sonoff_swv_rb_23
    action: switch.turn_off
    continue_on_error: true
  - target:
      entity_id: input_datetime.last_watering_canteiros
    data:
      timestamp: "{{ now().timestamp() }}"
    action: input_datetime.set_datetime
mode: single

I haven't thought about watering in over ten weeks. It just runs, which is exactly what I'd like the rest of it to grow into.

07 · Closing

What grew, and what didn't

I set out to grow vegetables, and somewhere along the way I built software for it, one layer at a time: beds, then a written record, then a dashboard I actually wanted to open, then an advisor that reads the garden as it is, then a watering system I've stopped thinking about.

None of it is finished, and today the app mostly mirrors what I already had before it, with a simpler interface and fewer moving parts to manage. That's the honest state of it. The plan is to use it, field-test it through a real season, and see whether it serves the purpose or just looks like it does. If it does, I'll keep going: memory, maybe more agents, an MCP to explore that path properly.

But if it grows, I want it to grow the quiet way. Something passive I reach for when I need it, not an autonomous agent on steroids chasing things to do. The watering already works like that. Sage should feel the same.

Simple, the way nature is.