npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@replylayer/sdk

v0.23.0

Published

Official TypeScript SDK for ReplyLayer — email for AI agents

Downloads

975

Readme

@replylayer/sdk

Official TypeScript SDK for ReplyLayer — secure email for AI agents.

Install

npm install @replylayer/sdk

Quick start

import { ReplyLayer } from '@replylayer/sdk';

const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

// Create a mailbox
const mailbox = await rl.mailboxes.create({ name: 'support' });

// Send an email
const sent = await rl.messages.send({
  from_mailbox: mailbox.name,
  to: '[email protected]',
  subject: 'Hello from my agent',
  body: 'Hi there!',
});

// Wait for a reply (long-poll, up to 30s)
const { message } = await rl.messages.wait(mailbox.id);
if (message) {
  console.log(`Got reply from ${message.sender}: ${message.subject}`);
}

// Browse conversation threads
const page = await rl.threads.list(mailbox.id);
for (const thread of page.data) {
  console.log(`${thread.subject} (${thread.message_count} messages)`);
}

Constructor options

new ReplyLayer({
  apiKey: 'rl_live_...',                      // required
  baseUrl: 'https://api.replylayer.ai',       // default
  maxRetries: 3,                              // retries on 429/5xx (0 = fail-fast)
  timeout: 30_000,                            // ms per request
  maxRetryAfterMs: 4_000_000,                 // cap on honoring a 429 Retry-After (default ~67min)
  onRetry: (info) => {},                      // silent-by-default retry hook
});

Retry behavior

