Skip to main content

Onboard a tenant

Before any booking can flow through Ratiba, a tenant has to exist. "Tenant" means more than a row in a database — it is a Postgres schema, a Keycloak realm, a set of WhatsApp credentials, and an initial catalog of services + staff. This page walks all four through.

The schema/realm step takes ~30 seconds. Catalog onboarding takes an additional 1-2 minutes depending on how much you upload.

This page assumes Dev setup is green and pilot-preflight.sh passes.


What "onboarding" creates

Per ADR-0002, Ratiba uses schema-per-tenant Postgres isolation. The full architectural rationale — the two-pool model, the public registry structure, and the per-tenant search_path contextvar — lives in Identity and tenancy. The table below covers what the onboarding endpoint creates:

ArtifactWhere it livesRole
public.tenants rowShared registrySlug, name, vertical, locale, payment_routing flags — the single lookup for any inbound message.
tenant_<slug> Postgres schemaPer-tenantAll business data — services, staff, appointments, payments, conversation checkpoints. Fully isolated from every other tenant.
Keycloak realm <slug>Keycloak serverTenant admin OIDC + per-tenant role bindings (ADR-0001).
public.tenant_admins rowShared registryOwner phone number gating admin-orchestrator commands.
WhatsApp credentialspublic.tenants columnswhatsapp_phone_number_id, whatsapp_access_token, whatsapp_business_account_id — encrypted at rest (ADR-0008).
Initial catalogtenant_<slug>.services, .staff, .staff_services, .service_relationsPopulated in Step 3 via the Vision-LLM importer (ADR-0010 D5+D7).

Atomic onboarding orchestrator

The orchestrator runs all provisioning steps with compensating actions. A failure at any step rolls back everything already created — a 5xx response never leaves leftover state.

The compensating actions are DROP SCHEMA … CASCADE (for schema + LangGraph tables) and transaction rollback (for the public.tenants and tenant_admins rows). Keycloak realm creation is the last step before commit specifically because Keycloak does not participate in a Postgres transaction — if it succeeds but the Postgres commit fails (extremely rare, but possible), the operator must manually delete the orphaned realm via DELETE /admin/realms/<slug> on the Keycloak admin API.


Step 1 — Hit the onboarding endpoint

The simplest path is POST /api/v1/admin/tenants. The endpoint requires the X-Admin-API-Key header — for local dev the key defaults to whatever you set in .env as ADMIN_API_KEY (e.g., dev-admin-key).

curl -X POST http://localhost:8010/api/v1/admin/tenants \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: dev-admin-key" \
-d '{
"name": "Spa Pilot",
"slug": "spa_pilot",
"vertical": "spa",
"owner_phone": "+254712345678",
"whatsapp_number": "+15555555555",
"locale": "en",
"timezone": "Africa/Nairobi",
"mpesa_enabled": true
}'

Slug rules: ^[a-z0-9_]+$ — lowercase alphanumerics and underscores only. Hyphens are rejected because the slug is interpolated directly into SET search_path TO tenant_<slug> (per ADR-0002 §Consequences §2). Leading digits pass the regex but will fail the Postgres SQL parser — prefer patterns like spa_pilot, salon_test_2, dental_clinic_a.

Expected response (HTTP 201):

{
"id": "01HXYZK9V8...",
"name": "Spa Pilot",
"slug": "spa_pilot",
"vertical": "spa",
"status": "trial",
"locale": "en",
"timezone": "Africa/Nairobi",
"whatsapp_number": "+15555555555",
"voice_number": null,
"mpesa_enabled": true,
"created_at": "2026-05-31T...",
"admins": []
}

You can also use the admin dashboard at http://localhost:3010/admin (Tenants tab → New Tenant form). The dashboard hits the same endpoint.

Step 2 — Verify the realm and schema

Three quick checks, one per artifact.

Keycloak realm exists. Get an admin token first, then inspect the realm:

TOKEN=$(curl -s -X POST \
"http://localhost:8281/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" \
-d "username=admin" \
-d "password=keycloak_dev" \
-d "grant_type=password" | jq -r '.access_token')

curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8281/admin/realms/spa_pilot | jq '.realm, .enabled'
# "spa_pilot"
# true

Postgres schema and tables. The schema name is always tenant_<slug>:

docker compose exec postgres psql -U ratiba ratiba -c "\dn" | grep tenant_spa_pilot
# tenant_spa_pilot | ratiba

docker compose exec postgres psql -U ratiba ratiba -c "\dt tenant_spa_pilot.*"

You should see at least: services, staff, staff_services, staff_schedules, appointments, payments, customers, customer_identities, customer_sessions, plus the LangGraph checkpoint tables (checkpoints, checkpoint_writes, checkpoint_blobs, checkpoints_archive).

tenant_admins row.

docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT phone_number, role, is_active \
FROM public.tenant_admins \
WHERE tenant_id = (SELECT id FROM public.tenants WHERE slug='spa_pilot');"

Expect your owner_phone with role=owner and is_active=true.

Step 3 — Onboard the catalog

