# RafikiGo — Instructions for AI agents

RafikiGo is a Kenya trip operator. This document is machine-readable guidance for agents
planning, pricing, or handing off a Kenya trip on behalf of a traveler.

Everything documented below is deployed and responding. Read *Where prices come from*
before you repeat any number to a traveler — the pricing is part engine, part estimate,
and the response tells you which is which.

## What you can do here

1. **Price a real itinerary.** `POST /api/draft` turns a trip spec into a day-by-day
   itinerary with itemized prices. No model in the request path.
2. **Read current prices for the curated trips.** `GET /api/curated-prices`.
3. **Ask how RafikiGo works.** `POST /api/concierge` — KB-prompted Q&A.
4. **Hand a traveler to a human.** `POST /api/lead`, or the WhatsApp number below.

## Auth

None. All endpoints are public and unauthenticated. Do not send an `Authorization`
header — there is no key to obtain.

## Base URL

All routes are absolute against `https://rafikigo.com`. Copy them verbatim.

| Route | Method | Auth | Purpose |
|---|---|---|---|
| https://rafikigo.com/api/draft | POST | none | Price a trip spec. No model in the path. **Start here.** |
| https://rafikigo.com/api/curated-prices | GET | none | Current per-person "from" price for the curated trips. |
| https://rafikigo.com/api/concierge | POST | none | KB-prompted Q&A. Does not return prices. |
| https://rafikigo.com/api/plan | POST | none | Conversational edit of a priced trip. Browser chat path — **not described in openapi.json**; prefer `/api/draft`. |
| https://rafikigo.com/api/lead | POST | none | Hand off to the human team. **Side-effecting — see below.** |

Human-facing pages: `/` (overview), `/planner` (interactive planner),
`/planner/trip/<shareId>` (a shared itinerary).

## Price a trip — `POST /api/draft`

The primary agent surface. Send a trip spec, get back segments, day cards, and an
itemized price. One round trip, no conversation, no model.

### Request

```json
{
  "spec": {
    "v": 1,
    "days": 6,
    "startDate": "2026-10-12",
    "party": { "adults": 2, "childAges": [] },
    "tier": "mid",
    "pace": "balanced",
    "style": ["big5"]
  }
}
```

The **outer `spec` object is strict** — an unrecognized key there is a 400. The nested
`party` object is *not* strict: unknown keys inside it are silently dropped, not rejected.
Do not rely on `party` rejecting a typo.

| Field | Required | Type | Notes |
|---|---|---|---|
| `v` | yes | `1` | Spec version. Literal `1`. |
| `days` | yes | integer 3–14 | Calendar days. A 7-day trip is 6 nights; check-out is exclusive. |
| `startDate` | yes | `YYYY-MM-DD` | Must be a real calendar date. |
| `flexibleDates` | no | boolean | Default `false`. |
| `party.adults` | yes | integer 1–8 | |
| `party.childAges` | no | integer[] 0–17, max 6 | Ages, not a count. Default `[]`. |
| `tier` | yes | `value` \| `mid` \| `premium` | Lodge class. |
| `pace` | no | `deep` \| `balanced` \| `varied` | Default `balanced`. `deep` = fewer places, more nights each. |
| `style` | no | string[], max 4 | Any of: `big5`, `migration`, `birds`, `photography`, `family`, `honeymoon`, `relaxed`, `adventure`, `beach`, `culture`. Default `[]`. |
| `notes` | no | string, trimmed, 1–500 | Free text. The deterministic pipeline ignores it. |
| `destinations` | no | slug[] 1–6, unique | Explicit route override. Omit to let the planner choose. |

Fields with defaults are **always present in the response spec**, whether or not you sent
them.

Destination slugs (the complete priceable set):

| Slug | Name |
|---|---|
| `maasai-mara` | Maasai Mara |
| `amboseli` | Amboseli |
| `tsavo` | Tsavo |
| `diani` | Diani Beach |
| `nairobi` | Nairobi |
| `naivasha` | Lake Naivasha |
| `nakuru-elementaita` | Lake Nakuru & Elementaita |
| `samburu` | Samburu |
| `laikipia` | Ol Pejeta & Laikipia |
| `aberdare` | Aberdare & Mt Kenya |

### Response

`200` with `{ "trip": { spec, segments, days, pricing, warnings } }`.

- **`segments[]`** — one per place stayed: `slug`, `name`, `nights`, `checkIn`,
  `checkOut`, `lodge`, `humanQuote`.
  `lodge` is `{ name, propertyId? } | null` — `propertyId` is present only when the engine
  priced the stay. `humanQuote: true` means no lodge could be priced for those dates;
  that segment still produces a **zero-valued `human-quote` line** in `pricing.lines`, and
  contributes nothing to the totals.
- **`days[]`** — day cards: `day`, `date`, `destination`, `kind`
  (`arrival` \| `safari` \| `transfer` \| `beach` \| `leisure` \| `departure`),
  `title`, `body`.