The client retries failed requests up to maxRetries times (default 3). The contract — read it before relying on retries:

  • 429 is retried on every method, including mutating ones (POST / PATCH / DELETE). A 429 is a pre-dispatch rate-limit rejection, so nothing happened server-side — retrying is safe. The wait honors the Retry-After header.
  • 5xx is retried only on non-mutating (GET) requests. A 5xx on a POST / PATCH / DELETE is not retried — the request may have executed, so a retry risks a double-send (or, for DELETE, retrying a lost-but-applied delete into a confusing 404). To retry a send / reply safely, pass an idempotency key — see Idempotent sends.
  • Multipart uploads are never retried (a retry would re-send the body).
  • Long Retry-After values block up to maxRetryAfterMs (default ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a server Retry-After exceeds this cap, the SDK throws the RateLimitError rather than sleeping — it never clamps-and-retries into a still-limited window. Interactive callers should set a low cap (e.g. maxRetryAfterMs: 30_000).
  • maxRetries: 0 is fail-fast — no implicit retry of any kind. Recommended for interactive / agent contexts where a request should surface its error immediately. Branch on RateLimitError.retryAfter and retry intentionally.
  • onRetry is silent by default — the SDK never writes to stdout/stderr. Pass an onRetry(info) hook to log or meter retries; it receives { attempt, error, delayMs, method, path }, may be async (it's awaited), and a throwing/rejecting callback is swallowed so it can't break the retry.
  • A retry sleep is abort-aware: a signal passed to a request (e.g. messages.wait({ signal })) interrupts an in-flight Retry-After wait.

Idempotent sends

Because a 5xx on a send is not auto-retried (a blind retry risks a second delivery + a second charge), the SDK gives you a way to retry it yourself safely. messages.send, messages.reply, and scheduled sends via drafts.create (with send_at) accept an idempotency key: a network-retried request carrying the same key produces at most one message and one charge — the server replays the original outcome and returns the same message_id instead of sending again.

import { randomUUID } from 'node:crypto';

const key = randomUUID(); // stable per send intent — persist it with the job

// First call sends; a same-key retry replays the first result (no second send).
const sent = await rl.messages.send(
  { from_mailbox: 'support', to: '[email protected]', subject: 'Hi', body: 'Hello' },
  { idempotencyKey: key },
);
console.log(sent.message_id); // a same-key retry returns this SAME id

The key travels as the Idempotency-Key request header and is permanent (no expiry). A non-throwing probe, rl.messages.getIdempotencyReplay(key), reports whether a key already produced a result (replay, with the prior message), is still in_flight, or is a miss — call it before a retry whose local inputs (a staged attachment, the original message) may no longer be available.

Resources

| Resource | Methods | |----------|---------| | rl.domains | create, list, get, verify, updateSelfHostedConfig, delete, setDefault, recheck | | rl.mailboxes | create, list, getMailbox, delete, update, setRecipientPolicy, setThreadReplies, setAgentSendContainment, setAgentSendPolicy, setAttachmentAccess, setSenderPolicy | | rl.mailboxes.allowlist | list, add, addBulk, delete, listBlockedAttempts | | rl.mailboxes.inboundAllowlist | list, add, addBulk, delete, blockedAttempts | | rl.messages | send, list, get, reply, getIdempotencyReplay, wait, release, block, report, delete, firewallRelease, markRead, setStarred, approveReview, denyReview | | rl.drafts | create, get, list, update, send, delete | | rl.threads | list, get, markRead, setStarred | | rl.attachments | getDownloadUrl, getPreview, upload, getUpload, deleteUpload | | rl.webhooks | create, list, get, update, delete, rotateSecret, test, listDeliveries, retryDelivery | | rl.recipients | create, list, delete, resend | | rl.suppressions | list, add, addBulk, delete | | rl.inboundBlocklist | list, add, addBulk, delete | | rl.apiKeys | create, list, revoke, rotate* | | rl.account | getUsage, getQuota, getLinkScanningStatus, enableLinkScanning, export | | rl.legalHolds | apply, release, list, get | | rl.health | check |

*apiKeys.rotate() revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new ReplyLayer instance with the returned key.

Drafts: scan-then-review-then-send

rl.drafts.create() runs the outbound scanner synchronously and attaches the verdict to the draft. The create-time verdict is UX — it lets an agent (or a human approver) see the likely outcome before clicking send. rl.drafts.send() re-runs the scanner authoritatively against the mailbox's current policy, so a stale cached verdict cannot slip through.

const draft = await rl.drafts.create({
  mailbox_id: mailbox.name,
  to: '[email protected]',
  subject: 'Re: your invoice',
  body: 'Thanks for your question.',
});
if (draft.worst_decision === 'allow') {
  const result = await rl.drafts.send(draft.id);
  console.log(`Sent ${result.message_id}`);
}

The send/reply/draft-send response carries two additive, nullable fields that explain a held send inline (no second read call). result.scan is the vendor-neutral scanner verdict (ScanSummary); result.hold_context ({ trigger_source, summary_reasons } or null) is the policy/human-review reason, non-null only when the delivery status diverges from scan.verdict because of a policy/human-review hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (trigger_source: mailbox_policy | scanner | both).

By default drafts.send() is synchronous — it returns SendMessageResponse once the scanner verdict is known, with scan and hold_context inline. Pass { async: true } to request the optimistic-ack path via the Prefer: respond-async header. The header is an advisory hint — the server decides. When async dispatch is available the server returns 202 with status: 'queued_for_dispatch' (AsyncSendAck) and the scanner runs in the background, so scan and hold_context are absent on the 202; otherwise the hint is ignored and you get the normal 200 SendMessageResponse. Always branch on the response — check status === 'queued_for_dispatch' rather than assuming a 202. For a queued send, poll messages.get(message_id) or listen for message.delivered / message.dispatch_failed webhooks to observe the terminal state. (messages.wait() is a mailbox long-poll for new inbound mail, not a way to observe a specific outbound message by ID.)

The send endpoint returns 409 with distinct codes:

  • DRAFT_REJECTED_BY_RESCAN — send-time scan flipped the verdict to block/quarantine. The draft stays in draft state; edit the body and retry. err.details carries scan, releasable (true for a quarantine hold the customer can release via POST /v1/drafts/:id/release-and-send, false for a terminal block), and, when a policy/human-review decision drove the hold, hold_context.
  • DRAFT_ALREADY_SENT — the draft was already sent (race or retry after success).
try {
  await rl.drafts.send(draft.id);
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'DRAFT_REJECTED_BY_RESCAN') {
    console.error('Rescan blocked it:', err.details);
  }
}

Outbound attachments (Pro+)

Attaching a file is a two-phase flow: upload the bytes to stage a handle, then reference handle.id in a send/reply/draft attachment_ids array. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text and filename) before it leaves. The mailbox must have outbound attachments explicitly enabled by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox return 403 OUTBOUND_ATTACHMENTS_DISABLED.

