multipart/form-data; Bearer token requiredchat_key. You then open a WebSocket to receive the reply as it is generated, token by token.POST /nandini/chat (this call) → returns data.chat_key + data.stream_token + data.status = "processing"./ws/stream?stream_token=<stream_token> to stream the reply - use stream_token here, NOT chat_key (see below for why).stream_tokenis fresh per message;chat_keyis stable for the whole thread - they are not interchangeable, and this matters more than it sounds. The underlying Redis Stream is append-only per key, and the socket always replays from the start on connect (see next paragraph) - so if it were keyed onchat_key, a thread's 2nd+ message would make the socket replay the FIRST reply's still-present completion frame and stop right there. Real, previously-shipped bug (fixed 2026-08-26): exactly this - every message after the first showed the first reply's answer in real time, whileGET /nandini/chats/{chat_key}(history/refetch) was always correct since it readschat_historydirectly, bypassing the stream entirely. Always use thestream_tokenfrom the response you JUST got for THIS message's socket.
You do not need to connect immediately. The stream is a persisted Redis Stream, not pub/sub - the worker can finish (and finish publishing) before your socket even completes its handshake, and connecting late still replays everything from the start, nothing is lost to that race. The full reply is also always saved regardless, so GET /nandini/chats/{chat_key}is a safe fallback if a socket drops mid-stream.
/ws/stream?stream_token=<stream_token>){"type":"end"} (JSON) and closes the socket. This is a control signal, NOT prose - do not render it; treat it (and/or the socket close) as done.{"type":"error","content":"..."} (JSON control frame, not prose) and closes. This is also what you get if a vision reply fails (see below) - the frontier model is not silently retried on a cheaper model for image turns, so a failure here means "please retry", not a bad answer.The WebSocket takes only stream_tokenas a query param (no auth header).
files (repeat the field for multiple). query and files are BOTH optional, but at least one must be present:query only - normal text chat, unchanged.files only (no query) - Nandini is asked to describe/answer about the attachment(s) generically.query + files together - Nandini actually LOOKS AT the image while answering your specific question about it (real-time vision on that turn), not a generic pre-summarized description. Attaching an image also forces that turn onto the deep/frontier model, regardless of the usual intent-based routing.Plan-gated: because attaching a file forces the costlier frontier model, filesis Sage/Guru only (whenX-Enforce-Paywall: true) - a free Seeker gets back200withsuccess: falseandcode: PLAN_LIMIT(not an HTTP error) if they includefiles, even with aquery. Seekers can still send unlimited text-only messages; only the attachment is blocked, not the chat itself.
 - render these as an inline image, never as a bare clickable link.[...](url) - dispatch by the URL's file extension: .pdf → embedded PDF viewer, .mp3/.wav → audio player, .mp4 → video player, anything else → a plain clickable link that opens in a new tab.processing (returned here) - the reply is being generated by the background worker. Open the WebSocket to receive it. This is the only status this endpoint returns; completion/failure are observed over the WebSocket, not via REST.chat_key. A new thread is created (its title is seeded from your first query) and a fresh chat_key (Chat-XXXXX) is returned. This works even if your first message is a file with no query - unlike attaching a file used to require an existing chat, this endpoint does not.chat_key. Your message is appended to that thread, and the worker feeds the last 16 messages back as context so Nandini remembers the conversation. (List threads with GET /nandini/chats.)kundali, marriage, palm_reading, tarot, naming) she reads only that report; if it has not been generated yet she warmly asks the user to create it first and never invents a reading. Pass family_key to ask about a family member's chart instead of the user's own.general (the default) is different: it bundles every completed report the subject has (not just kundali), and if family_key is omitted it also tries to figure out WHO the question is about directly from the message text - a relation word ('my father') or a name, matched against the user's real family list. Zero matches -> the user themself; one match -> that person; more than one (e.g. two brothers both matching 'brother') -> ALL of them, named, so Nandini can address each individually instead of guessing or asking - that turn is also forced onto the frontier model, since comparing multiple people's charts is a harder synthesis task.report_key (the report's own key - Tarot-XXXXX, Match-XXXXX for marriage, Baby-XXXXX for naming, or Palm-XXXXX) when the user tapped 'Talk to Nandini' from a specific report's screen, so she grounds on THAT report instead of always the newest. kundali and panchang/puja/general ignore report_key (kundali always resolves to the subject's single current chart).query (optional if files is present - the user's message); chat_key (optional - continue a thread; omit to start a new one); family_key (optional - Family-XXXXX, ask about a family member); chat_type (optional, default general - one of general | kundali | marriage | palm_reading | tarot | panchang | puja | naming; an unknown value is coerced to general); report_key (optional - pin to one specific report instance when the subject has several; see above); files (optional - one or more JPEG/PNG/WebP/PDF, ≤10MB each).200 - data.chat_key (continue the thread later - NOT for /ws/stream, see above) + data.stream_token (for /ws/stream - see above) + data.status (processing). Also returns 200 with success: false and code: PLAN_LIMIT (not an HTTP error) when header X-Enforce-Paywall: true is sent, and EITHER today's message count has hit the plan's messages_per_day limit, OR files were attached by a non-Sage/Guru (free Seeker) caller - image/file uploads in chat are a Sage/Guru-only feature; a Seeker can still send text-only messages freely401 - invalid/expired token | 404 - chat_key supplied but not found for this user | 409 - both query and files are empty, or a file is an unsupported type/over 10 MB