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

# Messaging Tools

> Conversation messaging and media handling

## Overview

Messaging tools handle sending messages in conversations and retrieving media attachments.

## send\_message

Send a message in an Arlo Health conversation.

### The read-first gate

You must reply with the latest conversation state in hand, so replies never ignore the AI's or provider's most recent message. Two ways to satisfy this:

* Pass `lastSeenMessageId` (the id of the newest message you've seen, from a prior `get_conversation` / `wait_for_reply`). Arlo treats it as an assertion about the conversation's newest message: if it matches, the send goes through with no extra round-trip; if a newer message has arrived, the send is refused even if you read the conversation earlier in the session.
* Or omit it and call `get_conversation` first. That read marks the conversation read for your session and satisfies the gate as long as no newer message lands in between.

If you're stale (a newer message arrived), the tool refuses the send but returns the latest conversation state inline under `conversation` — review it, then call `send_message` again. No separate `get_conversation` call is needed.

### Sends and waits for the reply

By default (`waitForReply: true`) this sends your message **and** waits for the next reply in one call — the AI's triage reply, a provider connecting, or a provider's message. The wait resolves only on replies **newer than the message it just sent**, so it never hands back the question you were answering. It is event-driven and resumable: if it returns `stillWaiting`, call `wait_for_reply` to keep waiting (do **not** re-call `send_message` — that would re-send).

Set `waitForReply: false` only to fire-and-forget.

### Parameters

| Parameter           | Type    | Required | Description                                                                                                                                                                                                        |
| ------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `conversationId`    | string  | Yes      | The conversation ID                                                                                                                                                                                                |
| `messages`          | array   | No       | Text message(s) to send, first person as the patient speaking                                                                                                                                                      |
| `lastSeenMessageId` | string  | No       | Newest message id you've seen — satisfies the read gate without a round-trip. A stale value refuses the send regardless of earlier reads                                                                           |
| `waitForReply`      | boolean | No       | Default `true`: wait for the next reply and return it inline. The window is host-dependent — \~55s on connector hosts, up to minutes on agent runtimes (see [wait windows](/mcp-tools/conversations#wait-windows)) |
| `media`             | object  | No       | Photo or video to send (agent runtimes only — see below)                                                                                                                                                           |
| `idempotencyKey`    | string  | No       | Agent runtimes only — see [Safe retries](#safe-retries-idempotency)                                                                                                                                                |

### Safe retries (idempotency)

Background loops and retrying runtimes can accidentally re-send a write into a medical record. `send_message`, `start_conversation`, and `confirm_provider_connection` accept an idempotency key: a replay of the same key within 24 hours returns the original result (tagged `_meta["arlo.health/idempotentReplay"]`) instead of executing again, and a replay while the first call is still running returns `duplicate_in_flight`. A failed call releases the key so a genuine retry re-runs.

Two ways to pass it:

* **Runtime-level (preferred):** set `_meta["arlo.health/idempotencyKey"]` on the tool call — works on every host, invisible to the model.
* **Model-level:** the `idempotencyKey` parameter, advertised only on agent runtimes.

Keys are scoped per account + tool. Use a fresh key per intended action (e.g. a UUID minted when the action is decided), and reuse that same key on retries of that action only.

### Behavior by conversation status

| Status             | Behavior                                                                                                                                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `IDLE`             | Re-engage: starts a **fresh triage** on the same thread. This is how you continue a past conversation                                                               |
| `TRIAGING`         | Sends; the AI will reply with follow-ups or complete triage                                                                                                         |
| `MATCHING`         | Sends; wait returns the moment a provider joins                                                                                                                     |
| `WITH_PROVIDER`    | Sends; messaging is asynchronous — wait for the provider's next message                                                                                             |
| `PAYMENT_REQUIRED` | **Will not send.** The patient must confirm payment first (Arlo never auto-charges). Use `confirm_provider_connection`, `create_payment_setup`, or `cancel_request` |
| `EMERGENCY`        | **Will not send** — the conversation was escalated for emergency care                                                                                               |

### Text messages — first person

Messages are delivered to the AI and provider as if the patient typed them. Write natural first-person responses ("I have...", "My throat still hurts..."), never third person. For a child or dependent, stay first person from the account holder's perspective ("My daughter's fever is down today.").

<CodeGroup>
  ```json Good theme={null}
  {
    "conversationId": "conv_abc123",
    "messages": ["No fever, but I've been feeling really tired and run down the past few days."]
  }
  ```

  ```json Bad theme={null}
  {
    "conversationId": "conv_abc123",
    "messages": ["no"]
  }
  ```
</CodeGroup>

<Tip>
  Don't just answer the specific question — include related details the user mentioned. Richer, natural responses help complete triage faster.
</Tip>

### Answering an `informationNeed` checklist

When a prior `wait_for_reply` / `get_conversation` returned an `informationNeed` list, that's the full set of what triage still needs. Gather those answers from the patient, then send them all in **one** call (use the `messages` array to batch) — this collapses many triage round-trips into one.

Two hard rules:

* Answer `redFlagGate` items first.
* Only report what the patient **actually said**. Never guess or fabricate a clinical answer — these messages become part of a medical record. If the patient doesn't know, say so in first person ("I'm not sure how long it's been").

```json theme={null}
{
  "conversationId": "conv_abc123",
  "messages": [
    "No fever at all.",
    "The pain started about four days ago and is worse when I swallow.",
    "I'm not taking any medications right now."
  ]
}
```

### Photo/Video messages

Behavior depends on the host:

* **Widget hosts (Claude.ai, ChatGPT)**: sending media yourself generally doesn't work — these hosts usually give the model no access to the user's files, so the bytes have to come from their device. Tell the user to tap the **+** button in the Arlo consultation panel to upload from their camera or camera roll, and don't offer to attach media you can't read.
* **Agent runtimes with file access (Claude Code, custom agents)**: send media via the `media` object. Preferred: put the file bytes as base64 in `media.data` — the server uploads and sends it for you. Or omit `media.data` to receive a short-lived (\~60s) presigned `uploadUrl` and PUT the file yourself.

If you do hold the file bytes on a widget host, `media.data` still works — the constraint is file access, not the host.

| Type    | Supported Content Types                 |
| ------- | --------------------------------------- |
| `photo` | `image/jpeg`, `image/png`, `image/heic` |
| `video` | `video/mp4`, `video/quicktime`          |

```json theme={null}
{
  "conversationId": "conv_abc123",
  "media": {
    "type": "photo",
    "contentType": "image/jpeg",
    "data": "<base64-encoded bytes>"
  }
}
```

### Returns

The shape varies by conversation status and options:

| Variant                              | Key fields                                                                                                                                                                     |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Delivered                            | `sent: true`, `messagesSent`, `status`, plus the folded-in wait result (`reply` / `replies` / `stillWaiting`) when `waitForReply`                                              |
| Triage completed into a payment gate | `paymentRequired: true`, `statusChanged: "PAYMENT_REQUIRED"`, `paymentGate`, `note`, `nextStep`, `nextStepArgs`                                                                |
| Provider joined while waiting        | `connected: true`, `status: "WITH_PROVIDER"`, `nextStep: "get_conversation"`                                                                                                   |
| Read-gate refusal                    | `sent: false`, `status: "READ_REQUIRED"`, `userMessage`, `agentInstructions[]`, `latestMessageId` (pass as `lastSeenMessageId` on retry), `conversation` (latest state inline) |
| Payment-gate refusal                 | `sent: false`, `status: "PAYMENT_REQUIRED"`, `userMessage`, `agentInstructions[]`, `options` (suggested tool calls)                                                            |
| Media fallback                       | `uploadUrl` (presigned PUT, when `media.data` omitted)                                                                                                                         |

Because the wait is folded in, a send can return any [`wait_for_reply` shape](/mcp-tools/conversations#wait_for_reply) — including `note`, `nextStep`, and `nextStepArgs` you can pass straight through.

#### Triage completed — payment required

This is the transition every consultation hits: your send finished triage, so the same call comes back holding the payment gate instead of a reply.

```json theme={null}
{
  "conversationId": "conv_abc123",
  "paymentRequired": true,
  "statusChanged": "PAYMENT_REQUIRED",
  "paymentGate": {
    "consultationSummary": "Based on what you've described, a provider can...",
    "ctaText": "Connect with a provider",
    "paymentType": "pay_per_use",
    "gateType": "PAY_PER_USE",
    "isLoading": false,
    "requestId": "req_abc123"
  },
  "note": "Triage is complete and the patient must confirm payment before connecting to a provider...",
  "nextStep": "confirm_provider_connection",
  "nextStepArgs": { "conversationId": "conv_abc123" }
}
```

Relay `paymentGate.consultationSummary` to the patient, then call `confirm_provider_connection` once they agree (`paymentType: "pay_per_use"`), or `create_payment_setup` first when it is `payment_setup_required`. Arlo never auto-charges.

#### Read-gate refusal

```json theme={null}
{
  "sent": false,
  "status": "READ_REQUIRED",
  "userMessage": "One moment — there's a new message I need to read first.",
  "agentInstructions": [
    "Review the conversation state returned below, then call send_message again with lastSeenMessageId set to latestMessageId."
  ],
  "latestMessageId": "msg_789",
  "conversation": { "status": "TRIAGING", "messages": [] }
}
```

Nothing was sent. Read the inline `conversation`, adapt your message to it if needed, then retry with `lastSeenMessageId: latestMessageId`.

<Warning>
  If the folded-in wait times out (`stillWaiting: true`), the message **is already sent** — continue with `wait_for_reply`; never re-call `send_message`. When the result carries `nextStepArgs.afterMessageId`, pass it through so the wait doesn't return your own send.
</Warning>

***

## get\_media\_url

Retrieve media attached to a message (photos, videos, or files). Messages from `get_conversation` show the media type but not the attachment itself — this tool fetches it.

### Parameters

| Parameter        | Type   | Required  | Description                         |
| ---------------- | ------ | --------- | ----------------------------------- |
| `conversationId` | string | Yes       | The conversation ID                 |
| `messageId`      | string | Yes       | The message ID containing the media |
| `mediaType`      | string | Yes       | `photo`, `video`, or `file`         |
| `fileName`       | string | For files | The file name from the message      |

### Behavior by media type

| Type    | What you get                                                                                                                                                                                   |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `photo` | Returned **inline as an image content block** so the model can see it directly, plus a signed URL fallback. Prefer the inline image — the S3 host is not always reachable from agent sandboxes |
| `video` | Signed URL only (too large to inline)                                                                                                                                                          |
| `file`  | PDFs are **rasterized server-side and returned as one image content block per page** (up to 10 pages) so the model can read the document directly. Other file types return a signed URL        |

<Note>
  Signed URLs expire (typically in 1 hour).
</Note>

### Example: View a Photo

```javascript theme={null}
const status = await getConversation({
  conversationId: "conv_abc123"
});

const photoMessage = status.messages.find(m => m.type === "photo");

// Returns the image inline plus a signed URL fallback
await getMediaUrl({
  conversationId: "conv_abc123",
  messageId: photoMessage.id,
  mediaType: "photo"
});
```

### Example: Read a PDF

```javascript theme={null}
await getMediaUrl({
  conversationId: "conv_abc123",
  messageId: "msg_xyz789",
  mediaType: "file",
  fileName: "lab_results.pdf"
});
// Returns one image content block per page (up to 10 pages)
```
