Concepts
Messages
Everything in the inbox is a message. POST /messages takes a title (≤180 characters), a Markdown body (≤50,000) and a kind, all end-to-end encrypted by the SDKs and CLI before they leave your machine:
| Kind | Use |
|---|---|
message |
A notification or update. Delivered, nothing to answer. |
report |
A message meant for careful reading, such as a summary or diff. |
files |
Mostly attachments. |
form |
A request: fields and/or action buttons; waits for an answer. |
secret |
An encrypted secret offered to you (Secrets). |
Requests move from pending to answered, expired or cancelled. Messages without a question are delivered. Each send needs an Idempotency-Key: retrying with the same key and body returns the original message; a different body is a 409. The SDKs and CLI generate keys for you; pass your own to survive restarts.
Forms
A form has fields (up to 30):
| Type | Answer | Options |
|---|---|---|
text, textarea |
string | minLength, maxLength, placeholder |
number |
number | minimum, maximum, integer |
boolean |
true/false | |
select, multiselect |
string / string[] | options, other |
date |
YYYY-MM-DD |
|
secret |
encrypted (Secrets) | label |
file |
attachment IDs | minItems, maxItems, secret |
Every field has id, label, optional required, help, default and nullable.
Choices are options: [{value, label, description?, preview?}]: description shows under the label and preview is Markdown (a mockup or code) shown while the option is selected. other: true adds an Other choice answered with free text: the answer is that text, or one extra item in a multiselect (up to 100 characters). A short header names the field in the card's pager.
Questions
questions asks several multiple-choice questions at once, in the shape of Claude Code's AskUserQuestion input. Each becomes a required choice field q1…qN with Other, whose values are the option labels; cards show one question at a time, the thread shows them as one form. The SDKs' questionAnswers (MCP ask returns it as answers) maps the answer back by question text, multiple choices joined by , . This lets an agent ask through Herald in place of its own question tool; see Agents.
{
"title": "Plan the sign-in refactor",
"questions": [
{
"question": "Which sign-in method should the app use?",
"header": "Auth method",
"options": [
{ "label": "Passkeys", "description": "Phishing-resistant; needs a fallback." },
{ "label": "Magic links", "description": "Simple, but depends on email." }
]
},
{
"question": "Which platforms ship first?",
"header": "Platforms",
"multiSelect": true,
"options": [{ "label": "Web" }, { "label": "macOS" }, { "label": "Android" }]
}
]
}
Instead of fields you can send a flat JSON Schema object as schema: scalar properties, enums, string-enum arrays, bounds, defaults and dates, plus x-herald-type: textarea|secret|file|secret-file. Nested objects, pattern and unknown keywords are rejected, so a form never silently drops a constraint.
{
"title": "Configure the release",
"schema": {
"type": "object",
"required": ["channel"],
"properties": {
"channel": { "type": "string", "enum": ["beta", "stable"] },
"notes": { "type": "string", "x-herald-type": "textarea" }
}
}
}
Actions
actions (1–8) are buttons that answer a form in one tap: {id, label, values}, where values is a complete, valid answer to the form (often {}). The answer carries actionId. Actions cannot be combined with secret or file fields. An approve/reject pair can also be answered from the lock screen on paid plans.
Blocks
blocks (up to 10) show structured content above the form:
code,diff,json:{type, text, language?, title?}, up to 50,000 characters.languageistext(default),sql,javascript,typescript,pythonorjson.table:{columns, rows}, up to 12 columns and 100 rows.image:{attachmentId, alt}for a PNG, JPEG, GIF or WebP attached to the same message.
requiresReview: true on a block or the message opens the full view before answering.
Files
Files are encrypted before upload. Reserve, send the bytes, and attach the result (up to 10 per message):
POST /fileswith{name: "encrypted", type: "application/octet-stream", size}(the encrypted size, ≤100 MB) returns{id, ...}, valid for 15 minutes.PUT /files/:idwith exactlysizeencrypted bytes, once.- Send a message whose encrypted content lists the attachment with its real name, type, size and key.
The SDKs' upload and the CLI's --file do all three. Downloading needs the message that carries the file, since it holds the key.
Secrets
Herald moves credentials end-to-end encrypted, so they never appear in message history, notifications or logs.
- Request a secret: a form with a
secretfield. Your device encrypts the answer to the asking connection only; it collects it once within 15 minutes. - Request a file privately: a
filefield withsecret: true. Only the asking connection can download the upload, and Herald deletes it 15 minutes after the answer. - Offer a secret: a
kind: "secret"message. When you open it, your device posts a one-time public key signed by your account; the sender checks the signature, encrypts to the key and Herald hands the ciphertext over once.
The CLI and MCP server move secrets only through files, never arguments or output:
herald ask --title 'API token' --field 'token:secret:API token' --out ~/.config/tool/token --deadline 10m
herald ask --title 'Staging kubeconfig' --field 'kubeconfig:secret-file' --out ~/.kube/staging
herald send --secret-file ./generated-password --title 'Staging password'
Output files are created with mode 0600 and must not already exist. Never put credentials in ordinary fields, titles, bodies or logs.
End-to-end encryption
Every account is end-to-end encrypted. The first device you sign in on creates the keys; save a recovery key or passkey when the app asks, so you can unlock new devices. A new device unlocks with a passkey, the recovery key or approval from an unlocked device, where you type the code the new device shows. Every device shows the same safety code.
- Connections. Each connection holds its own key, certified by your account; its token reads
hdr_….<seed>.<fingerprint>and only thehdr_…part is sent to Herald. Messages, answers, task inputs, logs, results, secret offers and files are encrypted on the device that writes them and readable only on your devices and the connections they are addressed to. Herald refuses anything else, including tokens without a key. - What Herald sees. Ciphertext, public keys and metadata: sender, times, status, deadlines, whether a record asks for an answer, thread links, destination and type definitions, task failure reasons and sizes. Records carry
sealed, titles read "Encrypted message", server search matches only the sender and notifications show the sender with "New message" or "Needs your reply"; your devices decrypt notification previews themselves, and Approve/Reject answers from a notification are encrypted on the device. - Keys.
GET /keyslists the account statement and connection certificates;/keys/*routes (human sessions) manage enrolment, backups and device approval. - Reset. Reset in Settings creates new keys. It deletes all activity and files and revokes every connection; other devices must unlock again.
Threads
Set replyTo to a message ID to continue its conversation. Replies get threadId and threadSequence; GET /messages/:id/thread lists the thread oldest first. A task you dispatch as a reply lets its runner read the thread up to that task.
Deadlines
A request expires at expiresAt (ISO time) or after expiresIn seconds (≤24 hours). The SDKs' timeout and the CLI's --deadline set it. Separately, a client's wait timeout only stops the client: the request stays answerable, and wait resumes with its ID.
Live updates
wss://app.herald.run/ws (scope events:read) sends {"type":"sync","revision":N} whenever your inbox changes. Then read GET /messages?after=<cursor> and advance your cursor to the highest revision seen. Updates coalesce and deletions appear only with tombstones=1, so process idempotently. The SDKs' wait and watch do this for you.