Saphan StudioDocs
Notifications

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.

  1. Create a Slack app in your own workspace.
  2. Add the two bot scopeschat:write and im:write. Add nothing else; the next section explains what you are declining and why it matters.
  3. Install the app to the workspace. You now hold a bot token.
  4. 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.
  5. 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.
  6. 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 is D…. ⛔ Not a U… — see below; this is the step that goes wrong.
  7. 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.

saphan config set notice.binding.person.sink slack
saphan config set notice.binding.person.destination D9876543210
saphan config set notice.binding.person.credentials_env SAPHAN_NOTICE_TOKEN
saphan config set notice.binding.person.locale en
saphan config set notice.binding.person.timezone Europe/Warsaw
saphan config set notice.binding.person.no_reply_line true
saphan config set notice.binding.person.note "Acts owed to you — you act in the console."

saphan config set notice.binding.ops.sink slack
saphan config set notice.binding.ops.destination C0123456789
saphan config set notice.binding.ops.credentials_env SAPHAN_NOTICE_TOKEN
saphan config set notice.binding.ops.locale en
saphan config set notice.binding.ops.timezone Europe/Warsaw
saphan config set notice.binding.ops.no_reply_line true
saphan config set notice.binding.ops.note "Notices only — decisions are made in the console."

saphan config set notice.binding.alerts.sink slack
saphan config set notice.binding.alerts.destination C9876543210
saphan config set notice.binding.alerts.credentials_env SAPHAN_NOTICE_TOKEN
saphan config set notice.binding.alerts.locale en
saphan config set notice.binding.alerts.timezone Europe/Warsaw
saphan config set notice.binding.alerts.no_reply_line true
saphan config set notice.binding.alerts.note "Delivery failures only — the other conversations are the ones that went quiet."

saphan config set notice.route.1.type owner.act_owed
saphan config set notice.route.1.binding person
saphan config set notice.route.2.type gate.signed
saphan config set notice.route.2.binding ops
saphan config set notice.route.3.type merge.landed
saphan config set notice.route.3.binding ops
saphan config set notice.route.4.type delivery.failed
saphan config set notice.route.4.binding alerts

saphan config set notice.sender.primary build-01.local

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.

  • person is a D…, one person's tray, and owner.act_owed is 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 by notice.route.<n>.actorowner.act_owed carries no actor for that key to match.
  • ops is the team channel, and it carries the two the fleet sends without asking anything of anyone: gate.signed and merge.landed.
  • alerts is 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:

saphan config show

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:

saphan notice tray

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:

saphan notice --help

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:

KeyWhat it holds
notice.binding.<name>.sinkthe kind of destination this binding delivers to
notice.binding.<name>.destinationwhere in that destination — for this sink, the C… or D… conversation id
notice.binding.<name>.formathow the notice is rendered for this destination
notice.binding.<name>.credentials_envthe name of the environment variable holding the token
notice.binding.<name>.credentials_filethe path of a file holding it instead
notice.binding.<name>.localewhich language catalogue renders it — see Languages and time
notice.binding.<name>.timezonethe local zone shown beside UTC, wherever a notice carries a time — not all of them do
notice.binding.<name>.no_reply_linetrue turns on the product's own line saying this conversation does not take replies
notice.binding.<name>.notea line of your own, in your own words
notice.route.<n>.typewhich notice type this route carries
notice.route.<n>.severity_minthe floor below which this route does not fire
notice.route.<n>.bindingwhich binding it delivers through
notice.route.<n>.actornarrows the route to one actor — on a type that carries one, and owner.act_owed does not
notice.sender.primarythe 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 keysink, 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):

saphan config set notice.sender.primary 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 hostname prints build-01.local, the id defaulted to the short build-01 while this key takes build-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 m1 and m2 has an m2 that still calls itself build-01.local, and m2 is 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 --config flag and no environment override for its location. So saphan config set writes 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_env must be set for the Saphan Studio process running on that host, or the path named by credentials_file must 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:

Slack showing that sending messages to this app is turned off

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:

PropertyWhat it means
One endpointThe sink talks to a single endpoint and nothing else. It does not discover destinations at run time.
Every redirect refused, by nameA 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 clientThe 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, everIts transport declares no proxy. Proxy environment variables do not redirect this traffic.
ok: false is not deliveryA 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.

On this page