# Build a daily message digest

> Pull an org admin's BloomText messages from the last day with OAuth and turn them into events, follow-ups, and to-dos inside your EHR or EMR.

Source: https://www.bloomtext.com/developers/api/guides/daily-digest/

Build a job that runs each morning, reads everything a clinician received in BloomText since the last run, and turns it into a short list of events, follow-ups, and to-dos in your EHR or EMR. The clinician sees what matters without reading every conversation.

> **Note:** This guide uses OAuth, which only org admins can connect for now, so the digest covers an admin's own conversations. For a digest of patient messages instead, use an [API key](https://www.bloomtext.com/developers/api/api-keys/) with [`GET /patient-messages`](https://www.bloomtext.com/developers/api/reference/list-patient-messages/), the same parameters, and `q=from:patient` in place of `q=-from:me`. A key reads every patient conversation in the range.

You need a [registered app](https://www.bloomtext.com/developers/api/oauth/#before-you-start) with the `messages.read` and `offline_access` scopes, a place to store each clinician's refresh token, and a model provider covered by a BAA.

```mermaid
flowchart LR
  C[Clinician connects BloomText] --> T[Store refresh token]
  T --> J[Morning job]
  J --> M["GET /me/messages?after=last run"]
  M --> X[Model extracts events, follow-ups, to-dos]
  X --> E[Show in your EHR]
```

### Let each clinician connect

Add a "Connect BloomText" button to the clinician's settings in your app. It starts the [authorization flow](https://www.bloomtext.com/developers/api/oauth/#the-authorization-flow) with these scopes:

```text
scope=messages.read offline_access
```

When the code exchange succeeds, store the refresh token against the clinician, encrypted, with the connection's `expires_at` from [the profile](https://www.bloomtext.com/developers/api/reference/get-profile/). Set their `lastDigestAt` to 24 hours ago so the first digest covers the last day. Read-only is enough: the digest never writes to BloomText, and reading never marks messages as read, so the clinician's unread badges stay as they were.

### Fetch everything since the last run

For each connected clinician, refresh the access token, then [list messages](https://www.bloomtext.com/developers/api/read-messages/) sent since the job last ran for them. Use a timestamp in `after` rather than a date so runs never overlap or leave gaps, `group_by=conversation` so each chat comes back whole, and `order=asc` so the model reads each chat in the order it happened.

```text filename="Query"
GET /v1/me/messages?after=2026-09-25T13:00:00Z&group_by=conversation&order=asc&page[limit]=100
```

Follow `next_cursor` until `has_more` is `false`. See [Pagination](https://www.bloomtext.com/developers/api/pagination/).

Filter with `q` as needed: `is:unread` for only what the clinician hasn't seen, `-from:me` to leave out their own messages, or `from:patient` to focus on patients. A second request with `q=is:failed` lists texts that didn't reach patients. See [Search messages](https://www.bloomtext.com/developers/api/search/) for every operator.

### Group messages by conversation

With `group_by=conversation`, each chat's messages arrive together. Each message carries its `conversation.title` and `sender.name`, so you can build a readable transcript for the model without extra requests.

### Extract the items

Ask the model for a small, structured list. Keep the instructions narrow: report what the messages say, and give no clinical advice.

### Show the digest and handle reconnects

Show the items in your app, each linked back to its conversation. If refreshing the token fails with `invalid_grant`, or `expires_at` is less than two weeks away, show a "Reconnect BloomText" prompt instead of a digest.

## Example job

```js filename="digest.mjs"
import Anthropic from '@anthropic-ai/sdk'

const anthropic = new Anthropic()
const API = 'https://api.bloomtext.com/v1'

const INSTRUCTIONS =
  'You summarize a clinician\'s BloomText messages for their morning digest. From the transcript, list every ' +
  'scheduled event, every follow-up someone is waiting on, and every to-do for the clinician. Report only what the ' +
  'messages say; never add clinical advice. Reply with JSON only: {"items": [{"kind": "event" | "follow_up" | "todo", ' +
  '"summary": string, "conversation_id": string, "due": string | null}]}'

async function listMessagesSince(accessToken, since) {
  const messages = []
  let cursor = null
  do {
    const url = new URL(`${API}/me/messages`)
    url.searchParams.set('after', since)
    url.searchParams.set('q', '-from:me')
    url.searchParams.set('group_by', 'conversation')
    url.searchParams.set('order', 'asc')
    url.searchParams.set('page[limit]', '100')
    if (cursor) url.searchParams.set('page[after]', cursor)

    const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
    if (!response.ok) throw new Error(`BloomText API error: ${response.status}`)
    const { data, pagination } = await response.json()
    messages.push(...data)
    cursor = pagination.has_more ? pagination.next_cursor : null
  } while (cursor)
  return messages
}

function transcript(messages) {
  const byConversation = Object.groupBy(messages, (m) => m.conversation.id)
  return Object.values(byConversation).map((thread) => [
    `## ${thread[0].conversation.title} (conversation ${thread[0].conversation.id})`,
    ...thread.map((m) => `[${m.created_at}] ${m.sender.name} (${m.sender.kind}): ${m.body ?? `[file: ${m.file?.name}]`}`),
  ].join('\n')).join('\n\n')
}

// Run each morning for each connected clinician.
export async function buildDigest(clinician, { getAccessToken, saveDigest, markRun }) {
  const startedAt = new Date().toISOString()
  const accessToken = await getAccessToken(clinician) // refreshes, or returns null if they must reconnect
  if (!accessToken) return saveDigest(clinician, { reconnect: true })

  const messages = await listMessagesSince(accessToken, clinician.lastDigestAt)
  if (messages.length === 0) return markRun(clinician, startedAt)

  const reply = await anthropic.messages.create({
    model: 'claude-sonnet-5',
    max_tokens: 2000,
    system: INSTRUCTIONS,
    messages: [{ role: 'user', content: transcript(messages) }],
  })
  const text = reply.content[0].text
  const { items } = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) // tolerate code fences

  await saveDigest(clinician, { items, messageCount: messages.length })
  await markRun(clinician, startedAt)
}
```

```python filename="digest.py"
import json
from datetime import datetime, timezone
from itertools import groupby

