Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ mode this prevents and the `~/.mcp.json` recipe.

### MCP tools

24 tools registered (see internal/tools/tools.go Register for the authoritative list):
26 tools registered (see internal/tools/tools.go Register for the authoritative list):
- `get_messages`, `get_conversation`, `search_messages` — cross-platform by default
- `list_conversations` — optional `source_platform` filter (sms, gchat, imessage, whatsapp)
- `get_person_messages` — all messages with a person across all platforms
Expand All @@ -104,6 +104,27 @@ mode this prevents and the `~/.mcp.json` recipe.
- `generate_viz` — self-contained HTML visualization combining data dashboards + narrative (see below)
- `render_story` — render a pre-built Story JSON into HTML viz; supports `photo_paths` (curated list) or `photos_dir`
- `send_message`, `draft_message`, `download_media`, `list_contacts`, `get_status`
- `list_outbox`, `cancel_outbox` — durable-send custody: see what is still queued/retrying, stop a stale send before it transmits

### Send truthfulness (2026-08-05 incident)

A send result's `transport_state` is `queued` (has NOT left the machine),
`transmitted` (transport accepted it — NOT proof of delivery), `delivered`
(delivery receipt observed), `uncertain`, `failed`, or `canceled`;
`settled`/`transmitted` are true only on transport acknowledgment, and the
result names the `platform` used and `conversation_id` written to. Sends
default to a 10-minute send window (`ttl_seconds`, env
`OPENMESSAGES_SEND_TTL_SECONDS`; 0 = never expire) — a message still queued
when the window closes cancels as expired instead of transmitting stale.
Near-identical resends to the same conversation within ~10 minutes are
blocked unless `force=true`. `wait_for_transmit=true` holds the call (up to
`wait_seconds`, max 120) until the transport acknowledges. The requested
platform is a hard contract: an unsendable platform fails with the reason
and queues nothing — there is never a silent fallback to another channel.
`get_status` and `/api/status` publish per-platform send capability
(`send.{sms,whatsapp,signal}`), which is what `resolve_contact_routes`
sendability and send-time enforcement both read; `connected`/`v2_send` alone
never imply a platform can send.

### HTTP API

Expand Down
40 changes: 40 additions & 0 deletions cmd/send_capability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cmd

import (
"github.com/maxghenis/openmessage/internal/app"
"github.com/maxghenis/openmessage/internal/sendcap"
"github.com/maxghenis/openmessage/internal/web"
)