// Phase 1 — stage the file (returns an opaque handle).
const handle = await rl.attachments.upload({
  mailboxId: 'support',
  file: await readFile('invoice.pdf'),   // Buffer/Uint8Array/Blob/ArrayBuffer
  filename: 'invoice.pdf',
  contentType: 'application/pdf',         // advisory — the server re-sniffs the bytes
});

// The content scan runs asynchronously. Poll until it leaves `pending`.
let status = handle.content_scan_status;          // 'pending' at upload time
while (status === 'pending') {
  await new Promise((r) => setTimeout(r, 1000));
  const polled = await rl.attachments.getUpload(handle.id);
  if ('status' in polled) break;                  // already consumed
  status = polled.content_scan_status;
}

// Phase 2 — reference the handle on a send. `clean` and `flagged` both send
// (a `flagged` finding flows to the message verdict, like a body finding);
// `error` is fail-closed.
const result = await rl.messages.send({
  from_mailbox: 'support',
  to: '[email protected]',
  subject: 'Your invoice',
  body: 'Attached.',
  attachment_ids: [handle.id],
});

A handle is consumed once at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with rl.attachments.deleteUpload(handle.id). Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (OUTBOUND_IMAGE_DISCLAIMER_REQUIRED). Drafts hold handles and consume them at dispatch; rl.drafts.update({ attachment_ids: null }) clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).

Recipient allowlist (mailbox containment)

By default a mailbox uses blocklist mode — the pre-send gate rejects recipients on suppressed_addresses and allows everyone else. Switching a mailbox to allowlist mode contains agent-origin outbound to a fixed list of recipients (plus thread participants): a prompt-injected or compromised agent key cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.

// Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
await rl.mailboxes.allowlist.add(mailbox.id, { email: '[email protected]' });
await rl.mailboxes.allowlist.addBulk(mailbox.id, { emails: ['[email protected]', '[email protected]'] });

// Flip the mode. Server returns 400 ALLOWLIST_EMPTY if the list is empty
// unless you pass forceEmpty: true to acknowledge the lockout.
await rl.mailboxes.setRecipientPolicy(mailbox.id, 'allowlist');

// Sends to off-list recipients now 403 with code RECIPIENT_NOT_ON_ALLOWLIST.
// Blocklist still runs first — a recipient on the do-not-contact list is
// rejected 403 with code RECIPIENT_SUPPRESSED (details.reason === 'suppressed').

// Deleting the last entry while in allowlist mode returns 409 ALLOWLIST_LAST_ENTRY;
// pass forceEmpty: true to acknowledge.
await rl.mailboxes.allowlist.delete(mailbox.id, '[email protected]', { forceEmpty: true });

A send/reply/draft-send to a recipient on your do-not-contact (suppression) list raises ReplyLayerError with err.code === 'RECIPIENT_SUPPRESSED' (HTTP 403, details.reason === 'suppressed'). This is terminal — escalate, don't retry; remove the suppression or send to a different recipient.

Allowlist mutations are admin-only — granting mutation to an LLM would defeat the agent-containment boundary. Agents can list (so they can see what they're allowed to email) but not add/addBulk/delete. Three new webhook events emit on change: recipient_allowlist.added, recipient_allowlist.removed, mailbox.recipient_policy_changed.

Domain entries

Entries can be either an exact email ([email protected]) or a bare-domain pattern (@corp.com) that matches every address at that domain. Exact-domain only — @corp.com matches *@corp.com but NOT [email protected].

// Allow everyone at @partner.com — one entry covers every address there.
await rl.mailboxes.allowlist.add(mailbox.id, { email: '@partner.com' });

// Block a whole competitor domain.
await rl.suppressions.add({ email: '@competitor.com' });

// Bulk mix emails + domains; partial-success buckets handle invalids.
const bulk = await rl.mailboxes.allowlist.addBulk(mailbox.id, {
  emails: ['[email protected]', '@partner.com', 'not-an-email'],
});
// bulk.added[0].pattern_type === 'email'
// bulk.added[1].pattern_type === 'domain'
// bulk.invalid[0]                 === { email: 'not-an-email', reason: 'invalid_format' }

Responses expose pattern_type: 'email' | 'domain' on every add/list/delete/bulk-added row. The field is optional on the type — pre-0.5.0 servers omit it.

Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (@, @.com, @foo, @corp-.com, non-ASCII) return 400 INVALID_EMAIL (message: "Invalid email or domain pattern").

Blocked attempts

Every send the allowlist gate rejects writes an append-only audit row and emits a deduped recipient_allowlist.blocked_attempt webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.

// Aggregated top-N view — grouped by (recipient, actor_id).
// next_cursor is always null for aggregated; the view is top-N, not paginated.
const { attempts } = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id);
for (const a of attempts) {
  console.log(`${a.recipient} × ${a.count} (last: ${a.last_attempted_at})`);
}