import anthropic
import requests

client = anthropic.Anthropic()
API = "https://api.bloomtext.com/v1"

INSTRUCTIONS = (
    "You summarize a clinician's BloomText messages for their morning digest. From the transcript, list every "
    "scheduled event, every follow-up someone is waiting on, and every to-do for the clinician. Report only what the "
    "messages say; never add clinical advice. Reply with JSON only: "
    '{"items": [{"kind": "event" | "follow_up" | "todo", "summary": string, "conversation_id": string, "due": string | null}]}'
)


def list_messages_since(access_token: str, since: str) -> list[dict]:
    messages = []
    params = {"after": since, "q": "-from:me", "group_by": "conversation", "order": "asc", "page[limit]": 100}
    while True:
        response = requests.get(f"{API}/me/messages", headers={"Authorization": f"Bearer {access_token}"}, params=params)
        response.raise_for_status()
        page = response.json()
        messages.extend(page["data"])
        if not page["pagination"]["has_more"]:
            return messages
        params["page[after]"] = page["pagination"]["next_cursor"]


def transcript(messages: list[dict]) -> str:
    sections = []
    ordered = sorted(messages, key=lambda m: (m["conversation"]["id"], m["created_at"]))
    for conversation_id, thread in groupby(ordered, key=lambda m: m["conversation"]["id"]):
        thread = list(thread)
        section = [f"## {thread[0]['conversation']['title']} (conversation {conversation_id})"]
        for m in thread:
            text = m["body"] or f"[file: {(m['file'] or {}).get('name')}]"
            section.append(f"[{m['created_at']}] {m['sender']['name']} ({m['sender']['kind']}): {text}")
        sections.append("\n".join(section))
    return "\n\n".join(sections)


def build_digest(clinician, get_access_token, save_digest, mark_run):
    """Run each morning for each connected clinician."""
    started_at = datetime.now(timezone.utc).isoformat()
    access_token = get_access_token(clinician)  # refreshes, or returns None if they must reconnect
    if access_token is None:
        return save_digest(clinician, {"reconnect": True})

    messages = list_messages_since(access_token, clinician.last_digest_at)
    if not messages:
        return mark_run(clinician, started_at)

    reply = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=2000,
        system=INSTRUCTIONS,
        messages=[{"role": "user", "content": transcript(messages)}],
    )
    text = reply.content[0].text
    items = json.loads(text[text.index("{"):text.rindex("}") + 1])["items"]  # tolerate code fences

    save_digest(clinician, {"items": items, "message_count": len(messages)})
    mark_run(clinician, started_at)
```

`getAccessToken` refreshes the clinician's token and saves the new refresh token, as shown in [Refresh an access token](https://www.bloomtext.com/developers/api/oauth/#refresh-an-access-token). The job records `startedAt` only after the digest is saved, so a failed run is picked up by the next one.

## Keep it safe

- The model sees PHI. Your model provider needs a BAA with your organization, and the transcript should never leave infrastructure your BAA covers.
- Store only what you show: the extracted items and links back to BloomText, not a copy of every message.
- For anything clinical, link rather than paraphrase. Each item carries its `conversation_id`, so the clinician can open the original conversation before acting.
- Stay read-only. The digest doesn't need `messages.write`; if you later add "reply from the digest", request it separately. See [Build an AI agent](https://www.bloomtext.com/developers/api/ai-agents/).
- Spread the work out. Running every clinician at 7:00 sharp can hit [rate limits](https://www.bloomtext.com/developers/api/rate-limits/). Stagger jobs over the hour before the clinic opens.
