/packs / slack

slack

Mentions and direct messages in a Slack workspace, answered in their threads.

  • integration
  • sources
  • Slack

As of yardr at commit 67389f7, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member, merges · lands.

01 / apply it

yardr pack apply slack

yardr carries this pack in its binary; the name is enough. yardr pack check slack shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/slack/

From this site, as the files below.

02 / the files

Every file, as it ships.

# Slack as a pack of kind source: the integration, imported into the
# yard and into no depot. What Slack is here: the mentions of a bot in a
# workspace and its one-to-one direct messages, heard through the Events
# API and answered in their threads with yardr reply.
#
#   yardr pack apply slack
#
# The source is named slack: yardr knows Slack as this pack alone.
# app-manifest.yaml is the Slack app as one document, for the operator to
# paste where Slack creates an app from a manifest; it is no declaration
# and yardr never reads it.
name: slack
kind: source
setup: SETUP.md

sources:
  slack:
    declaration: slack.yaml
# The yard's Slack app as one document, for the operator to paste where
# Slack creates an app from a manifest (api.slack.com/apps, Create New
# App, From a manifest), SETUP.md step 1. It names the bot user and the
# scopes, and deliberately no event subscriptions and no Request URL: a
# manifest that names events without a Request URL or Socket Mode does
# not validate, so the app is created before its hook address exists and
# the two bot events are subscribed in step 6, once the hook stands and
# Slack can verify its address.
#
# The scopes are the bot's alone, and only these six (section 3.7 step 1
# of docs/design/2026-10-17-yardr-pcxd-slack.md): app_mentions:read so
# the bot is told when it is mentioned, the four histories so
# conversations.replies reads a heard thread back on the bot token in
# each kind of conversation, and chat:write so it answers in the thread.
# mpim:history in particular is a reading scope only: it lets a heard
# group thread be read back, and no message.mpim event is ever
# subscribed, so it widens nothing that is heard.
_metadata:
  major_version: 1
display_information:
  name: yardr
features:
  bot_user:
    display_name: yardr
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - groups:history
      - im:history
      - mpim:history
      - chat:write
settings:
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false
# Setting up Slack

The pack wires Slack into the yard as the declared source `slack`: the
mentions of a bot in a workspace and its one-to-one direct messages,
heard through the Events API and answered in their threads with `yardr
reply`. A mention in a channel the bot is invited into lands in the
yardmaster's inbox; a one-to-one direct message lands whole, mentioned
or not. What lands is answered where it stood, in its thread. Group
direct messages are untested: whether Slack delivers a mention in one
as `app_mention` is checked in a live trial, and until then this walk
promises only channel mentions and one-to-one direct messages.

The yard side is cards the whole way: a crew member asks, and the
operator answers on the card, in footplate or in `yardr chat` (`ctrl+o`),
where a secret field shows dots. What is not a card is named as the
operator's: Slack's pages in a browser, the invitation in Slack itself,
and the networking that reaches the yard. The steps use `mckean` as the
operator's user name; replace it with the operator's user name in this
yard if it differs.

1. **The app, from the pack's manifest, in a browser.** At
   api.slack.com/apps, Create New App, From a manifest: paste
   `packs/slack/app-manifest.yaml` from a checkout of yardr (or from the
   pack as the yard installed it). The manifest names the bot user and
   the scopes (`app_mentions:read`, `channels:history`,
   `groups:history`, `im:history`, `mpim:history`, `chat:write` — the
   four histories are bot scopes, and they are what
   `conversations.replies` reads a thread back with on the bot token in
   each kind of conversation; `mpim:history` in particular is a reading
   scope only: it lets a heard group thread be read back, and no
   `message.mpim` event is ever subscribed, so it widens nothing that is
   heard), and carries no event subscriptions and no Request URL. A
   manifest that names events without a Request URL does not validate,
   so the events cannot stand in the document the app is created from
   while its hook address is not yet known; they are added in step 6,
   once the hook stands and Slack can verify its address. Install the
   app to the workspace. Two values Slack shows are copied from there
   into cards below: the signing secret (Basic Information) and, after
   install, the bot token `xoxb-…` (OAuth & Permissions). Making the app
   and installing it are the operator's in Slack's pages; no terminal is
   involved, and no card can do them, since the yard holds no Slack
   credential yet by which it could act.
