HoldSendWrite offline. Publish exactly once.GitHub

0007: Emergency messages

Date: 2026-10-29. Status: accepted.

Context

Phase 2 of the roadmap is an emergency module: help a short text message get out of a phone that has no signal, without sending it twice, and without lying about what the platform can do. It is a delivery aid, not an emergency service. Nothing here contacts emergency services, and apps that use it must keep telling people to call their local emergency number first.

People under stress behave differently from people writing a post:

The core already has most of what this needs: one key per item for life, the written time kept apart from the arrival time, honest cancel, and items that wait for a person.

Choice

An emergency outbox is an ordinary outbox with stricter rules, in its own queue. @holdsend/core/emergency exports EmergencyOutbox, which wraps an Outbox over its own store (a separate IndexedDB database, or a separate key on React Native), and its own sender lock, so a large upload in the main queue cannot hold an emergency message up. Messages are text only: a string, trimmed, not empty, at most maxLength characters (default 1,000).

The first message goes by itself; later ones wait for the person. With autoSend: "first" (the default), a message written while no other emergency message from the same person is waiting is sent automatically when the connection returns. A message stuck after the server refused it does not count as waiting: it needs the person's decision anyway. A message written while an earlier one is still waiting is held: it is shown, and it goes only when the person presses Send for it or Send all. "all" sends every message automatically and "none" holds every one. Held messages are sent oldest first.

Repeated taps are one message. A message whose text matches a waiting message from the same person, ignoring case, spacing at the ends and repeated spaces, and written within dedupeWithinMs (default ten minutes) of it, is not queued again. write() returns the existing message and duplicate: true. A message that was already delivered is never matched: writing the same words after delivery is a deliberate new message.

Old messages ask first. Even the first message is not sent automatically once it is older than confirmAfterMs, which defaults to one day here instead of the core's thirty days. The app shows when it was written and asks. The time is stored on each message (askBy, from a new askAfterMs option on add()), so a service worker that sends with a plain Outbox and the core's default policy follows it too.

The decision is made inside the store's atomic update. Two tabs pressing Send at the same moment must not both decide "this is the first message", and must not both queue the same words. The core gets one general hook for this, add(kind, payload, { decide }). decide runs inside the same atomic update that adds the item, with the items queued at that instant, and returns either an existing item (add nothing and return it), "hold", or nothing. decide gets copies of the queued items, so it cannot change them. A held item is a new optional flag on OutboxItem (held: true). needsConfirmation treats it like an item past the confirmation age, so approve(), statusOf() and every sender already handle it.

SMS is a hand-off, never a silent send. smsLink(to, body, { platform }) builds an sms: link that opens the phone's messaging app with the text filled in. The person still presses send there. One recipient uses the standard form (sms:<number>?body=<text>). Several recipients use the form each platform accepts, since iOS does not follow the standard for that case. How phones read these links has changed between versions, so apps should test them on real phones. Before a hand-off, the app calls cancel(key). If the message never left the device it is removed, so it cannot arrive twice. If it may already have left, cancel says "in_flight", and the app must tell the person the message may arrive twice. An SMS needs a phone signal, but often works when mobile data does not.

Background sending where the platform has it, and the truth where it does not. On the web, the emergency queue uses its own Background Sync tag and the existing helpers. requestBackgroundSync() returns "background" only after a registration succeeded. On iOS, in Firefox and in React Native today, messages go only once the app is open with a connection, and the app must say so and offer SMS. The docs carry a per-platform table.

The server side needs nothing new. Emergency messages go through handleIdempotent like any other create, with requireKey: true and records kept for as long as a phone may retry.

Why

Limits, stated plainly

Edit this page on GitHub