// sendCapabilityProvider builds the /api/status "send" block from the live
// transport snapshots and, when the v2 send stack is active, the adapter
// registry. See internal/sendcap for the semantics.
func sendCapabilityProvider(
a *app.App,
stack *v2Stack,
transports bool,
) func() map[string]web.SendPlatformCapability {
accountForPlatform := map[string]string{
sendcap.PlatformSMS: googleAccountID,
sendcap.PlatformWhatsApp: whatsappAccountID,
sendcap.PlatformSignal: signalAccountID,
}
return func() map[string]web.SendPlatformCapability {
inputs := sendcap.Inputs{
TransportsEnabled: transports,
Google: a.GoogleStatus(),
WhatsApp: a.WhatsAppStatus(),
Signal: a.SignalStatus(),
}
if stack != nil {
inputs.AdapterTextSend = func(platform string) bool {
accountID, ok := accountForPlatform[platform]
if !ok {
return false
}
return stack.Registry.Capabilities(accountID).TextSend
}
}
return sendcap.Compute(inputs)
}
}
2 changes: 1 addition & 1 deletion cmd/send_outbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ func writeCLIDelivery(output io.Writer, delivery outboxDelivery, key string, sch
fmt.Fprintf(output, "outbox_id=%s state=%s idempotency_key=%s\n", delivery.OutboxID, delivery.State, key)
switch delivery.State {
case "confirmed":
fmt.Fprintln(output, "message delivery confirmed")
fmt.Fprintln(output, "transmitted: the transport accepted the message. Transport acceptance is not delivery — verify in-thread before reporting it as sent.")
case "not_dispatched":
if !scheduled {
fmt.Fprintln(output, "queued; app retries automatically. Do not resend.")
Expand Down
2 changes: 2 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ func RunServe(logger zerolog.Logger, args ...string) error {

v2Options := v2SendWebOptions(stack, v2Send)
v2IngestCounters := v2IngestCountersProvider(stack)
sendCapability := sendCapabilityProvider(a, stack, transports)

httpEnabled := opts.web || opts.mcpSSE
if httpEnabled {
Expand All @@ -675,6 +676,7 @@ func RunServe(logger zerolog.Logger, args ...string) error {
Auth: controlAuth,
V2: v2Options,
V2IngestCounters: v2IngestCounters,
SendCapability: sendCapability,
Reads: reads,
V2Primary: v2Primary,
Client: a.GetClient,
Expand Down
47 changes: 47 additions & 0 deletions docs/agent-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,53 @@ can migrate the schema under the older one.
`serve ... --transports` alongside the app: those are daemon shapes and will
fight the app for the WhatsApp/Signal sessions exactly as described above.

## Send states are transport truth (post 2026-08-05 incident)

On 2026-08-05 a send reported as `{ok:true, settled:true, state:"confirmed"}`
did not reach the recipient until ~15 hours later, seconds behind a manual
day-of retry — a double-text. The send surface now reports transport truth
and gives agents custody of queued sends:

- **`transport_state`** in every durable send result: `queued` (the message
has NOT left this machine), `transmitted` (the platform transport accepted
it — a remote message ID exists, but that is NOT proof the recipient got
it), `delivered` (a delivery/read receipt was observed in the store),
`uncertain`, `failed`, `canceled`. `settled` and `transmitted` are true
only on transport acknowledgment; `uncertain` is reported as
settled:false + uncertain:true. Results carry the `platform` actually used
and the `conversation_id` written to.
- **Send window (TTL).** MCP sends default to a 10-minute window
(`ttl_seconds` per call; installation default via
`OPENMESSAGES_SEND_TTL_SECONDS`; 0 disables). A send still queued when the
window closes is canceled (`expired: true`) instead of transmitting stale.
The daemon HTTP API takes `ttl_ms` on the outbox submit routes.
- **Near-duplicate guard.** A text nearly identical to one submitted to the
same conversation within ~10 minutes is refused (HTTP 409 /
`near_duplicate_blocked`) naming the prior outbox item; pass `force=true`
for a deliberate repeat. Same-key replays (the documented lost-response
retry) bypass the guard and hit idempotent dedup instead.
- **`wait_for_transmit: true`** holds the tool call (bounded by
`wait_seconds`, default 25, max 120) through auto-retrying states until
the transport acknowledges, so an agent can report truthfully in one call.
- **`list_outbox` / `cancel_outbox`** show and stop queued sends. Cancel
only works before the transport boundary (queued/not_dispatched).
- **Per-platform send capability** is published at `/api/status` under
`send.{sms,whatsapp,signal}` (`available` / `queueable` / `reason`) and
rendered by `get_status`. `connected: true` and `v2_send: true` do NOT
mean a platform can send — the WhatsApp connection can be up for receiving
while sends fail. Hard-down platforms (unpaired, adapter unregistered,
auth revoked) are refused at send time with the same reason
`resolve_contact_routes` shows; transient disconnects (`queueable`) still
queue, truthfully reported and bounded by the TTL.
- **No silent channel substitution.** The requested platform is a hard
contract; `send_to_conversation` accepts a `platform` argument that fails
on mismatch instead of sending. A 404 on a send now says the daemon could
not resolve the conversation in its serving store (the 2026-08-05
WhatsApp shape) rather than a bare "not found".

The old incident guidance — verify in-thread with a fresh timestamp before
reporting "sent" — still applies verbatim to anything beyond `delivered`.

## Pairing & the "zombie session"

**Symptom:** sends fail with `OUTGOING_FAILED:UNKNOWN`; `/api/status` shows
Expand Down
6 changes: 6 additions & 0 deletions internal/cutover/carry.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ func CarryPendingOutbox(
TransportRequestID: intent.TransportRequestID,
ScheduledForMS: intent.ScheduledForMS,
}
// A carried intent keeps its send window: if the window closed while
// the stores were cut over, the fresh store's expiry sweep cancels it
// instead of transmitting stale.
if intent.ExpiresAtMS != nil {
item.ExpiresAtMS = *intent.ExpiresAtMS
}
message := sqlite.Message{
MessageID: *intent.LocalMessageID,
ConversationID: conversation.ConversationID,
Expand Down
97 changes: 95 additions & 2 deletions internal/localapi/localapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import (
"net"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -70,16 +72,41 @@ func NewClient(baseURL, token string) *Client {
}
}

// PlatformSendCapability mirrors one entry of the daemon's /api/status "send"
// block: whether a send on that platform is expected to dispatch promptly,
// with the daemon's reason when it is not. Queueable marks a self-healing
// outage where a durable send is still accepted and waits.
type PlatformSendCapability struct {
Available bool `json:"available"`
Queueable bool `json:"queueable"`
Reason string `json:"reason,omitempty"`
}

// DaemonStatus is the subset of /api/status used for daemon-truth decisions.
type DaemonStatus struct {
Connected bool `json:"connected"`
V2Send bool `json:"v2_send"`
V2Primary bool `json:"v2_primary"`
Auth struct {
// Send is keyed by send platform ("sms", "whatsapp", "signal"). Nil on
// daemons older than the send-capability block; callers must treat a
// missing map as "unknown", not as "available".
Send map[string]PlatformSendCapability `json:"send"`
Auth struct {
DataDir string `json:"data_dir"`
} `json:"auth"`
}

// SendCapabilityFor reports the daemon's send capability for a platform. The
// second result is false when the daemon did not publish a send block (older
// daemon) or does not know the platform — unknown, not unavailable.
func (s DaemonStatus) SendCapabilityFor(platform string) (PlatformSendCapability, bool) {
if s.Send == nil {
return PlatformSendCapability{}, false
}
capability, ok := s.Send[platform]
return capability, ok
}

// SendsViaOutbox reports whether the daemon expects sends on the durable
// /api/v1/outbox surface rather than the legacy /api/send route.
func (s DaemonStatus) SendsViaOutbox() bool {
Expand Down Expand Up @@ -113,6 +140,11 @@ type TextSubmission struct {
ReplyToID string `json:"reply_to_id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
NotBeforeMS *int64 `json:"not_before_ms,omitempty"`
// TTLMS bounds how long the daemon may hold the send before canceling it
// as expired instead of transmitting stale. Nil means no expiry.
TTLMS *int64 `json:"ttl_ms,omitempty"`
// Force bypasses the daemon's near-duplicate guard for a deliberate resend.
Force bool `json:"force,omitempty"`
}

// MediaSubmission is a durable media send routed at POST /api/v1/outbox/media.
Expand All @@ -125,6 +157,7 @@ type MediaSubmission struct {
ReplyToID string
IdempotencyKey string
NotBeforeMS *int64
TTLMS *int64
Content io.Reader
}

Expand All @@ -134,18 +167,43 @@ type Submission struct {
LocalMessageID string `json:"local_message_id"`
State string `json:"state"`
ScheduledForMS int64 `json:"scheduled_for_ms"`
ExpiresAtMS int64 `json:"expires_at_ms,omitempty"`
Deduplicated bool `json:"deduplicated"`
}

// Delivery mirrors the daemon's v1 delivery response.
// Delivery mirrors the daemon's v1 delivery response. AccountID,
// ConversationID, Platform, ExpiresAtMS, and Expired are empty against
// daemons older than the truthful-send-states change.
type Delivery struct {
OutboxID string `json:"outbox_id"`
AccountID string `json:"account_id"`
ConversationID string `json:"conversation_id"`
Platform string `json:"platform"`
State string `json:"state"`
LocalMessageID string `json:"local_message_id"`
RemoteMessageID string `json:"remote_message_id"`
ErrorClass string `json:"error_class"`
ErrorCode string `json:"error_code"`
Warning string `json:"warning"`
ExpiresAtMS int64 `json:"expires_at_ms"`
Expired bool `json:"expired"`
}

// PendingDelivery mirrors one row of the daemon's GET /api/v1/outbox response.
type PendingDelivery struct {
OutboxID string `json:"outbox_id"`
AccountID string `json:"account_id"`
ConversationID string `json:"conversation_id"`
Kind string `json:"kind"`
State string `json:"state"`
ScheduledForMS int64 `json:"scheduled_for_ms"`
NextAttemptMS *int64 `json:"next_attempt_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
AttemptCount int64 `json:"attempt_count"`
CreatedAtMS int64 `json:"created_at_ms"`
Summary string `json:"summary"`
ErrorClass string `json:"error_class"`
ErrorCode string `json:"error_code"`
}

// Settled reports whether the delivery reached a state the dispatcher will
Expand Down Expand Up @@ -229,6 +287,9 @@ func multipartMediaBody(submission MediaSubmission) (io.ReadCloser, string) {
if submission.NotBeforeMS != nil {
fields["not_before_ms"] = fmt.Sprintf("%d", *submission.NotBeforeMS)
}
if submission.TTLMS != nil {
fields["ttl_ms"] = fmt.Sprintf("%d", *submission.TTLMS)
}
for name, value := range fields {
if value == "" {
continue
Expand Down Expand Up @@ -274,6 +335,38 @@ func (c *Client) Delivery(ctx context.Context, outboxID string) (Delivery, error
return delivery, nil
}

// ListPending fetches the daemon's outbox tray, optionally scoped to one
// conversation.
func (c *Client) ListPending(ctx context.Context, conversationID string, limit int) ([]PendingDelivery, error) {
query := url.Values{}
if strings.TrimSpace(conversationID) != "" {
query.Set("conversation_id", strings.TrimSpace(conversationID))
}
if limit > 0 {
query.Set("limit", strconv.Itoa(limit))
}
path := "/api/v1/outbox"
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
var pending []PendingDelivery
if _, err := c.getJSON(ctx, path, &pending); err != nil {
return nil, err
}
return pending, nil
}

// CancelDelivery cancels one queued or retrying outbox item on the daemon.
// The daemon refuses (HTTP 409) once the intent crossed the transport
// boundary; the returned delivery reflects the post-cancel state.
func (c *Client) CancelDelivery(ctx context.Context, outboxID string) (Delivery, error) {
var delivery Delivery
if err := c.postJSON(ctx, "/api/v1/outbox/"+url.PathEscape(outboxID)+"/cancel", struct{}{}, &delivery); err != nil {
return Delivery{}, err
}
return delivery, nil
}

// WaitDelivery polls the outbox item until it settles, ctx ends, or
// settleTimeout elapses. It returns the last observed delivery; the bool
// reports whether that delivery settled. A non-nil error means the state
Expand Down
Loading
Loading