A multi-tenant conversational care API — same core, different brand + safety policy per tenant. Ships with symptom triage, 22 offline-cacheable clinical assessments, results storage and nearest-facility routing.
The catalogue is 3 bilingual instruments (PHQ-9, GAD-7, PC-PTSD-5) plus 19 ported from the AHADI website. Each carries a direction of symptom (higher = more difficulty) or wellbeing (higher = better) — read it before rendering a score, or a top WHO-5 result reads as severe. Instruments with mixed answer sets per question expose scale on the item rather than the instrument, so resolve item.scale || tool.scale. A tenant can curate the list via clinical.enabledAssessments; omit it for the full catalogue.
Every tenant call requires a Bearer token. Admin routes require a separate X-Admin-Key header.
Authorization: Bearer <tenant_api_key> X-Admin-Key: <admin_key> # admin routes only
Rate limits are enforced per tenant (default 60 req/min). Exceeded requests return 429.
Single-turn or multi-turn reply. The service enforces the tenant's safety policy (hotline or facility) automatically.
| Field | Type | Notes |
|---|---|---|
message | string | required; max 4000 characters (413 above that) |
language | en · sw · mix · auto | optional; omit it (or send auto) and the language is detected from the message. Only if the message carries no signal does it fall back to sticky_language, then the tenant default. An explicit value always wins. |
sticky_language | string | optional; the language this conversation is already in. Used only when the current turn carries no signal of its own — a filler like "ok" or "ndio" then stays in the conversation's language instead of snapping back to the tenant default. |
history | array | optional; [{role,content}], last 24 kept (override with MAX_HISTORY_MESSAGES). Bring your own history — the API will use it and store nothing more than trace metadata when no_store is true. |
session_id | string | optional |
user_ref | string | optional; opaque id used for results storage |
no_store | bool | optional; when true we don't persist any message content on our side. See Custom chat storage. |
{
"reply": "…",
"language": "sw",
"safety": { "crisis": false, "protocolEnforced": false },
"quality": { "overall": 0.87, "safety": 1, "empathy": 0.25,
"conciseness": 1, "languageMatch": 1, "protocolAdherence": 1 },
"usage": { "prompt_tokens": 645, "completion_tokens": 32, "total_tokens": 677 },
"trace_id": "trc_…",
"latency_ms": 1470
}
Deterministic triage — sub-millisecond, no LLM cost. Returns condition candidates and the recommended assessment tool.
{ "symptoms": "I feel anxious and cannot sleep", "language": "en" }
{
"crisis": false,
"conditions": [{ "condition": "anxiety",
"recommendedTool": "gad7", "confidence": 1.0,
"matchedTerms": ["anxious","cannot sleep"] }],
"top": { "condition": "anxiety", "recommendedTool": "gad7" },
"guidance":"Try the GAD7 self-assessment for a clearer picture."
}
Public-domain screening instruments shipped as JSON. Clients cache them locally and can score offline; the server can also score on demand.
List assessments enabled for the tenant.
Full instrument definition (items, scale, bands, crisis-flag rules). Cache-Control: public, max-age=86400. Supported ids: phq9, gad7, pcptsd5.
Score user answers. If user_ref is provided, the result is auto-saved.
The response carries a debrief in narrative: a warm, plain-language
explanation grounded in the specific items the person rated highest, so the result explains
why it is what it is rather than only naming a band. Render this as the reply the user
reads — total and severity are context, not the message. Pass
debrief: false when you only need the numbers (dashboards, batch re-scoring);
that also skips the model call.
When crisis is true the safety protocol is already woven into
narrative, so do not append crisisProtocol as well or the user reads
it twice. Without an OPENAI_API_KEY, or if the model call fails, a deterministic
template debrief is returned instead; narrativeSource says which was used.
{
"user_ref": "user-abc-123",
"language": "sw",
"debrief": true,
"answers": [{ "itemId": 1, "value": 2 }, { "itemId": 2, "value": 3 } … ]
}
{
"assessmentId": "phq9", "assessmentName": "PHQ-9", "measures": "depression",
"total": 15, "maxTotal": 27, "severity": "moderately_severe",
"crisis": true,
"recommendation": "Clinical review recommended.",
"narrative": "Are you safe right now? ... What you rated highest was ...",
"narrativeSource": "openai",
"items": [{ "id": 1, "value": 2, "text": "Little interest or pleasure...",
"answerLabel": "More than half the days", "elevated": true }],
"elevated": [ "highest-rated items first - what the debrief leads with" ],
"crisisProtocol": {
"safetyQuestion": "Are you safe right now?",
"directive": "This needs urgent clinical attention. Please go to the nearest health facility right away."
},
"savedId": "res_…"
}
Per-user, tenant-scoped result storage. Retrieve via an opaque user_ref — you control the id scheme, no PII stored.
Save arbitrary {user_ref, type, payload}.
Every organisation can keep chat history, assessments and results in their own ecosystem (Firebase, Postgres, Salesforce, internal EMR — anything). We support three modes:
| Mode | Where messages / results are stored | How to configure |
|---|---|---|
server | On our side (default). Retrievable via /v1/results. | Do nothing. |
client | Only on your side. | Set tenant storage.mode = "client", or send no_store: true on any request. |
both | Ours + mirrored to your webhook in real time. | Set tenant storage.mode = "both" and storage.webhookUrl. |
Pass the full conversation on every POST /v1/chat request:
POST /v1/chat
{
"message": "I feel worthless",
"history": [
{ "role": "user", "content": "hi" },
{ "role": "assistant", "content": "Hi, I'm here to help." },
{ "role": "user", "content": "I can't sleep" },
{ "role": "assistant", "content": "Sorry to hear that. How long has this been going on?" }
],
"no_store": true
}
The model uses everything in history. With no_store: true, we don't persist previews of the user or assistant text — only trace metadata (latency, safety flags, quality) which you can turn off entirely by using storage mode client.
If you set storage.webhookUrl on your tenant, we POST every event to that URL as it happens:
POST https://your-app.example.com/ahadicare/events
X-AhadiCare-Tenant: emkf
X-AhadiCare-Signature: <hmac_sha256(body, tenant.storage.webhookSecret)>
Content-Type: application/json
{
"eventType": "chat.response",
"timestamp": "2026-09-02T…Z",
"tenantId": "emkf",
"traceId": "trc_…",
"userRef": "user-42",
"sessionId": "web-…",
"safety": { "crisis": false, "harmMethodRequested": false, "enforced": false },
"quality": { "overall": 0.87, "safety": 1, … },
"preview": { "user": "…", "assistant": "…" }
}
Event types you'll receive: chat.response, clinical.assessment.scored, clinical.analyze. Verify each request by recomputing HMAC-SHA256(body, webhookSecret) and comparing to the X-AhadiCare-Signature header.
Send no_store: true on POST /v1/assessments/:id/score to receive the score without saving it on our side. The response includes "stored": false so your client knows to persist it locally.
Returns nearby facilities sorted by great-circle distance. Used when a tenant's emergency.policy = facility.
Run the deterministic crisis heuristic without spending on an LLM call. Returns {crisis, harmMethodRequested, protocolLines}.
Create a tenant with a full Tenant model.
Rolling totals: messages, avg latency, avg quality, crisis count.
Every tenant is a full brand + safety config. Same runtime, wildly different behavior.
{
"tenantId": "emkf",
"displayName": "EMKF",
"organisation": "Emmanuel Mental Kindness Foundation",
"assistantName":"EMKF Assistant",
"persona": "a supportive mental wellness assistant…",
"domain": "mental wellness triage",
"supportedLanguages": ["en","sw","mix"],
"defaultLanguage": "en",
"emergency": {
"policy": "facility", // or "hotline"
"urgentPhraseEn": "This needs urgent clinical attention. Please go to the nearest health facility right away.",
"urgentPhraseSw": "Hii inahitaji msaada wa kimatibabu haraka. Tafadhali nenda kituo cha afya kilicho karibu nawe sasa hivi.",
"safetyQuestionEn": "Are you safe right now?",
"safetyQuestionSw": "Uko salama sasa hivi?"
},
"style": { "maxSentences": 2, "allowEmoji": false, "allowAsterisks": false },
"clinical": { "enabledAssessments": ["phq9","gad7","pcptsd5"], "offlineCacheable": true },
"storage": {
"mode": "both", // "server" | "client" | "both"
"webhookUrl": "https://your-app.example.com/…", // set for real-time mirror
"webhookSecret": "***" // HMAC signing secret
},
"rateLimits": { "rpm": 120, "monthlyTokenCap": 10000000 }
}
curl -X POST http://localhost:4000/v1/chat \
-H "Authorization: Bearer sk_demo_emkf_public_key_replace_me" \
-H "Content-Type: application/json" \
-d '{"message":"Ninajisikia vibaya sana leo","language":"sw"}'