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

# One Agent for Many Units

> A hotel chain served by a single agent: each hotel is configured in the portal and picked by the WhatsApp number that received the message

A hotel chain wants one WhatsApp assistant per hotel. Every hotel has its own name, check-in times,
amenities, PMS code, payment merchant and escalation rules. Writing one agent per hotel means 90
copies of the same code. Writing one agent with the hotels hardcoded means a deploy every time the
front desk changes a check-out time.

This guide builds **one agent** that serves every hotel:

* each hotel is an item of a [collection](https://docs.runflow.ai/core-concepts/agent-config.md#collections-one-agent-many-units),
  edited by the operations team in the portal;
* the [Conversation Hub](https://docs.runflow.ai/core-concepts/conversation-hub.md) sends the id of the WhatsApp number that
  received the message (`phoneNumberId`), and the agent uses it to pick the hotel;
* a new hotel goes live by adding an item in the portal and listing its number. No deploy.

```mermaid theme={null}
flowchart LR
  G[Guest on WhatsApp] --> H[Conversation Hub]
  H -- "phoneNumberId" --> A[Single agent]
  P[Portal: Configuration tab] -- "hotels{...}" --> A
  A -- "hotel whose channelIds include the number" --> R[Answer with that hotel's rules]
```

## 1. Declare the hotel schema

```ts manifest/index.ts theme={null}
import { z } from 'zod/v4';
import { defineManifest } from '@runflow-ai/sdk';

const hotel = z.object({
  name: z.string().min(1).meta({ title: 'Name' }),
  channelIds: z
    .array(z.string())
    .meta({ title: 'WhatsApp numbers', description: 'Phone number ids (from the Meta app) that serve this hotel' }),
  checkIn: z.string().default('14:00').meta({ title: 'Check-in' }),
  checkOut: z.string().default('12:00').meta({ title: 'Check-out' }),
  amenities: z
    .object({
      pool: z.boolean().default(false).meta({ title: 'Pool' }),
      parking: z.boolean().default(false).meta({ title: 'Parking' }),
      parkingNotes: z
        .string()
        .optional()
        .meta({ title: 'Parking notes', visibleWhen: { field: 'parking', equals: true } }),
    })
    .meta({ title: 'Amenities' }),
  pms: z
    .object({
      propertyCode: z.string().min(1).meta({ title: 'Property code' }),
      chainCode: z.string().default('ATR').meta({ title: 'Chain code' }),
    })
    .meta({ title: 'PMS' }),
  payment: z
    .object({
      merchantId: z.string().min(1).meta({ title: 'Merchant ID' }),
      linkExpiryHours: z.number().int().min(1).max(168).default(24).meta({ title: 'Link expiry', unit: 'h' }),
    })
    .meta({ title: 'Payment' }),
  handoff: z
    .object({
      queue: z.string().default('front-desk').meta({ title: 'Queue' }),
      topics: z.array(z.string()).default(['complaint', 'refund']).meta({ title: 'Topics that go to a human' }),
    })
    .meta({ title: 'Handoff' }),
  notes: z
    .string()
    .max(4000)
    .optional()
    .meta({ title: 'Extra instructions', description: 'Added to the prompt', ui: { widget: 'textarea' } }),
});

export const configSchema = z.object({
  tone: z
    .enum(['formal', 'casual'])
    .default('formal')
    .meta({ title: 'Tone', ui: { labels: { formal: 'Formal', casual: 'Casual' } } }),
  hotels: z
    .record(z.string(), hotel)
    .default({})
    .meta({
      title: 'Hotels',
      description: 'One item per hotel',
      ui: { itemLabel: 'name', itemName: 'hotel', itemSubtitle: 'pms.propertyCode' },
    }),
});

export default defineManifest({
  config: configSchema,
  playground: {
    schema: z.object({ phoneNumberId: z.string().meta({ title: 'WhatsApp phone number id' }) }),
    fixtures: [
      { name: 'Hotel Copacabana', metadata: { phoneNumberId: '100000000000001' } },
      { name: 'Number without a hotel', metadata: { phoneNumberId: '100000000000999' } },
    ],
  },
});
```

What each piece buys you in the portal:

* `itemLabel: 'name'` lists the hotels by name, and `itemSubtitle: 'pms.propertyCode'` shows the PMS
  code next to it, so two "Hotel Centro" in different cities are easy to tell apart.
* The nested objects (`amenities`, `pms`, `payment`, `handoff`) become sub-sections of each hotel,
  reachable from the side rail.
* `.min(1)` on `name`, `propertyCode` and `merchantId` makes them required: a hotel can't be saved
  without them.
* Fields with `.default()` come pre-filled for every new hotel.

## 2. Route by number

```ts main.ts theme={null}
import { Agent, openai, config, configInfo, readMetadata } from '@runflow-ai/sdk';
import type { z } from 'zod/v4';
import { configSchema } from './manifest';

type Config = z.infer<typeof configSchema>;
type Hotel = Config['hotels'][string];

function numberOf(input): string | undefined {
  const fromTester = readMetadata<{ phoneNumberId?: string }>(input).phoneNumberId;
  return fromTester ?? input.request?.body?.phoneNumberId;
}

function hotelFor(cfg: Config, phoneNumberId: string | undefined) {
  if (!phoneNumberId) return null;
  const match = Object.entries(cfg.hotels).find(([, h]) => h.channelIds.includes(phoneNumberId));
  return match ? { id: match[0], hotel: match[1] } : null;
}

function instructionsFor(cfg: Config, hotel: Hotel): string {
  const parking = hotel.amenities.parking ? hotel.amenities.parkingNotes ?? 'available' : 'not available';
  return [
    `You are the WhatsApp assistant of ${hotel.name}. Use a ${cfg.tone} tone.`,
    `Check-in from ${hotel.checkIn}, check-out until ${hotel.checkOut}.`,
    `Pool: ${hotel.amenities.pool ? 'yes' : 'no'}. Parking: ${parking}.`,
    `Hand the conversation to the "${hotel.handoff.queue}" queue when the topic is: ${hotel.handoff.topics.join(', ')}.`,
    hotel.notes ?? '',
  ]
    .filter(Boolean)
    .join('\n');
}

export async function main(input) {
  const cfg = config<Config>();
  const phoneNumberId = numberOf(input);
  const match = hotelFor(cfg, phoneNumberId);

  if (!match) {
    return {
      message: 'Thanks for your message! A member of our team will reply shortly.',
      handoff: true,
      reason: `no hotel configured for number ${phoneNumberId ?? '(none)'}`,
    };
  }

  const agent = new Agent({
    name: `Concierge ${match.hotel.name}`,
    model: openai('gpt-4o'),
    instructions: instructionsFor(cfg, match.hotel),
  });

  const output = await agent.process(input);
  return { ...output, hotelId: match.id, config: configInfo() };
}
```

A few choices in this code:

* **The Hub sends `phoneNumberId` at the top of the request body**, next to `metadata` (which carries
  `conversationId`, `contactPhone` and `contactName`). The portal tester and `rf test` send metadata
  only, so `numberOf` reads the playground value first and the Hub field after it.
* **An unknown number hands off** instead of guessing a hotel. A guest of hotel A should never get
  hotel B's prices because a number wasn't listed yet.
* **`hotelId` and `configInfo()` in the output** let you trace, in the execution logs, which hotel and
  which config version produced each answer.

> [!TIP]
>
> Keep the PMS and payment calls in tools that receive `match.hotel.pms` and `match.hotel.payment` as
> parameters. The tool code stays the same for every hotel; only the config changes.

## 3. Deploy and add the first hotels

```bash theme={null}
rf agents deploy
```

Open the agent in the portal and go to **Configuration**. In staging, click **+** next to **Hotels**,
type the name (the id is suggested from it), and fill in the fields. Required fields are marked, and
the save stays blocked until they are filled.

![Hotel form in dark mode with the Amenities sub-section selected](https://mintcdn.com/runflowai/omhuHUWlUZuaGpxb/images/agent-config/workbench-dark.jpg?fit=max\&auto=format\&n=omhuHUWlUZuaGpxb\&q=85\&s=31e54f3d0ef2e4603f6614e8b7a2c078)

Each hotel opens as one form, with its sub-sections in the rail

To bring a batch of hotels in from a spreadsheet, declare them in `manifest/staging.json` and deploy
once. They show up in the portal and the team can adjust them from there:

```json manifest/staging.json theme={null}
{
  "hotels": {
    "hotel-centro": {
      "name": "Hotel Centro",
      "channelIds": ["100000000000002"],
      "amenities": { "pool": false, "parking": true, "parkingNotes": "Valet, R$ 40/day" },
      "pms": { "propertyCode": "CTR01" },
      "payment": { "merchantId": "merchant-ctr" }
    }
  }
}
```

## 4. Test before going live

In the agent tester, pick the **Hotel Copacabana** scenario (from the playground fixtures) and send a
message: the answer should use Copacabana's check-in time. Then pick **Number without a hotel** and
confirm the handoff.

Locally, pull the staging values and run:

```bash theme={null}
rf config pull
rf test
```

## 5. Promote

When staging is right, click **Promote to production** in the Configuration tab. The dialog lists every
field and hotel that changes. Promote the agent code first if this is the first version with the
`hotels` field.

## Operating it day to day

| Task | Where | Deploy? |
| - | - | - |
| Change a hotel's check-out time | Configuration tab, staging, then promote | No |
| Open a new hotel | Add an item, list its phone number id, promote | No |
| Move a WhatsApp number to another hotel | Edit `channelIds` on both hotels | No |
| Add a new field to every hotel | Add it to the schema with a `.default()`, deploy | Yes |
| Undo a bad change | **History**, pick the version, **Restore** | No |

> [!WARNING]
>
> If two hotels list the same phone number id, the agent picks the first one it finds. When you move a
> number between hotels, remove it from the old hotel in the same save.

## Next steps

> [card] [Agent Config](https://docs.runflow.ai/core-concepts/agent-config.md)
>
> Every schema type, UI hint and CLI command

> [card] [Conversation Hub SDK](https://docs.runflow.ai/core-concepts/conversation-hub.md)
>
> Reply, send media and hand off to humans from the agent


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.