2. **Apply the pack, on a card.** A crew member proposes it and the
   operator approves:
   ```sh
   yardr bead ask <bead> "Add Slack to the yard?" --run "pack apply slack" --context "The yard hears the workspace's mentions and direct messages through it."
   ```
   The card shows the declaration whole, as the apply card renders every
   field of it, the two scripts with their checksums among them.
3. **The bot token, on a secret card.**
   ```sh
   yardr bead ask <bead> "The Slack bot token" --secret slack --context "OAuth & Permissions in the Slack app, after install: xoxb-…"
   ```
   Typed unseen, written by the server to the source's env file as
   `SLACK_BOT_TOKEN`; in no note, as every secret ask.
4. **Whose token it is.**
   ```sh
   yardr source check slack
   ```
   The yardmaster runs it where it may make the read call, else mails
   the operator the command. It prints the selected fields of
   `auth.test`, one per line under the status line:
   ```text
   slack: check call whoami (POST /auth.test): 200 in …ms, … bytes
   slack: user_id: U…
   slack: team_id: T…
   slack: bot_id: B…
   ```
   The first is the id step 7 sets, a token Slack refuses fails the
   check here rather than at the first mention, and a check that prints
   no `user_id` line has not shown whose token it holds: setup stops
   there rather than guessing an id from elsewhere.
5. **The public hook address, on a card if it is not already set.**
   ```sh
   yardr bead ask <bead> "Where the yard is reached for webhooks" --run "settings set hook_public_url https://<his host>/hooks" --context "Slack posts the workspace's events to the hook's URL under it."
   ```
   The URL of the slack hook is then
   `https://<his host>/hooks/slack`. The address reaching the yard — a
   tunnel or funnel in front of `hook_addr` — is networking outside
   yardr, the operator's, and is the one piece of plumbing no card
   carries.
6. **The signing secret and the hook, on two cards, then the events in
   the browser.** First the secret:
   ```sh
   yardr bead ask <bead> "Slack's signing secret" --secret-hook slack --source slack --context "Basic Information in the Slack app."
   ```
   then the hook:
   ```sh
   yardr bead ask <bead> "Set up the Slack webhook?" --run "hook add slack --source slack --unknown unknown" --context "The yard takes the workspace's mentions and direct messages at POST /hooks/slack."
   ```
   `--unknown unknown` is the decided word, for the whole workspace: a
   sender no user maps is dropped and offers the operator a mapping
   card, until they are mapped or ignored. Unlike telegram there is no
   registration call for the yard to make on approval: Slack takes its
   Request URL only in its own configuration, so approving the hook
   stands the endpoint up and nothing is sent anywhere. Then, in the
   browser, the operator opens the app's Event Subscriptions, turns it
   on, pastes the hook's URL (`https://<his host>/hooks/slack`) as the
   Request URL, and subscribes to the two bot events, exactly these
   two:
   ```text
   app_mention
   message.im
   ```
   Never subscribe `message.mpim`: Slack delivers it for every group
   message, mentioned or not, so the yard would receive whole group
   conversations it was never addressed in, and each group mention
   would arrive a second time beside its `app_mention`. With the two
   above, a group direct message is heard only when it mentions the
   bot — once that live trial confirms Slack delivers it — while a
   one-to-one direct message is heard whole. Slack sends a
   `url_verification` challenge to the URL when it is saved; the yard
   answers it from the hook just made, and the app shows verified. This
   is the visit the manifest of step 1 deliberately left undone: only
   now does the app have a reachable Request URL to name beside its
   events, so the subscriptions validate. This paste is a step Slack
   leaves no API for; it is in a browser, not a terminal, and the
   browser is the operator's.
7. **The bot's identity, on a card.** With the `user_id` and the
   `bot_id` step 4 printed, so `@bot` is a mention of the yardmaster:
   ```sh
   yardr bead ask <bead> "Whose mention the bot's is" --run "crew identity yardmaster --source slack --id <user_id from step 4> --key <bot_id from step 4>" --context "The user_id and bot_id yardr source check slack printed in step 4."
   ```
   The key is the bot id because the echo of the bot's own post may
   carry only `bot_id`: with both set the yard drops its own posts
   before hearing, under any Unknown word, and offers no mapping card
   for them.
