Build a daily message 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.
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 with GET /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 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.
Let each clinician connect
Add a “Connect BloomText” button to the clinician’s settings in your app. It starts the authorization flow with these scopes:
scope=messages.read offline_accessWhen the code exchange succeeds, store the refresh token against the clinician, encrypted, with the connection’s expires_at from the 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 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.
GET /v1/me/messages?after=2026-09-25T13:00:00Z&group_by=conversation&order=asc&page[limit]=100Follow next_cursor until has_more is false. See 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 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
JavaScript
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)
}getAccessToken refreshes the clinician’s token and saves the new refresh token, as shown in 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. - Spread the work out. Running every clinician at 7:00 sharp can hit rate limits. Stagger jobs over the hour before the clinic opens.