Two-way notifications
A notification does not have to end at the banner. It can carry buttons that open a link, call your server back, take a typed answer or run a command, a script or a Shortcut on the Mac. This guide shows how to add each kind and what the user sees when they press it. When you finish you will have a banner with working buttons and a program that hears what the user chose. The full list of fields, kinds and rules is in the actions reference.
Before you start#
- Herald is running, and you can send a notification. If not, follow Send your first notification.
- The examples use
$HERALDand$TOKEN. Define them once as shown in Connect. - The examples send as the fictional app
example.bidbot. Use your own app id.
How buttons work#
A button is part of the notification. You send it in buttons, and each button does one thing. Herald draws it on the
banner, waits for the user to press it, then does what the button says and closes the banner.
The buttons you send are the issuer's buttons. You can also declare them once in the app's manifest, and the user can hide, rename or add to them in the Designer without changing your code. Where actions come from explains the merge.
Add a button that opens a link#
A link button is the simplest: it opens a page in the default browser. It needs no registration and no approval.
-
Send a notification with a
urlbutton.curl -s -X POST "$HERALD/v1/notify" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "app": "example.bidbot", "id": "bid-42", "title": "Bid accepted", "body": "Your bid of $4,200 was accepted.", "buttons": [{"label": "Open bid", "url": "https://example.com/bids/42"}] }'The banner appears with an Open bid button.
-
Press Open bid.
The page opens in your browser and the banner closes. Only
http,httpsandmailtolinks open.
Add a button that calls your server#
A callback button tells your program which button the user pressed. Herald sends one HTTP request to a URL you give it. Use it for anything your program has to decide, such as accepting an offer.
-
Start a server that listens on your Mac. This one prints each press and answers
200.from http.server import BaseHTTPRequestHandler, HTTPServer import json class Hook(BaseHTTPRequestHandler): def do_POST(self): size = int(self.headers["Content-Length"]) event = json.loads(self.rfile.read(size)) print(event["notificationId"], event["action"], event.get("payload")) self.send_response(200) # any 2xx closes the banner self.end_headers() HTTPServer(("127.0.0.1", 5123), Hook).serve_forever() -
Tell Herald where the server is, by registering the app with a
callbackURL.curl -s -X POST "$HERALD/v1/register" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"app": "example.bidbot", "appName": "BidBot", "callbackURL": "http://127.0.0.1:5123/herald"}'The reply is
{"ok": true}. You can instead put aurlinside each button'scallback. SeePOST /v1/register. -
Send a notification with callback buttons.
curl -s -X POST "$HERALD/v1/notify" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "app": "example.bidbot", "id": "bid-43", "title": "Counter-offer from Acme", "body": "They offer $3,900. Accept?", "persistent": true, "buttons": [ {"label": "Accept", "callback": {"payload": {"decision": "accept"}}}, {"label": "Decline", "style": "destructive", "callback": {"payload": {"decision": "decline"}}} ] }'The banner stays on screen until you press a button.
The banner this request shows. Decline is styled destructive, so it is red. -
Press Accept.
Your server prints
bid-43 Accept {'decision': 'accept'}and the banner closes. Decline is styleddestructive, so Herald asks Run "Decline"? first and only then sends the request.
The server received this body. The action field is the label of the button, and payload is the data you attached.
{
"notificationId": "bid-43",
"app": "example.bidbot",
"action": "Accept",
"payload": {"decision": "accept"}
}
Answer with a 2xx status once the work is done. Any other status keeps the banner on screen and shows Action
failed, so the user can try again. Herald waits 5 seconds and retries once after a temporary failure.
Callbacks has the timing, the retry rules and the answers Herald accepts.
Add a reply field#
A reply button turns the banner into a small form. The user types an answer and sends it, without opening any window. Use it when you need a sentence, not a choice.
-
Send a persistent notification with a
replybutton. The field needs the banner to stay on screen, so usepersistent.curl -s -X POST "$HERALD/v1/notify" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "app": "example.bidbot", "id": "bid-44", "title": "Counter-offer from Acme", "body": "What should we answer?", "persistent": true, "buttons": [{"label": "Reply", "reply": {"placeholder": "Message to Acme"}}] }' -
Press Reply on the banner.
The buttons give way to a text field with the placeholder Message to Acme, a Send button and a close button.
The reply field replaces the buttons inside the banner. The field shows the placeholder you sent. -
Type an answer and press Send.
The banner closes. Herald stores the answer on the notification in History and in the app's reply queue.
-
Read the answer. If your program can wait, ask Herald to hold the request until the reply exists.
curl -s "$HERALD/v1/replies/wait?app=example.bidbot&id=bid-44&timeout=60" \ -H "Authorization: Bearer $TOKEN"{ "replied": true, "reply": { "notificationId": "bid-44", "app": "example.bidbot", "text": "Counter at $4,000", "repliedAt": "2026-10-02T14:21:07.000Z", "title": "Counter-offer from Acme" } }If no one replies within the timeout, the answer is
{"replied": false, "timedOut": true}. The parameters are inGET /v1/replies/wait.To read the queue without waiting, use
GET /v1/replies.
There are other ways to receive a reply:
- An agent reads the same answer with the MCP tools
wait_for_replyandget_replies. - A program that runs a server can give the reply button a
callback. The typed text arrives aspayload.replyin the request. - Notifications that come through the cloud relay can carry a voice reply, where the banner records and transcribes the answer on the Mac.
See Cloud for the relay.
Add a command button#
A command button runs a shell command on the Mac as the user. Because that is powerful, Herald only runs a command from an app the user has trusted.
-
Register the app and say it wants to run commands.
curl -s -X POST "$HERALD/v1/register" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"app": "example.bidbot", "appName": "BidBot", "allowCommands": true}'Asking is not enough. The user still has to agree, in the next steps.
-
Send a notification with a
commandbutton.curl -s -X POST "$HERALD/v1/notify" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "app": "example.bidbot", "id": "bid-45", "title": "Report ready", "persistent": true, "buttons": [{"label": "Open report", "command": "open ~/Reports/bids.pdf"}] }' -
Press Open report.
The buttons give way to a question in the banner: Run this command for BidBot? The command is shown in a box, with Run once, Always allow BidBot and Cancel.
The question replaces the buttons. It shows the exact command, and Run once runs it this time only. -
Press Run once.
The command runs and the banner closes. Always allow BidBot turns on Allow this app to run commands, scripts and Shortcuts under Settings > Apps, so later presses run at once. Turn the switch off to ask again.
If the app never registered with allowCommands, the press fails with commands are not allowed for BidBot and
nothing runs. The command text is never edited. The notification's data arrives on standard input as JSON and in
HERALD_* environment variables, so a sender's text can never become part of a command line. See
What a process receives.
A script button, which names a file in Herald's scripts folder, and a shortcut button, which names an installed
Apple Shortcut, work the same way and ask under the same switch. The question reads Run this script for BidBot? or
Run this Shortcut for BidBot? and names the script with its SHA-256, or the Shortcut with its input text:
{"label": "Forward", "shortcut": "Forward to phone", "input": "{title}"}
Change the buttons without changing the sender#
You can change an app's buttons yourself, in the Designer, without asking the app to change. Open the template, choose the Actions tab, and you see the app's buttons followed by the ones you added. For each of the app's buttons you can:
- Hide it, rename it, or change its style.
- Move it, or give it an icon.
You can also add your own button: a link, a shell command, a script or an Apple Shortcut.
Code you add this way, such as a command, a script or a Shortcut, is confirmed once for the template the first time it runs. Herald asks again if the code changes. Settings > Actions lists the confirmations, with a Revoke button for each.
Designing a banner walks through the Designer. The rules and fields are in Action rules.
Run an action when nobody answers#
A follow-up runs one action when a banner is left unattended: nobody dismisses it, presses a button, replies or opens it for the time you set. Use it to forward a missed banner with a Shortcut. You can send one with a notification:
curl -s -X POST "$HERALD/v1/notify" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"app": "example.bidbot",
"id": "bid-46",
"title": "Bid accepted",
"persistent": true,
"followUp": {"after": "10m", "action": {"id": "fwd", "label": "Forward", "kind": "shortcut",
"shortcut": "Forward to phone", "input": "{title}"}}
}'
The action needs the same approval as an issuer's button of that kind: the app must be registered with allowCommands,
and you answer the question on the banner the first time. After it runs, the banner stays and shows
Follow-up ran: Forward. The timer, the approval and the record are in
Follow-ups. The whole task, from the Shortcut to the Designer, is in
Forward a notification you missed.
Check that it works#
Send the link notification from the first section and press its button. If the page opens and the banner closes, the button path works. For a callback, watch your server print the press. To see the buttons without a real send, use the Designer's preview, which shows every action the manifest declares.
If it does not work#
| Symptom | Cause | Fix |
|---|---|---|
The banner shows Action failed with no callback URL. | The button has no callback.url and the app has no callbackURL. | Register the app with a callbackURL. |
The banner shows Action failed with connection refused. | Nothing listens at the callback address. | Start your server, or correct the address. |
The banner shows Action failed with HTTP 500. | Your server answered with an error. | Fix the server. Herald retried once already. |
| The banner shows commands are not allowed for BidBot. | The app was not registered with allowCommands. | Register with allowCommands:, then press the button again. |
send_notification refuses a command, script or Shortcut button. | The MCP tool blocks code that runs on the Mac unless asked. | Pass allowCommandButtons:, or send the button through the API. |
A link button shows this link type is not allowed. | The link uses a scheme other than http, https or mailto. | Use one of those schemes. |
| The banner closes before the user can answer. | A timeout or the app's defaults closed it. | Send with "persistent": true. |
Related#
- Actions reference: every kind, field, rule, approval and failure.
- Manifests: declare an app's buttons once and offer them by id.
- Forward a notification you missed: a follow-up that forwards a banner to your phone.
- Notifications API: the button object a notification sends.
- Replies API: read what the user typed, and the callback request.
- App settings API: registration and per-app approvals.
- Designing a banner: the Designer and its Actions tab.