Skip to main content
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.
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).

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:

Getting the conversation

fromTrigger

The conversation that activated this agent — the standard path for triggered agents.

byId

Lazy handle for a known conversation id.

byPhone

Proactive sends — resolve (or create) the thread from a phone number.
Proactive flows (cron jobs, external events) usually only know the phone number:
byPhone matches every stored phone variant (with/without +, with/without the Brazilian mobile 9).

Inbound media

Every inbound media message (audio, image, video, document, sticker) reaches the agent after the Hub downloads it, with a presigned URL (6h TTL) patched into the Meta-shaped envelope — messages[0].audio.link, messages[0].image.link, and so on. Captions are optional; a bare image triggers the agent the same way. Don’t use the accompanying media id: it is Meta’s single-use media id and the Hub’s own download has already consumed it — always read link. Voice notes additionally carry the URL as the trigger’s message body and in metadata.audioUrl, ready for transcription.

Sending messages

Interactive messages

sendInteractive covers every interactive type WhatsApp supports. button and list are also composable by attendants in the inbox; the rest are agent-only, because they need ids that live outside the portal (published Flows, catalogs, payment configurations).
A body over Meta’s 1024-char limit is not rejected: the Hub sends the excess as leading text bubbles and returns their ids in leadMessageIds, keeping the tail right above the buttons. You never have to pre-chunk.
Every send returns metaMessageId — the wamid an inbound tap, submission or payment will reference through context.id.

Flow (native form)

flowToken is yours. It travels to Meta untouched and comes back inside the submission (nfm_replyresponse_json), so it is what correlates an answer with the context that asked for it — an order id, a checkout attempt, a profile edit.
screen picks the entry point: with it the message navigates to that screen and data prefills it; without it the Flow resolves its first screen on your Flow endpoint (data_exchange), and sending data is an error. Use mode: 'draft' to render a Flow that is not published yet.
Carousels outside a template are not part of Meta’s stable Cloud API surface. The Hub sends exactly the documented payload; if your WABA rejects it, the Graph error lands in the message’s failReason and shows up in the inbox. Smoke-test it on your own number before a flow depends on it.

Payments (order_details / order_status)

Every amount is an integer in cents, and totalAmount has to equal subtotal + tax + shipping − discount — the Hub rejects the send with a 400 when it doesn’t, which reads far better than Meta’s own error. Who owns the charge is a choice — exactly one of the two, never both:
  • paymentConfiguration — a payment configuration registered on your WABA; Meta/your PSP generates the charge.
  • pixDynamicCode — an inline PIX charge your backend generated ({ code, merchantName, key, keyType }, where code is the EMV “copia e cola” payload). Use it when the charge id, expiry and reconciliation live on your side.

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.

Typing indicator & read receipts

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.

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

Legacy sync agents

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

Contacts, tags & notes

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

Custom fields (metadata)

Contacts carry a flat metadata object — key/value pairs for whatever your domain needs (CPF, birth date, plan, consent flags). Both create and update accept it:
Write semantics — shallow partial merge:
  • A write only touches the keys it mentions; every other key stays as-is.
  • Sending null for a key removes it.
  • On create with upsert: true, metadata is merged into the existing contact’s metadata (it never wipes what’s already there).
Limits: contact.metadata comes back on every read that returns a contact — contacts.getByPhone and conv.get().contact. The value type is exported as ContactMetadata from @runflow-ai/sdk/conversation (re-exported from the SDK root as ConversationContactMetadata).
Metadata is strictly per contact — there is no shared field schema. A key written on one contact exists only on that contact; the portal renders whatever keys each contact carries, and any key matching the rules above is accepted.

Metadata in the inbox

The fields your agent writes are not just storage — they surface in the attendants’ console. Each contact’s keys render on their profile and on the inbox side panel, and the inbox can filter conversations by connection (which WhatsApp number the thread lives on), by agent, and by custom field — the attendant types any key and value, and the filter does a case-insensitive partial match on that key’s value. Pull up, say, every conversation whose contact has plano containing premium on the sales number. These filters are a portal feature. The agent API does not expose filtered conversation listing — from agent code you reach conversations through fromTrigger, byId and byPhone.

Error handling

API failures throw ConversationApiError with status, a typed code when available, and the raw body:
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).

Status callbacks

Meta reports what happened to every message you sent — delivered, read, failed. The Hub always consumes those: the ticks move in the inbox and the message row gets its status either way. Forwarding them to the agent is opt-in, because each callback becomes one agent execution — a 1,000-message campaign can mean a few thousand runs. Turn it on only for flows that actually track delivery (a reminder that escalates when it goes unread, for instance).
In the Runflow portal: Agents → configure the agent → Encaminhar entregas/leituras, where each agent inherits the organization default or overrides it. The organization-wide value lives in Settings → Recursos (admins only).
When it is on, the agent is triggered with an empty message, metadata.kind = 'status' and the raw Meta status payload — the same wamid your send returned as metaMessageId.

API surface