Versioned archive. You are viewing v0.38.0. For the latest released guidance, use Latest release. Report archive issues on GitHub.
You decided what your service sends and to whom, and now you want to author what Registry Messaging
may send on its behalf. This page covers the package: the providers messages travel through, the
sender profiles callers select, the templates Messaging renders, and the access profiles that decide
who may do what. At the end, messagingctl check accepts the package and reports the digest an
operator records before a runtime serves it.
The package holds no endpoint, credential, or database address. Those belong to the operator’s runtime file, which Deploy Registry Messaging covers.
To watch the starter package send before you change it, run
Send your first message first: it delivers one SMS and one email
on your machine with messagingctl dev.
Get messagingctl
Section titled “Get messagingctl”The first release eligible to publish messagingctl is v0.38.0, after image-package
onboarding is complete. For a published release that contains
Messaging, choose <tag> from the
latest release, authenticate
the messagingctl-<tag>-linux-amd64 asset through release/VERIFY.md at that tag, then install it:
tag="${TAG:?set TAG to a published tag that includes Registry Messaging}"mkdir -p ~/.local/bininstall -m 0755 "messagingctl-${tag}-linux-amd64" ~/.local/bin/messagingctlexport PATH="$HOME/.local/bin:$PATH"messagingctl --versionThe install replaces an existing ~/.local/bin/messagingctl. Preserve that file first when you
need a rollback path. Until a published release contains Messaging, or for another platform,
build messagingctl from a Registry Stack checkout:
cargo build --release --locked -p registry-messagingctlexport PATH="$PWD/target/release:$PATH"On macOS, Cargo builds the FIPS crypto library as a dynamic library the shell cannot find on its own. Build through the repository’s helper instead, in the terminal you run the binary from:
. scripts/cargo-runtime-library-path.shregistry_cargo_build "$PWD" --release --locked -p registry-messagingctlexport PATH="$PWD/target/release:$PATH"A source build writes target/release/messagingctl. The command reads files and prints reports;
check and preview open no connection. Every command takes --format human|json and exits 0 on
success, 1 on a refusal, 2 on a usage error, and 3 when a file, secret, or database could not be
reached. Run messagingctl <command> --help for each command’s arguments.
A human failure is one error[CODE] PATH: MESSAGE line on standard error, followed by a next:
line naming what to do. With --format json, every report opens with ok, command, and
status, and a failure lists its diagnostics, each with a code, path, message, and
suggestedAction. The one exception is a successful preview, which prints the route body the
HTTP preview answers, unwrapped.
Start from the starter
Section titled “Start from the starter”messagingctl init writes an editable project into a directory that must not exist yet:
messagingctl init ./noticescreated: ./notices messaging.yaml providers/sms-gateway/provider.yaml providers/sms-gateway/scripts/interpret.rhai providers/sms-gateway/scripts/prepare.rhai providers/sms-gateway/scripts/receipt.rhai runtime.example.yaml templates/appointment-reminder-sms/1/en/text.j2 templates/appointment-reminder-sms/1/sample.json templates/appointment-reminder-sms/1/schema.json templates/appointment-reminder-sms/1/template.yaml templates/appointment-reminder/1/en/html.j2 templates/appointment-reminder/1/en/subject.j2 templates/appointment-reminder/1/en/text.j2 templates/appointment-reminder/1/fr/html.j2 templates/appointment-reminder/1/fr/subject.j2 templates/appointment-reminder/1/fr/text.j2 templates/appointment-reminder/1/sample.json templates/appointment-reminder/1/schema.json templates/appointment-reminder/1/template.yamlnext: Run messagingctl check --project DIRECTORY, then messagingctl dev DIRECTORY to run it locally against PostgreSQL and Mailpit containers.next: Run messagingctl package DIRECTORY --output PACKAGE, point runtime.yaml at PACKAGE, run messagingctl plan, then messagingctl apply, then messaging serve.The directory is an editable project. messaging.yaml, templates/, and providers/ describe
what the runtime may send. Keep runtime.example.yaml for deployment configuration; packaging
excludes that file and development state. Use project-mode checks while editing, then produce a
separate installed directory for handover.
Declare providers and sender profiles
Section titled “Declare providers and sender profiles”messaging.yaml opens with its type and declares the providers messages travel through:
apiVersion: registry.registrystack.org/messaging-package/v1alpha1kind: MessagingPackageproviders: - id: mail-relay kind: smtp - id: sms-gateway kind: http idempotentSubmit: trueA provider’s kind is smtp, which carries email, or http, which carries whatever its scripts
send. idempotentSubmit: true declares that the provider drops a repeat submission carrying the
same idempotency key. Only an http provider may set it, and only then does its prepare script see
the key. Every key in the manifest is closed: an unknown field is a refusal, not a line that is
ignored.
A sender profile is what a caller names when it submits. It fixes the channel, the provider carrying it, and the sender identity:
senderProfiles: - id: transactional channel: email provider: mail-relay sender: notices@example.org - id: reminders-sms channel: sms provider: sms-gateway sender: Registry maximumSegments: 2An SMS sender profile sets maximumSegments, from 1 to 10, and a rendered SMS needing more is
refused with 422 content.too-many-segments.
Choose how a sender profile retries
Section titled “Choose how a sender profile retries”A sender profile also sets how the worker sends its messages. Each message keeps the policy its profile had when it was accepted, so a later package change does not alter a queued message.
| Key | Default | Bounds |
|---|---|---|
retry.maximumAttempts | 5 | 1 to 20 |
retry.initialDelaySeconds | 30 | at least 1 |
retry.maximumDelaySeconds | 3600 | initialDelaySeconds to 86400 |
onUncertain | hold | hold or retry |
acceptDuplicates | false | |
defaultExpirySeconds | 86400 | 60 to 2592000 |
A send that definitely failed is retried with exponential backoff, doubling from the initial delay
up to the maximum, with jitter. A send that may have reached the provider, including one cut off by
its time budget, is uncertain. Under hold, the message stops as unknown until an operator
settles it. Under retry, it is sent again under the same provider idempotency key, and the
package is refused unless the profile’s provider declares idempotentSubmit: true or the profile
sets acceptDuplicates: true. Set acceptDuplicates only when a duplicate message is better for
the recipient than a missed one.
A message whose request names no expiresAt expires defaultExpirySeconds after acceptance, or at
the end of the operator’s payload retention if that comes first, and is never sent after it
expires.
Declare who may call
Section titled “Declare who may call”Each access profile names the callers it admits and what they may do. A token resolves to a profile by the client it was issued to, the scopes it carries, and the actor kind when the profile names one:
accessProfiles: - id: case-notices principalClaim: sub requiredScopes: [messaging:send] requesterClients: [case-system] actorKind: service role: sender senderProfiles: [transactional, reminders-sms] templates: [appointment-reminder, appointment-reminder-sms] requestsPerMinute: 60 burst: 10 - id: operations principalClaim: sub requiredScopes: [messaging:operate] requesterClients: [operations-console] role: operator requestsPerMinute: 60 burst: 10| Key | Meaning |
|---|---|
principalClaim | The token claim naming the caller. With the issuer, it scopes the messages a sender may read and cancel |
requiredScopes | Scopes the token must carry |
requesterClients | The OAuth clients this profile admits. A client belongs to exactly one profile, and the operator’s runtime file must admit it |
actorKind | human, agent, or service. Omitted means any |
role | sender submits and previews. operator reads and cancels every message and may not submit |
senderProfiles, templates | What a sender may use: at least one of each. An operator lists none |
allowDirectContent | Lets a sender submit text instead of naming a template. An operator may not set it |
requestsPerMinute, burst, dailyLimit | Positive limits on the profile’s callers |
Messaging inherits no authorization from a calling product. A service allowed to act on a case is
not thereby allowed to send; this profile decides. Keep allowDirectContent off unless a caller
has a reason to send text no reviewer saw: a template is the reviewed wording.
requestsPerMinute and burst bound how fast each caller of the profile submits. Every
POST /v1/messages that passes the role check is charged to a bucket kept per caller, by token
issuer and subject, and a submission past the burst is refused 429 rate-limit.exceeded with a
Retry-After header. The bucket lives in the runtime process, so each replica enforces its own and
a restart refills it.
dailyLimit bounds the messages the whole profile accepted in the last 24 hours. It is counted from
the accepted messages when a submission is accepted, so it holds across replicas and restarts. A
submission past it is refused 429 quota.exceeded, with Retry-After set to when the oldest
counted message leaves the window. A replayed submission is charged to the rate but not to the
daily limit.
Write a template version
Section titled “Write a template version”templates in messaging.yaml lists every version the package ships, and each lives under
templates/<id>/<version>/. A version is a label, so quote a numeric one:
templates: - id: appointment-reminder version: "1" - id: appointment-reminder-sms version: "1"A version directory holds:
template.yaml, which declares thechannel, up to 32localessuch asenorpt-BR, and theparts. An email version renderssubject,text, and optionallyhtml. An SMS version renders exactlytext.schema.json, a JSON Schema (draft 2020-12) the caller’s data must satisfy before anything renders. It may not reference anything outside itself.sample.json, optional data every locale must render at load, which is howmessagingctl checkreports each SMS locale’s segment count.<locale>/<part>.j2for every declared locale and part, at most 64 KiB each.
The starter’s email version declares:
channel: emaillocales: [en, fr]parts: [subject, text, html]and its English text part reads:
Hello {{ name }},
Your appointment at {{ office }} is on {{ day|date }}.
Please bring this notice with you.Templates are Jinja without a loader: no include, import, extends, or macros, no built-in
filters or globals, and no file, network, or clock access. A missing or null value refuses the
render instead of printing nothing. Two filters format for the requested locale: date for an ISO
calendar date and number(decimals) for a number. HTML parts escape every value and cannot opt out,
text and SMS parts strip control characters, and a subject strips newlines. A locale the version
does not declare is refused with 422 template.locale-unavailable, never answered in another
language.
To change wording a deployment already sends, add a new version directory and list it, and leave the old version in place until no caller names it.
Add an HTTP provider
Section titled “Add an HTTP provider”Every http provider has a directory providers/<id>/ holding provider.yaml and the Rhai scripts
it names. The starter’s sms-gateway is a generic JSON gateway that deduplicates on an idempotency
key and reports delivery through a signed callback:
prepareScript: scripts/prepare.rhaiinterpretScript: scripts/interpret.rhaireceiptScript: scripts/receipt.rhairequest: method: post headers: [idempotency-key, x-request-id]responseHeaders: [x-request-id]capabilities: receipts: callback concurrencyLimit: 8| Key | Meaning |
|---|---|
prepareScript | Required. Shapes one message into a request |
interpretScript | Optional. Classifies the provider’s response; without it, the status code decides |
receiptScript | Required exactly when capabilities.receipts is callback |
request.method | post, or get, which the operator’s runtime file must acknowledge |
request.headers, responseHeaders | The only header names the prepare script may set and the interpret script may read |
capabilities.receipts | none, callback, or reconcile. Only callback gives messages a delivery report |
capabilities.concurrencyLimit | 1 to 64. The operator’s connection may lower it, never raise it |
capabilities.ratePerSecond | Optional, 1 to 1000. Paces how often this runtime process starts a send |
With ratePerSecond set, at most one send to the provider starts every 1 / ratePerSecond seconds
in each runtime process. An attempt first waits for one of the connection’s concurrencyLimit sends
in flight, then for its slot, so the rate spaces requests as they leave, even when sends queue behind
a slow one. Both waits together last up to ten seconds on top of the send’s own time budget, and never
past the message’s expiresAt. An attempt whose slot does not open in time sends nothing and is
retried under its dispatch policy; a message whose expiry passes first is not sent again, and expires,
or stays unknown when an earlier attempt may have reached the provider and its policy retries.
provider.yaml is closed and at most 64 KiB, and each script is at most 64 KiB and must compile
with exactly its entry point when the package loads. A file the provider does not name, a hidden
entry, or a directory for an smtp provider is refused.
The scripts see plain data only, never a credential, a URL, or a header value the runtime owns:
| Script | Receives | Returns |
|---|---|---|
prepare(message, profile) | message: messageId, generation, attempt, channel, recipient, parts, and idempotencyKey when the provider declares idempotentSubmit. profile: id, channel, sender, maximumSegments | target relative to the operator’s baseUrl, declared headers, bodyFormat json or form, and body. Messaging serializes the body |
interpret(response) | status, the declared response headers, and the decoded body | outcome: accepted with a providerReference, transient with an optional retryAfter, permanent with an optional code, or maybe-sent |
receipt(request) | A verified callback’s method, form, query, and json, each only when the callback verifier signs it: hmac-sha256-body leaves query empty, and hmac-sha1-url-form leaves json as () | providerReference and report (sent, delivered, or undelivered) with an optional code, or () to ignore the callback. A throw answers 422 callback.unreadable |
Without an interpret script, a 2xx is accepted, 429 and 503 are transient, 408 and any other
5xx are maybe-sent, because the request was written and the provider may have acted on it, and
any other status is permanent with the code http.<status>. An interpret script that fails falls back
to the status, except that an unreadable 2xx is maybe-sent, because a provider may answer 200
with an error body. Report maybe-sent whenever the provider may have taken the message: that is
what keeps a message from being sent twice under onUncertain: hold.
The Registry Stack repository carries two more example provider directories under
products/messaging/examples/providers/: mock, a loopback mock for local runs, and
form-sms-gateway, a form-encoded SMS provider with a form callback signed by
hmac-sha1-url-form.
Check and preview the package
Section titled “Check and preview the package”messagingctl check --project validates the editable project, renders every sample,
and reports the digest:
messagingctl check --project ./noticesok: ./noticespackage digest: sha256:beb567ec1a2813ac361cc1765151e398dbbb6e77bd886889769ed21c029f43f4template: appointment-reminder 1 (email) sample en: renders sample fr: renderstemplate: appointment-reminder-sms 1 (sms) sample en: 1 segment(s), gsm7 85 unitsaccess profile: case-notices (sender)access profile: operations (operator)messagingctl preview renders one template version with the data you give it, so you can read
each locale before a caller sends it:
messagingctl preview --project ./notices appointment-reminder 1 \ --locale fr --data ./notices/templates/appointment-reminder/1/sample.jsontemplate: appointment-reminder 1 (email, fr)package digest: sha256:beb567ec1a2813ac361cc1765151e398dbbb6e77bd886889769ed21c029f43f4--- subjectVotre rendez-vous du 01/10/2026--- textBonjour Ada Lovelace,
Votre rendez-vous à Central Registry Office est fixé au 01/10/2026.
Merci d'apporter cet avis.--- html<p>Bonjour Ada Lovelace,</p><p>Votre rendez-vous à Central Registry Office est fixé au 01/10/2026.</p><p>Merci d'apporter cet avis.</p>An SMS preview adds the encoding and segment count the runtime charges against
maximumSegments:
messagingctl preview --project ./notices appointment-reminder-sms 1 \ --locale en --data ./notices/templates/appointment-reminder-sms/1/sample.jsontemplate: appointment-reminder-sms 1 (sms, en)package digest: sha256:beb567ec1a2813ac361cc1765151e398dbbb6e77bd886889769ed21c029f43f4--- textReminder: Ada Lovelace, your appointment at Central Registry Office is on 01/10/2026.--- sms: 1 segment(s), gsm7 85 unitsA refusal exits 1 and names what failed. Data missing two of the schema’s required members:
error[template.data-invalid] /: the data does not satisfy the template schema: `/` fails `required`; `/` fails `required`; data /: required data /: required next: Correct the template, locale, or data the message names, then retry.A locale the version does not declare:
error[template.locale-unavailable] /: the template does not ship this locale next: Correct the template, locale, or data the message names, then retry.The same preview is available to a sender over HTTP, and it answers the same bytes
messagingctl --format json preview prints for the same package, template, locale, and data.
Hand over the package
Section titled “Hand over the package”Produce an installed package in a directory that does not exist yet:
messagingctl package ./notices --output ./notices-installedmessagingctl check --package ./notices-installedUse --dry-run to inspect the selected files without writing, and --revision TEXT when you need
explicit source provenance. Give the operator the installed directory and its reported digest.
The directory contains the shared checksum envelope, product configuration, providers, and templates,
with no runtime credentials or development state.
The operator reviews messagingctl plan, records the digest with messagingctl apply, and restarts
onto it. The runtime
verifies the package and optional package.expectedDigest before database work, then uses only the
verified file buffers. Editing the authoring project never changes what a running deployment sends.
- Deploy Registry Messaging to give each provider its connection, record the package, and serve it.
- Registry Messaging API for the submission, status, and callback contract your callers use.
- Registry Messaging overview for what stays with the caller’s source of record.