> ## Documentation Index
> Fetch the complete documentation index at: https://docs.handauncle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send Chat Message

> Unified chat endpoint for the Handa Uncle assistant.

### Authentication modes (select one in the playground)

**Option 1: Registered users (bearerAuth)**
1. Obtain an Auth0 access token (e.g. via `/api/v1/auth/signup` or `/api/v1/auth/signin`).
2. Include `Authorization: Bearer <token>` on every chat request.

**Option 2: Guest/device users (deviceAuth)**
1. Call `GET /app/launch` with `x-device-id` and `x-platform` to receive a synthetic `userId`.
2. Send `x-device-id`, `x-platform`, and optionally `x-user-id` (from launch) on `/api/v1/ai/chat` until `FREE_MESSAGE_THRESHOLD` is hit.
3. The `(threshold + 1)` request returns `429 FREE_LIMIT_EXCEEDED` with `error.details.is_guest = true` and `requires_signup = true`; show the signup prompt.
4. After signup, switch to the Authorization header—conversations continue under the same `userId`. Optional `x-user-email` / `x-user-phone` hints help link devices to existing accounts.

### Streaming vs buffered
- Set `X-Stream-Response: true` (alias `Stream: true`) to receive text/event-stream chunks (`token`, `tool_call`, `tool_result`, `done`).
- Omit the header or set it to `false` for the default buffered JSON response.

Unified endpoint for sending a user message to the Handa Uncle assistant. Supports
both JSON responses and server-sent events (SSE) streaming with the same payload.

## Authentication & headers

**Select your authentication method in the playground above** using the security dropdown.

| Scenario                                       | Required headers                                                                                                                                 |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Option 1: Registered user (bearerAuth)**     | `Authorization: Bearer <Auth0 access token>`                                                                                                     |
| **Option 2: Guest / device flow (deviceAuth)** | `x-device-id` (required), `x-platform` (required: android/ios/web), `x-user-id` (optional), `x-user-email` (optional), `x-user-phone` (optional) |
| Streaming toggle                               | `X-Stream-Response: false` or `Stream: false` to disable streaming (**streaming is ON by default**)                                              |

> The two authentication methods (bearerAuth and deviceAuth) are mutually exclusive—choose one based on whether the user is registered or a guest. The playground will show the appropriate fields based on your selection.

<Note>
  **Streaming is enabled by default.** If you want a buffered JSON response instead of SSE,
  explicitly set `X-Stream-Response: false` or `Stream: false` in your request headers.
</Note>

Every request is also subject to the chat rate limit (20 req/min) and the free-tier
message counter enforced by `usageLimitMiddleware`.

### Guest/device auth flow (deviceAuth option)

When using **deviceAuth** in the playground, provide these headers:

* **x-device-id** (required): Unique device identifier
* **x-platform** (required): One of `android`, `ios`, or `web`
* **x-user-id** (optional): User ID returned from `GET /app/launch`
* **x-user-email** (optional): Email hint for linking
* **x-user-phone** (optional): Phone hint in E.164 format

The complete guest experience flow:

1. **Launch** – call `GET /app/launch` with `x-device-id` and `x-platform`. The
   response returns a synthetic `userId` (e.g. `U-813e62a0-...`) even when no
   Auth0 account exists.
2. **Chat as guest** – send `POST /api/v1/ai/chat` with the device headers plus
   `x-user-id` returned from the launch step. Requests work immediately until
   the free counter reaches the configured `FREE_MESSAGE_THRESHOLD` (default 100).
3. **Hit the limit** – on the `(threshold + 1)` request the API responds with
   `429 FREE_LIMIT_EXCEEDED` and the payload includes
   `error.details.isGuest = true` and `error.details.requiresSignup = true`.
   Frontends should show the signup modal at this point.
4. **Continue after signup** – once the user signs up and receives an Auth0
   access token, switch to the bearer header. The `userId` and conversation
   history stay intact because the backend links the identity via the stored
   device metadata.

> Device headers are treated with the same chat-rate limiting and audit trails as
> JWTs. Spoofed or missing headers are rejected with `401 UNAUTHORIZED`.

## Request body