8. **The yardmaster's own credential, for answering, on a card.**
   `yardr reply` speaks with the answering crew member's copy of the
   bot token:
   ```sh
   yardr bead ask <bead> "The token it answers with" --secret slack --for-crew yardmaster --context "OAuth & Permissions in the Slack app, after install: xoxb-…"
   ```
   The yard's server writes it into the crew member's env file, or with
   none set into `~/.yardr/crew/yardmaster.env`, which is then set as
   `yardr crew set` sets it. The running session does not see the new
   value: it takes effect when it next starts, so restart it:
   ```sh
   yardr crew restart yardmaster
   ```
   Rotation rewrites both files, the source's and the crew member's.
9. **The subscription, and an invitation.** The yardmaster subscribes
   itself from its pane — an agent step, not an operator terminal —
   to mentions, and to one-to-one direct messages whole:
   ```sh
   yardr subscribe yardmaster slack --scope mentions --where type=message
   ```
   ```sh
   yardr subscribe yardmaster slack --scope all --where type=message --where room=dm
   ```
   `mentions`, not telegram's `all`: a workspace is many channels of
   other people's talk, and the bot is woken by being addressed, not
   by everything said where it stands. The operator then invites the
   bot into each channel it should hear (`/invite @<bot>` in Slack),
   without which Slack sends the channel nothing. A first mention is
   then the test: it lands in the yardmaster's inbox as
   ```text
   5210 10-17 18:02  hook (mentioned) slack message create by ada (U012AB3CD): <@U…> can the shed land on Monday?
   ```
   and `yardr reply 5210 -` answers in that thread, with the words on stdin
   (here: Noted; I will post here when it lands.). The thread is read back
   with `yardr thread show 5210` on this channel delivery too — the bot
   belongs to the channel it was invited into, and its token holds
   `channels:history` — and `yardr hook show 5210` holds the delivery itself
   meanwhile.
   The companion check in the same walk: a one-to-one direct message
   mentioning nobody still lands whole by the `room=dm` subscription.
   A group message mentioning nobody lands nowhere — Slack sends no
   event for it under step 6's two subscriptions — and a group mention
   is not tried here: group direct messages stay untested until the
   live trial.

