> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Greenhouse relay control

> Switch a grow light and a fan on a Raspberry Pi from chat, and turn the light off every night with a job that messages the owner

This agent switches two relays on a Raspberry Pi from any conversation: a grow light and a fan, addressed by name through a small HTTP API on the Pi. A [job](/concepts/jobs) turns the light off at 22:00 every night and messages the owner. It is five files, four TypeScript and one Python, and every TypeScript file compiles against `lua-cli` 3.33.0; run it with the steps on [Running any example](/examples/overview#running-any-example).

*Verified against lua-cli 3.33.0.*

## The conversation

1. The end user writes "turn the fan on". The model calls `set_output` with `output: "fan"` and `state: "on"`; the tool posts both values to the Pi, which switches the relay and returns the new state.
2. The persona repeats the state back and never switches twice for one request.
3. At 22:00, the `lights-out` job calls the same tool class directly, then sends a message to the user id in `OWNER_USER_ID`.

## Primitives and channels

* [Skill and tools](/concepts/skills-and-tools): `greenhouse`, with `set_output`.
* [Job](/concepts/jobs): `lights-out`, cron `0 22 * * *` in `Europe/Berlin`, three attempts with a 60-second backoff.
* Runtime objects: `User.get(userId)`, `user.send`, and `env` for `RELAY_CONTROLLER_URL`, `RELAY_CONTROLLER_KEY`, and `OWNER_USER_ID`.
* Channels: any. The nightly message goes to the owner on the channel they last wrote from, when that is WhatsApp, Messenger, Instagram, Teams, SMS, or MessageBird; `user.send` resolves `true` either way, so confirm delivery with `lua logs`.

## The code

The Pi maps names to pins and rejects anything else; the agent never sends a pin number.

```python edge_api.py theme={null}
import os
from flask import Flask, jsonify, request
from gpiozero import OutputDevice

API_KEY = os.environ["EDGE_API_KEY"]
# The only outputs that exist, by name; the agent never sends a pin number.
OUTPUTS = {"grow_light": 17, "fan": 18}
relays = {name: OutputDevice(pin, active_high=False, initial_value=False) for name, pin in OUTPUTS.items()}

app = Flask(__name__)


@app.post("/relay")
def set_relay():
    if request.headers.get("X-API-Key") != API_KEY:
        return jsonify({"error": "unauthorized"}), 401
    body = request.get_json(silent=True) or {}
    output, state = body.get("output"), body.get("state")
    if output not in relays or state not in ("on", "off"):
        return jsonify({"error": "unknown output or state"}), 400
    relays[output].on() if state == "on" else relays[output].off()
    return jsonify({"output": output, "state": state})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5001)
```

The tool's enums mirror the Pi's map, so the model can only name outputs that exist.

```ts src/skills/tools/SetOutputTool.ts theme={null}
import { LuaTool, env } from 'lua-cli';
import { z } from 'zod';

export const OUTPUTS = ['grow_light', 'fan'] as const;

export default class SetOutputTool implements LuaTool {
  name = 'set_output';
  description = 'Turn the grow light or the fan on or off';

  inputSchema = z.object({
    output: z.enum(OUTPUTS),
    state: z.enum(['on', 'off']),
  });

  async execute(input: z.infer<typeof this.inputSchema>) {
    const base = env('RELAY_CONTROLLER_URL');
    const key = env('RELAY_CONTROLLER_KEY');
    if (!base || !key) {
      throw new Error('RELAY_CONTROLLER_URL and RELAY_CONTROLLER_KEY must be set');
    }

    const res = await fetch(`${base}/relay`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
      body: JSON.stringify(input),
    });
    if (!res.ok) {
      throw new Error(`Relay controller responded ${res.status}`);
    }

    return (await res.json()) as { output: string; state: 'on' | 'off' };
  }
}
```

The skill's `context` prevents double switching.

```ts src/skills/greenhouse.skill.ts theme={null}
import { LuaSkill } from 'lua-cli';
import SetOutputTool from './tools/SetOutputTool';

export default new LuaSkill({
  name: 'greenhouse',
  description: 'Switch the greenhouse grow light and fan',
  context: `set_output switches one output at a time. Confirm which output the user means
when it is ambiguous, and repeat the new state back after the call.
Do not toggle an output more than once per request.`,
  tools: [new SetOutputTool()],
});
```

The job reuses the tool class and messages a configured end user, because a job has no conversation to reply into.

```ts src/jobs/LightsOutJob.ts theme={null}
import { LuaJob, User, env } from 'lua-cli';
import SetOutputTool from '../skills/tools/SetOutputTool';

export default new LuaJob({
  name: 'lights-out',
  description: 'Turn the grow light off at 22:00 every night and tell the owner',
  schedule: { type: 'cron', expression: '0 22 * * *', timezone: 'Europe/Berlin' },
  timeout: 30,
  retry: { maxAttempts: 3, backoffSeconds: 60 },

  execute: async () => {
    const result = await new SetOutputTool().execute({ output: 'grow_light', state: 'off' });

    // A job runs outside any conversation, so the recipient is configured, not implied.
    const ownerId = env('OWNER_USER_ID');
    const owner = ownerId ? await User.get(ownerId) : null;
    await owner?.send([{ type: 'text', text: 'The grow light is off for the night.' }]);

    return result;
  },
});
```

The agent registers the skill and the job.

```ts src/index.ts theme={null}
import { LuaAgent } from 'lua-cli';
import greenhouseSkill from './skills/greenhouse.skill';
import lightsOut from './jobs/LightsOutJob';

const agent = new LuaAgent({
  name: 'greenhouse-control',
  persona: `You control the outputs in a small greenhouse: a grow light and a fan.
Confirm which output you are switching, switch it once, and report the new state.
If the controller is unreachable, say so and do not retry on your own.`,
  skills: [greenhouseSkill],
  jobs: [lightsOut],
});
```

## First run

On the Pi (Raspberry Pi OS), install the dependencies and start the API with a secret of your own.

```bash theme={null}
sudo apt install -y python3-pip python3-venv python3-libgpiod
python3 -m venv .venv && source .venv/bin/activate && pip install flask gpiozero
EDGE_API_KEY=<secret> python edge_api.py
```

From your laptop on the same network, switch the fan on.

```bash theme={null}
curl -X POST http://raspberrypi.local:5001/relay \
  -H "X-API-Key: <secret>" -H "Content-Type: application/json" \
  -d '{"output":"fan","state":"on"}'
```

In the agent project, set the variables and run the tool and the job locally; both reach the Pi over your network.

```bash theme={null}
lua env sandbox -k RELAY_CONTROLLER_URL -v http://raspberrypi.local:5001
lua env sandbox -k RELAY_CONTROLLER_KEY -v <secret>
lua env sandbox -k OWNER_USER_ID -v <your-user-id>
lua test --ci skill --name set_output --input '{"output":"grow_light","state":"on"}'
lua test --ci job --name lights-out
```

`lua chat --ci -e sandbox -m "Turn the fan on" -t` runs the conversation with the model. It uploads the `.env` values with the sandbox version, but the turn runs on the platform, which can't reach `raspberrypi.local`, so point `RELAY_CONTROLLER_URL` at an address the platform can reach first (see [Ways to make it yours](#ways-to-make-it-yours)). Then release it with `lua push all --ci --force`, `lua version create --ci -m "<message>"`, and `lua version promote <n>`; [Release an agent to production](/ship/releasing) explains what each command changes.

## Ways to make it yours

* Add an output in two places: the `OUTPUTS` map in `edge_api.py` and the `OUTPUTS` tuple in `SetOutputTool.ts`.
* Deployed code runs on the platform, so `RELAY_CONTROLLER_URL` must be an HTTPS address the platform can reach; `raspberrypi.local` works only for `lua test` on your own network. To avoid an inbound port, connect the Pi as a [device](/concepts/devices): its commands become tools without any agent code.
* Find your user id for `OWNER_USER_ID` by returning `user._luaProfile.userId` from a temporary tool, where `user` is `await User.get()`.
* Add a morning job that turns the light on, or read a sensor through a second endpoint and store readings with `Data.create`.

## Next steps

<Columns cols={2}>
  <Card title="Schedule a job" href="/build/schedule-a-job">
    Cron, interval, and one-time schedules, retries, and running a job by hand.
  </Card>

  <Card title="Connect your first device" href="/devices/quickstart">
    A Node script on the Pi that dials out and exposes its commands as tools.
  </Card>

  <Card title="Send proactive messages" href="/build/send-proactive-messages">
    Message an end user from a job or webhook on any channel.
  </Card>
</Columns>
