> ## 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.

# Door access over WhatsApp

> Unlock a door for guests with an active booking, identified by the WhatsApp number the channel verified, with a rate limit and an audit log

Guests message this agent on WhatsApp to open a door. The `unlock_door` tool identifies the guest by the phone number [WhatsApp](/channels/whatsapp) verified, looks for an active booking in [`Data`](/reference/sdk/data), applies a per-guest rate limit, pulses a relay through a small HTTP API on a Raspberry Pi, and writes an audit entry. A staff-only tool registers bookings. 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. A guest writes "open the front door". The model calls `unlock_door` with `door: "front"`; the tool reads the guest's phone numbers from the profile, finds a booking that is active now and includes that door, checks that the guest has fewer than three unlocks in the last minute, calls the Pi, and logs the unlock.
2. A guest with no booking gets a refusal from the tool, and the persona tells them to contact the front desk. The model can't bypass the check, because the check lives inside the only tool that unlocks.
3. At check-in, a staff member calls `register_guest`. The tool's `condition` hides it from everyone whose profile isn't marked `role: 'staff'`.

## Primitives and channels

* [Skill and tools](/concepts/skills-and-tools): `door-access`, with `unlock_door` and `register_guest`.
* Runtime objects: `User.get` and `_luaProfile.mobileNumbers`, `Data.get`, `Data.create`, and `env` for `DOOR_CONTROLLER_URL`, `DOOR_CONTROLLER_KEY`, and `UNLOCK_MS`.
* Channels: WhatsApp. The verified phone number is the whole identity model, so on a channel without one the tool refuses.
* Hardware: a Raspberry Pi with a relay module on BCM pins 17 (front) and 27 (garage), driving a fail-secure electric strike from its own 12 V supply.

<Warning>
  Never power the lock from the Pi. Use a relay module with optical isolation, a separate 12 V supply for the strike, and a flyback diode across the coil.
</Warning>

## The code

The Pi accepts only the door names it knows and caps the pulse length; the agent never sends a pin number.

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

API_KEY = os.environ["EDGE_API_KEY"]
# The only doors that exist, by name; the agent never sends a pin number.
DOORS = {"front": 17, "garage": 27}
MAX_UNLOCK_MS = 10000

app = Flask(__name__)
last_unlock = 0.0


@app.get("/health")
def health():
    return jsonify({"ok": True})


@app.post("/door/unlock")
def unlock():
    global last_unlock
    if request.headers.get("X-API-Key") != API_KEY:
        return jsonify({"error": "unauthorized"}), 401
    body = request.get_json(silent=True) or {}
    door = body.get("door")
    if door not in DOORS:
        return jsonify({"error": "unknown door"}), 400
    if time.time() - last_unlock < 2:
        return jsonify({"error": "rate limited"}), 429
    ms = min(int(body.get("ms", 3000)), MAX_UNLOCK_MS)

    relay = OutputDevice(DOORS[door], active_high=False, initial_value=False)
    try:
        relay.on()
        time.sleep(ms / 1000)
        relay.off()
    finally:
        relay.close()
    last_unlock = time.time()
    return jsonify({"ok": True, "door": door, "ms": ms})


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

The unlock tool takes identity from the profile, checks the booking and the rate limit, calls the Pi, and writes the audit entry; the door names are an enum, so the model can only name doors that exist.

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

export const DOORS = ['front', 'garage'] as const;

export default class UnlockDoorTool implements LuaTool {
  name = 'unlock_door';
  description =
    'Unlock a door for the guest in this conversation. Refuses when they have no active booking for that door.';

  inputSchema = z.object({
    door: z.enum(DOORS).describe('Which door to unlock'),
  });

  async execute(input: z.infer<typeof this.inputSchema>) {
    // Identity comes from the channel's verified phone number, never from the model.
    const user = await User.get();
    const phones = user?._luaProfile.mobileNumbers ?? [];
    if (!user || phones.length === 0) {
      return { unlocked: false, reason: 'This channel has no verified phone number' };
    }

    const now = Date.now();
    const bookings = await Data.get(
      'guests',
      { phone: { $in: phones }, status: 'active', startAt: { $lte: now }, endAt: { $gte: now } },
      1,
      10,
    );
    const booking = bookings.data.find(
      (b) => Array.isArray(b.data.doors) && b.data.doors.includes(input.door),
    );
    if (!booking) {
      return { unlocked: false, reason: 'No active booking for this door; suggest the front desk' };
    }

    // Three unlocks per minute per guest, on top of the throttle on the controller.
    const recent = await Data.get(
      'door_logs',
      { userId: user._luaProfile.userId, at: { $gte: now - 60_000 } },
      1,
      3,
    );
    if (recent.data.length >= 3) {
      return { unlocked: false, reason: 'Too many unlock requests in the last minute' };
    }

    const base = env('DOOR_CONTROLLER_URL');
    const key = env('DOOR_CONTROLLER_KEY');
    if (!base || !key) {
      throw new Error('DOOR_CONTROLLER_URL and DOOR_CONTROLLER_KEY must be set');
    }
    const ms = Number(env('UNLOCK_MS') ?? 3000);
    const res = await fetch(`${base}/door/unlock`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
      body: JSON.stringify({ door: input.door, ms }),
    });
    if (!res.ok) {
      throw new Error(`Door controller responded ${res.status}`);
    }

    await Data.create(
      'door_logs',
      { userId: user._luaProfile.userId, door: input.door, bookingId: booking.id, at: now },
      `unlock ${input.door} ${user._luaProfile.userId}`,
    );

