Blog · · 10 min read

AI agent notifications for background work that needs attention

AI agent notifications for background work that needs attention

AI agent notifications fail when a product sends whatever the runtime reports straight to a phone, inbox, or chat. A premature completion event can release work too early. A duplicate webhook can alert twice. An approval notification can remain clickable after the underlying request has changed. The user either stops trusting the alerts or acts on stale information.

The fix belongs at the boundary rather than in the notification copy. Verify each runtime signal against authoritative run state, create one durable attention record, and route that record through channels that respect consent, urgency, and acknowledgement. This guide provides the state model, schema, dispatch sequence, failure rules, and tests needed to make background agent alerts reliable.

Treat attention as a product resource

A model should not call send_notification with arbitrary text and a destination, and a provider callback should not become a user-facing alert by default. Build a notification pipeline with six explicit stages:

  1. Record a versioned run transition such as completed, failed, or input_required.
  2. Apply a deterministic attention policy that decides whether the transition needs user attention, can wait for a digest, or should remain in the product inbox.
  3. Create one durable notification record for one recipient and transition version.
  4. Let delivery workers render channel-specific messages and attempt web push, mobile push, email, or another allowed route.
  5. Record delivery, open, dismissal, or an explicit user response without confusing those events with business completion.
  6. Reconcile any open or action against current server state, rejecting expired or superseded requests.

Support and operations teams can then inspect one record that explains why an alert was created, where it went, and whether the user could still act.

Why direct runtime notifications fail

A runtime callback proves that a message arrived. It does not prove that the product's business state is current. OpenAI's webhook documentation says failed deliveries can be retried for up to 72 hours and rare duplicate events can occur, with webhook-id available as a deduplication key. Signature verification proves origin. It does not make duplicate processing safe or establish that a run is still in the reported state.

Long-running protocols expose more useful lifecycle semantics. MCP Tasks defines durable working, input_required, completed, failed, and cancelled states, with polling and optional task notifications. Those states belong in the product's run record before they become attention events.

Direct project reports show what happens when the layers are collapsed. A GitHub App issue reports a completion notification while the sub-agent was still running; the orchestrator treated the signal as freed capacity and started more work. A Claude Code report describes simultaneous completion notifications not being delivered. Another Claude Code report says a background notification interrupted and submitted text that the user was still typing. These are author reports about particular products, not universal runtime behavior. They are useful failure fixtures.

Keep runtime events, durable state, attention policy, and presentation separate.

Define the AI agent notification record

Create an immutable notification record after the authoritative run transition commits. Keep mutable delivery attempts and acknowledgements as child records. The following schema is an implementation recommendation:

{
  "notification_id": "ntf_81d",
  "tenant_id": "tenant_42",
  "recipient_user_id": "user_19",
  "run_id": "run_7f3",
  "transition_version": 12,
  "attention_type": "input_required",
  "urgency": "time_sensitive",
  "state": "pending",
  "subject_version": "invoice_batch:v31",
  "input_request_id": "approval_6",
  "expires_at": "2026-08-25T15:00:00Z",
  "created_at": "2026-08-24T15:00:00Z"
}

Each field prevents a specific class of failure:

  • tenant_id and recipient_user_id bind routing to current product identity instead of a model-supplied address.
  • run_id and transition_version make the event deduplicable and traceable to authoritative state.
  • attention_type separates completion, failure, and input requests instead of encoding meaning in free-form copy.
  • subject_version records which product object the user was asked to inspect.
  • input_request_id binds an approval button to one pending request.
  • expires_at gives the server a clear stale-action rule.

Use a unique constraint such as (tenant_id, recipient_user_id, run_id, transition_version, attention_type). A retried callback then returns the existing notification record. It never creates another alert merely because delivery was attempted again.

Verify state before creating attention

Process provider webhooks and runtime notifications through a narrow ingress. Verify the source signature, deduplicate the inbound event, then load the run from your own state store. Create the attention record and outbox entry in the same transaction as any accepted state transition.