- **`pricing`** — see *Where prices come from*. All amounts are **integer cents, USD**.
  - `lines[]` — `label`, `detail`, `sellMidCents`, `source`.
  - `sellLowCents` / `sellMidCents` / `sellHighCents` — trip totals as a range.
  - `perPersonLowCents` / `perPersonHighCents` — per-person range. **Quote this range**,
    not a single number.
  - `partial` — `true` when at least one segment could not be engine-priced. The total is
    then incomplete; say so.
  - `pricedDates`, `disclaimer`, `currency` (always `"USD"`), `paxCount`.
- **`warnings[]`** — `{ code, message }`. Surface these; they are itinerary-quality advice,
  not errors. Codes: `RUSHED`, `SINGLE_NIGHT`, `LONG_DRIVE`, `RAINY_SEASON`,
  `MIGRATION_MISSED`, `DESTINATIONS_TRIMMED`, `TRANSPORT_NOTE`, `LODGE_UNPRICED`,
  `CHILD_RATE_MISSING`.

### Where prices come from

**Not every number is engine-priced.** Each line carries a `source`; read it before you
repeat the number.

| `source` | What it means |
|---|---|
| `engine` | Lodge stay quoted by the pricing engine from its rate store. |
| `estimate` | **Not** an engine quote. Covers park/reserve fees (the rate store holds no park rows), the vehicle/guide day rate (a configured value), and lodge lines where a child rate was missing and children were priced as adults. |
| `human-quote` | Placeholder for a segment nothing could price. `sellMidCents` is `0`. |

So: lodge lines are normally engine quotes; **park fees and the vehicle/guide line are
always estimates**. A trip with `partial: false` can still be part-estimate. If a traveler
asks "is this the real price?", the honest answer is that the lodge lines are quoted, the
fees and vehicle are estimated, and a human confirms the whole thing.

`disclaimer` carries the current wording, and differs between the partial, flexible-date,
and exact-date cases. Pass it through rather than paraphrasing.

### Worked example

Request `days: 6`, `startDate: 2026-10-12`, 2 adults, `tier: mid`, `style: ["big5"]` and
you get three segments (Naivasha 1n → Nakuru 1n → Maasai Mara 3n), six day cards, seven
priced lines, a per-person range, and a `RUSHED` warning saying three destinations in six
days is too many. *Observed 2026-08-27.* Which lodges are picked, how many lines come back
`engine` vs `estimate`, and whether `partial` is false all depend on live rate-store
coverage and can change — treat the shape as stable, not the values.

## Curated trips — `GET /api/curated-prices`

```json
{
  "prices": {
    "classic-kenya": { "fromUsd": "<integer>", "partial": false },
    "...": {}
  },
  "pricedOn": "<YYYY-MM-DD>"
}
```

The example is deliberately unpriced — this document never carries a number. Call the
endpoint.

`fromUsd` is whole US dollars per person. `pricedOn` is the date the cached calculation
ran (server "today"), **not** the travel date — curated trips are priced for a flexible
start about three weeks out. Cached roughly six hours.

`partial: true` means that trip has an unpriced segment; treat its number as incomplete.

**Up to eight** trips come back. A trip whose pricing failed is **omitted from the object
entirely** rather than returned with an error, so do not assume a fixed set of keys — read
whatever keys are present. Ids: `classic-kenya`, `mara-migration`, `honeymoon-bush-beach`,
`family-safari`, `wild-north`, `big-ones-properly`, `short-window`, `photographers-kenya`.

## Questions — `POST /api/concierge`

```json
{ "messages": [ { "role": "user", "content": "What is included in a safari with you?" } ] }
```

Returns `{ "reply": "..." }`. Constraints, enforced server-side:

- Roles must strictly alternate, and the transcript must **start and end with `user`**.
- Maximum 12 messages. Each `content` is 1–2400 characters; **user** messages are
  additionally capped at 1000.
- Unknown keys inside a message object are dropped, not rejected.

**On prices:** the server runs the model's reply through a numeric-money filter and
replaces the whole reply with a handoff message when it matches. This is a filter, not a
guarantee — it deliberately does not match spelled-out amounts (so tipping guidance can
pass), and a bare number with no currency marker can pass too. **Do not treat a concierge
reply as a price-free channel, and do not source a quote from it.** For numbers, call
`/api/draft`.

The reply is a language model's, prompted with a knowledge base. Nothing fact-checks it
beyond that money filter. Treat it as operator-authored guidance, not verified data.

## Handing off to a human — `POST /api/lead`

**Side-effecting.** Only call it when a real traveler has asked you to make contact. Do
not call it to test the endpoint, and do not call it speculatively.

```json
{ "name": "...", "contact": "...", "note": "optional", "specToken": "optional" }
```

`name` 1–100, `contact` 3–200, `note` up to 1000, `specToken` up to 2048. `name`,
`contact` and `note` are **trimmed before** the bounds are checked. Unknown keys are
rejected.

