All posts

Build an AI guest-messaging agent for Airbnb with Claude

Connect Claude to an Airbnb inbox with MCP in 5 minutes, then ship a webhook agent that answers guests on Airbnb, Booking.com and Vrbo. Tested code.

RepullΒ·November 11, 2026Β·10 min read

What you'll build. First, Claude connected to your Airbnb inbox through an MCP server, so you can ask "who hasn't sent an arrival time?" and get answers from live data (5 minutes, no code). Then a small production agent: a guest writes on Airbnb, your server gets a signed webhook, Claude decides whether to answer or hand off to a human, and the reply goes back on the same conversation. The same code handles Booking.com and Vrbo.

Every command and the full agent below were run end to end against a live Repull workspace on November 11, 2026.

What you need

  • A Repull API key: the free plan is a live key, no card.
  • An Airbnb host account with at least one listing.
  • An Anthropic API key, for Part 2.
  • Node.js 20 or newer.

You don't need your own Airbnb partner access, which Airbnb isn't granting to new applicants. Your host account connects through Repull's existing Airbnb partner integration. (Why: How to get Airbnb API access in 2026.)


Step 0: connect Airbnb in messaging mode

Messaging access lets you read reservations and conversations, send guest messages and reply to reviews. It can't touch listings, prices or the calendar, and it works even when the account is already connected to a PMS such as Hostaway or Guesty, because Airbnb limits only full property-management access to one app.

curl -X POST https://api.repull.dev/v1/connect/airbnb \
  -H "Authorization: Bearer $REPULL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"redirectUrl": "https://example.com/done", "accessType": "messaging"}'

Open the url in the response, approve on Airbnb, and you're connected. Check that your conversations are there:

curl "https://api.repull.dev/v1/conversations?limit=5" \
  -H "Authorization: Bearer $REPULL_API_KEY"

Part 1: your Airbnb inbox inside Claude (5 minutes, no code)

Repull publishes an open-source MCP server, @repull/mcp. It gives Claude read tools for reservations, listings, guests and conversations.

Claude Code:

claude mcp add repull \
  --env REPULL_API_KEY=sk_live_YOUR_KEY \
  -- npx -y @repull/mcp

Claude Desktop (claude_desktop_config.json) or Cursor (~/.cursor/mcp.json):

{
  "mcpServers": {
    "repull": {
      "command": "npx",
      "args": ["-y", "@repull/mcp"],
      "env": { "REPULL_API_KEY": "sk_live_YOUR_KEY" }
    }
  }
}

Restart, then ask:

Who is checking in this week, and which of them haven't told us their arrival time?

Summarise the last conversation with each guest arriving in the next 3 days. Flag anything that needs a human.

Draft a reply to the newest unanswered guest message.

Claude calls tools such as repull_list_reservations, repull_list_conversations and repull_list_conversation_messages and answers from your live data.

That's a useful assistant, but it only acts when you ask, and the npm server reads only. To let Claude send replies from a chat, with a confirmation before each send, use the Repull AI Connector in Claude or ChatGPT (no keys: add Repull under Settings β†’ Connectors in Claude, or Settings β†’ Apps in ChatGPT). See also the Airbnb MCP server. Part 2 makes the agent answer on its own.


Part 2: an agent that answers guests on its own (25 minutes)

Guest writes on Airbnb
  β†’ Repull sends reservation.message.received to your server (signed)
  β†’ your server loads the conversation and reservation from Repull
  β†’ Claude returns { action: "reply" | "handoff", message, reason }
  β†’ reply: POST it to the conversation  Β·  handoff: alert a human

1. Set up the project

mkdir guest-agent && cd guest-agent
npm init -y && npm pkg set type=module
npm install express @anthropic-ai/sdk zod
npm install -D tsx typescript @types/express @types/node

.env:

REPULL_API_KEY=sk_live_...
REPULL_WEBHOOK_SECRET=whsec_...   # from step 3
ANTHROPIC_API_KEY=sk-ant-...

2. Give the agent your house facts

Claude should answer only from facts you provide. Put them in listings.json, keyed by Repull listing id (GET /v1/listings shows the ids):

{
  "42399": {
    "name": "Sable Loft",
    "checkIn": "4:00 pm, self check-in with keypad (code sent on arrival day)",
    "checkOut": "10:00 am",
    "wifi": "Network SableLoft, password on the fridge",
    "parking": "One free spot, P2 level, space 14",
    "earlyCheckIn": "Possible from 2 pm if the night before is empty; ask the host",
    "pets": "Not allowed",
    "neighbourhood": "Grocery store 5 minutes' walk; hot springs 10 minutes' drive"
  }
}