    return { unlocked: true, door: input.door, relocksInSeconds: Math.round(ms / 1000) };
  }
}
```

The staff tool is hidden by its `condition` unless the profile says `role: 'staff'`; a hidden tool is not in the model's tool list at all.

```ts src/skills/tools/RegisterGuestTool.ts theme={null}
import { LuaTool, Data, User } from 'lua-cli';
import { z } from 'zod';
import { DOORS } from './UnlockDoorTool';

export default class RegisterGuestTool implements LuaTool {
  name = 'register_guest';
  description = 'Staff only: give a guest phone number access to doors between check-in and check-out';

  inputSchema = z.object({
    phone: z.string().regex(/^\+[1-9]\d{6,14}$/).describe('Guest WhatsApp number in E.164 form'),
    name: z.string(),
    doors: z.array(z.enum(DOORS)).min(1),
    startAt: z.string().datetime().describe('Check-in, ISO 8601'),
    endAt: z.string().datetime().describe('Check-out, ISO 8601'),
  });

  // Hidden from the model unless the person in the conversation is staff.
  async condition() {
    const user = await User.get();
    return user?.data.role === 'staff';
  }

  async execute(input: z.infer<typeof this.inputSchema>) {
    const startAt = Date.parse(input.startAt);
    const endAt = Date.parse(input.endAt);
    if (startAt >= endAt) {
      return { registered: false, reason: 'Check-out must be after check-in' };
    }

    const entry = await Data.create(
      'guests',
      { phone: input.phone, name: input.name, doors: input.doors, startAt, endAt, status: 'active' },
      `${input.name} ${input.phone} ${input.doors.join(' ')}`,
    );

    return { registered: true, bookingId: entry.id, doors: input.doors, validUntil: input.endAt };
  }
}
```

The skill's `context` tells the model that a refusal is final.

```ts src/skills/door-access.skill.ts theme={null}
import { LuaSkill } from 'lua-cli';
import UnlockDoorTool from './tools/UnlockDoorTool';
import RegisterGuestTool from './tools/RegisterGuestTool';

export default new LuaSkill({
  name: 'door-access',
  description: 'Unlock building doors for guests with an active booking',
  context: `Guests message on WhatsApp to open a door. Ask which door if it is unclear (front or
garage), then call unlock_door; it checks the booking itself, so never argue with a refusal.
Report how long the door stays open. Staff use register_guest at check-in.`,
  tools: [new UnlockDoorTool(), new RegisterGuestTool()],
});
```

The persona keeps replies short and never describes the hardware.

```ts src/index.ts theme={null}
import { LuaAgent } from 'lua-cli';
import doorAccessSkill from './skills/door-access.skill';

const agent = new LuaAgent({
  name: 'harbor-house-doors',
  persona: `You are the door assistant for Harbor House.
Be brief. Unlock only through the tool, confirm the door name before unlocking, and when
access is refused, say why in one sentence and point the guest to the front desk.
Never mention pins, wiring, or the controller.`,
  skills: [doorAccessSkill],
});
```

## 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, pulse the front door once.

```bash theme={null}
curl -X POST http://raspberrypi.local:5001/door/unlock \
  -H "X-API-Key: <secret>" -H "Content-Type: application/json" \
  -d '{"door":"front","ms":3000}'
```

In the agent project, set the variables and run the tool. Under `lua test`, `User.get()` resolves to your own developer profile; with no channel-verified phone number on it, the tool refuses before it touches the Pi, which is what it does on any channel without a verified number.

```bash theme={null}
lua env sandbox -k DOOR_CONTROLLER_URL -v http://raspberrypi.local:5001
lua env sandbox -k DOOR_CONTROLLER_KEY -v <secret>
lua test --ci skill --name unlock_door --input '{"door":"front"}'
```

```text Output theme={null}
✅ Compiled 4 primitives (1 agent, 1 skill, 2 tools) in 520ms
✅ Selected tool: unlock_door
Input: {
  "door": "front"
}
🚀 Executing tool...
✅ Tool execution successful!

Tool returned: Object — fields: unlocked, reason
Output:
{ unlocked: false, reason: 'This channel has no verified phone number' }
```

For the real thing, connect a WhatsApp number ([WhatsApp](/channels/whatsapp)), point `DOOR_CONTROLLER_URL` at an address the platform can reach (see [Ways to make it yours](#ways-to-make-it-yours)), and set the variables with `lua env production`. 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), register your own number with `register_guest` from a staff conversation, and send "open the front door" from that phone.

## Ways to make it yours

* Deployed code runs on the platform, so `DOOR_CONTROLLER_URL` must be an HTTPS address the platform can reach, such as a reverse proxy or tunnel in front of the Pi; `raspberrypi.local` works only for `lua test` on your own network. To avoid an inbound port, connect the Pi as a [device](/concepts/devices) instead, so it dials out and its commands become tools.
* Mark staff by writing `role: 'staff'` to their profile from a tool or the [user data REST endpoints](/reference/rest/user-data); `condition` hides `register_guest` from everyone else.
* Create bookings from your property system with a [webhook](/build/handle-a-webhook) instead of `register_guest`, writing the same fields to `guests`.
* Tune the limits: three unlocks per minute in `unlock_door`, a two-second throttle and a 10-second cap in `edge_api.py`, and `UNLOCK_MS` per building.

## Next steps

<Columns cols={2}>
  <Card title="Identify users" href="/build/identify-users">
    What the profile holds per channel and how to add fields such as `role`.
  </Card>

  <Card title="Connect your first device" href="/devices/quickstart">
    Let hardware dial out to the platform instead of exposing an HTTP port.
  </Card>

  <Card title="Store and search data" href="/build/store-and-search-data">
    Filters, pagination, and indexes for collections such as `guests`.
  </Card>
</Columns>