The schema is ready but empty. Per ADR-0010, tenant owners onboard their catalog via a Vision-LLM-assisted upload — drag a phone photo of the existing menu, paste plain text, or both. For the full explanation of the importer, the extraction pipeline, and cross-sell relation detection, see Catalog onboarding.

Open the catalog page:

http://localhost:3010/admin/catalog

You will see an upload zone. Try one of:

  • Drag-drop a phone photo of an actual spa/salon menu (any decent quality phone snap works).
  • Paste plain text — the fixture backend/tests/fixtures/sample_catalog.txt works if you have no real menu.

The frontend POSTs to /api/v1/admin/catalog/import. The Vision LLM (Anthropic Claude Sonnet — app/services/catalog_importer.py) extracts service rows: name, price (KES), duration, optional category. Confidence is colour-coded:

ColourMeaningAction required
GreenHigh confidence on name + priceAuto-acceptable
YellowName confident but price ambiguousOwner reviews before commit
RedPrice extraction failed or below safety floor (ADR-0010 D7 — "never auto-fill price")Owner must type price manually; UI blocks commit otherwise

Click Submit. The frontend POSTs to /api/v1/admin/catalog/commit, which writes service rows to tenant_spa_pilot.services and any detected relations to tenant_spa_pilot.service_relations.

Add staff. The catalog page has a Staff tab — add at least one staff member with a recurring weekly schedule. The booking FSM reads tenant_spa_pilot.staff_schedules to propose available slots; without a schedule, COLLECT_SLOT stalls.

For a faster alternative that skips the UI entirely and seeds both catalog and knowledge snippets programmatically, see Seed test data.

Step 4 — Verify catalog persistence

docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT name, price_kes, duration_minutes \
FROM tenant_spa_pilot.services \
ORDER BY name;"

Example output:

name | price_kes | duration_minutes
-------------------+-----------+------------------
Haircut | 1500 | 30
Manicure | 1500 | 45
Pedicure | 800 | 30

Check cross-sell pairs:

docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT s1.name AS primary_svc, s2.name AS related, sr.relation_type \
FROM tenant_spa_pilot.service_relations sr \
JOIN tenant_spa_pilot.services s1 ON sr.service_id = s1.id \
JOIN tenant_spa_pilot.services s2 ON sr.related_service_id = s2.id;"

If the LLM detected typical cross-sell pairs (manicure + pedicure, haircut + beard-trim) you will see them here.


What just happened

End-to-end, the atomic onboarding orchestrator:

  1. Inserted a public.tenants row into the shared registry.
  2. Created a tenant_spa_pilot Postgres schema and ran the full Alembic migration set against it (including PostgresSaver.setup() for the LangGraph checkpointer tables).
  3. Created a Keycloak realm spa_pilot with the per-tenant admin client.
  4. Inserted a public.tenant_admins row, gating future admin-orchestrator commands by phone number.
  5. (Step 3) Ran Vision-LLM extraction on your upload, let you review, then committed the catalog.

For the architectural rationale — the two-pool model, contextvar search_path propagation, the three-table public registry, and cross-channel identity — see Identity and tenancy.


Troubleshooting

Keycloak admin auth fails on the token request. The most common cause is a stale KEYCLOAK_ADMIN_REALM_PASSWORD. Default in docker-compose.yml is keycloak_dev. If Keycloak just booted, wait ~10s — its master realm can take a moment to come fully online after the container first reports (healthy).

tenant_<slug> schema is missing tables. The orchestrator successfully ran CREATE SCHEMA but failed before completing Alembic migrations. The compensating action should have dropped the half-created schema, but if a stale schema survived a non-graceful crash, drop it manually and re-POST:

docker compose exec postgres psql -U ratiba ratiba \
-c "DROP SCHEMA tenant_spa_pilot CASCADE;"

LLM extraction returns nothing or 500s. ANTHROPIC_API_KEY is missing or invalid. Check the backend log for catalog_importer.extract errors — a 401 from Anthropic means the key is bad; a timeout (60s+) means Anthropic's API is slow, retry once.

/admin/catalog returns 403 on load. Keycloak session expired or you are hitting the dashboard with a different browser profile than the one you logged into Keycloak with. Visit http://localhost:3010/admin/logout and re-login.

Slug regex rejects your input. Only ^[a-z0-9_]+$. No hyphens, no uppercase. Leading digits pass the regex but break the Postgres SQL parser for the schema identifier — use spa_pilot, not 1spa or spa-pilot.

Onboarding call hangs for 30+ seconds. Keycloak realm creation is the step with the highest latency. If the Keycloak container just restarted, its master-realm token endpoint can be slow for the first 30s after becoming (healthy). Retry the POST after the delay clears — the orchestrator's compensating actions guarantee no orphaned state from a partial failure.


What next

  • Seed test data — programmatic alternative to the UI for bulk-populating knowledge snippets and catalog rows, useful for test automation.
  • First booking — now that a tenant and catalog exist, send a WhatsApp message and watch the FSM book a haircut.
  • Personality dials — set the agent's tone, honorifics, and cross-sell aggressiveness for this tenant.
  • Catalog onboarding — the longer story behind Step 3.