| Field                    | Type     | Required | Notes                                                                                                                                                                 |
| ------------------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message`                | `string` | ✅        | 1–2000 characters of user input.                                                                                                                                      |
| `conversationId`         | `string` | ⛔        | MongoDB ObjectId; omit to create a new conversation.                                                                                                                  |
| `model`                  | `string` | ⛔        | Overrides the configured default (subject to allowlists).                                                                                                             |
| `attachments[].fileId`   | `string` | ⛔        | Reference to a file uploaded via the File Upload service.                                                                                                             |
| `attachments[].data`     | `string` | ⛔        | Base64 payload for inline uploads (max 20MB after decoding).                                                                                                          |
| `attachments[].mimeType` | `string` | ⛔        | Required when `data` is provided.                                                                                                                                     |
| `attachments[].filename` | `string` | ⛔        | Required when `data` is provided.                                                                                                                                     |
| `prepromptKey`           | `string` | ⛔        | Optional identifier for a backend-managed pre-prompt. When present, the chat pipeline injects the masked instructions tied to this key right after the system prompt. |

Attachments must contain either `fileId` or (`data` + `mimeType` + `filename`). The
backend validates file size, ownership, and converts supported media into the AI SDK
multimodal format before invoking the LLM.

### Masked pre-prompts

Administrators can define reusable "masked" instructions (e.g., persona tweaks or
guardrails) via the Management API (`POST /api/v1/preprompts`, protected by the backend
secret). Client applications should fetch the user-facing catalog from
`GET /api/v1/public/preprompts` and send the selected `prepromptKey` alongside the chat
payload. Users only see the friendly label, while the backend silently injects the
corresponding hidden prompt into the LLM context.

### Guest vs registered usage messaging

`usageLimitMiddleware` inspects `c.get('isGuest')` and returns tailored limit
errors:

```json theme={null}
{
  "success": false,
  "error": {
    "message": "Please sign up to continue chatting and unlock more features!",
    "code": "FREE_LIMIT_EXCEEDED",
    "details": {
      "currentCount": 100,
      "threshold": 100,
      "remaining": 0,
      "isGuest": true,
      "requiresSignup": true
    }
  },
  "meta": { "...": "..." }
}
```

Registered users see `"Please upgrade your account..."` plus
`"isGuest": false`, `"requiresSignup": false`. Use the flags to decide whether
to show a signup modal or upsell screen.

## Responses

### Buffered JSON

```json theme={null}
{
  "success": true,
  "data": {
    "conversationId": "665f5e6fcb6e4c73dc6dca01",
    "messageId": "665f5e91cb6e4c73dc6dca05",
    "role": "assistant",
    "content": "Equity funds carry higher volatility...",
    "model": "gpt-4o-mini",
    "tokenCount": 812,
    "toolCalls": [
      {
        "toolName": "portfolio_lookup",
        "args": { "accountId": "abc123" },
        "result": { "holdings": 4 }
      }
    ]
  },
  "meta": {
    "timestamp": "2025-12-01T12:00:00.000Z",
    "requestId": "req_abc"
  }
}
```

### Streaming (SSE)

Each chunk is sent as `data: <json>\n\n`. Expect the following shapes:

* `{"type":"token","content":"..."}` – incremental tokens.
* `{"type":"toolCall","toolName":"...","args":{...}}` – tool invocation start.
* `{"type":"toolResult","toolName":"...","result":{...}}` – tool output.
* `{"type":"done","conversationId":"...","messageId":"...","tokenCount":123,"meta":{"timestamp":"...","streaming":true,"durationMs":1400}}`
* `{"type":"error","message":"Streaming failed","error":"..."}` – terminal failures.

Clients should treat the stream as finished when a `done` or `error` event arrives.

## Error codes

| HTTP  | `error.code`          | When it fires                                                                                          |
| ----- | --------------------- | ------------------------------------------------------------------------------------------------------ |
| `401` | `UNAUTHORIZED`        | Missing bearer token and device headers.                                                               |
| `429` | `RATE_LIMIT_EXCEEDED` | More than 20 requests/min per user/device.                                                             |
| `429` | `FREE_LIMIT_EXCEEDED` | Guest or free-tier user exhausted the free quota (`details.requiresSignup` will be `true` for guests). |
| `500` | `INTERNAL_ERROR`      | Upstream failure (LLM, storage, RAG, etc.).                                                            |

Retries should respect backoff when rate limited. When `FREE_LIMIT_EXCEEDED`, surface a
signup or upgrade prompt before re-sending traffic.

## Example calls

### Authenticated user with streaming (default)

```bash theme={null}
curl -N -X POST https://api.handauncle.com/api/v1/ai/chat \
  -H 'Authorization: Bearer <ACCESS_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
        "message": "Please summarise my portfolio",
        "conversationId": "665f5e6fcb6e4c73dc6dca01"
      }'
