Build an AI agent
An AI agent can take routine work off your staff: answering scheduling questions, confirming appointments, and routing everything else to a person. In BloomText an agent is just an app user, so it follows the same rules as any integration.
- Visible: every message it sends shows the app user as the sender.
- Scoped: it reads and writes only conversations it’s been added to, with only the scopes its key has.
- Revocable: an admin can revoke its key or remove it from a conversation at any time.
Two ways to connect
Build the loop
Create a dedicated app user
Name it so staff recognize it, like “Scheduling Assistant”. Grant only what the loop needs:
conversations:read messages:read messages:write reactions:writeReceive new messages
Subscribe to conversation.message.created and verify each signature. Ignore events where sender_id is your own app user, or the agent will answer itself.
Load context
Fetch the last few messages with List messages so the model sees the conversation, not a single line.
Decide and reply
Call your model with the context and a narrow instruction set. Reply in the thread with Send a message, setting reply_to_message_id.
Hand off to people
Anything outside the agent’s job goes to staff: post in an internal group and stay quiet in the patient thread.
JavaScript
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
const API = 'https://api.bloomtext.com/v1'
const headers = { Authorization: `Bearer ${process.env.BLOOMTEXT_API_KEY}` }
const APP_USER_ID = process.env.BLOOMTEXT_APP_USER_ID
const STAFF_GROUP_ID = process.env.BLOOMTEXT_STAFF_GROUP_ID
// Call this from your verified webhook handler.
export async function handleMessageCreated(event) {
const { conversation_id, message_id, sender_id } = event.payload
if (sender_id === APP_USER_ID) return // don't answer ourselves
const history = await fetch(`${API}/conversations/${conversation_id}/messages?page[limit]=20`, { headers })
.then((r) => r.json())
const reply = await anthropic.messages.create({
model: 'claude-sonnet-5',
max_tokens: 300,
system:
'You are the scheduling assistant for a medical practice. Answer only questions about appointment times, ' +
'directions, and forms. For anything clinical or anything you are unsure about, respond with exactly HANDOFF.',
messages: [{ role: 'user', content: history.data.map((m) => `${m.sender_id}: ${m.body ?? '[file]'}`).join('\n') }],
})
const text = reply.content[0].text.trim()
const post = (conversationId, body) =>
fetch(`${API}/conversations/${conversationId}/messages`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': `${message_id}:${conversationId}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (text === 'HANDOFF') {
await post(STAFF_GROUP_ID, { body: `Needs a staff reply: conversation ${conversation_id}` })
} else {
await post(conversation_id, { body: text, reply_to_message_id: message_id })
}
}The idempotency key is derived from the incoming message ID, so a retried webhook can never make the agent reply twice. See Idempotency.
Guardrails that matter
- Keep a narrow job. A clear list of what the agent may answer beats a long list of what it may not.
- Make handoff the default. When the model is unsure, it should route to staff, not guess.
- Never let it act clinically. No diagnoses, dosing, or triage advice in automated replies.
- Use a model provider with a BAA. The agent reads PHI, so its provider needs a BAA with your organization.
- Log every decision with the incoming message ID, the reply, and whether it handed off.
- Start read-only. Run the agent in “suggest” mode, posting drafts to a staff group, before you let it reply to patients.