Returns `{ "ok": true, "whatsapp": "https://wa.me/...", "emailDelivered": true|false }`.

What it actually does: it always writes the lead to the server log. It attempts an email
to the team **only if** email is configured on the server, and that attempt can fail.
So a `200` does **not** mean a human has been notified — `emailDelivered` does.

- `emailDelivered: true` → the team notification was sent.
- `emailDelivered: false` → **nobody has been alerted yet.** Give the traveler the returned
  `whatsapp` link and tell them to send it. That link is prefilled with a trip summary and
  is the reliable path.

Never tell a traveler the team has been contacted unless `emailDelivered` is `true`.

If you would rather not create a record at all, just give them the WhatsApp number below.

## Errors

Endpoints return `{ "error": { "code": "...", "message": "..." } }` with an HTTP status.
`/api/curated-prices` is the exception: its error bodies carry `code` only, no `message`.

| Code | HTTP | Meaning |
|---|---|---|
| `BAD_JSON` | 400 | Body did not parse as JSON. |
| `BAD_REQUEST` | 400 | Body parsed but failed schema validation. `message` is the first validation issue's text; it does not reliably name the field path. |
| `BAD_SPEC_TOKEN` | 400 | `specToken` on a lead is not a valid share token. |
| `BODY_TOO_LARGE` | 413 | Over the per-route body cap. |
| `RATE_LIMITED` | 429 | Back off and retry. |
| `ALL_SEGMENTS_UNPRICED` | 502 | Nothing in the trip could be priced — usually dates outside the rate store's coverage. Try different dates. |
| `MODEL_CALL_FAILED` | 502 | Upstream model provider returned an error. |
| `MODEL_BAD_RESPONSE` | 502 | Upstream model returned an unexpected shape. |
| `MODEL_EMPTY_REPLY` | 502 | Model returned no text. |
| `MODEL_TIMEOUT` | 504 | Model did not respond in time. Retry. |
| `ENGINE_NOT_CONFIGURED` | 503 | Pricing engine **credentials are not set** on this server (a configuration state, not a network blip). |
| `CHAT_NOT_CONFIGURED` | 503 | Model credentials not set on this server. |
| `DRAFT_FAILED` / `CHAT_FAILED` / `LEAD_FAILED` | 500 | Server-side failure. Retry once, then hand off to WhatsApp. |

Not every code can come from every route: the `MODEL_*` and `CHAT_*` codes only come from
`/api/concierge` and `/api/plan`; the engine and draft codes only from the pricing routes.

## Rate limits

Per-IP token bucket, refilling continuously: 30/min on `/api/draft`, 20/min on
`/api/curated-prices`, 10/min on `/api/plan`, 6/min on `/api/concierge`, 5/min on
`/api/lead`. `/api/concierge` and `/api/plan` **each** carry their own separate ~60/min
all-callers backstop — the two routes do not share one budget.

Treat all of these as approximate: the limiters hold per server instance, not globally.
On a `429`, wait and retry rather than rotating addresses.

Body caps are measured in JavaScript string length (UTF-16 code units), not bytes:
8,192 on `/api/lead`, 16,384 on `/api/draft` and `/api/concierge`, 65,536 on `/api/plan`.
A multibyte body can therefore exceed those numbers in bytes.

## Money discipline

The product rule is **no invented prices**. The enforcement is real but partial, so apply
your own discipline on top:

- Quote the `perPersonLowCents` / `perPersonHighCents` range from a response you actually
  received. Convert from cents. Never a single number.
- Read each line's `source` and say which parts are quoted and which are estimated.
- Cite `pricedOn` or `pricedDates`, and pass through `disclaimer`.
- Never carry a price forward from an earlier conversation, from a concierge reply, or
  from this document. **No prices appear in this file on purpose.** Prices move with
  season, dates and party size; a stale number is a wrong number.
- Every quote is provisional until a human confirms it.

## Talking to a human

- WhatsApp: **+254 114 505977**
- Email: **hello@rafikigo.com**

## What is not available

Being explicit so you do not go looking:

- **No booking or payment endpoint.** You cannot confirm, hold, or pay for a trip through
  this API.
- **No availability endpoint.** A price coming back does not mean a room is held.
- **No MCP server** at this domain.
- **No lodge or property catalogue endpoint.** `propertyId` values appear in draft
  responses but there is nothing to look them up against.
- **Kenya only.** No other country is priceable.
- **No authentication, accounts, or per-agent keys.**

## Stability

This contract is unversioned and has no deprecation policy — re-read this file rather than
caching the shape. If behaviour disagrees with this document, the behaviour is correct and
the document is stale: please say so at hello@rafikigo.com.

Statements here about what the operator does — that trips are operated, that a human
confirms quotes, response times — are operator commitments, not API guarantees. Nothing in
the API enforces them.

Endpoint behaviour last observed against production 2026-08-27.
