> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Conversation Hub SDK

> Reply to WhatsApp conversations, send media, hand off to humans, and manage contacts, tags and notes — all from agent code

The **Conversation Hub SDK** (`@runflow-ai/sdk/conversation`) lets an agent operate inside the Conversation Hub — Runflow's WhatsApp/human-in-the-loop console. Answer the conversation that triggered the agent, show a typing indicator, send templates and media, open a ticket for a human attendant, and keep the contact's profile (tags, internal notes) up to date.

<Note>
  Requires the **Conversation Hub** feature enabled for your tenant. All calls are authenticated with a tenant-scoped API key; conversations from other tenants return **404** (existence is never leaked).
</Note>

## Setup

One click: with the feature enabled, open **Credentials** in the Runflow portal, pick **Conversation Hub** in the credential type dropdown and hit **Generate key**. The key is minted by the Conversation Hub, stored encrypted as the `conversation-api-key` credential, and never shown in the browser. Re-running it **rotates** the key (the previous one is revoked instantly).

That's it — the SDK resolves the key from the credentials store on the first call and talks to the production Conversation Hub by default. Just import the default instance:

```ts theme={null}
import { conversation } from '@runflow-ai/sdk/conversation';
```

## Getting the conversation

<CardGroup cols={3}>
  <Card title="fromTrigger" icon="bolt">
    The conversation that activated this agent — the standard path for triggered agents.
  </Card>

  <Card title="byId" icon="hashtag">
    Lazy handle for a known conversation id.
  </Card>

  <Card title="byPhone" icon="phone">
    Proactive sends — resolve (or create) the thread from a phone number.
  </Card>
</CardGroup>

```ts theme={null}
export async function main(input) {
  // Triggered agent: the Conversation Hub fires your HTTP trigger per inbound
  // message (bursts are debounced per conversation).
  const conv = conversation.fromTrigger(input); // reads input.metadata.conversationId

  const { contact, messages, status, humanInLoop } = await conv.get({ messages: 20 });
  await conv.sendText(`Oi ${contact.name}!`);
}
```

Proactive flows (cron jobs, external events) usually only know the phone number:

```ts theme={null}
const conv = await conversation.byPhone('+5511999998888', {
  connectionId: 'cn_...', // optional — pin a specific WhatsApp number
  create: true,           // create contact (+ conversation) when missing
});

conv.snapshot;            // resolved state: contact, contactCreated, conversationCreated
await conv.sendTemplate({ name: 'followup', language: 'pt_BR' });
```

<Note>
  `byPhone` matches every stored phone variant (with/without `+`, with/without the Brazilian mobile 9).
</Note>

## Sending messages

```ts theme={null}
// Free text — only inside Meta's 24h customer-service window
await conv.sendText('Seu pedido saiu para entrega!');

// Outside the window sendText fails with code OUTSIDE_24H_WINDOW — use a template
await conv.sendTemplate({ name: 'order_update', language: 'pt_BR' });

// Media: base64, bytes, or a URL (URLs are downloaded inside the agent process)
await conv.sendMedia({ mediaKind: 'image', url: 'https://cdn.example.com/boleto.png', caption: 'Segue o boleto' });

// Reply buttons (up to 3) and lists
await conv.sendButtons('Confirma o agendamento?', [
  { id: 'yes', title: 'Sim' },
  { id: 'no', title: 'Não' },
]);
await conv.sendInteractive({
  type: 'list',
  body: 'Escolha um horário',
  actionTitle: 'Horários',
  sections: [{ title: 'Amanhã', rows: [{ id: 'h9', title: '09:00' }, { id: 'h14', title: '14:00' }] }],
});
```

### Safe retries (idempotency)

Every send accepts an `idempotencyKey`. Retrying with the same key returns the original message instead of double-sending — the reply carries `deduplicated: true`.

```ts theme={null}
await conv.sendText('Pedido confirmado!', {
  idempotencyKey: `${conv.conversationId}:${input.metadata?.messageId ?? 'reply'}`,
});
```

## Typing indicator & read receipts

```ts theme={null}
await conv.typing();   // "typing..." bubble + marks the last inbound message as read
await conv.markRead(); // blue ticks only
```

Both anchor on the contact's last inbound message (Meta requires it), so they return **422** on a conversation with no inbound yet. The typing bubble auto-dismisses in \~25s or when your reply arrives — call `typing()` right when the trigger fires, before slow work.

## Handoff & resolution

`transfer` opens a ticket in the attendants' queue and flips the conversation out of AI mode. `resolve` closes the case.

