Slack
The app and its scopes, why the destination must be a conversation id, where the token goes, the three bindings and eight routes to copy, which machine sends, and what the delivery path refuses.
This page is the setup, end to end, plus the two things people ask afterwards: what happens if they reply, and what exactly the product talks to.
Setting it up, step by step
Eight steps. Each one ends with something you hold or something you have done, so you can stop between any two and know where you are.
- Create a Slack app in your own workspace.
- Add the two bot scopes —
chat:writeandim:write. Add nothing else; the next section explains what each one is for and what you are declining. - Install the app to the workspace. You now hold a bot token.
- Put the token in the installation's one credentials file, on the sending machine — and keep the value out of every configuration file, ticket and chat message. The configuration only ever holds the name of the entry; see Where the token goes below.
- Invite the app to the channels it should post in. An app that is not in a channel cannot post to it, and the error you get back if you skip this does not obviously say so.
- Copy each conversation's id. You want three: one person's tray for the notices that
ask a human to act, a team conversation for the ones that ask nothing, and a third for the
message that says the notices stopped arriving — the section that carries the block
explains why all three are separate. A channel is
C…, a direct message isD…. ⛔ Not aU…— see below; this is the step that goes wrong. - Set the three bindings, the eight routes, and the sending machine — the block further down is copy-pasteable.
- Read it back with
saphan config show, and look in the tray (saphan notice tray) after the first event you expected to hear about.
The app, and its scopes
The app lives in your workspace. You create it, you install it, and you hold its token — the product ships no app of its own.
The product makes one call and one call only: it posts a message (chat.postMessage) into
the conversation a binding names. It never reads a conversation, lists channels, opens a
conversation or looks anyone up.
chat:writeis the scope that call needs.im:writeis what lets the app open a direct-message conversation with a person. The product never does that itself; it is there so that you can obtain theD…id of that conversation (see Getting aD…below). If every destination you use is a channel, you can leave it out.
⚠ The product does not inspect the scopes your app holds. The write-only guarantee on the section page rests on what the product calls, and you can check its second half yourself: it is visible in the app's own scope list, in your workspace, granted by your hand. An app that cannot read cannot act on a reply, whatever any documentation says.
And here is the scope it does not ask for, which is the more useful fact: im:history.
That is the scope an app needs to read a direct message. An inbound path — answering a
reply, running a slash command, listening for events — needs it, and granting it would let
this product read your direct messages. Do not grant it. That is why the reply guarantee
further down this page is not a promise we ask you to believe: it is a capability you never
handed over.
Where the token goes
The token lives in one credentials file for the installation, and a binding names its entry in that file. Nothing else is read: ⛔ the process environment is not consulted for a credential, and a binding has no file of its own.
| What | Where |
|---|---|
| The file | a JSON object of "NAME": "value" pairs, mode 0600 (owner-only). The usual place is credentials.json beside the configuration file, for example ~/.saphan/etc/credentials.json. |
| Which file | credentials.file — an absolute path. There is no default: a configuration that names a credential and declares no file is refused. |
| Which entry | notice.binding.<name>.credential — the NAME of the entry, never the token. Names are letters, digits and _, not starting with a digit. |
The file is read and checked every time the configuration is loaded — not when the first
notice is sent. A file with the wrong mode (credentials-file-mode), a file that is not a JSON
object, or a binding naming an entry the file does not hold (credentials-name-unknown) makes
the configuration refuse to load, by name. So put the file in place before you name its
entry.
⛔ If you have an older configuration with notice.binding.<name>.credentials_env or
credentials_file: both are retired and refused as credentials-key-retired, with the
command that replaces them in the refusal. Move the token into the one file and delete the old
one.
The destination is a C… or a D…, never a U…
notice.binding.<name>.destination takes a conversation id, and there are two kinds:
C…— a channel. Use this for a team channel everyone reads.D…— a direct message conversation. Use this for one person's tray.
A user id — the identifier beginning with U — is refused at the moment you set
it (binding-destination-user-id), and it is the one people reach for first.
⛔ Why the refusal, when a U… looks like it would work: the chat vendor's own
documentation gives conflicting answers about where a message addressed to a U… is
delivered, and a successful API response does not settle the conflict. You would get an ok
back and still not know which conversation the notice landed in — or whether the person you
had in mind ever saw it.
So the product refuses the ambiguous identifier where you can still fix it, at set time,
rather than accepting it and leaving you to discover at delivery time that "sent" and
"arrived where you meant" were different facts. An empty destination is refused too
(binding-destination-empty).
To find the right one: open the conversation you want the notices in and take that
conversation's own id — C… for a channel, D… for a direct message.
Getting a D…
A D… is the id of the direct-message conversation between the app and that person. The
vendor's conversations.open method, called with the app's token and the person's U…,
returns it — that is the call im:write permits. The U… goes into that call, and the D…
it returns goes into the binding.
⚠ The product does not make that call for you, and this site does not walk through making
it. If you cannot get a D…, use a channel that only that person reads: the property the
arrangement depends on is not that the destination is a D…; it is that the notices asking for
an act do not land in the conversation everyone else is reading.
The bindings and the routes, to copy
Three bindings and eight routes, because both numbers are load-bearing and the paragraphs
under the block say why. Replace person, ops and alerts with whatever you want the
bindings called, and every value with yours.
⛔ Every notice type must have a route, or the configuration does not load. Once any route
exists, a configuration that leaves a type the product knows of routed nowhere is refused as
route-type-unrouted, naming the missing types — by saphan config show, by the server daemon
and by every other command except saphan config set itself, which keeps working so you can
finish. That is why the block routes every type listed on
What goes out, and why you should paste the route lines in
one sitting. A route naming a binding that is not declared is refused the same way
(route-binding-undeclared).
⛔ The last line is the one to stop at. notice.sender.primary takes the sending machine's
operating system hostname — what hostname prints on that host — and not the registry
machine id every other part of this site names a machine by. The section Whose machine
sends below is where that is spelled out.
Three bindings, because three conversations are doing three different jobs.
personis aD…, one person's tray, and it carries the types that ask something of a human: an owed act, a seat that has been logged out, and the two stalls that will not clear on their own. ⛔ A request that arrives in a channel becomes everyone's and therefore nobody's. It is also the safest home forseat.hard_logout, the most disclosing type — What goes out says what it carries. Theowner.act_owedroute is narrowed by which binding it delivers through and not bynotice.route.<n>.actor— that type carries no actor.opsis the team channel, and it carries the three that report without asking:gate.signed,merge.landedandsilence.terminal_no_successor.alertsis a third conversation used by nothing else, for the reason in the next paragraph.
The split is a starting point, not a rule of the product: two routes may name the same type with different bindings, which is how one notice reaches both a channel and a person.
⛔ delivery.failed goes to a conversation no other route uses, and this is the line to read
twice. It is the message that tells you the routed notices stopped arriving. The product will
not send a delivery.failed through the very binding whose delivery failed — that pairing is
skipped at delivery time and the daemon's log names it route-delivery-failed-loop — so if
delivery.failed shares a binding with other types, a failure on that binding is reported
nowhere. Nor is a failed delivery.failed itself reported again. ⚠ None of this is
refused when you set the route; giving delivery.failed a binding of its own is what makes it
arrive.
no_reply_line is a switch, not a sentence. It takes true or false, and true turns
on the product's own line saying the conversation does not read replies. The sentence that is
yours goes in note — see
Languages and time for the rules it must obey. The
block sets both on all three bindings, deliberately: whoever reads the failure conversation
is as likely to reply to a notice as whoever reads the team channel.
What a new binding receives first. By default a binding starts at the moment the sender
first considers it, and never carries the backlog of undelivered notices that predates it.
notice.binding.<name>.deliver_from changes that: now (the default, spelled out), all
(every undelivered notice in the record, however old), or an RFC 3339 instant with a zone such
as 2026-10-01T09:00:00Z for a bounded backfill.
Reading it back, and when nothing arrives
First, read the configuration back:
It prints every notice.* key under its dotted name — each binding, each route in your own
numbering, and notice.sender.primary — with credential as the name of the entry and
note in full. If it refuses instead, the refusal names what is wrong: an unrouted type, an
undeclared binding, a credentials file it cannot use. The file itself is the configuration file
described under Configuration; in it, a dotted name splits into a
section and a key, so notice.binding.person.sink is sink under [notice.binding.person].
Second, look at the server daemon's log on the sending machine. On every pass, the daemon on each machine either sends or says why it did not:
| The log line names | What it means |
|---|---|
notice-sender-primary-undeclared | routes exist and notice.sender.primary does not; nothing is sent. The line prints the exact command to declare this machine. |
notice-sender-not-primary | this machine is not the declared sender; it names the one that is. If every machine says this, notice.sender.primary names a machine you do not run. |
… route(s) declared and NOTHING can deliver them | no binding could be built into a sender — the reason follows on the line. |
considered, attempted, delivered, failed | a pass that tried, with any refusals named at the end of the line. |
GAVE UP | a notice and a binding the sender stopped trying, after its last attempt. |
Every notice stays in the record while it is undelivered; a machine that is not the sender loses nothing by declining.
Third, look in the tray. This verb is read-only, changes nothing, and answers even when every binding is down:
It lists every notice-and-binding pair the sender gave up on — after five attempts, waiting
30 seconds after the first failure and doubling each time. It exits 2 when the tray holds
anything and 0, saying so, when it is empty; --json prints it as JSON. See
the command reference.
A notice you were expecting, sitting in the tray, says the fleet produced it and could not get
it out — the fault is on the delivery side: the credential, the destination, the conversation
the app was never invited to. An empty tray while nothing arrives sends you back to the
daemon's log: the sender may not be running where you think, or the binding may have started
after the event (see deliver_from above).
Every key that exists on a binding, a route and the sender:
| Key | What it holds |
|---|---|
notice.binding.<name>.sink | the kind of destination — slack, the only one there is |
notice.binding.<name>.destination | the C… or D… conversation id |
notice.binding.<name>.format | how the notice is rendered — plaintext, the only one there is; optional |
notice.binding.<name>.credential | the name of the token's entry in the credentials file |
notice.binding.<name>.locale | which language renders it — see Languages and time |
notice.binding.<name>.timezone | an IANA zone name, shown beside UTC wherever a notice carries a time — not all of them do |
notice.binding.<name>.no_reply_line | true turns on the product's own line saying this conversation does not read replies; false or absent leaves it off |
notice.binding.<name>.note | a line of your own, in your own words |
notice.binding.<name>.deliver_from | how far back the binding's history reaches: now (default), all, or an RFC 3339 instant |
notice.route.<n>.type | which notice type this route carries; required |
notice.route.<n>.binding | which declared binding it delivers through; required |
notice.route.<n>.severity_min | the lowest severity it carries: info, notable, red or urgent, in that order; omit it to carry every severity |
notice.route.<n>.actor | narrows the route to one actor; omit it to match every actor — and owner.act_owed carries none |
notice.sender.primary | the sending machine's operating system hostname — exactly one; a list is refused |
A route's <n> is a number; a name there is refused (route-index-not-a-number). Four further
matcher keys — machine, stream, tier and workspace — are refused by name as
route-matcher-deferred: they are not implemented, and the refusal exists so that a key that
filters nothing never looks like a key that is filtering. Any other key is refused with the
accepted ones listed in the error.
Whose machine sends
The notices are sent by the saphan-server daemon on one machine: the one whose operating
system hostname equals notice.sender.primary. Take that string exactly as hostname prints
it on that host, whether it comes back short (build-01) or dotted (build-01.local):
⛔ This one key is the exception to how the rest of this site names a machine. Everywhere
else a machine is named by its registry machine id — the short string your fleet admitted
it under, the one the machine commands take as --machine, and
the one that heads a [host.<id>] section in the key reference.
This key does not take that id.
⚠ The two strings coincide only when hostname already prints the short form and nobody
overrode the id. A machine's id defaults to that host's short hostname when it is
admitted — the bootstrap page shows the prompt offering it.
Two ordinary situations part them:
- A dotted hostname, with every default accepted. On a host whose
hostnameprintsbuild-01.local, the id defaulted tobuild-01while this key takesbuild-01.local. That is the case the copy block above ships as its worked value. - An overridden id. A fleet whose machines are admitted as
m1andm2has anm2that still calls itselfbuild-01.local, andm2is not the string this key takes.
A hostname naming no machine you run is not refused when you set it. What you get instead
is the daemon log line notice-sender-not-primary on every machine — which is why the log is
the second place to look above. Read the string off the machine rather than off your memory
of what you called it.
And the credentials file has to be readable on that same machine. The daemon reads its own configuration and the credentials file it names, on its own host. So:
- Put both on the sending machine. Set the notice keys in the configuration the daemon on
the primary host reads, and place the credentials file on that host's disk, mode
0600. - The failure this ordering prevents is the quiet one: set everything on your laptop, and every read-back on that laptop shows exactly what you intended — on a machine that is not the one sending. A configuration that reads back correctly is not evidence that it reads back on the right host.
If you reply to the message
Nothing happens. Not "nothing yet" — nothing.
The honest sentence is: the app cannot read your reply; that is the guarantee, not a gap. Your reply is not delivered to anyone in the fleet, is not stored in the record, and does not appear in any audit trail. Nobody is notified that you wrote it. If the notice told you a human act is owed, the act is owed until a human performs it at the console or on the command line, and a reply in the conversation moves nothing.
⚠ This is worth saying to the people who will read the notices before you turn it on. The first person to type "ok, approved" under a notice will assume it counted.
And you do not have to take our word for any of it. Slack says it itself, in the conversation, above the box you would type into:

That line is not ours. It is the chat vendor reporting the app's own configuration back to the person about to type — the guarantee, enforced one layer below anything we wrote.
What the delivery path does, and what it refuses
For whoever has to sign off on outbound network behaviour:
| Property | What it means |
|---|---|
| One endpoint | The sink talks to the vendor's API endpoint and nothing else. It does not discover destinations at run time. |
| Every redirect refused, by name | A redirect response is not followed — the token travels in the request body, and a redirect that replayed the body would hand it to another host. It is refused, and the refusal names where it pointed, so a redirected delivery is a failure you can see, never a delivery to somewhere you did not choose. |
| Its own HTTP client | The sink uses a client of its own rather than the process-wide one, so nothing configured elsewhere in the process can change where or how it connects. |
| No proxy, ever | Its transport declares no proxy. Proxy environment variables do not redirect this traffic. |
| No confirmation is not delivery | A response that carries no error and no evidence of a posted message is treated as a failure, not as a delivered message. |
| Bounded retries | A failed delivery is retried up to five attempts in all, 30 seconds after the first failure and doubling each time; then it is given up, listed in the tray, and reported as delivery.failed. |
The fleet's notice type for a delivery that fails — delivery.failed — is listed with the
others under What goes out.
Notifications
One-way notices from your fleet to a chat destination: what they cost to turn on, and what they deliberately cannot do.
What goes out
Every notice type, when each is sent and how severe it is, what a notice carries, the rule that the body of the work never travels, and how routes choose what goes where.