3. Subscribe to guest messages

curl -X POST https://api.repull.dev/v1/webhooks \
  -H "Authorization: Bearer $REPULL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://YOUR-TUNNEL.example.com/webhooks/repull",
    "events": ["reservation.message.received"],
    "description": "guest agent"
  }'

The response includes secret, shown once: put it in REPULL_WEBHOOK_SECRET. For local development, expose port 3000 with any tunnel, for example cloudflared tunnel --url http://localhost:3000.

4. The agent

agent.ts:

import express from "express";
import crypto from "node:crypto";
import fs from "node:fs";
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";

const REPULL = "https://api.repull.dev";
const listings: Record<string, Record<string, string>> = JSON.parse(
  fs.readFileSync("listings.json", "utf8"),
);
const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY
const seen = new Set<string>(); // use your database in production

// --- 1. Verify Repull's signature: HMAC-SHA256 over "<t>.<raw body>" ---
function verify(raw: Buffer, header: string | undefined): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-minute window
  const expected = crypto
    .createHmac("sha256", process.env.REPULL_WEBHOOK_SECRET!)
    .update(`${parts.t}.${raw.toString("utf8")}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function repull(path: string, init: RequestInit = {}) {
  const res = await fetch(`${REPULL}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.REPULL_API_KEY}`,
      "Content-Type": "application/json",
      ...(init.headers as Record<string, string>),
    },
  });
  const body = await res.json();
  if (!res.ok) {
    throw Object.assign(new Error(body?.error?.message ?? res.statusText), { status: res.status, body });
  }
  return body;
}

// --- 2. What Claude must return ---
const Decision = z.object({
  action: z.enum(["reply", "handoff"]),
  message: z.string().describe("The reply to send to the guest. Empty when handing off."),
  reason: z.string().describe("One line for the host: why this action."),
});

const SYSTEM = `You answer guests of a short-term rental on the host's behalf.
Answer ONLY from the house facts and reservation details you are given.
If the answer is not in them, or the guest asks for a refund, a discount, a date change,
reports damage, a safety issue, or is upset: choose "handoff".
Write like a friendly, brief host. Match the guest's language.
Never include links, email addresses or phone numbers: the channel may refuse the message.`;

async function handle(event: any) {
  const { threadId, reservationId, listingId, body } = event.data;

  // Load context: the newest 30 messages (oldest first for the prompt) and the reservation.
  const thread = await repull(`/v1/conversations/${threadId}/messages?limit=30&order=desc`);
  const transcript = thread.data
    .reverse()
    .map((m: any) => `${m.direction === "inbound" ? "Guest" : "Host"}: ${m.body ?? ""}`)
    .join("\n");

  const reservation = reservationId ? await repull(`/v1/reservations/${reservationId}`) : null;
  const facts = listings[String(reservation?.listingId ?? listingId)];

  const prompt = [
    `House facts:\n${JSON.stringify(facts ?? "none provided", null, 2)}`,
    reservation
      ? `Reservation: ${reservation.guestName}, ${reservation.checkIn} to ${reservation.checkOut}, status ${reservation.status}, via ${reservation.platform}.`
      : "No reservation yet (this is an inquiry).",
    `Conversation so far:\n${transcript || "(no earlier messages)"}`,
    `Newest guest message:\n${body}`,
  ].join("\n\n");

  const response = await anthropic.messages.parse({
    model: "claude-opus-5-5",
    max_tokens: 4000,
    output_config: { effort: "low", format: zodOutputFormat(Decision) },
    system: SYSTEM,
    messages: [{ role: "user", content: prompt }],
  });

  const decision = response.parsed_output;
  if (response.stop_reason === "refusal" || !decision || decision.action === "handoff" || !facts) {
    console.log(`HANDOFF thread ${threadId}: ${decision?.reason ?? "no decision"}`);
    return; // notify the host here: Slack, SMS, email
  }

  // --- 3. Reply on the same conversation. The Idempotency-Key makes a retry safe. ---
  try {
    const sent = await repull(`/v1/conversations/${threadId}/messages`, {
      method: "POST",
      headers: { "Idempotency-Key": `agent-${event.eventId}` },
      body: JSON.stringify({ message: decision.message }),
    });
    console.log(`REPLIED thread ${threadId} (message ${sent.id ?? "?"}): ${decision.message}`);
  } catch (err: any) {
    // e.g. 422 message_not_sent: the channel refused it. Hand off instead of retrying blindly.
    console.log(`SEND FAILED thread ${threadId}: ${err.status} ${err.body?.error?.code}`);
  }
}

const GUEST_SIDE = new Set(["guest", "booker", "cotraveler"]);

const app = express();
app.post("/webhooks/repull", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.header("X-Repull-Signature"))) {
    res.sendStatus(401);
    return;
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // acknowledge first; Repull retries anything that isn't 2xx

  if (event.event !== "reservation.message.received") return;
  if (!GUEST_SIDE.has(event.data?.from?.type)) return; // never answer the host, the channel or support
  if (seen.has(event.eventId)) return; // eventId is stable across retries and replays
  seen.add(event.eventId);

  handle(event).catch((e) => console.error(`agent error: ${e.status ?? ""} ${e.message}`));
});

app.listen(3000, () => console.log("guest agent on :3000"));

Run it:

npx tsx --env-file=.env agent.ts

A few choices in that file are deliberate:

  • The signature is checked over the raw body, before parsing, with a 5-minute window and a constant-time compare. A bad signature gets 401.
  • It acknowledges first, then works. Repull retries any delivery that doesn't get a 2xx within 10 seconds.
  • Structured output. messages.parse with a Zod schema means the reply is always { action, message, reason }, never free text you have to interpret.
  • No facts, no answer. If the listing has no entry in listings.json, the agent hands off instead of guessing.

5. Test it

Ask Repull to send a sample event to your endpoint (use the subscription id from step 3):

curl -X POST https://api.repull.dev/v1/webhooks/$WEBHOOK_ID/test/reservation.message.received \
  -H "Authorization: Bearer $REPULL_API_KEY"

The response shows "success": true, "statusCode": 200 once your signature check passes. The sample event refers to an example conversation that isn't in your workspace, so the agent logs agent error: 404 Conversation … not found after acknowledging it: that's expected, and it shows the error path works.

For the real test, message your own listing from a guest account ("What time can we check in, and is there parking?") and watch the reply arrive in the Airbnb thread. In our run, the agent answered:

Hi Sam! Early check-in from 2 pm is sometimes possible if the night before is free. I'll check with the host and confirm. The wifi network is SableLoft, and you'll find the password on the fridge. See you soon!

Then try "Our flight got cancelled, can I get a refund for the first night?" and confirm it hands off. Ours logged: HANDOFF … Guest requests a refund for the first night due to a cancelled flight; refunds require host decision.

Every delivery and its response code is listed under GET /v1/webhooks/{id}/deliveries and in the dashboard, where you can replay any delivery.


Things that will bite you in production

  • Answer once per event. Repull retries a failed delivery 5 times over about 26 hours, and a replay reuses the same eventId. Dedupe on eventId in your database, not in memory, and keep the Idempotency-Key on the send.
  • Only answer the guest side. from.type is guest, booker or cotraveler for guests; host replies and messages from Airbnb support arrive on the same webhook stream.
  • No contact details or links. Airbnb refuses messages it reads as taking the guest off-platform. A refused send comes back as 422 message_not_sent: hand off, don't retry.
  • Ground every answer. The facts check makes the agent hand off when it has nothing to go on. Don't remove it to "improve coverage".
  • Let a human see everything. Log each decision with its reason. Run in draft mode for a week (post decisions to Slack, send nothing), then turn sending on for the categories it gets right.
  • Quiet hours and handoffs matter more than prompt wording. A 2 a.m. "I'll check with the host" beats a wrong door code.

Add Booking.com and Vrbo

The agent doesn't change. Connect more channels and the same webhook fires for them, with data.channel and, for Booking.com, data.hotelId:

  • Booking.com, without replacing the host's channel manager: the Extranet login reads reservations, messages and reviews and sends replies.
  • Vrbo: the host signs in with their Vrbo account; accessType: "messaging" keeps it to messages, inquiries and reviews.

Both connect from the same hosted Connect session (POST /v1/connect). See Use Repull alongside a PMS.

Where to go next

  • Build it for many hosts. Give each customer a workspace, send them through hosted Connect in your branding, and route events by account. Repull for platforms.
  • Reviews. Subscribe to review.created and draft a reply the same way.
  • Inquiries. inquiry.created plus pre-approval turns the agent into a booking assistant.

Questions: hello@repull.dev.


Related: How to get Airbnb API access in 2026 Β· The Airbnb API Β· MCP server docs Β· Verify webhook signatures Β· Channel APIs compared

Run it on your own listing

The Free plan is a live key. Connect one listing in messaging mode and point the agent at it.

Get a free API key