# Build an AI agent

> Build an AI agent that reads and replies in BloomText conversations, with safe scopes and human handoff.

Source: https://www.bloomtext.com/developers/api/ai-agents/

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](https://www.bloomtext.com/developers/api/concepts/#app-users), 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

  - [MCP server](https://www.bloomtext.com/developers/api/mcp/)
  - [REST API + webhooks](#build-the-loop)

## Build the loop

```mermaid
flowchart LR
  W[conversation.message.created] --> F[Fetch recent messages]
  F --> D{Can the agent answer?}
  D -->|Yes| R[Reply in the thread]
  D -->|No| H[Post to a staff group]
  R --> L[Log the decision]
  H --> L
```

### Create a dedicated app user

Name it so staff recognize it, like "Scheduling Assistant". Grant only what the loop needs:

```text
conversations:read  messages:read  messages:write  reactions:write
```

### Receive new messages

Subscribe to [`conversation.message.created`](https://www.bloomtext.com/developers/api/reference/webhook-events/#conversationmessagecreated) and [verify each signature](https://www.bloomtext.com/developers/api/webhooks/#verify-signatures). 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](https://www.bloomtext.com/developers/api/reference/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](https://www.bloomtext.com/developers/api/reference/create-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.

```js filename="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 })
  }
}
```

```python filename="agent.py"
import os

import anthropic
import requests

client = anthropic.Anthropic()
API = "https://api.bloomtext.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BLOOMTEXT_API_KEY']}"}
APP_USER_ID = os.environ["BLOOMTEXT_APP_USER_ID"]
STAFF_GROUP_ID = os.environ["BLOOMTEXT_STAFF_GROUP_ID"]

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."
)


def post(conversation_id: str, body: dict, key: str) -> None:
    requests.post(
        f"{API}/conversations/{conversation_id}/messages",
        headers={**HEADERS, "Idempotency-Key": key},
        json=body,
    ).raise_for_status()


def handle_message_created(event: dict) -> None:
    """Call this from your verified webhook handler."""
    payload = event["payload"]
    if payload["sender_id"] == APP_USER_ID:
        return  # don't answer ourselves

    history = requests.get(
        f"{API}/conversations/{payload['conversation_id']}/messages",
        headers=HEADERS,
        params={"page[limit]": 20},
    ).json()["data"]

    reply = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=300,
        system=SYSTEM,
        messages=[{"role": "user", "content": "\n".join(f"{m['sender_id']}: {m['body'] or '[file]'}" for m in history)}],
    )
    text = reply.content[0].text.strip()
    key = f"{payload['message_id']}:reply"

    if text == "HANDOFF":
        post(STAFF_GROUP_ID, {"body": f"Needs a staff reply: conversation {payload['conversation_id']}"}, key)
    else:
        post(payload["conversation_id"], {"body": text, "reply_to_message_id": payload["message_id"]}, key)
```

> **Note:** The idempotency key is derived from the incoming message ID, so a retried webhook can never make the agent reply twice. See [Idempotency](https://www.bloomtext.com/developers/api/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.