handle_runtime_signal(signal):
    verify_source_signature(signal)

    if inbound_events.exists(signal.source, signal.event_id):
        return accepted_duplicate

    transaction:
        inbound_events.insert(signal.source, signal.event_id)
        run = runs.lock(signal.run_id)
        observed = runtime.read_current_state(signal.run_id)

        transition = reconcile(run, observed, signal)
        if transition is not valid:
            record_mismatch(run, signal, observed)
            return accepted_without_alert

        runs.apply(transition)
        decision = attention_policy.evaluate(run, transition)

        if decision.should_notify:
            notification = notifications.upsert(
                recipient=resolve_current_owner(run),
                run_id=run.id,
                transition_version=run.version,
                attention_type=decision.type,
                expires_at=decision.expires_at
            )
            outbox.insert(notification.id)

    return accepted

The extra state read is not always necessary. If the runtime emits events from the same transactionally consistent store that owns the run, the committed transition is already authoritative. Use reconciliation when a provider callback can arrive early, late, duplicated, or out of order.

Never alert completed because a model produced a confident final message. Completion should come from the durable run state and, for action-taking agents, from verified product effects. If the effect is uncertain, notify needs_review or outcome_unknown instead of success.

Decide what deserves interruption

Not every state change deserves push. Put deterministic attention policy ahead of channel selection.

Use these default decision rules:

  • input_required is time-sensitive only when work is blocked and the request has a meaningful expiry.
  • failed is urgent when the user must repair something or data may be inconsistent. A safely retried transient error stays silent.
  • completed can use push when the user explicitly asked to be notified or the work took long enough that the active session is gone. Otherwise, update the in-product inbox.
  • progress changes belong in the live interface or a digest, not one alert per tool call.
  • superseded requests close or replace the prior notification.
  • multiple low-priority completions for the same project can become one digest.

Web push is a scarce interruption channel. The W3C Push API supports delivery while a web application is inactive, but notes that push uses more resources, can have higher latency than direct communication, and may face service limits. Use an active stream or in-product update while the user is present. Reserve push for information that remains useful after the active connection disappears.

Resolve the recipient when dispatch starts, not when the model proposes the work. The initiating user may have left the tenant, lost access to the subject, disabled a channel, or transferred ownership while the run was active.

For each delivery attempt:

  1. Recheck tenant membership and access to the run's subject.
  2. Load current channel consent, quiet hours, locale, and accessibility preferences.
  3. Apply urgency policy. A quiet-hours bypass should require an explicit product rule, not model judgment.
  4. Render the minimum useful information for that channel.
  5. Store the provider message ID, attempt number, and next retry time.
  6. Stop retrying when the record expires, the user acknowledges it elsewhere, or a newer transition supersedes it.

Browser permission is part of this contract. MDN's Notifications API guide says permission should be requested in response to a user gesture and documents notification tags that replace an earlier pending notification. A browser tag can reduce visible duplicates, but server-side uniqueness is still required because email, mobile, and in-product channels do not share that browser queue.

Keep payloads sparse. A lock-screen notification should not expose sensitive tool arguments, customer records, or generated content. Include a neutral summary and an opaque notification identifier. Load details only after the app authenticates the user and authorizes current access.

A notification is a pointer, not an authorization grant. Its deep link should identify the notification and intended product route. The server then authenticates the user, checks current access, and loads the latest run snapshot.

For an approval or input request, do not encode an executable answer in the URL. Show the current action, arguments, evidence, risk, and expiry after the page loads. Submission should carry:

  • the authenticated user;
  • input_request_id;
  • expected run and request versions;
  • the explicit response;
  • a request idempotency key.

Reject the action when the request was already answered, expired, cancelled, superseded, or no longer authorized. The page can explain the current state rather than returning a generic error.

Opening a notification is not approval. Dismissing it is not rejection. Even delivery and display receipts have different meanings. MDN notes that a close event does not guarantee that the user closed the notification. Keep channel telemetry separate from business decisions.

Handle delivery and state failures