```

> **Note:** No `X-Stream-Response` header needed—streaming is the default behavior.

### Guest/device auth with streaming (default)

```bash theme={null}
curl -N -X POST https://api.handauncle.com/api/v1/ai/chat \
  -H 'x-device-id: ios-device-3f92' \
  -H 'x-user-id: U-813e62a0-a3fc-4c7e-bf4e-3f915d9e9f10' \
  -H 'x-platform: ios' \
  -H 'Content-Type: application/json' \
  -d '{ "message": "Hello from a guest!" }'
```

### Non-streaming (buffered JSON response)

To get a complete JSON response instead of SSE streaming, explicitly disable streaming:

```bash theme={null}
curl -X POST https://api.handauncle.com/api/v1/ai/chat \
  -H 'Authorization: Bearer <ACCESS_TOKEN>' \
  -H 'X-Stream-Response: false' \
  -H 'Content-Type: application/json' \
  -d '{ "message": "What is a mutual fund?" }'
```

Both calls share the same response envelope; the only difference is how the caller
authenticates and whether streaming is explicitly disabled.


## OpenAPI

````yaml POST /api/v1/ai/chat
openapi: 3.1.0
info:
  title: Handa Uncle API
  version: 1.0.0
  description: Public specification for the Handa Uncle web and mobile APIs.
  contact:
    name: Handa Uncle Engineering
    email: hello@handauncle.com
servers:
  - url: https://handauncle-backend-prod-205012263523.asia-south1.run.app
    description: Prod API base (Cloud Run)
  - url: https://api.handauncle.com
    description: Production
  - url: https://staging-api.handauncle.com
    description: Staging
  - url: http://localhost:8080
    description: Local development
security: []
tags:
  - name: Mobile App
    description: Endpoints used by the native Handa Uncle app.
  - name: Platform
    description: Cross-service operational endpoints.
  - name: Auth
    description: Authentication endpoints handled by the backend adapter.
  - name: AI
    description: LLM chat endpoints supporting streaming and device-auth guests.
  - name: OTP
    description: Phone OTP lifecycle powered by Exotel.
  - name: Webhooks
    description: Server-to-server hooks invoked by Auth0.
  - name: Conversations
    description: Authenticated user conversation management.
  - name: Chat Share
    description: Create, manage, and consume shared conversations.
  - name: Files
    description: Upload, list, and delete user files.
  - name: Prompt management
    description: Admin and public APIs for managing masked pre-prompts.
  - name: User Profile
    description: Profile card collection and user data for personalized AI responses.
  - name: Visual Embeds
    description: Manage visual embed images that attach to AI chat responses.
  - name: Second Opinion
    description: Admin and public APIs for Second Opinion suggestion cards.
  - name: Profile Cards
    description: >-
      APIs for managing profile card questions used in onboarding and
      personalization.
paths:
  /api/v1/ai/chat:
    post:
      tags:
        - AI
      summary: Send chat message (streaming or buffered)
      description: >-
        Unified chat endpoint for the Handa Uncle assistant.


        ### Authentication modes (select one in the playground)


        **Option 1: Registered users (bearerAuth)**

        1. Obtain an Auth0 access token (e.g. via `/api/v1/auth/signup` or
        `/api/v1/auth/signin`).

        2. Include `Authorization: Bearer <token>` on every chat request.


        **Option 2: Guest/device users (deviceAuth)**

        1. Call `GET /app/launch` with `x-device-id` and `x-platform` to receive
        a synthetic `userId`.

        2. Send `x-device-id`, `x-platform`, and optionally `x-user-id` (from
        launch) on `/api/v1/ai/chat` until `FREE_MESSAGE_THRESHOLD` is hit.

        3. The `(threshold + 1)` request returns `429 FREE_LIMIT_EXCEEDED` with
        `error.details.is_guest = true` and `requires_signup = true`; show the
        signup prompt.

        4. After signup, switch to the Authorization header—conversations
        continue under the same `userId`. Optional `x-user-email` /
        `x-user-phone` hints help link devices to existing accounts.


        ### Streaming vs buffered

        - Set `X-Stream-Response: true` (alias `Stream: true`) to receive
        text/event-stream chunks (`token`, `tool_call`, `tool_result`, `done`).

        - Omit the header or set it to `false` for the default buffered JSON
        response.
      operationId: aiChat
      parameters:
        - name: x-device-id
          in: header
          required: false
          description: >-
            Required when Authorization header is omitted. Stable identifier for
            the calling device.
          schema:
            type: string
            minLength: 1
        - name: x-platform
          in: header
          required: false
          description: Client platform. Required with device auth.
          schema:
            type: string
            enum:
              - android
              - ios
              - web
        - name: x-user-id
          in: header
          required: false
          description: >-
            Optional hint for mapping the device to an existing user (returned
            by `GET /app/launch`). Strongly recommended so conversations persist
            once the guest signs up.
          schema:
            type: string
        - name: x-user-email
          in: header
          required: false
          description: Optional email hint for device-auth flows.
          schema:
            type: string
            format: email
        - name: x-user-phone
          in: header
          required: false
          description: Optional phone hint in E.164 format for device-auth flows.
          schema:
            type: string
            pattern: ^\+?[1-9]\d{7,14}$
        - name: X-Stream-Response
          in: header
          required: false
          description: >-
            **Streaming is ON by default.** Set to `false` to receive buffered
            JSON instead of SSE streaming.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
            default: 'true'
        - name: Stream
          in: header
          required: false
          description: >-
            Alias for `X-Stream-Response`. **Streaming is ON by default.** Set
            to `false` to disable.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
            default: 'true'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChatMessageRequest'
      responses:
        '200':
          description: Chat response streamed or buffered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChatMessageResponseEnvelope'
            text/event-stream:
              schema:
                $ref: '#/components/schemas/ChatStreamEvent'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/UnauthorizedError'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
      security:
        - bearerAuth: []
        - deviceAuth: []
components:
  schemas:
    ChatMessageRequest:
      type: object
      required:
        - message
      properties:
        message:
          type: string
          minLength: 1
          maxLength: 2000
        conversationId:
          type: string
          pattern: ^[0-9a-fA-F]{24}$
          description: Existing conversation ID. Omit to start a new thread.
        model:
          type: string
          description: Optional override for the default model.
        prepromptKey:
          type: string
          description: >-
            Optional identifier for a backend-managed pre-prompt. When supplied,
            the associated masked instructions are injected right after the
            system prompt.
          minLength: 2
          maxLength: 64
          pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}$
        inputType:
          type: string
          enum:
            - text
            - file
            - voice
          description: >-
            Optional hint for how the conversation was initiated. Use 'voice'
            for voice input, 'file' for file uploads (auto-detected if
            attachments present), or 'text' for plain messages (default).
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/ChatAttachment'
    ChatMessageResponseEnvelope:
      type: object
      required:
        - success
        - data
        - meta
      properties:
        success:
          type: boolean
          enum:
            - true
        data:
          $ref: '#/components/schemas/ChatMessageData'
        meta:
          $ref: '#/components/schemas/ResponseMeta'
    ChatStreamEvent:
      oneOf:
        - $ref: '#/components/schemas/ChatStreamToken'
        - $ref: '#/components/schemas/ChatStreamToolCall'
        - $ref: '#/components/schemas/ChatStreamToolResult'
        - $ref: '#/components/schemas/ChatStreamDone'
        - $ref: '#/components/schemas/ChatStreamError'
    ChatAttachment:
      type: object
      properties:
        fileId:
          type: string
          description: Reference to an uploaded file (MongoDB ObjectId).
          pattern: ^[0-9a-fA-F]{24}$
        data:
          type: string
          description: Base64 encoded file contents.
        mimeType:
          type: string
          description: Required when inline data is provided.
        filename:
          type: string
          description: Required when inline data is provided.
      anyOf:
        - required:
            - fileId
        - required:
            - data
            - mimeType
            - filename
    ChatMessageData:
      type: object
      required:
        - conversationId
        - messageId
        - role
        - content
        - model
        - tokenCount
      properties:
        conversationId:
          type: string
        messageId:
          type: string
        role:
          type: string
          enum:
            - assistant
        content:
          type: string
        model:
          type: string
        tokenCount:
          type: integer
        toolCalls:
          type: array
          items:
            $ref: '#/components/schemas/ChatToolCall'
    ResponseMeta:
      type: object
      properties:
        timestamp:
          type: string
          format: date-time
        requestId:
          type: string
          description: Server generated correlation identifier.
      required:
        - timestamp
        - requestId
    ChatStreamToken:
      type: object
      required:
        - type
        - content
      properties:
        type:
          type: string
          enum:
            - token
        content:
          type: string
    ChatStreamToolCall:
      type: object
      required:
        - type
        - toolName
        - args
      properties:
        type:
          type: string
          enum:
            - tool_call
        toolName:
          type: string
        args:
          type: object
          additionalProperties: true
    ChatStreamToolResult:
      type: object
      required:
        - type
        - toolName
        - result
      properties:
        type:
          type: string
          enum:
            - tool_result
        toolName:
          type: string
        result:
          type: object
          additionalProperties: true
    ChatStreamDone:
      type: object
      required:
        - type
        - conversationId
        - messageId
        - role
        - model
        - tokenCount
        - meta
      properties:
        type:
          type: string
          enum:
            - done
        conversationId:
          type: string
        messageId:
          type: string
        role:
          type: string
          enum:
            - assistant
        model:
          type: string
        tokenCount:
          type: integer
        toolCalls:
          type: array
          items:
            $ref: '#/components/schemas/ChatToolCall'
        meta:
          type: object
          required:
            - timestamp
            - streaming
            - durationMs
          properties:
            timestamp:
              type: string
              format: date-time
            streaming:
              type: boolean
            durationMs:
              type: integer
    ChatStreamError:
      type: object
      required:
        - type
        - message
      properties:
        type:
          type: string
          enum:
            - error
        message:
          type: string
        error:
          type: string
    ErrorResponse:
      type: object
      properties:
        success:
          type: boolean
          enum:
            - false
        error:
          $ref: '#/components/schemas/ErrorObject'
        meta:
          $ref: '#/components/schemas/ResponseMeta'
      required:
        - success
        - error
        - meta
    ChatToolCall:
      type: object
      required:
        - toolName
        - args
      properties:
        toolName:
          type: string
        args:
          type: object
          additionalProperties: true
        result:
          type: object
          additionalProperties: true
    ErrorObject:
      type: object
      properties:
        message:
          type: string
          description: Human-readable error message describing the issue.
          example: Please enter a valid 10-digit phone number (e.g., 9876543210)
        code:
          type: string
          description: Error code for programmatic handling.
          example: VALIDATION_ERROR
        details:
          type: array
          description: >-
            Array of field-specific validation errors (for VALIDATION_ERROR
            responses).
          items:
            type: object
            properties:
              field:
                type: string
                description: Field name that failed validation.
                example: phone
              message:
                type: string
                description: Specific validation error message for this field.
                example: Please enter a valid 10-digit phone number (e.g., 9876543210)
            required:
              - field
              - message
      required:
        - message
  responses:
    ValidationError:
      description: The request payload or headers were invalid.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnauthorizedError:
      description: Authentication failed or is missing.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    TooManyRequests:
      description: Rate limit or usage threshold exceeded.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            rateLimit:
              summary: Per-minute chat rate limit hit
              value:
                success: false
                error:
                  message: Rate limit exceeded. Please try again later.
                  code: RATE_LIMIT_EXCEEDED
                  details:
                    window: 60s
                    limit: 20
                meta:
                  timestamp: '2025-12-01T12:00:00.000Z'
                  requestId: req_rate_limit
            guestFreeLimit:
              summary: Guest user exhausted the free threshold
              value:
                success: false
                error:
                  message: >-
                    Please sign up to continue chatting and unlock more
                    features!
                  code: FREE_LIMIT_EXCEEDED
                  details:
                    currentCount: 100
                    threshold: 100
                    remaining: 0
                    isGuest: true
                    requiresSignup: true
                meta:
                  timestamp: '2025-12-01T12:05:00.000Z'
                  requestId: req_guest_limit
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Auth0 access token for registered users.
    deviceAuth:
      type: apiKey
      in: header
      name: x-device-id
      description: >-
        Device-based authentication for guest users. Requires x-device-id,
        x-user-id (optional), and x-platform headers.

````