# Answer keywords like MENU and HOURS automatically

> Reply from your own number as soon as somebody texts a keyword, with keyword rules in the dashboard or with a webhook handler that looks the answer up in your own data.

Rosa runs a taco truck. Her sign says “Text MENU, HOURS or WHERE to 555-555-0100”, and while she’s cooking she can’t answer. She wants each of those words answered within seconds from her own number, and WHERE answered with wherever the truck is parked today, which changes every morning.

## What you’ll have

- An answer from your number within seconds to anybody who texts one of your keywords.
- Everything else left for you to answer yourself.
- Stop words handled the usual way: STOP, UNSUBSCRIBE and the rest are never treated as keywords.

## What you need

- A BlueBot account with your Mac connected, and a Mac that stays awake: an answer can only go while it’s on.
- Your keywords and their answers.

### Let BlueBot see texts from new people

Keywords are mostly texted by people you’ve never texted. By default the Mac copies to BlueBot only the chats with people BlueBot has texted, so a stranger’s MENU would stay in Messages and never reach a rule. Open **Settings** in the dashboard and set **Which chats to copy** to **Everything**, or make this request:

```bash
curl -X PATCH "https://usebluebot.com/api/v1/settings" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inbound_mode": "everything"
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const settings = await client.settings.update({
  inboundMode: 'everything',
});
console.log(settings);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

settings = client.settings.update(inbound_mode="everything")
print(settings)
```

That copies every chat on your Mac, personal ones included, into BlueBot. If your business number is also your personal number, think about that before you turn it on.

There are two ways to build the replies. Keyword rules in the dashboard need no code and suit answers that stay the same. The API way is a webhook handler, for answers that come from your own data, such as today’s location.

## With keyword rules

Keyword rules are set up in the dashboard; they have no API yet.

### Make a rule for each keyword

In the dashboard, open **Automations**, then **Keyword replies**, and press **New rule**. Set it to match when the **whole message** is `MENU`. Capital letters don’t matter, so `menu` and `Menu` match too.

Choose **Reply with a template** and write the answer, or pick a saved template:

```text title="MENU"
Hi from Rosa’s Tacos. Tacos $3.50 (carnitas, al pastor, pollo, hongos), burrito $11, quesadilla $9, horchata $4. Text WHERE to find the truck today.
```

Add a rule for `HOURS` the same way:

```text title="HOURS"
We’re open Tuesday to Saturday, 11 AM to 2 PM and 5 PM to 9 PM. Text WHERE to find the truck today.
```

### Add what each rule does besides replying