Who is heard is what the subscriptions above carry, and who each
speaker is. The pack hears mapped members as themselves: a delivery
whose sender is a user's `slack` identity arrives named as them, and a
subscription may filter with `--where speaker=<user>`. An unmapped
sender is what the hook's word says: under step 6's `unknown` their
delivery is dropped and offers the operator one mapping card per source
and sender — a pick among the yard's users, each naming the identity it
writes, "someone new" named by the pick's words, and "nobody", who is
ignored — under `allow` it is heard as an outsider, and under `ignore`
it is dropped with no card. Picking nobody ignores that sender without
a second card; `yardr source show slack` and `yardr source ignore
slack` list the ignored ids, and `yardr source ignore slack --rm <id>`
hears them again. Three limits, where you meet them: a reply run again
after its answer was lost posts twice, since the service takes no
idempotency key; `yardr thread show` on a delivery whose conversation
the bot has left, or whose kind its token has no history scope for,
fails with Slack's reason rather than showing an empty thread; and
`yardr reply --to` stands with `thread show`, since it finds the
message to answer under by listing the thread. Edits and deletes in
direct messages arrive under `message.im` and are ignored, so a mention
edited after it was heard stands as it was heard. The bot's own posts
name it as actor where Slack sends them back, and never reach its
inbox: a delivery whose sender is the crew member's `slack` identity,
by its id or its key, is dropped before hearing, with no mapping card,
under any Unknown word.
#!/bin/sh
# A verified Slack Events API body to the yard's envelope: stdin
# {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
# internal/hooks feeds it the Events API bodies its tests recorded and
# compares the envelopes with the design's (sections 3.1 and 3.6 of
# docs/design/2026-10-17-yardr-pcxd-slack.md).
#
# Every body gets an envelope, an unknown event too: the yard subscribes
# to app_mention and message.im alone (SETUP.md step 6), so anything else
# is answered as type event and matches no subscription, and a non-zero
# exit would be answered 400, which the service retries. Nothing of the
# body is ever printed to stderr.
#
# Edits and deletes arrive through message.im as a message of subtype
# message_changed or message_deleted, carrying no top-level user or text
# (the edited message sits under message): they are answered as type
# event like anything unsubscribed, so neither is delivered as a phantom
# message nor offers a mapping card. A mention edited after it was heard
# stands as it was heard.
exec jq -c '
def obj: if type == "object" then . else {} end;
def str($v): if ($v | type) == "string" then $v elif $v == null then "" else "\($v)" end;
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. The jq
# POSIX space class includes the Unicode spaces split by Go strings.Fields.
def trunc: (gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def ordered: reduce .[] as $x ([]; if index($x) then . else . + [$x] end);
# mentions is the envelope'"'"'s shape for this source: the member ids a
# text mentions as <@U…>, once each, in order, exactly as written: ids
# match exactly. A |label some surfaces add is not part of the id, and
# channel (<#C…>) and link (<http…>) references mention nobody.
def mentions($text):
  ([$text | scan("<@([A-Z][A-Z0-9]+)(\\|[^>]+)?>") | .[0]] | ordered);

(.body | if type == "object" then . else error("slack event is not an object") end) as $b
| (($b.event | obj) | if (.type | type) == "string" then . else null end) as $e
| if $e != null and ($e.type == "app_mention" or $e.type == "message")
    and ($e.subtype != "message_changed" and $e.subtype != "message_deleted") then
    ($e.channel // "") as $channel
    | ($e.thread_ts // $e.ts // "") as $parent
    | (($e.text // "") | if type == "string" then . else "" end) as $raw
    | (if $raw != "" then $raw
       elif (($e.files // []) | length) > 0 then "[file]"
       else "[message]" end) as $text
    | {source: "slack", type: "message", action: "create",
       actor: {id: str($e.user // $e.bot_id)},
       thread: {id: "\($channel):\($parent)"},
       text: ($text | trunc), mentions: mentions($raw),
       message: str($e.ts), parent: str($parent),
       fields: {channel: [$channel], team: [str($b.team_id // $e.team)],
                sender: [str($e.user // $e.bot_id)],
                room: [(if $e.channel_type == "im" or ($channel | startswith("D")) then "dm" else "channel" end)]}}
  else
    {source: "slack", type: "event", actor: {}, thread: {}, mentions: []}
    | if $e != null and (($e.user | type) == "string") then . + {fields: {sender: [$e.user]}} else . end
  end
'
#!/bin/bash
# thread: what the yard does in a Slack thread: reply, posted, read. One
# JSON object on stdin, one on stdout, as the thread key of the declaration
# says. SLACK_BOT_TOKEN and YARDR_SOURCE_BASE, the declaration's base, are
# in the environment. Needs curl and jq.
#
# The thread a delivery carries is <channel>:<parent ts>, and every
# command reads the channel and the thread back from it: the one bot
# token carries the history scope for each kind of conversation, so a
# channel, a group-direct-message and a direct-message thread are read
# with the very token replies are posted with. chat.postMessage takes no
# idempotency key, so posted answers what there is to find, nothing, and
# a reply retried after a lost answer posts twice.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }

: "${SLACK_BOT_TOKEN:?SLACK_BOT_TOKEN is not set}"
: "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"

# api <method> <curl argument>...: the whole answer. The token goes to curl
# as a config line on its stdin, written by the shell itself: an argument
# can be read by every process on the machine while curl runs.
api() {
  local method=$1
  shift
  printf 'header = "Authorization: Bearer %s"\n' "$SLACK_BOT_TOKEN" \
    | curl -sS --fail-with-body -K - "$YARDR_SOURCE_BASE/$method" "$@"
}

# call <method> <body as JSON>: a write method, which Slack takes a JSON
# body for.
call() {
  api "$1" -H 'Content-Type: application/json' -d "$2"
}

# callform <method> <name=value>...: a read method, whose arguments Slack
# takes form-encoded. Slack's changelog of 1 October 2017 says a read
# method does not understand them as JSON, and conversations.replies sent
# a JSON body answered invalid_arguments, though its method page lists
# JSON among its content types. curl encodes each value, so a cursor's =,
# + and / reach Slack as they are.
callform() {
  local method=$1 pair
  shift
  local data=()
  for pair in "$@"; do data+=(--data-urlencode "$pair"); done
  api "$method" "${data[@]}"
}

# ok: whether the answer on stdin is a success; if not, Slack's reason on
# stderr. Slack answers ok:false with its reason under error where the
# bot may not do what was asked (not_in_channel, missing_scope): that is
# a refusal, never an empty thread.
ok() {
  local page; page=$(cat)
  jq -e '.ok == true' >/dev/null <<<"$page" \
    || { jq -r '"slack \(.error // "refused the call")"' >&2 <<<"$page"; return 1; }
}

case "$1" in
reply) # chat.postMessage in the thread: the channel and the thread_ts
  # are the thread's, or the parent's where the core found another
  # message to answer under (--to). The parent and the key go nowhere
  # else: the service takes no idempotency key.
  thread=$(arg thread)
  parent=$(arg parent)
  body=$(arg body)
  [ -n "$thread" ] || { echo "thread.reply was handed no thread" >&2; exit 1; }
  [ -n "$body" ] || { echo "thread.reply was handed no body" >&2; exit 1; }
  channel=${thread%%:*}; ts=${thread#*:}
  [ -n "$channel" ] && [ -n "$ts" ] && [ "$channel" != "$thread" ] \
    || { echo "thread.reply was handed no channel and thread: $thread" >&2; exit 1; }
  [ -n "$parent" ] || parent=$ts
  out=$(call chat.postMessage "$(jq -n -c --arg c "$channel" --arg t "$body" --arg p "$parent" \
    '{channel: $c, text: $t, thread_ts: $p}')") || {
    code=$?
    err=$(jq -r '.error // empty' <<<"$out" 2>/dev/null)
    echo "chat.postMessage to channel $channel failed${err:+: slack $err} (curl exit $code)" >&2
    exit 1
  }
  ok <<<"$out" || exit 1
  jq -c '{id: (.ts // "")} | if .id == "" then error("chat.postMessage answered no ts") else . end' <<<"$out" ;;
posted) # {} always: without an idempotency key there is nothing to find
  printf '%s' '{}' ;;
read) # conversations.replies for the thread's parent, every page: each
  # answer holds at most 1000 messages, and the thread is the pages
  # joined oldest first. A message's parent is its thread's, its own ts
  # where it is the thread's first. The channel, the ts and, from the
  # second page on, the cursor go form-encoded; the first page is asked
  # without one.
  thread=$(arg thread)
  [ -n "$thread" ] || { echo "thread.read was handed no thread" >&2; exit 1; }
  channel=${thread%%:*}; ts=${thread#*:}
  [ -n "$channel" ] && [ -n "$ts" ] && [ "$channel" != "$thread" ] \
    || { echo "thread.read was handed no channel and thread: $thread" >&2; exit 1; }
  cursor=""; all='[]'
  while :; do
    page=$(callform conversations.replies "channel=$channel" "ts=$ts" ${cursor:+"cursor=$cursor"}) || {
      code=$?
      err=$(jq -r '.error // empty' <<<"$page" 2>/dev/null)
      echo "conversations.replies for channel $channel failed${err:+: slack $err} (curl exit $code)" >&2
      exit 1
    }
    ok <<<"$page" || exit 1
    all=$(jq -c --argjson a "$all" '$a + (.messages // [])' <<<"$page")
    [ "$(jq -r '.has_more // false' <<<"$page")" = true ] || break
    cursor=$(jq -r '.response_metadata.next_cursor // ""' <<<"$page")
    [ -n "$cursor" ] || break
  done
  jq -c '{messages: [$all[] | {id: (.ts // ""), parent: (.thread_ts // .ts // ""),
    author: {id: (.user // .bot_id // "")},
    at: ((.ts // "0" | split(".")[0] | tonumber) | gmtime | strftime("%Y-%m-%dT%H:%M:%SZ")),
    body: (.text // "")}]}' --argjson all "$all" <<<null ;;
*) echo "unknown: $1" >&2; exit 2 ;;
esac
# Slack as a declared source: the mentions of a bot in a workspace and
# its one-to-one direct messages, heard through the Events API and
# answered in their threads. The design is
# docs/design/2026-10-17-yardr-pcxd-slack.md; section 3.1 is this file.
#
# Checked against api.slack.com on 17 October 2026: the API is HTTPS at
# https://slack.com/api/<method> with the bot's token (xoxb-...) as
# Authorization: Bearer; auth.test answers the bot's own ids (user_id,
# team_id, bot_id); chat.postMessage with channel, text and thread_ts
# answers in a thread, answering the posted ts; conversations.replies
# with channel and a parent ts lists the parent and its replies oldest
# first, cursor-paginated with has_more and response_metadata.next_cursor.
# A write method takes a JSON body (chat.postMessage here); a read method
# takes its arguments form-encoded (conversations.replies), since Slack's
# changelog of 1 October 2017 says read methods do not understand JSON
# attributes, and conversations.replies sent a JSON body answered
# invalid_arguments, although its page on docs.slack.dev lists JSON among
# its content types (read 3 November 2026).
# The yard subscribes to two bot events only, app_mention and message.im
# (SETUP.md step 6), so a group direct message is heard on a mention
# alone, by its app_mention, and message.mpim is never subscribed.
#
# There is no notify key in a first version: the wish is to write to the
# yard, not to be told by it. Hearing is by who speaks, matched against
# yardr user identity --source slack (the hear key below).
# There is no link or thread.state: a Slack thread has no status, and a
# channel message is no bead's link.
name: slack
about: Mentions and direct messages in a Slack workspace, answered in their threads.
base: https://slack.com/api
credential:
  env: SLACK_BOT_TOKEN            # the bot's token: the name, never the value
  header: "Authorization: Bearer {credential}"
calls:
  whoami:                         # yardr source check slack: whose token this is
    kind: read
    about: Who the bot token acts as (auth.test)
    request: { method: POST, path: /auth.test }
    answer: { select: [user_id, team_id, bot_id] }
  post:
    kind: update
    about: One top-level message to a channel (chat.postMessage); threaded answers go by thread.reply
    params:
      channel: { type: string, pattern: "^[CDG][A-Z0-9]+$" }
      text:    { type: string, max: 4000 }
    request:
      method: POST
      path: /chat.postMessage
      body: { channel: "{channel}", text: "{text}" }
check: whoami

# ---- what it sends us ----------------------------------------------------

hook:
  # Adapter.Verify stays data, checked by the core: the signing secret is
  # handed to no command, and an unverified body reaches none. Slack signs
  # the version, the timestamp and the body together over the literal
  # basestring v0:<timestamp>:<raw body>, with the timestamp in its own
  # header; a request more than five minutes from the receiver's clock is
  # refused. A retry repeats the event's own id, not the bytes alone.
  verify:
    scheme: hmac-sha256
    signed: "v0:{timestamp}:{body}"
    signature: { header: X-Slack-Signature, encoding: hex, prefix: "v0=" }
    delivery:  { body: event_id }
    timestamp: { header: X-Slack-Request-Timestamp, unit: s, window: 5m }
  bodies: repeat
  # Slack's deliveries follow chat activity, not a rhythm, so silence is never quiet.
  untimed: true

  # The url_verification handshake is answered by the receiver after
  # verification, before normalize: a verified body whose type is
  # url_verification is answered at once with its challenge, and records
  # nothing. An unsigned challenge is refused as any bad signature is.
  challenge: { body: type, value: url_verification, answer: challenge }

  # Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
  # envelope. The core sets source and delivery itself.
  normalize: [slack/normalize]

  # The keys its events' fields may carry: a subscription's --where may
  # name them beside type, action, team, project, repo and label. room is
  # dm for a one-to-one direct message and channel for everything else,
  # a channel of either kind and a group direct message alike, since no
  # mark in an app_mention reliably tells a group from a channel; it has
  # no group word, and none is promised.
  keys: [channel, team, sender, room]

# ---- who is heard ---------------------------------------------------------
# A delivery is kept when its sender is a user of the yard, matched by
# identity: fields.sender holds the member's id, which normalize also
# sets as actor.id. The core adds fields.speaker for a kept delivery
# (the yard user's name, or outside where the hook's --unknown word
# allows an unmapped sender); speaker is the derived filter key for
# this hear, and stands in no hook.keys above, which names only what
# normalize may set.

hear:
  field: sender
  by: identity

# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under SLACK_BOT_TOKEN, with YARDR_SOURCE_BASE from base
# above. The thread a delivery carries is <channel>:<parent ts>, and
# every command reads the channel and the thread back from it: the one
# bot token carries the history scope for each kind of conversation, so
# channel, group-direct-message and direct-message threads are read back
# with the very token replies are posted with, and no second credential
# is asked for, given or handed to any command. The shapes are in
# docs/integrations.md. chat.postMessage takes no idempotency key, so
# posted answers what there is to find: nothing.

thread:
  reply:  [slack/thread, reply]      # in  {thread, parent, body, key}      out {id}
  posted: [slack/thread, posted]     # in  {thread, key}                    out {} always
  read:   [slack/thread, read]       # in  {thread}                         out {messages: [...]}