Kulcare Docs
DeveloperHealth Diary

Health Diary — Mobile Integration Guide

Issue: #2291 · Merged in: #2417, #2518 Status: implemented and running. Every payload below was captured from a live server, not written from the spec.

This describes the API as built. Where it differs from the original requirements note that started the work, this document wins — and §11 lists the differences explicitly so nothing is a surprise.

Companion documents: developer guide · QA guide · product spec


1. Base path and conventions

/patient/health-diary/*

Hyphenated, per the contract. It is the only hyphenated path in the codebase, kept because this app is its only consumer.

Request headers

HeaderRequiredNotes
AuthorizationYesBearer <access token>
Content-TypeOn writesapplication/json
device-id, device-nameRecommendedSame as the rest of the patient API
X-On-Behalf-OfCaregiver mode onlyThe dependent's Identity::Patient id — see §8

Authentication needs a live session, not just a valid JWT. The token is decoded and a matching active Identity::Session row is looked up by token hash. A structurally valid JWT with no session gets 401. This trips people building test tooling; use the normal sign-in flow.

Envelopes

Single object:

{ "data": { }, "meta": { "request_id": "..." } }

List — flat pagination, not the nested meta.pagination used elsewhere in this API:

{ "data": [ ], "meta": { "request_id": "...", "total": 14, "page": 1, "per_page": 2, "pages": 7 } }

Error:

{ "data": null, "error": { "code": "not_found", "message": "This isn't available any more.", "details": [] }, "meta": { "request_id": "..." } }

error.message is written for patients and is safe to display verbatim. error.code is what you branch on. Every error carries both, including 429.


2. Folders

Four defaults are created the first time the patient touches the diary. They are addressed by slug; anything the patient creates is addressed by uuid. Both arrive in the same id field.

GET    /patient/health-diary/folders
POST   /patient/health-diary/folders          { "name", "icon?", "color?" }
PATCH  /patient/health-diary/folders/:id      { "name?", "icon?", "color?" }
DELETE /patient/health-diary/folders/:id
{
  "data": [
    { "id": "general",   "name": "General Health", "icon": "heart-outline",         "color": "#2563eb", "is_default": true, "thread_count": 3 },
    { "id": "diabetes",  "name": "Diabetes",       "icon": "water-outline",         "color": "#dc2626", "is_default": true, "thread_count": 0 },
    { "id": "back-pain", "name": "Back Pain",      "icon": "body-outline",          "color": "#4f46e5", "is_default": true, "thread_count": 1 },
    { "id": "personal",  "name": "Personal Notes", "icon": "document-text-outline", "color": "#059669", "is_default": true, "thread_count": 0 }
  ],
  "meta": { "request_id": "..." }
}
  • Deleting a default folder returns 400 cannot_delete_default_folder. Do not offer the control.
  • Deleting a custom folder moves its threads to General and reports how many: { "data": { "deleted": true, "moved_thread_count": 2 } }. Say so in your confirm dialog — patients hesitate otherwise.

3. Threads

GET    /patient/health-diary/threads?folder_id=&page=&per_page=
POST   /patient/health-diary/threads          { "folder_id?", "title?", "entry_point?" }
PATCH  /patient/health-diary/threads/:id      { "title?", "folder_id?" }
DELETE /patient/health-diary/threads/:id
{
  "data": [{
    "id": "01a0b1ee-1dee-7549-a7a7-639564ac8689",
    "folder_id": "general",
    "title": "I have had a sore throat for two days an...",
    "message_count": 2,
    "last_message_preview": "Most sore throats like this settle on their own...",
    "created_at": "2026-09-18T00:32:47Z",
    "updated_at": "2026-09-18T00:33:29Z"
  }],
  "meta": { "request_id": "...", "total": 14, "page": 1, "per_page": 2, "pages": 7 }
}

per_page maxes at 50. You rarely need POST /threads: sending a message with no thread_id creates one and titles it from the first sentence.


4. Sending a message

POST /patient/health-diary/chat/messages
{
  "thread_id": "01a0b1ee-...",        // omit to start a new thread
  "folder_id": "general",             // optional; defaults to General
  "content": "I have had a sore throat for two days and it hurts to swallow",
  "type": "text",
  "language": "en",
  "client_message_id": "<uuid>",      // see idempotency below
  "entry_point": "chip"               // chip | free_text | dashboard | voice | cta
}

Returns the thread, both messages, and the diary entry generated from the exchange:

{
  "data": {
    "thread": { "id": "...", "folder_id": "general", "title": "...", "message_count": 2, "..." : "..." },
    "is_new_thread": true,
    "user_message": {
      "id": "01a0b1ee-1df5-761c-a0b1-45e077991b2e",
      "role": "user",
      "content": "I have had a sore throat for two days and it hurts to swallow",
      "client_message_id": "bench-4-1789691567524",
      "type": "text",
      "timestamp": "2026-09-18T00:32:47Z"
    },
    "assistant_message": {
      "id": "01a0b1ee-1dfc-7be4-af09-e47c337ce086",
      "role": "assistant",
      "content": "Most sore throats like this settle on their own and don't need a doctor. ... \n\nThis is general information, not medical advice. Please check with a healthcare professional.",
      "type": "text",
      "timestamp": "2026-09-18T00:32:47Z",
      "ctas": [
        { "action": "talk_to_doctor",   "label": "Talk to a Doctor" },
        { "action": "see_care_options", "label": "See Care Options" },
        { "action": "create_folder",    "label": "Track sore throat", "data": { "folder_name": "sore throat" } }
      ]
    },
    "diary_entry": { "id": "...", "title": "...", "tags": ["sore throat"], "date": "..." }
  },
  "meta": { "request_id": "..." }
}

Four things the client must honour

The disclaimer is the last paragraph of content. It is appended server-side on every assistant turn and is not a separate field. Render it — do not strip it, and do not hide it behind a "show more".

safety_flag: "emergency" means render the guidance first. When present, the emergency guidance is the first paragraph of content. Show it above the rest, with an icon and a label as well as colour — a patient who reads one line must read that one.

CTAs are already validated. At most three, see_care_options always present, labels localized to the request language. A create_folder action always carries data.folder_name. create_folder should create the folder and move the current thread into it — offering to track a topic and leaving the patient to file it is the admin this feature avoids.

The patient's message is saved before the model is called. So on 503 ai_unavailable their words are already persisted: keep them on screen, say the note is saved, offer retry. Never roll the message back.

Idempotency

client_message_id is unique per patient. Generate it once when the message is composed, keep it across retries, and only regenerate after a success.

  • Repeat of a completed send → the original exchange, 200. Nothing is re-sent.
  • Repeat while the first is still in flight → 409 duplicate_request. Back off and retry; do not resend with a new id.
  • Repeat after the first failed (503 ai_unavailable, 500 internal_error) → the server asks the model again for the same saved note and returns the new exchange. This is what Retry is for: same id, same words.
  • The same id with different words → 409 idempotency_conflict. An id belongs to one note; mint a new one for every new note, even straight after a failure. (The web client once reused the failed note's id for the next note, and the server answered with the old exchange — the new note was silently lost.)

On React Native this is a non-issue, but for the record: crypto.randomUUID() exists only in a secure context. The web client had to add a fallback after messages silently vanished over plain HTTP.


5. Reading a thread (cursor paging)

GET /patient/health-diary/chat/threads/:thread_id/messages?limit=50&cursor=<id>&direction=before

limit defaults to 50, max 100. direction is before (older, the default) or after (newer).

{
  "data": [ { "id": "...", "role": "user", "...": "..." } ],
  "meta": { "request_id": "...", "thread_id": "...", "has_more": false, "cursor": "01a0b1ee-1df5-761c-a0b1-45e077991b2e" }
}

A note whose reply failed comes back marked. This covers a reply the server gave up on and one stranded by a crashed request, which is exactly the set a repeat of the key will resume. The list returns complete rows only, so a failed assistant turn is not in it. Instead the patient's own message carries "reply_failed": true, plus the language it was written in. Offer a retry on that note and send the SAME client_message_id again with the same content: the server resumes that exchange. Sending a new key writes a second note.

{ "id": "...", "role": "user", "content": "My knee has been swelling since Monday",
  "client_message_id": "…", "language": "hi", "reply_failed": true }

There are no page numbers here. Message ids are uuidv7, which sort by creation time, so the id is the cursor. To load older messages, send meta.cursor back as cursor with direction=before, and stop when has_more is false. meta.cursor is always the edge of the page nearest the next fetch in the same direction: the oldest row for direction=before, the newest for direction=after. Ignoring this silently caps a thread at its newest 50 messages — the web client shipped that bug.


6. Entries and the visit summary

GET    /patient/health-diary/entries?folder_id=&tags=a,b&from_date=&to_date=&page=&per_page=
GET    /patient/health-diary/entries/:id
DELETE /patient/health-diary/entries/:id
GET    /patient/health-diary/summary?days=30

An entry is generated from each exchange — the patient never files anything.

{
  "data": [{
    "id": "01a0b1ee-c2e4-763c-98b5-29bb49f75f72",
    "title": "I have had a sore throat for two days an...",
    "tags": ["sore throat"],
    "content": "I have had a sore throat for two days and it hurts to swallow",
    "ai_summary": "Most sore throats like this settle on their own...",
    "thread_id": "01a0b1ee-1dee-7549-a7a7-639564ac8689",
    "date": "2026-09-18T00:32:47Z",
    "folder_id": "general"
  }],
  "meta": { "request_id": "...", "total": 10, "page": 1, "per_page": 1, "pages": 10 }
}

The summary is cached per patient per period and refreshed on a freshness window:

{
  "data": {
    "summary": "On 18 September the patient recorded a sore throat lasting two days...",
    "period_days": 30,
    "entry_count": 10,
    "top_tags": [ { "tag": "sore throat", "count": 7 }, { "tag": "headache", "count": 2 } ],
    "generated_at": "2026-09-18T01:27:48Z"
  },
  "meta": { "request_id": "..." }
}

Never call /summary on a screen that loads automatically. A cache miss costs a model call, so a dashboard or tab that fetches it on mount is an AI call per patient per session. Fetch it when the patient asks for it. Treat it as a "what to tell your doctor" handout — that framing is what makes the diary worth keeping. Show generated_at so a cached one is honest about its age. 404 not_found means not enough entries yet, not an error to shout about.


7. Errors

HTTPerror.codeWhat the client should do
400validation_errorField-level; details[] names the fields. Also a malformed cursor
400content_moderatedShow error.message, keep the patient's text. Usually the note was refused and not saved; when only the reply was withheld the note is saved and the message says so. Do not offer a same-words retry
400message_too_longContent exceeds 2000 characters
400cannot_delete_default_folderDo not offer the control at all
401unauthorizedYour existing refresh interceptor
403forbiddenInvalid X-On-Behalf-Of relationship
404not_foundAlso returned for another patient's rows — never 403, so nothing leaks
409duplicate_requestAn identical send is still in flight (§4)
409idempotency_conflictA client_message_id reused for different words (§4). Mint a new id
429rate_limitSee §9
503ai_unavailableThe message is saved. Keep it visible, offer retry
500internal_errorUsually the note is saved and only the reply failed -- but not guaranteed. Keep the text on screen and retry with the same client_message_id: if it was saved the server resumes it, and if not it is saved now. Never both

8. Caregiver mode

Send X-On-Behalf-Of: <dependent Identity::Patient id>. Every row is written with the dependent as owner and the caregiver as recorder, so the record belongs to the right person and the action is still attributable. A target who is not a dependent of the authenticated user gets 403 forbidden.

Two rules the web client learned the hard way:

  • Clear thread and folder selection when the subject changes. Otherwise the previous person's thread id is refetched under the new header and the patient lands on a 404 inside someone else's diary.
  • Bind a pending send to the subject it was composed under. The header is read at request time, so retrying a failed send after switching dependents posts one person's symptoms into another's diary.

Also: name the subject in the composer placeholder ("Tell us how Amma is feeling"), not only in a banner. Banners stop being read.


9. Rate limits

Per patient, per hour, keyed on the acting user — a caregiver's sends count against the caregiver.

EndpointLimit
POST /chat/messages and POST /chat/messages/voice, combined30
GET /summary30
Everything else100

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; a 429 also carries Retry-After (seconds).


9a. Voice notes — POST /patient/health-diary/chat/messages/voice

Implemented. multipart/form-data, and the response is an ordinary send response with one extra field.

PartRequiredNotes
audioyesaudio/m4a, audio/x-m4a, audio/mp4, audio/aac, audio/webm, audio/ogg, audio/opus. A ;codecs= suffix is fine. Max 4 MB -- two minutes of AAC at 256 kbps
duration_msyesRefused without it (duration_required), and anything over 120000 is refused before the audio is read. It is also compared with the length the speech service measures, and a mismatch is logged
languageno (default en)See the support table below
thread_id, folder_id, client_message_id, entry_pointnoIdentical to POST /chat/messages. entry_point defaults to voice

The response is the send response plus transcript — what we heard. Show it. A voice note the patient cannot check is one they cannot trust, and a misheard medicine name is not a small thing.

Language support

LanguageVoice
enyes
hi, pa, ta, te, aryes
mlnoThe only model family that transcribes Malayalam is not enabled on our speech plan. The endpoint refuses it up front with voice_language_unsupported — it does not spend a call and then blame the recording

Hide the mic for ml. The endpoint will refuse it either way, but a mic that is offered and then fails makes the patient speak before finding out. Typing works in every language.

Errors specific to this endpoint

CodeHTTPWhat the app should do
audio_required400The recording came through empty. Offer to record again
unsupported_audio_format400Container we cannot read. This should not happen if you record with the platform default
audio_too_large400Over 4 MB. Lower the bitrate
audio_too_long400Over two minutes. Enforce the cap locally too, so this is never reached
duration_required400duration_ms missing or zero. Send the length you recorded
message_too_long400The transcript is over 2000 characters. It is returned in details[0].message: put it in the composer so the patient can shorten it rather than say it all again
voice_language_unsupported422Fall back to typing. Do not say we could not make out the recording — their speech was fine
transcription_failed422Silence, or speech the model could not resolve. Offer typing

Every other error (rate_limit, content_moderated, ai_unavailable, …) behaves exactly as on POST /chat/messages, because once the audio is words it is the same send. Voice and text share one send allowance of 30 an hour.

A voice note's user message comes back with type: "voice" and a transcription block (source: "server", confidence), so the app can mark it as spoken. Release the recording only after a successful response -- on any failure, keep it so the patient can retry or play it back.

No audio is kept. The upload is read into memory, transcribed, and dropped — it is never written to the database or to object storage, and there is no recording to fetch. (Rails' multipart parser may buffer a large upload in a request-scoped temporary file, which is deleted when the request ends; nothing in the diary reads or keeps it.)


10. Not built yet

State
Ably streaming (health_diary.* events)Not implemented; the blocking response is the whole interface for now
Emergency detectionThe pipeline and the safety_flag exist, but the term list is an English seed pending clinical review. Do not advertise emergency detection until it lands
Content moderationPermissive default — no provider chosen yet

11. Differences from the original requirements doc

The spec saidWhat was builtWhy
Voice in every listed languageVoice in en, hi, pa, ta, te, ar — not mlMalayalam cannot be transcribed on our speech plan; see §9a
Audio as AAC/M4AContainer allowlist covering AAC/M4A/MP4 and WebM/OggThe validation exists to reject junk, not enforce a codec; browsers record WebM
—409 duplicate_request addedA repeat while the first send is in flight has no other honest answer
Streaming optionalDeferred entirelyThe blocking response is sufficient to launch

12. Open questions for the mobile team

  1. Response envelope. The list meta here is flat (total/page/per_page/pages) rather than the nested meta.pagination the rest of this API uses. Kept because the contract asked for it and this app is the only consumer. Say now if you would rather converge on house conventions — it is cheap today and a rewrite once you have coded against it.
  2. Languages. The diary accepts en, hi, ml, pa, ta, te, ar, but the patient app's own preference allowlist is en, hi, es. If you take language from the app locale, five of the seven are unreachable. Own selector, or expand the allowlist?
  3. Which model. Currently DeepSeek Flash via OpenRouter. Latency has ranged from 2s to 42s, which matters to how you design the waiting state — assume it can be slow.

On this page