Skip to Content
API keys are issued to organizations with a signed BAA. Request API access →
Build an AI agent

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:write

Receive 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.

agent.mjs
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.
Last updated on