Saphan StudioDocs
Notifications

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.

  1. Create a Slack app in your own workspace.
  2. Add the two bot scopes — chat:write and im:write. Add nothing else; the next section explains what each one is for and what you are declining.
  3. Install the app to the workspace. You now hold a bot token.
  4. 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.
  5. 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.
  6. 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 is D…. ⛔ Not a U… — see below; this is the step that goes wrong.
  7. Set the three bindings, the eight routes, and the sending machine — the block further down is copy-pasteable.
  8. 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:write is the scope that call needs.
  • im:write is 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 the D… id of that conversation (see Getting a D… 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.

WhatWhere
The filea 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 filecredentials.file — an absolute path. There is no default: a configuration that names a credential and declares no file is refused.
Which entrynotice.binding.<name>.credential — the NAME of the entry, never the token. Names are letters, digits and _, not starting with a digit.
{
  "SAPHAN_NOTICE_TOKEN": "xoxb-…"
}

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.

saphan config set credentials.file /home/you/.saphan/etc/credentials.json

saphan config set notice.binding.person.sink slack
saphan config set notice.binding.person.destination D9876543210
saphan config set notice.binding.person.credential 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.credential 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.credential 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 seat.hard_logout
saphan config set notice.route.2.binding person
saphan config set notice.route.3.type silence.refusal_unrepaired
saphan config set notice.route.3.binding person
saphan config set notice.route.4.type silence.verdict_missing
saphan config set notice.route.4.binding person
saphan config set notice.route.5.type gate.signed
saphan config set notice.route.5.binding ops
saphan config set notice.route.6.type merge.landed
saphan config set notice.route.6.binding ops
saphan config set notice.route.7.type silence.terminal_no_successor
saphan config set notice.route.7.binding ops
saphan config set notice.route.8.type delivery.failed
saphan config set notice.route.8.binding alerts

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

⛔ 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.

  • person is a D…, 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 for seat.hard_logout, the most disclosing type — What goes out says what it carries. The owner.act_owed route is narrowed by which binding it delivers through and not by notice.route.<n>.actor — that type carries no actor.
  • ops is the team channel, and it carries the three that report without asking: gate.signed, merge.landed and silence.terminal_no_successor.
  • alerts is 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:

saphan config show

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 namesWhat it means
notice-sender-primary-undeclaredroutes exist and notice.sender.primary does not; nothing is sent. The line prints the exact command to declare this machine.
notice-sender-not-primarythis 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 themno binding could be built into a sender — the reason follows on the line.
considered, attempted, delivered, faileda pass that tried, with any refusals named at the end of the line.
GAVE UPa 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:

saphan notice tray

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:

KeyWhat it holds
notice.binding.<name>.sinkthe kind of destination — slack, the only one there is
notice.binding.<name>.destinationthe C… or D… conversation id
notice.binding.<name>.formathow the notice is rendered — plaintext, the only one there is; optional
notice.binding.<name>.credentialthe name of the token's entry in the credentials file
notice.binding.<name>.localewhich language renders it — see Languages and time
notice.binding.<name>.timezonean IANA zone name, 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 read replies; false or absent leaves it off
notice.binding.<name>.notea line of your own, in your own words
notice.binding.<name>.deliver_fromhow far back the binding's history reaches: now (default), all, or an RFC 3339 instant
notice.route.<n>.typewhich notice type this route carries; required
notice.route.<n>.bindingwhich declared binding it delivers through; required
notice.route.<n>.severity_minthe lowest severity it carries: info, notable, red or urgent, in that order; omit it to carry every severity
notice.route.<n>.actornarrows the route to one actor; omit it to match every actor — and owner.act_owed carries none
notice.sender.primarythe 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):

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. 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 hostname prints build-01.local, the id defaulted to build-01 while this key takes build-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 m1 and m2 has an m2 that still calls itself build-01.local, and m2 is 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:

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 the vendor's API endpoint and nothing else. It does not discover destinations at run time.
Every redirect refused, by nameA 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 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.
No confirmation is not deliveryA response that carries no error and no evidence of a posted message is treated as a failure, not as a delivered message.
Bounded retriesA 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.