// "Blocked this week" — same aggregate with a recency filter (1..365 days).
const week = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id, {
  withinDays: 7,
});

// Raw per-attempt history for forensic drill-in. Paginates via tuple cursor.
const raw = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id, {
  aggregate: false,
  limit: 100,
});

Webhook deliveries are deduped server-side to at most one per (account, mailbox, recipient) per 60 seconds — a looping agent produces one delivery, not hundreds, keeping your subscription below the 20-abandoned-deliveries auto-disable threshold. Full attempt history is always available via listBlockedAttempts.

The MCP tool list_allowlist_blocked_attempts exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).

Delivery history & manual retry

rl.webhooks.listDeliveries(id, { limit?, before_at?, before_id? }) returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. before_at and before_id must be provided together — the SDK omits the cursor entirely if only one is given.

let page = await rl.webhooks.listDeliveries(webhookId, { limit: 50 });
while (page.has_more) {
  page = await rl.webhooks.listDeliveries(webhookId, {
    limit: 50,
    before_at: page.next_before_at,
    before_id: page.next_before_id,
  });
}

rl.webhooks.retryDelivery(webhookId, deliveryId) re-queues a single failed delivery. The API rejects retries on non-failed deliveries or deliveries whose parent webhook is disabled — surfaced as ReplyLayerError with .code set to DELIVERY_NOT_FAILED or WEBHOOK_DISABLED:

try {
  await rl.webhooks.retryDelivery(webhookId, deliveryId);
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'WEBHOOK_DISABLED') {
    // Resume the webhook (PATCH enabled=true) before retrying.
  }
}

Mailbox settings

Each mailbox carries a scanner policy and a PII delivery mode:

// Redact PII before delivering inbound bodies to the agent
await rl.mailboxes.update(mailbox.id, {
  scanner_policy: { language_mode: 'english_only' },
  pii_mode: 'redacted',
});

pii_mode values:

  • 'passthrough' (default) — message reads return body.content as the plaintext display projection; dashboard session callers may opt into sanitized HTML.
  • 'redacted'body.content is plaintext with detected PII spans replaced by <TYPE> tags (e.g. <EMAIL_ADDRESS>, <PHONE_NUMBER>). Requires Starter tier or above; sandbox accounts get 403 TIER_LIMIT.

pii_mode='redacted' also applies to outbound webhook payloads: message.* events have sender/recipient/to<EMAIL_ADDRESS> and subject<REDACTED> before signing. The HMAC covers the redacted body — verifyWebhookSignature works without any client-side changes.

Advanced PII config (Pro+)

pii_redaction_config gives per-detector control over redaction (e.g. "leave email visible, redact everything else") and operator-level rendering (partial_mask for credit cards, hash_replace for emails you want to dedupe without exposing). Pro+ feature; only meaningful when pii_mode='redacted'.

// Per-detector toggle: show emails to the agent, keep everything else redacted.
await rl.mailboxes.update(mailbox.id, {
  pii_mode: 'redacted',
  pii_redaction_config: {
    EMAIL_ADDRESS: { redact: false },
  },
});

// partial_mask: render credit cards as ****-****-****-1111 (separators preserved).
// `keep_last` is 1-6; `mask_char` defaults to '*'.
await rl.mailboxes.update(mailbox.id, {
  pii_redaction_config: {
    CREDIT_CARD: {
      redact: true,
      operator: { kind: 'partial_mask', keep_last: 4 },
    },
  },
});