```ts theme={null}
// Queue of a department (by id or exact name)
await conv.transfer({ department: 'Vendas', reason: 'pediu humano', priority: 'HIGH' });

// Straight to a specific attendant (by id or email) — ticket comes pre-assigned
await conv.transfer({ attendant: 'paula@empresa.com', reason: 'cliente VIP' });

// Close the conversation — the AI stops processing new inbound messages
await conv.resolve('caso encerrado');
```

### Choosing where to hand off

`conversation.directory` is a read-only view of your workspace's service structure — the **departments** and the **attendants** (human agents) registered in the Conversation Hub. Use it when the agent needs to decide the transfer target at runtime instead of hard-coding a department name.

```ts theme={null}
// Which departments exist?
const departments = await conversation.directory.departments();
// [{ id, name, color, icon, description, members, agents }]

// Who works in Vendas, and how busy are they right now?
const team = await conversation.directory.attendants({ department: 'Vendas' });
// [{ id, name, email, status, load, active, ... }]
```

Each attendant carries two live signals: `status` (presence: `online` / `busy` / `away` / `offline`) and `load` (how many human conversations they are handling at this moment). A common pattern — route to the least-busy online attendant, falling back to the department queue when nobody is available:

```ts theme={null}
const best = team
  .filter((a) => a.active && a.status === 'online')
  .sort((a, b) => a.load - b.load)[0];

await conv.transfer({
  attendant: best?.id,      // undefined → plain department-queue transfer
  department: 'Vendas',
  reason: 'transbordo',
});
```

### Legacy sync agents

Agents invoked through the direct execution endpoint (no HTTP trigger) answer with the `reply` helper — `intent` drives the handoff:

```ts theme={null}
import { reply } from '@runflow-ai/sdk/conversation';

return reply('transferindo você para um atendente', { intent: 'escalate' });
// intents: 'escalate' (opens a ticket) · 'resolve' (sends then closes) · 'continue'
```

## Contacts, tags & notes

```ts theme={null}
// Contacts
const contact = await conversation.contacts.getByPhone('+5511999998888');
await conversation.contacts.update(contact.id, { handlingArea: 'Financeiro' });
await conversation.contacts.create({ name: 'Maria', phone: '+5511988887777', upsert: true });

// Tag a contact (tags are auto-created on first use)
await conversation.contacts.addTag(contact.id, 'vip', '#f59e0b');
await conversation.contacts.removeTag(contact.id, 'inadimplente');

// Tag entity CRUD
const tags = await conversation.tags.list();          // [{ id, name, color, contacts }]
await conversation.tags.create('vip', '#f59e0b');
await conversation.tags.update(tagId, { name: 'VIP' });
await conversation.tags.remove(tagId);                 // also unlinks it from every contact

// Internal notes — show up in the attendant's context panel
await conv.addNote('Cliente pediu boleto até sexta', { agentName: 'Bot Cobrança' });
await conversation.contacts.listNotes(contact.id);
await conversation.contacts.removeNote(contact.id, noteId);
```

<Note>
  Agent-authored notes have no human author — the portal shows the `agentName` label (default: `Agente AI`).
</Note>

## Error handling

API failures throw `ConversationApiError` with `status`, a typed `code` when available, and the raw `body`:

```ts theme={null}
import { ConversationApiError } from '@runflow-ai/sdk/conversation';

try {
  await conv.sendText('...');
} catch (err) {
  if (err instanceof ConversationApiError && err.code === 'OUTSIDE_24H_WINDOW') {
    await conv.sendTemplate({ name: 'reengage' });
  } else {
    throw err;
  }
}
```

Common codes: `OUTSIDE_24H_WINDOW` (free-form send outside Meta's window), `CONTACT_NOT_FOUND` / `CONVERSATION_NOT_FOUND` (`byPhone` without `create`), `CONNECTION_NOT_FOUND` (unknown `connectionId`).

## API surface

| Handle (`conv`)                                                               | Hub (`conversation`)                                                                         |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `get({ messages? })`                                                          | `fromTrigger(input)` · `byId(id)` · `byPhone(phone, opts?)`                                  |
| `sendText` · `sendTemplate` · `sendMedia` · `sendButtons` · `sendInteractive` | `contacts.*` — create, getByPhone, update, addTag, removeTag, addNote, listNotes, removeNote |
| `typing()` · `markRead()`                                                     | `tags.*` — list, create, update, remove                                                      |
| `transfer(opts?)` · `resolve(reason?)`                                        | `directory.departments()` · `directory.attendants({ department? })`                          |
| `addNote(text, { agentName? })`                                               | `reply(message, { intent?, metadata? })` (sync path)                                         |