A rule can also tag the person (`menu-request`) or enroll them in a [sequence](https://usebluebot.com/docs/guides/drip-sequence). Tag MENU requests, so you know who asked when you post a special later.

### Put the rules in order

The first rule that matches wins. Put whole-message rules above any **contains** or **pattern** rule, so a text like “menu please” isn’t caught by a broader rule first. Then save.

A text that matches no rule is left for you to answer from the inbox or your phone.

Keep WHERE out of the rules if it changes every day: a rule’s answer is fixed until you edit it. The API way below answers it from wherever you record the day’s spot.

> **Words that can’t be keywords.** STOP, STOPALL, UNSUBSCRIBE, CANCEL, END and QUIT put the person on your stop list, and START, UNSTOP and YES take somebody who stopped by text back off it. Those belong to the stop list, and a keyword rule never fires on them.

## With the API

The handler gets every incoming text through a webhook, reads it the way BlueBot reads stop words (trimmed, in lower case, without a full stop or an exclamation mark at the end), and answers the keywords it knows. The answer’s Idempotency-Key is the incoming text’s id, so a webhook delivered twice is answered once.

### Make a webhook for incoming texts

In the dashboard, open **Developers**, then **Webhooks**, and add your handler’s address with the `message.received` event. Copy the signing secret it shows once.

```bash
curl -X POST "https://usebluebot.com/api/v1/webhooks" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://rosastacos.example/hooks/bluebot",
    "events": [
      "message.received"
    ],
    "description": "Keyword replies"
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.create({
  url: 'https://rosastacos.example/hooks/bluebot',
  events: ['message.received'],
  description: 'Keyword replies',
});
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.create(
    url="https://rosastacos.example/hooks/bluebot",
    events=["message.received"],
    description="Keyword replies",
)
print(webhook)
```

### Write the handler

```ts title="TypeScript"
// app/hooks/bluebot/route.ts (Next.js)
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';
import { todaysSpot } from '@/lib/schedule'; // your own data: where the truck is today

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const ANSWERS: Record<string, () => Promise<string> | string> = {
  menu: () => 'Hi from Rosa’s Tacos. Tacos $3.50 (carnitas, al pastor, pollo, hongos), burrito $11, quesadilla $9, horchata $4. Text WHERE to find the truck today.',
  hours: () => 'We’re open Tuesday to Saturday, 11 AM to 2 PM and 5 PM to 9 PM. Text WHERE to find the truck today.',
  where: async () => {
    const spot = await todaysSpot();
    return spot ? `Today we’re at ${spot.place} until ${spot.until}. See you there.` : 'We’re closed today. Text HOURS for when we’re open.';
  },
};

// The way BlueBot reads a stop word: trimmed, lower case, no full stop or exclamation mark at the end.
const keywordOf = (text: string | null) => (text ?? '').trim().toLowerCase().replace(/[.!]+$/, '');

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 });
    throw error;
  }

  if (event.type !== 'message.received') return new Response(null, { status: 204 });
  const message = event.data;
  const answer = ANSWERS[keywordOf(message.text)];
  if (!answer || message.chatId || !message.from) return new Response(null, { status: 204 }); // not a keyword, or a group chat

  await client.messages.send(
    { to: message.from, text: await answer(), metadata: { keyword: keywordOf(message.text), in_reply_to: message.id } },
    { idempotencyKey: `keyword-${message.id}` },
  );
  return new Response(null, { status: 204 });
}
```

```python title="Python"
# Flask
import os
import re

from flask import Flask, abort, request
from bluebot import BlueBot, WebhookVerificationError

from .schedule import todays_spot  # your own data: where the truck is today

app = Flask(__name__)
client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

def where() -> str:
    spot = todays_spot()
    if not spot:
        return "We’re closed today. Text HOURS for when we’re open."
    return f"Today we’re at {spot.place} until {spot.until}. See you there."

ANSWERS = {
    "menu": lambda: "Hi from Rosa’s Tacos. Tacos $3.50 (carnitas, al pastor, pollo, hongos), burrito $11, "
    "quesadilla $9, horchata $4. Text WHERE to find the truck today.",
    "hours": lambda: "We’re open Tuesday to Saturday, 11 AM to 2 PM and 5 PM to 9 PM. Text WHERE to find the truck today.",
    "where": where,
}

def keyword_of(text: str | None) -> str:
    # The way BlueBot reads a stop word: trimmed, lower case, no full stop or exclamation mark at the end.
    return re.sub(r"[.!]+$", "", (text or "").strip().lower())

@app.post("/hooks/bluebot")
def bluebot_event():
    try:
        event = client.webhooks.verify(request.get_data(), request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        abort(400)

    if event.type != "message.received":
        return "", 204
    message = event.data
    answer = ANSWERS.get(keyword_of(message.text))
    if not answer or message.chat_id or not message.from_:
        return "", 204  # not a keyword, or a group chat

    client.messages.send(
        to=message.from_,
        text=answer(),
        metadata={"keyword": keyword_of(message.text), "in_reply_to": message.id},
        idempotency_key=f"keyword-{message.id}",
    )
    return "", 204
```

The answer to a WHERE, as a request:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: keyword-msg_2Bf8QzD4kJmA6nXwEuTr1c" \
  -d '{
    "to": "+15555550123",
    "text": "Today we’re at Pier 7, by the ferry, until 2 PM. See you there.",
    "metadata": {
      "keyword": "where",
      "in_reply_to": "msg_2Bf8QzD4kJmA6nXwEuTr1c"
    }
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const message = await client.messages.send({
  to: '+15555550123',
  text: 'Today we’re at Pier 7, by the ferry, until 2 PM. See you there.',
  metadata: {
    keyword: 'where',
    in_reply_to: 'msg_2Bf8QzD4kJmA6nXwEuTr1c',
  },
}, { idempotencyKey: 'keyword-msg_2Bf8QzD4kJmA6nXwEuTr1c' });
console.log(message);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

message = client.messages.send(
    to="+15555550123",
    text="Today we’re at Pier 7, by the ferry, until 2 PM. See you there.",
    metadata={
        "keyword": "where",
        "in_reply_to": "msg_2Bf8QzD4kJmA6nXwEuTr1c",
    },
    idempotency_key="keyword-msg_2Bf8QzD4kJmA6nXwEuTr1c",
)
print(message)
```

### Leave stop words alone

The handler needs no code for STOP: BlueBot has already put the person on your stop list by the time your webhook hears about it, and it cancels any text to them, so a keyword in your map that happened to be a stop word would never be answered anyway. Keep `stop`, `start`, `yes` and the other stop and start words out of your map so nobody is surprised.

Answer texts as fast as you can, but answer the webhook first if your lookup is slow: BlueBot waits 10 seconds for a reply before it counts the delivery as failed and tries again. The Idempotency-Key makes the retry safe either way.

## Test it

Text MENU to your BlueBot number from your own phone, then HOURS, then a text that isn’t a keyword.

| You got                                              | It means                                                                                                                      | Do next                                                                                                                                                   |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The menu on your phone within seconds                | It works.                                                                                                                     | Try `Menu.` and `menu!` too.                                                                                                                              |
| Nothing, and your MENU text isn’t in BlueBot’s inbox | The Mac didn’t copy it: **Which chats to copy** is on **People BlueBot has texted**, and BlueBot has never texted your phone. | Set it to **Everything**, as above.                                                                                                                       |
| Your text is in the inbox, but no answer             | No rule matched, or your handler didn’t answer.                                                                               | Check the rule matches the whole message. For the API way, the webhook’s deliveries in **Developers**, then **Webhooks** show what your handler answered. |
| The answer waits with `hold.reason: "quiet_hours"`   | It’s quiet hours, and answers wait like any text.                                                                             | Shorten or turn off quiet hours if people text late, in **Settings**, then **Sending rules**.                                                             |
| Two answers to one MENU                              | Two rules matched, or a rule and your handler both answered.                                                                  | Use one way per keyword.                                                                                                                                  |

## When it goes wrong

- **The Mac is asleep.** A text that arrives while the Mac sleeps is read when it wakes, and the answer goes then. Keep a Mac that answers keywords awake: in **System Settings**, **Energy**, stop it sleeping when the display is off.
- **A keyword in a sentence.** “What’s on the menu?” doesn’t match a whole-message rule. Add a **contains** rule for `menu` below the whole-message ones if you want it answered, and expect it to catch “I loved the menu” too.
- **Somebody texts MENU every five minutes.** Each one is answered. A handler can remember when it last answered a person and skip a repeat within an hour.
- **Quiet hours.** A keyword answer is a text like any other and waits through quiet hours. For a business that’s open late, set quiet hours to match.

## Next

- [Add a “Text us” button](https://usebluebot.com/docs/guides/text-us-button): Bring people to your number from your website.
- [Follow up three times](https://usebluebot.com/docs/guides/drip-sequence): Enroll a keyword texter in a sequence that stops on reply.
- [Stop words](https://usebluebot.com/docs/concepts/stop-words): The words BlueBot keeps for the stop list.
- [Receiving texts](https://usebluebot.com/docs/sending/receiving): Which chats the Mac copies and what a text in carries.
- [Webhook events](https://usebluebot.com/docs/webhooks/events#messagereceived): The `message.received` payload, field by field.
- [AI receptionist](https://usebluebot.com/docs/guides/ai-receptionist): Use Claude to answer any text, including ones with no keyword.
