Skip to content

Astromesh Herald

Astromesh Herald connects messaging channels — WhatsApp today, Telegram, web chat and email reserved — to agents running behind Astromesh Nexus. It carries traffic in both directions:

  • Herald → Nexus. An inbound channel message invokes an agent through POST /api/v1/agents/:name/run, with an Idempotency-Key and a stable session_id per channel conversation.
  • Nexus → Herald. Nexus — and therefore agents — send proactive messages through Herald’s own REST API, delivered asynchronously through the same outbox pipeline as agent replies.

Herald never talks to the Astromesh runtime, and it never decides on its own whether an agent exists or who may run it. That is Nexus’s call, checked when a binding is created and again on every run. Herald holds channel credentials and conversation state; it holds no opinion about agents.

It is also not a WhatsApp SDK. WhatsApp is one adapter behind a channel port, and the provider’s policies — the 24-hour window, template approval — are deliberately kept inside that adapter rather than pushed into the core contract.

Replies and proactive sends share one queue on purpose. There is a single delivery path to reason about, a single retry budget, and a single place to look when a message did not arrive.

When a message arrives, Herald asks one question first: does a conversation already exist for (tenant, channel, external_user)?

It does. The message goes to that conversation’s agent, under its existing session_id. Nothing else is decided.

It does not. The binding’s entry agent gets the message and decides, answering with a documented convention in data.route:

{
"answer": "Te ayudo con tu reclamo.",
"data": {
"route": { "start_session": true, "handoff_agent": "reclamos" }
}
}
data.routeWhat Herald does
start_session: truePersists the conversation, owned by handoff_agent if given, else the entry agent
start_session: falsePersists nothing — the agent’s answer (a courtesy refusal, say) is still delivered
absentDefaults to starting the session with the entry agent

This is what keeps a wrong-number message from costing a conversation row and a session that lives forever. The agent that greets is allowed to decide that there is nothing to greet.

Every outgoing message — an agent’s reply, a proactive send, an operator’s test — is a row in one Postgres outbox, drained by a polling worker.

PropertyBehaviour
ClaimingFOR UPDATE SKIP LOCKED, so N replicas can drain the same queue safely
BackoffExponential, 30s × 2ⁿ
Give-upAfter 10 attempts
GuaranteeAt-least-once
Poll interval2s (--outbox-poll-interval)

Because delivery is asynchronous, a 202 from the send API means queued, not delivered. Poll GET /api/v1/messages/:id for the real status.

ChannelStateNotes
WhatsAppLiveMeta Cloud API: hub.challenge handshake, X-Hub-Signature-256 verification, text/image/audio/video/document parsing, Graph API sends, template sends, media download
EchoDevelopmentThe whole pipeline with no provider account. Unauthenticated, so it only registers behind --enable-echo
TelegramReservedFull ports.ChannelAdapter implementation returning ErrNotImplemented
Web chatReservedReserves the channel; needs a WebSocket endpoint to activate
SMTPReservedOutbound only, by design

A reserved channel is a real port implementation that refuses work. The contract exists; the provider integration does not. See WhatsApp setup for the only one you can put in front of customers today.

Hexagonal — ports and adapters — with interfaces defined by their consumers, not their implementations.

cmd/herald/ composition root: manual wiring, flags with env defaults
pkg/heraldclient/ public Go client of the send API (stdlib only — Nexus imports this)
internal/
domain/ Message, Conversation, Binding, RouteDecision, OutboxEntry + backoff
ports/ ChannelAdapter, AgentClient, segregated stores, use cases
app/ receive (two-way routing), send, dispatch worker
adapters/
channel/whatsapp/ Meta Cloud API
channel/echo/ development channel, behind --enable-echo
channel/telegram/ stub
channel/webchat/ stub
channel/smtp/ stub, outbound-only by design
agentclient/nexus/ HTTP client: /run, X-API-Key, Idempotency-Key, 30s, 5xx retry
store/postgres/ plain SQL over pgx, JSONB, AES-GCM-encrypted credentials
crypto/ AES-GCM sealing of channel-account secrets (HERALD_ENC_KEY)
server/ Gin: /webhooks/:channel, /api/v1/messages/*, /admin/*, /ui/*
webui/ go:embed tree of the built operator console

Absent credential, absent route. Every credential is optional and its absence unregisters its surface: no --admin-token means no /admin, no --send-keys means no send API, no WhatsApp pair means /webhooks/whatsapp is a 404. A misconfigured deployment does not expose a half-authenticated endpoint — it exposes nothing.

Herald embeds a React console in the binary (go:embed), served at /ui/. The static files carry no data: every number comes from the token-guarded /admin API, so signing in is pasting the admin token. Disable it with --ui=false.

Five screens: an overview (active conversations, outbox state, messages per day and per channel), a conversation list with its message thread, an outbox monitor showing how much of each entry’s 10-attempt budget is spent, plus routing-rule and channel-account CRUD. The operator test send lives on the outbox screen, because that is where its result shows up.

Channel-account credentials are AES-GCM sealed at rest and masked (••• + last 4) in every response, including the ones the console reads.

An agent can now start a conversation instead of only answering one. The send_message builtin tool, shipped in astromesh v0.40.0, goes out through Nexus (POST /api/v1/runs/messages/send), which queues in Herald’s outbox and answers with an id.

The credential is the interesting part. The runtime pool is shared between tenants, so giving it per-tenant secrets would gather every tenant’s key into one process. Instead Nexus mints a token per invocation, valid for one tenant, one capability and one run, and drops it into the run context as _nexus_run_token. It dies with the run. There is nothing to rotate.

  • Quick Start — the whole pipeline running locally, no provider account
  • WhatsApp Setup — Meta Cloud API, the 24-hour window, templates
  • API Reference — send API, operator plane, heraldclient, every flag