// hash_replace: <EMAIL_ADDRESS:a3f1b9c2>. Deterministic per account; opaque
// across accounts. Lets your agent dedupe without seeing raw values.
await rl.mailboxes.update(mailbox.id, {
  pii_redaction_config: {
    EMAIL_ADDRESS: {
      redact: true,
      operator: { kind: 'hash_replace' },
    },
  },
});

// Reset to platform default.
await rl.mailboxes.update(mailbox.id, { pii_redaction_config: {} });

Tier gate. Any non-default value (a redact: false entry OR an operator with kind: 'partial_mask' or kind: 'hash_replace') requires the pii_advanced_controls feature (Pro+). Non-feature accounts can still PATCH {}, null, or default-shape entries ({ redact: true }, { kind: 'replace_with_type' }).

partial_mask whitelist. PERSON and EMAIL_ADDRESS are rejected (422) — partial-masking a name produces nonsense; partial-masking an email is hard to do well in v1. Use hash_replace for those instead.

Downgrade behavior. If you configure non-default pii_redaction_config on Pro and then downgrade, the persisted JSONB stays on the row but the read-side IGNORES it. Reads fall back to platform default. Re-upgrading restores the config instantly. The dashboard surfaces a "Saved but inactive" banner in this state.

Webhook scope-out. Advanced PII config does NOT apply to webhook payload metadata. Webhook delivery continues to use pii_mode for envelope-level field redaction; per-detector and operator control is API read-side only. Hosted webhook docs live at https://replylayer.ai/docs/webhooks.

Outbound PII send safety. ScannerPolicy.outbound_pii_policy tunes send decisions for the local outbound PII scanner by type:

await rl.mailboxes.update(mailbox.id, {
  scanner_policy: {
    outbound_pii_policy: {
      ssn: 'quarantine',
      credit_card: 'review',
      phone_number: 'allow_with_warning',
    },
    outbound_review_policy: {
      approval_note: 'required_for_sensitive_pii',
    },
  },
});

Supported actions are allow, allow_with_warning, review, quarantine, and block. review routes matching sends to Pending approval; enabling it requires both Pro+ outbound PII controls and the review queue feature. Relaxing below platform defaults requires Pro+ (pii_advanced_controls); default or stricter values are accepted on every tier. Outbound PII scan results include pii_type?: 'ssn' | 'credit_card' | 'phone_number' so clients can inspect which type drove the action.

Approval notes are optional by default. Set outbound_review_policy.approval_note to required_for_sensitive_pii when approvers must add a note before sending SSN or credit-card review holds.

Agent Attachment Access

Each mailbox has a per-mailbox gate (attachment_access_enabled, default off) that controls whether agent-role API keys can download attachment bytes via the attachment endpoint. Admin keys, pre-scoping keys, and dashboard sessions always bypass. When the flag is off for a mailbox, agent-key requests return 403 ATTACHMENT_ACCESS_DISABLED — surfaced as ReplyLayerError with .code === 'ATTACHMENT_ACCESS_DISABLED':

try {
  await rl.attachments.getDownloadUrl(messageId, 0);
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'ATTACHMENT_ACCESS_DISABLED') {
    // Admin can enable via the dashboard (Mailbox settings → Agent Attachment Access)
    // or via POST /v1/mailboxes/:id/attachment-access (requires accept_disclaimer_version).
  }
}

Why it exists: ReplyLayer scans attachment bytes (virus/AV, MIME mismatch, policy) but does NOT extract text from raw downloads for prompt-injection scanning. Images are a separate raw-download family because prompt-injection text can be embedded in pixels and later exposed by OCR, vision models, or multimodal LLMs. Including image requires accept_image_risk_version unless the mailbox already has current image-risk acceptance. See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full policy contract.

Human dashboard sessions and admin/pre-scoping keys can download clean stored metadata_only attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.

See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.

Malicious link scanning (URL reputation)

Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an admin action — it turns on an account-wide sub-processor data flow — so agent-scoped keys can read status but not enable it (they get ForbiddenError).

Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:

const status = await rl.account.getLinkScanningStatus();
// { active, accepted_version, current_version, privacy_ok }

if (!status.active && status.privacy_ok) {
  // current_version is the disclaimer you are acknowledging
  const res = await rl.account.enableLinkScanning({ accept_web_risk_version: status.current_version });
  // res.url_reputation.active === true; res.disclosure.notice / res.disclosure.advisory_url
}

If privacy_ok is false the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (enableLinkScanning would otherwise fail with a 409 PRIVACY_VERSION_TOO_OLD).

Trusted instruction sources

Every inbound message read carries agent_safety_context with untrusted_content: true — the body is external data, not instructions for the agent to act on. Trusted instruction sources is a read-path-only relaxation of that default for one specific, customer-designated, verified sender address per mailbox (address-grain only — there is no domain-wide grant). It does not change what a resulting send is allowed to do; the only related send-side control is a per-mailbox strict-recipient toggle, configured outside the SDK.

The relaxation is entirely operator-configured, server-side — there is no client-side opt-in and no request header to send. It applies on a read only when every layer is configured: the platform feature is enabled, the mailbox's instruction-trust mode is on, the designated sender's message passed Mailgun sender verification (verified_aligned), the message scanned clean and is available, and the reading API key is a role='agent' key with the per-key capability enabled. All of that configuration happens in the dashboard (each a loosening change requiring session re-auth); the SDK simply returns whatever the server decides:

const rl = new ReplyLayer({
  apiKey: process.env.REPLYLAYER_API_KEY!,
});

const { message } = await rl.messages.wait(mailbox.id);
if (message?.agent_safety_context?.instruction_trust) {
  // Gate passed: guidance was REPLACED with trusted-instruction wording (the
  // agent may act on this verified sender's own explicit request in this
  // message). untrusted_content is still true — this is metadata, not a
  // content-safety judgment.
  console.log(message.agent_safety_context.guidance);
  console.log(message.agent_safety_context.instruction_trust);
  // { version: 'v1', match: 'address', verified_domain, verdict: 'verified_aligned', provenance: 'managed' }
}

A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard. Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in — and because relaxation is decided purely server-side, a copied or hijacked API key cannot influence it.

Mailbox identifiers

Every SDK method that takes a mailboxId argument accepts either the mailbox's UUID or its name. The server resolves names against the authenticated account's active mailboxes. rl.messages.list('support-bot', ...) and rl.messages.list('a1b2...', ...) are equivalent.

Pagination

List endpoints return a Page<T> with data, hasMore, and cursor:

const page = await rl.messages.list(mailboxId, { limit: 50 });
console.log(page.data);     // MessageSummary[]
console.log(page.hasMore);  // boolean
console.log(page.cursor);   // string | undefined

Pass autoPaginate: true for an async iterator:

for await (const msg of rl.messages.list(mailboxId, { autoPaginate: true })) {
  console.log(msg.subject);
}

Error handling

import { ReplyLayer, RateLimitError, NotFoundError } from '@replylayer/sdk';

try {
  await rl.messages.get('nonexistent');
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log('Message not found');
  } else if (err instanceof RateLimitError) {
    console.log(`Rate limited, retry after ${err.retryAfter}s`);
  }
}

Error classes: ReplyLayerError (base), AuthenticationError (401), ForbiddenError (403), NotFoundError (404), ValidationError (400/422), RateLimitError (429).

Webhook signature verification

For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.

Verify inbound webhook signatures from ReplyLayer:

import { verifyWebhookSignature } from '@replylayer/sdk';

const isValid = verifyWebhookSignature(
  requestBody,                           // raw body string
  request.headers['x-replylayer-signature'],
  process.env.WEBHOOK_SIGNING_SECRET!,
  { tolerance: 300 },                    // optional, seconds (default 300)
);

Once verified, parse and dispatch on the event type. The discriminator field is event, not type:

const payload = JSON.parse(requestBody);
// payload.event is the discriminator — NOT payload.type
if (payload.event === 'message.received') {
  // handle inbound message
} else if (payload.event === 'message.dispatch_failed') {
  // handle failed outbound send
}

Requirements

  • Node.js >= 20
  • No external dependencies (uses native fetch)

License

MIT