Design recovery around independent failures:

  • When a runtime event arrives twice, return the existing inbound-event and notification records instead of rendering a second logical alert.
  • When the runtime says complete but product state says running, record a mismatch, suppress the success alert, and retry bounded reconciliation. Escalate if the states do not converge.
  • When the notification record commits before a worker crashes, let the transactional outbox release the same pending record after restart.
  • When a push provider accepts the request but the receipt is unknown, retry with the same delivery identity if the provider supports it. Otherwise, record delivery_unknown and rely on server-side notification uniqueness plus channel replacement.
  • If one channel fails, keep the in-product inbox authoritative. Fall back only to channels the user enabled for that urgency class.
  • If the user acts on another device, commit the business response once, mark the notification acknowledged, and close or supersede pending deliveries.
  • If the run changes after alert creation, mark the old record stale. Opening it loads the latest snapshot, and its former action cannot execute.
  • If the recipient loses access, cancel queued delivery and redact any stored preview that is no longer permitted.

A practitioner who built Agent Reachout described missing finished, blocked, and decision-required agent work, then used Telegram for notifications and replies. That report supports cross-device attention as a real need. The product implementation still has to preserve authorization, state versioning, expiry, and idempotency around the chat channel.

Verify notification reliability

Test the pipeline with forced races instead of checking that one happy-path push appears.

Your automated suite should prove that:

  • a duplicated provider webhook creates one notification record;
  • an out-of-order completion signal cannot move a terminal failed run back to completed;
  • a premature completion report produces no success alert until authoritative state agrees;
  • two agents finishing simultaneously create two distinct records without dropping either;
  • ten repeated progress events produce no push storm;
  • an acknowledged notification stops pending retries on other channels;
  • quiet hours defer low-priority completion but not an explicitly configured urgent failure;
  • revoked membership blocks delivery and deep-link access;
  • a stale approval link cannot submit after the request version changes;
  • opening on a second device shows current state and does not repeat the run;
  • notification copy contains no sensitive payload when the device is locked;
  • an outbox worker crash neither loses the record nor creates a second logical alert.

Track metrics by attention type and channel: records created, policy-suppressed events, dispatch latency, duplicate suppression, provider acceptance, delivery when available, opens, acknowledgement time, stale opens, expired input requests, fallback use, and opt-outs after delivery. A high send count is not success. The useful outcome is that users see important state in time, can act safely, and are not trained to ignore the product.

Start with one blocked background run

Choose one existing flow that can enter input_required while the user is away. Add the notification record, transition-version uniqueness, transactional outbox, one consented channel, and a deep link that reloads current state. Then force duplicate callbacks, delayed delivery, a permission change, and an already-answered request.

Do not add more channels until the test proves four facts: one state transition creates one logical alert, the current authorized user receives it, stale actions cannot execute, and acknowledgement reconciles everywhere. Once those invariants hold, the same AI agent notification contract can support completion, failure, proactive conditions, digests, and mobile approvals without false completion or alert spam.

References

  • OpenAI Webhooks supports webhook signature verification, completion events, delivery retries, and deduplication with the webhook ID.
  • Model Context Protocol Tasks defines durable task states, input requests, polling, optional task notifications, cancellation, and terminal results.
  • GitHub App issue 330 supplies an author-reported premature completion signal that caused orchestration over-admission.
  • Claude Code issue 20754 supplies an author report of missing notifications when several background agents finished together.
  • Claude Code issue 86128 supplies an author report of a completion notification interrupting an active input buffer.
  • W3C Push API defines asynchronous background push delivery, subscription concepts, notification navigation data, and delivery constraints.
  • MDN: Using the Notifications API supports consent timing, service-worker delivery, replacement tags, event semantics, and notification navigation.
  • Hacker News: Agent Reachout supplies a practitioner report about cross-device notifications and replies for completed, blocked, and decision-required agent work.

Be first in line.

Join the waitlist and we'll email you the moment it's ready. No sales call.

Try the demo →
or talk to us