Slack
The app and its two scopes, why the destination must be a conversation id, the three bindings and four routes to copy, which machine sends and where the token has to be, 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
Seven 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 you are declining and why it matters. - Install the app to the workspace. You now hold a bot token.
- Put the token in an environment variable, or in a file — and keep the value out of every configuration file, ticket and chat message. The product only ever learns the name of the variable or the path of the file.
- Invite the app to the conversations 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 notice that
asks a human to act, a team conversation for the two 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 four routes, and the sending machine — the block further down is copy-pasteable.
A short honest note about where this list comes from: it is the same sequence, in the same order, that the people who built this connector had to work through — including getting step 6 wrong on the first attempt. It is written down so you do not have to.
The app, and the two 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 and stores no credential for you.
It asks for two scopes, and it asks for no more than two. Between them they permit one thing: posting a message into a conversation it has been invited to. Nothing it holds permits reading a conversation, listing the workspace's channels, or seeing who said what.
That is worth more than a sentence of ours, because you can check it: the write-only guarantee on the section page is not something you have to take on trust from us — 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.
The two are chat:write and im:write. Select those, and nothing else.
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.
It is not requested, so the product cannot read them, whatever it or anyone else claims. That is why the reply guarantee further down this page is not a promise we ask you to believe: it is a capability we do not hold.
The token itself never goes into configuration. A binding holds either the name of an
environment variable (notice.binding.<name>.credentials_env) or the path of a file
(notice.binding.<name>.credentials_file) — the two keys are the pair the product offers.
Whose machine that name or path is read on is a question with one answer, and it has a
section of its own further down this page.
⚠ Which of the two you must set, and whether setting both is legal, is not printed here.
The examples below use credentials_env. If you want the file instead, set
credentials_file and let the refusal be your instrument if there is 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, 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.
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. Both are
destinations; the one you must not take is the person's U….
The bindings and the routes, to copy
Three bindings and four 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.
⛔ 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. It is the one exception, and the two
strings are the same only when hostname on that host already prints the short form and nobody
overrode the id — the build-01.local on the line above is a host where they differ, with
nothing overridden. 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, andowner.act_owedis the only thing routed to it. ⛔ That type is the one that asks a human for an act, and a request that arrives in a channel becomes everyone's and therefore nobody's. It is also why this route is narrowed by which binding it delivers through and not bynotice.route.<n>.actor—owner.act_owedcarries no actor for that key to match.opsis the team channel, and it carries the two the fleet sends without asking anything of anyone:gate.signedandmerge.landed.alertsis a third conversation used by nothing else, for the reason in the next paragraph.
⚠ On getting a D… in the first place, and this page will not pretend it is solved. Step
5 above — inviting the app — is a channel act, and this page does not state the equivalent
for a direct message, nor how you obtain a D… for a person who has never exchanged a message
with the app. If you cannot get one, 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
owner.act_owed does not land in the conversation everyone else is reading.
Four routes — and that is fewer routes than the product has types. The four in the block are the ones this page can describe; What goes out lists the rest, and it says plainly which of them it cannot yet describe.
⛔ A type no route names is an event the fleet has been told nothing to do with — it is
produced and delivered nowhere, silently. That is a real gap in the block above, named here
rather than left for you to find. To close it, add a route per type you want to receive,
using the same two lines as notice.route.4 with the next free number and a binding you have
already set.
⛔ delivery.failed goes to a conversation neither of the other two uses, and this is the
line to read twice. It is the message that tells you the routed notices stopped arriving. Sent
through a binding whose delivery just failed, it is a report that travels the path it is
reporting on — so it does not arrive either, and the silence you are left with is
indistinguishable from a quiet fleet. ⚠ This page does not state whether the product
refuses a delivery.failed route pointed at the binding it would be reporting on. Do not
rely on being stopped.
no_reply_line is a switch, not a sentence. It takes true, and what it turns on is the
product's own line saying the conversation does not accept replies. The sentence that is
yours goes in note — see
Languages and time for the five 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, and the
warning under If you reply to the message, further down this page, applies to every
conversation the product posts into.
And if nothing arrives at all — not even the failure notice — there are three places to look, in this order. First read the configuration back and confirm the bindings and routes you meant are the ones that are actually there:
⚠ What that command prints for a notice.* key is not stated on this site, and it is not
guessed at here. What is certain is the file it is printing from: the configuration is
~/.saphan/config on the machine you ran saphan config set on, in the format described
under Configuration, and reading it is the read-back that cannot be
wrong about its own contents.
⚠ A key does not appear in that file spelled the way you typed it here. The dotted name
splits into a section and a key, so notice.binding.person.sink is sink under
[notice.binding.person]. The key reference gives the rule and the
three sections these fourteen keys land in — that is what you are matching against when you
read the file back, not the lines you pasted.
Second, look on the machine named in notice.sender.primary, because that is the machine
that attempts the delivery. ⛔ Read that one key against the section below before you conclude
the configuration is right: it is the only key here that names a machine, and it does not name
it the way the rest of this site does.
Third, there is something to look at rather than nothing. This verb is read-only and changes nothing:
It lists the notices the fleet gave up on — the ones it stopped attempting to deliver. That makes two different silences tell you two different things: a notice you were expecting, sitting in that list, says the fleet produced it and could not get it out, so the fault is on the delivery side — the credential, the destination, the conversation the app was never invited to. Nothing in the list while nothing arrives sends you further back instead, to whether the event produced a notice at all: a type with no route is an event the fleet was told nothing to do with.
⛔ What it does not do is tell you that notice.sender.primary names the wrong machine.
Read it as a list of abandoned notices and nothing more; no output named on this site
diagnoses a wrong primary, which is why the section below spends so long on which string that
key takes. ⚠ This page still does not name a command that prints the outcome of an individual
delivery attempt, and it will not invent one.
⚠ You will not find this verb on the generated command reference, and that
absence is a fact about those pages rather than about your binary. They are generated from one
build's own help text, and the build the published ones were generated from does not carry this
verb: saphan notice has no row on every command and no page of its
own beneath it. The only instrument that settles which verbs your build has is the build — ask
it, on the host you are standing on:
⛔ If that comes back as an unknown-command refusal, read it as an answer about your build: this build does not carry the verb, so the third place above is not a place you have to look — there is nothing there for you. Which build first carries it is not stated anywhere on this site; that is a question for whoever supplied your binary.
Every key that exists on a binding and on a route:
| Key | What it holds |
|---|---|
notice.binding.<name>.sink | the kind of destination this binding delivers to |
notice.binding.<name>.destination | where in that destination — for this sink, the C… or D… conversation id |
notice.binding.<name>.format | how the notice is rendered for this destination |
notice.binding.<name>.credentials_env | the name of the environment variable holding the token |
notice.binding.<name>.credentials_file | the path of a file holding it instead |
notice.binding.<name>.locale | which language catalogue renders it — see Languages and time |
notice.binding.<name>.timezone | the local zone 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 take replies |
notice.binding.<name>.note | a line of your own, in your own words |
notice.route.<n>.type | which notice type this route carries |
notice.route.<n>.severity_min | the floor below which this route does not fire |
notice.route.<n>.binding | which binding it delivers through |
notice.route.<n>.actor | narrows the route to one actor — on a type that carries one, and owner.act_owed does not |
notice.sender.primary | the sending machine's operating system hostname — not its registry machine id, and not the name of a binding. The one exception on this site, explained in the next section |
⚠ Where this page does not print the accepted values for a key — sink, format and
severity_min — set it and read the refusal. Unknown keys and invalid values are refused
with the full accepted vocabulary in the error, which is the same behaviour every other
configuration key has and is documented under Configuration.
⛔ If saphan config set refuses one of these fourteen keys as unknown, read the refusal
as an answer about your build, not as a typo. The error message lists the keys the binary
you are holding accepts, and that list — not this page and not the
key reference — is the authority on what your build will take.
You will see them missing from one other place on this site: the Known keys: block on the
generated command reference was produced from one build's own
help and does not list them. ⚠ Which release first accepts these keys is not stated
anywhere on this site, and this page does not guess at it. So if the refusal names a list
with no notice.* in it, what you have learned is that this build does not take them —
which build does is a question for whoever supplied your binary.
Whose machine sends, and whose environment holds the token
notice.sender.primary names the machine that sends, and it takes that machine's operating
system hostname — the name the machine reports for itself, which is what hostname prints
when you run it on that host. Take that string exactly as printed, 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, and it is the
exception because the operating system is the thing being asked. 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, the one that heads
a [host.<id>] section in the key reference, and the same string
that names the stanza described under SSH host keys. This
key does not take that id.
⚠ The two strings coincide in one narrow case only, and it is not the common one. A machine's
id defaults to that host's own short hostname when it is admitted — the bootstrap page shows
the prompt offering it — so the id and this key are the same
string only when hostname on that host already prints the short form and nobody
overrode the id. Two ordinary situations part them, and only one of them involves anybody having
overridden anything:
- A dotted hostname, with every default accepted. On a host whose
hostnameprintsbuild-01.local, the id defaulted to the shortbuild-01while this key takesbuild-01.local. Nothing was overridden — the id takes the short form by construction and this key takes what the operating system prints. 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.
⛔ Getting it wrong is silent, and that is the whole reason this page spends four paragraphs
on one string. Nothing on this site says a hostname naming no machine you run is refused when
you set it, and nothing here prints the outcome of a delivery afterwards — so the failure you
get is that the notices do not arrive, and neither does the notice about the notices:
delivery.failed is sent by the same sender. Read the string off the machine rather than off
your own memory of what you called it.
Read it together with one fact from Configuration, because the two together answer the question every deployment eventually asks — where does the token have to be?
- The configuration is one file per operator, on one machine:
~/.saphan/config. There is no--configflag and no environment override for its location. Sosaphan config setwrites on the machine you are logged into, and a binding is only in effect on a machine whose own file carries it. - Put both on the sending machine. Set the notice keys on the host you named as primary,
and place the credential there: the environment variable named by
credentials_envmust be set for the Saphan Studio process running on that host, or the path named bycredentials_filemust exist on that host's disk. Neither the variable's value nor the file's contents ever enter the configuration — the configuration holds only the name and the path. - 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.
⚠ Two things this page does not tell you, named rather than guessed at: which Saphan Studio process on that host performs the delivery, and what a machine that is not the primary does with a notice. The half you can act on is the positive one — the credential goes where the primary is.
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 a single endpoint and nothing else. It does not discover destinations at run time. |
| Every redirect refused, by name | A redirect response is not followed. It is refused, and the refusal says which one it was — 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. |
ok: false is not delivery | A response of {"ok": false} carrying no error is treated as a failure, not as a delivered message. A reply that says nothing succeeded is never read as success. |
The fleet has a notice type of its own for a delivery that fails — delivery.failed, listed
with the others under What goes out.