Observability for chat-based agents (iMessage, WhatsApp, Telegram, SMS, …): message volume, channel mix, live inbox with human-in-loop replies, contacts, and opt-out compliance — on a normalized schema so every platform looks the same.
your agent ──adapter──▶ @agent-obs/core ──▶ Postgres (obs_messages / obs_contacts)
▲
dashboard (Next.js) ── /api/observ/* ──────────────┘ inbox "Send" ──▶ your agent's control endpoint
| Path | What it is |
|---|---|
packages/core |
Ingestion SDK + schema.sql. Bounded write queue, idempotent inserts, sticky-channel resolution, opt-out helpers. |
packages/adapters/spectrum-imessage |
Reference adapter: Spectrum (spectrum-ts) iMessage → normalized events. Copy it to write your own. |
apps/dashboard |
Next.js 16 + Tailwind v4 dashboard styled with Pho (@photon-ai/pho-ui): Analytics, Inbox (observe + reply), Contacts. |
The dashboard self-hosts its fonts from apps/dashboard/public/fonts/. Azeret
Mono and Instrument Serif (both OFL) are included; PolySans is commercial and
not distributed here — without it the UI falls back to your system sans. If
you have a license, drop PolySansVF.woff2 into that folder (gitignored).
Using an AI coding agent (Claude Code, Cursor, …)? Clone this repo, open it
in your agent, and say “set this up for my agent” — AGENTS.md
is a step-by-step runbook it will follow, including database setup, env
configuration (your own Supabase keys — none ship with this repo), wiring the
SDK into your agent, and end-to-end verification.
Manual path:
-
Database — run
packages/core/schema.sqlin your Supabase SQL editor (or psql). Idempotent. -
Instrument your agent
import { createObservability } from "@agent-obs/core"; import { spectrumIMessageAdapter, outboundEvent } from "@agent-obs/adapter-spectrum-imessage"; const obs = createObservability({ supabaseUrl: process.env.SUPABASE_URL!, serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY!, }); await obs.seedChannelCache(); // survive restarts without "unknown" channels // inbound loop const event = spectrumIMessageAdapter.toEvent(message); if (event) { obs.logMessage(event); // non-blocking; never throws into your reply path obs.upsertContact({ handle: event.conversationId, platform: event.platform, channel: event.channel }); } // after each outbound send const channel = await obs.resolveOutboundChannel(handle); obs.logMessage(outboundEvent(handle, text, channel, sent?.id));
-
Human-in-loop send — expose a tiny HTTP endpoint on your agent (
POST /send { conversationId, text }, checked against anx-agent-secretheader) and setAGENT_CONTROL_URL/AGENT_CONTROL_SECRET. Guard it withawait obs.isOptedOut(handle)for opt-out compliance. -
Dashboard
npm install cp apps/dashboard/.env.example apps/dashboard/.env.local # fill in Supabase creds npm run dev # → http://localhost:3000
- Write an adapter (
packages/adapters/whatsapp) — onetoEvent()mapping. - Add a registry entry in
apps/dashboard/src/channels.config.ts:{ key: "WhatsApp", label: "WhatsApp", color: "#25d366", badgeBg: "#dcf7e8", badgeFg: "#0b7a53", tile: true }
That's it — badges, thread bubbles, channel mix, and analytics tiles all pick it up. No schema migration (channels are not CHECK-constrained by design).
The schema is vanilla Postgres (packages/core/schema.sql); Supabase is the
default because its free tier + SQL editor + REST API make "clone → running"
a 10-minute path. Pick by use-case:
| Use-case | Recommendation | What it takes |
|---|---|---|
| Getting started, side project, one agent | Supabase (default) | Works out of the box — core writes via PostgREST, dashboard reads via supabase-js RPC. |
| You already run Postgres, want serverless scale-to-zero, or branch-per-PR dev | Neon (or RDS / self-hosted Postgres) | Same schema.sql, applied via psql "$DATABASE_URL" -f packages/core/schema.sql. Swap the two transport points: the core SDK's HTTP writes → a small pg insert sink, and apps/dashboard/src/server/observ.ts's db().rpc(...) calls → pg/postgres.js calling the same SQL functions. The SQL itself is unchanged. |
| High-volume analytics — millions of messages/day, long retention, heavy dashboards | ClickHouse (or Tinybird) for the message log | Columnar storage makes the windowed aggregates near-free at scale. Keep contacts + inbox on Postgres (row-level reads); dual-write messages, move obs_message_stats/obs_message_series to ClickHouse queries. This is a v2 move — don't start here. |
| You want live-updating UI over SQL aggregates | Convex | Reactive queries replace this template's polling + read-cache entirely, but you give up SQL GROUP BY — stats become function-side aggregation or maintained counters. A different architecture, not a drop-in swap. |
Rule of thumb: stay on the default until the aggregates are slow (that's tens of millions of rows with these indexes), then reach for ClickHouse for the log while Postgres keeps the inbox.
- Sticky channel, never downgrade. Providers omit the transport on some
messages (cloud iMessage omits
sender.servicesporadically). Learn the channel from any confident message, persist it on the contact, and never let an untagged message overwrite it withunknown. - Outbound transport is inferred. Send results usually don't carry the
channel (Apple decides iMessage→SMS fallback asynchronously). Use
resolveOutboundChannel()— memory cache → persisted contact. - Observability must never hurt the agent. All writes are queued, bounded, timed out, and shed under flood.
- Reads collapse under concurrency. The dashboard's read layer uses a single-flight micro-cache: N clients polling the same window = 1 DB query per TTL. Aggregates run in SQL, one round-trip.
- Don't call
space.getMembers()on Spectrum DMs — it throws; the transport lives onmessage.sender.service.