Build an AI agent
Give an AI agent BloomText tools so it can answer questions like “what did I miss yesterday?”, “has anyone replied about the Doe referral?”, or “what am I supposed to follow up on this week?”.
Pick the credential by the job:
- For patient work (look up a patient, read what they wrote, text them), use an API key. Tools take a patient ID, phone number, or MRN.
- For an admin’s own inbox (search their chats, summarize, reply), use the admin’s OAuth token. Only org admins can connect apps for now.
The rest of this page builds the inbox assistant. Through OAuth, the agent acts as the admin who connected it. Under /me it sees only the admin’s own conversations, and it can also use the Patient API, always within the scopes the admin approved. Anything it sends appears under the admin’s name, with your app’s name as the “via” label for staff. The admin can disconnect it at any time.
Give the model tools, not tokens
Your code holds the user’s access token and makes every API request. The model sees only tool definitions and results. Three tools cover most assistants:
| Tool | Calls | Scope |
|---|---|---|
search_messages | GET /me/messages?q=…&after=… | messages.read |
read_conversation | GET /me/messages?conversation_id=… | messages.read |
propose_message | Nothing. It shows a draft to the user, who sends it from your UI. To let the agent send on its own, swap in a send_message tool that calls Send a message (POST /me/conversations/{id}/messages). | messages.write |
For patient work, add a find_patient tool that calls GET /patients?mrn=… or ?phone=…, and read a patient’s history with GET /patient-messages?patient_id=…. With an API key, point search_messages at GET /patient-messages, which covers patient conversations rather than the admin’s own.
The search operators are short, so a one-sentence tool description that lists them is enough. Put time in separate after and before inputs, not in q.
Example
JavaScript
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
const API = 'https://api.bloomtext.com/v1'
const tools = [
{
name: 'search_messages',
description:
"Search the user's BloomText messages across all their conversations, newest first. q takes words and operators: " +
'free text, "phrases", from:me|patient|staff, mrn:, is:unread, is:failed, -term, OR. ' +
'Put the time range in after and before (YYYY-MM-DD or an RFC 3339 timestamp), not in q.',
input_schema: {
type: 'object',
properties: {
q: { type: 'string' },
after: { type: 'string' },
before: { type: 'string' },
order: { type: 'string', enum: ['desc', 'asc'] },
},
},
},
{
name: 'read_conversation',
description: 'Read the most recent messages in one conversation, from everyone in it, for context.',
input_schema: { type: 'object', properties: { conversation_id: { type: 'string' } }, required: ['conversation_id'] },
},
{
name: 'propose_message',
description: 'Suggest a message for the user to review and send. It is never sent without the user clicking Send.',
input_schema: {
type: 'object',
properties: { conversation_id: { type: 'string' }, body: { type: 'string' } },
required: ['conversation_id', 'body'],
},
},
]
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const compact = (m) => ({ id: m.id, at: m.created_at, conversation: m.conversation, from: `${m.sender.name} (${m.sender.kind})`, body: m.body, unread: m.unread })
async function bloomtext(accessToken, path, params = {}) {
const url = new URL(`${API}${path}`)
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
if (!response.ok) return { error: (await response.json()).code }
return (await response.json()).data.map(compact)
}
// Answers one question for one user. `accessToken` never reaches the model.
export async function ask(accessToken, question, { showDraft }) {
const messages = [{ role: 'user', content: question }]
while (true) {
const reply = await anthropic.messages.create({
model: 'claude-sonnet-5',
max_tokens: 2000,
system: `You help a clinic staff member with their BloomText messages. Today is ${new Date().toDateString()}. Report what messages say; never give clinical advice.`,
tools,
messages,
})
messages.push({ role: 'assistant', content: reply.content })
if (reply.stop_reason !== 'tool_use') return reply.content.find((block) => block.type === 'text')?.text
const results = []
for (const call of reply.content.filter((block) => block.type === 'tool_use')) {
let result
if (call.name === 'search_messages') {
const { q, after, before, order = 'desc' } = call.input
result = await bloomtext(accessToken, '/me/messages', { ...(q && { q }), ...(after && { after }), ...(before && { before }), order, 'page[limit]': '50' })
}
// IDs come from the model, so check them before they reach a request.
if (call.name !== 'search_messages' && !UUID.test(call.input.conversation_id)) result = { error: 'invalid_conversation_id' }
else if (call.name === 'read_conversation') result = await bloomtext(accessToken, '/me/messages', { conversation_id: call.input.conversation_id, 'page[limit]': '30' })
else if (call.name === 'propose_message') result = await showDraft(call.input) // your UI shows the conversation and text
results.push({ type: 'tool_result', tool_use_id: call.id, content: JSON.stringify(result) })
}
messages.push({ role: 'user', content: results })
}
}When the user clicks Send on a draft, your code calls Send a message (POST /me/conversations/{id}/messages) with their token and an idempotency key derived from the draft, so a double click sends once.
Guardrails that matter
- Decide what the agent may send on its own. Its messages appear under the user’s name, so let it send routine, predictable messages directly and propose everything else. Tell users which messages it sends for them.
- Treat message text as untrusted. Patients and staff write what the agent reads, and that text can try to steer the model. Check every ID the model passes to a tool, and never let message content widen what the agent may do.
- Start read-only. Ask for
messages.readfirst. Requestmessages.writeonly when you add sending, and explain why on your own screen before the user approves it. - Keep the job narrow. A clear list of what the agent may do beats a long list of what it may not.
- Never let it act clinically: no diagnoses, dosing, or triage advice. Have it quote and link the original messages instead.
- Keep the token out of the model. Tools take IDs and queries, never credentials.
- Know what changes read state. Searching and reading never mark anything as read, but a message the agent sends marks its conversation as read. If the user should still see the conversation as unread, have the agent propose the reply instead.
- Use a model provider that has a BAA with your organization. The agent reads PHI.
- Log every tool call with the user, the query, and whether a draft was sent.