@baremetallabs-ai/agent-apps-notifications
v0.1.2
Published
`@baremetallabs-ai/agent-apps-notifications` is the public, dependency-free Node 22+ contract for first-party AgentMail, AgentChat, and AgentPhone webhook notifications. It can be used by the AgentApps relay and eve notification channel without `@sovereig
Downloads
539
Readme
AgentApps notifications
@baremetallabs-ai/agent-apps-notifications is the public, dependency-free Node 22+ contract for first-party AgentMail, AgentChat, and AgentPhone webhook notifications. It can be used by the AgentApps relay and eve notification channel without @sovereign-ai/core. Publication is tracked in #1758; relay and eve integration are #1759 and #1760.
Install and API
npm install @baremetallabs-ai/agent-apps-notificationsThe root export provides parseNotification(payload, sender, idempotencyKey): NotificationDelivery, signNotificationBody(rawBody, currentSecret): string, verifyNotificationBody(rawBody, signatureHeader, currentSecret, previousSecret?): boolean, formatFileOfferOutcome(notice), NotificationValidationError, and the notification and receipt types. sender is a trusted AuthenticatedNotificationSender, derived from authenticated sender or install credentials. JSON fields never establish app identity or adoption permission.
A receiver reads the original request bytes (HQ caps webhook bodies at 1 MiB), authenticates the sender or install, verifies X-Webhook-Signature, then decodes JSON and parses the notification. Never verify a reserialized object. The sender signs only with the current secret. A receiver can also accept one previous secret during rotation; this contract does not set rotation timing.
import {
parseNotification,
signNotificationBody,
verifyNotificationBody,
} from '@baremetallabs-ai/agent-apps-notifications';
const rawBody = new TextEncoder().encode(
JSON.stringify({
correlationId: 'agentmail:thread-1',
content: 'New mail received.',
})
);
const signature = signNotificationBody(rawBody, 'current-secret');
const sender = { app: 'agentmail' } as const; // Obtained from authenticated credentials.
if (!verifyNotificationBody(rawBody, signature, 'current-secret', 'previous-secret')) {
throw new Error('Invalid webhook signature');
}
const delivery = parseNotification(
JSON.parse(new TextDecoder().decode(rawBody)),
sender,
'mail-delivery-1'
);
// Atomically claim and persist the delivered message and its receipt in the consumer store.Every request requires a nonblank Idempotency-Key. X-Webhook-Signature is sha256= plus 64 hexadecimal characters. HMAC-SHA-256 covers the exact original request-body bytes. Modified bytes or an invalid signature fail authentication. The package's parser receives decoded JSON only after verification.
The parser trims the header delivery key. For ordinary messages it rejects whitespace-only correlationId or content and returns both trimmed, matching HQ's stored content and receipt comparison. Signature verification still uses the original body bytes before this normalization. File-offer body fields remain untrimmed; a blank teams: suffix or whitespace-only offerId is invalid.
Wire forms and permissions
Ordinary message JSON has required nonempty correlationId and content strings, plus only the app-specific optional claim below. Unknown fields, schema, attachments, kind, and fields of the other form are rejected. The app comes from credentials, not JSON.
| Authenticated app | Correlation | Optional claim | Delivery key |
| ----------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AgentMail | agentmail:<nonempty threadId> | sourceMessageId, a canonical agentmail-message:v1: base64url JSON object with exactly nonempty mailboxId, messageId, threadId; the thread must match the correlation | Any nonblank key |
| AgentChat | teams:<nonempty conversation> | inboundFileContext only with an activity key | Any nonblank key; context requires agentchat-teams-activity: followed by 43 base64url characters |
| AgentPhone | phone:<lowercase UUIDv4 callId> | originatingSessionId only when allowOriginatingSessionAdoption is true | Call event: agentphone-call-event:<callId>:connected or :completed; consult: agentphone-consult:<64 lowercase hex>; other nonblank keys are allowed |
AgentPhone consults have the same JSON shape as ordinary phone messages. They require adoption permission and a valid originatingSessionId. The consumer additionally checks its stored consult record's call, origin, pending or delivered state, and exact original request body before completing or replaying delivery. The package cannot establish that a referenced session exists; the consumer decides whether to adopt it.
The origin reference must be nonblank, at most 1,500 UTF-8 bytes, with valid UTF-16, and cannot be . or .., contain /, or use the reserved __...__ form. This deliberately rejects disabled-adoption, malformed-origin, and non-phone origin claims that HQ currently accepts and ignores or classifies as unusable. HQ remains unchanged.
inboundFileContext contains exactly hasUsableText (boolean), failures (array), and stagedFileIndexes (empty array for these webhooks). Each failure contains index (nonnegative integer), optional nonempty name, reason, optional reason-dependent recoveryDisposition, and optional operatorEvidence with exactly attemptCount, elapsedMs, recordedAt, and expiresAt. Counts are nonnegative integers; timestamps must parse with Date.parse. Failure indexes increase strictly and form a dense zero-based partition with staged indexes. Legacy reasons (unusable_metadata, count_limit, size_limit, source_fetch_failed, staging_failed, workspace_delivery_failed, unsupported_item, not_supported_in_conversation) disallow disposition. Dynamic reasons (transient_timeout, credential_rejected, service_failure, transfer_failure, staging_incomplete, source_unavailable, ingress_unavailable, recovery_budget_exhausted, staging_refused, unsupported_runtime) require one of exhausted, unavailable_on_immediate_path, not_retryable, not_attempted.
AgentChat file-offer outcomes are a distinct eight-field body: kind: "file_offer_outcome", correlationId, offerId, outcome, createdAt, expiresAt, settledAt, idempotencyKey. Only an authenticated AgentChat sender can use this form. The body key and header must both equal agentchat-file-offer:<offerId>. The terminal outcomes are delivered, declined, expired, unavailable_or_changed, authorization_refused, delivery_failed, and delivery_unknown. Timestamps must parse with Date.parse; chronological ordering is not required. formatFileOfferOutcome provides HQ-compatible delivered text.
Representative requests
All examples send Content-Type: application/json, the displayed Idempotency-Key, and X-Webhook-Signature: sha256=<HMAC-SHA-256 of the exact JSON bytes with the current secret>. Construct the header with signNotificationBody(rawBody, currentSecret). Each successful first delivery returns HTTP 201 with {"sessionId":"session-1","messageId":"message-1"}; an unchanged completed retry returns the identical status and body.
{
"correlationId": "agentmail:thread-1",
"content": "New mail received.",
"sourceMessageId": "agentmail-message:v1:eyJtYWlsYm94SWQiOiJtYWlsYm94LTEiLCJtZXNzYWdlSWQiOiJtZXNzYWdlLTEiLCJ0aHJlYWRJZCI6InRocmVhZC0xIn0"
}AgentMail inbound uses a delivery key such as mail-inbound-1. Outbound resolution omits sourceMessageId.
{
"correlationId": "teams:conversation-1",
"content": "An attachment could not be staged.",
"inboundFileContext": {
"hasUsableText": false,
"failures": [
{
"index": 0,
"reason": "transient_timeout",
"recoveryDisposition": "exhausted",
"operatorEvidence": {
"attemptCount": 2,
"elapsedMs": 1000,
"recordedAt": "2026-01-01T00:00:00Z",
"expiresAt": "2026-01-02T00:00:00Z"
}
}
],
"stagedFileIndexes": []
}
}The AgentChat context example uses Idempotency-Key: agentchat-teams-activity:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA. Personal and shared chat messages without context use the same ordinary form.
{
"kind": "file_offer_outcome",
"correlationId": "teams:conversation-1",
"offerId": "offer-1",
"outcome": "delivered",
"createdAt": "2026-01-01T00:00:00Z",
"expiresAt": "2026-01-02T00:00:00Z",
"settledAt": "2026-01-01T01:00:00Z",
"idempotencyKey": "agentchat-file-offer:offer-1"
}The file-offer header key is also agentchat-file-offer:offer-1.
{
"correlationId": "phone:123e4567-e89b-42d3-a456-426614174000",
"originatingSessionId": "session-1",
"content": "Call connected."
}The phone call example uses Idempotency-Key: agentphone-call-event:123e4567-e89b-42d3-a456-426614174000:connected. Completion uses :completed. A consult delivery uses the same body shape and a key such as agentphone-consult:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.
Receipt rules and delivery guarantee
A consumer atomically claims (authenticated sender/install, conversation, delivery key), creates or enqueues one message, and stores a completed HTTP 201 receipt with sessionId and messageId. An in-progress same-content retry does not create a duplicate. A completed same-content retry returns the stored receipt unchanged. Reusing the key with changed delivered content conflicts. Authenticate and validate claims on every attempt, including replay.
For ordinary messages compare correlationId, content, and optional inboundFileContext. Validated sourceMessageId and originatingSessionId are identity claims and do not change ordinary delivered-content comparison. For file offers compare correlationId and the formatted delivered content. Consults additionally require the matching stored consult record and exact original body. The package exports types and rules; atomic persistence and retention belong to each consumer.
Delivery is at least once when the sender retries and best effort otherwise. This contract does not promise exactly-once delivery. Mandatory Idempotency-Key and optional previous-secret verification are new contract rules relative to today's HQ receiver.
Executable conformance examples: src/notification.test.ts, src/auth.test.ts, src/receipt.test.ts. Run npm run test in this workspace. npm run verify:package packs the package and compiles/runs a clean consumer outside the workspace. This package changes no first-party sender, HQ, AgentApps relay, eve receiver, rotation timing, or npm publication behavior.
Inbound AgentPhone texts use phone-text:<64 lowercase hex> as the correlation and
agentphone-text:<64 lowercase hex> as the required Idempotency-Key. During transition,
the legacy JSON body contains only correlationId and content. New senders include
sourceMessageId equal to the idempotency key, senderNumber as canonical E.164, and
conversationBinding: { purchaseOperationId, destinationNumber }. The signed binding
lets the receiver verify that the sender number belongs to the correlation. The parser
returns the validated source ID and sender number as facts and keeps the binding internal.
Group texts add remoteNumbers with the complete ordered remote member set. The parser
checks the group correlation against that set and the binding. A revised conversation
also supplies providerConversationSid and providerRevision so the revision correlation
can be verified. Group claims remain behind the worker's identity-claims rollout flag.
Partial or inconsistent claims fail before delivery. Text delivery cannot adopt an
originating session or carry file context.
Deploy AgentApps, Eve, and HQ receivers that accept both forms before enabling the
worker's complete-form sender.
Set AGENTPHONE_TEXT_IDENTITY_CLAIMS=true on the AgentPhone events worker only after the
accepting AgentApps, Eve, and HQ receivers are deployed. The worker defaults to the legacy
body; its idempotency key and rendered content remain the same in either mode.
Maintainer release
Source: https://github.com/baremetallabs-ai/sovereign-ai. This package and the eve extension version independently under SemVer. Commit each new manifest version and lockfile entry before requesting a release; publication never renames or bumps a package. The first version was 0.1.0; the current contract is 0.1.2.
The @baremetallabs-ai npm organization uses the existing short-lived NPM_TOKEN for manual releases. CI runs the notifications 0.1.2 dry run first, then the Eve 0.1.4 dry run. After merge, the orchestrator manually dispatches Publish AgentApps npm package with package=notifications, version=0.1.2, dry_run=false, then package=eve, version=0.1.4, dry_run=false. Normal merges and tags never publish. A dry run uses dry_run=true and cannot write to the registry.
A private source repository reports provenance disabled: private source repository and must pass every other gate. New releases from a public source require provenance. Historical private-source releases do not need retroactive attestation. Every exact registry version, including an already occupied one, must pass packed-content, whole-tarball secret, clean-consumer, and registry-signature verification. An unsafe occupied version stops the release without choosing another version.
A published contract remains available if the extension fails. The pair is pair incomplete until the exact Eve version passes verification; retry the same versions to verify an occupied package as a no-op or publish the missing one. Trusted publishing can replace the token later; the workflow retains id-token: write for that switch.
