Skip to content

Disciple — the second surface

The chat is a member typing a question at the bot. This is the bot reading a post somebody wrote for other humans and, when it has something useful, commenting under it.

The answering is not duplicated. Both surfaces call the same ask.answer — the same safety gate, the same confidence floor, the same model, the same validation. Two surfaces that answer differently are two things to reason about, and the second one is always the one nobody checks.

What is built, and what is waiting

Everything except an API key and two things their documentation does not say. The code runs, is tested, and refuses to pretend: with DECIPLE_DISCIPLE_BASE_URL empty the adapter is simply off, and the rest of the system is unaffected.

It was first written against guesses, then corrected against Disciple's public documentation at https://api-docs.disciplemedia.com. Three of the guesses were wrong, and the corrections are the reason this section changed:

Guessed Documented What the guess would have cost
HMAC-SHA256 signature HMAC-SHA1 Every genuine delivery rejected as a forgery
Post text in body text Empty post, never commented on, no error anywhere
Author is a name An object, {id, email, display_name} Author always empty; the bot could answer itself
Comment text in body text Every comment refused
No author on a comment author_id required Every comment refused

Confirmed as guessed: POST /posts/{id}/comments, Authorization: Bearer …, and the X-Disciple-Signature header.

Piece State
Post shape, payload mapping Built, corrected against the docs
Webhook endpoint, signature check, fast ack Built, SHA1 per the docs
Answering once, despite retries Built and tested
Spam / low-value filter Built — the criteria need agreeing
Same /ask path for both surfaces Built and tested
Posting a comment Built, matches the documented endpoint and body
Reading a post back Built — no such endpoint is documented; nothing calls it
Toward Health brand on the chat Already done in Week 2

What the bot does with a post

Four outcomes, and only one of them speaks in public.

Outcome On the chat Here
A lesson fits Serves it Comments with the sentence, lesson, link and disclaimer
Off topic "That's outside what I cover" Says nothing
Needs a person "I've passed this to the team" Says nothing, emails the team
Crisis Answers with resources immediately Says nothing, emails the team — see the open question below

The difference is that here the bot was not asked. It is choosing to speak underneath somebody's post, in front of the community, so the bar is not "do I have something to say" but "is this worth saying in public". Posting "that's outside what I cover" under a member's update is noise; saying nothing is not.

Silence is recorded, never merely done. Every post gets a row in disciple_replies naming what happened, including the ones that were ignored, because nobody ever reports a comment that failed to appear.

Answering once, not twice

Webhooks are delivered at least once. Every sender retries when it does not get a prompt 2xx, which means the same post arrives again exactly when we are slow, restarting, or briefly broken — the moments a second reply is most likely and least wanted. On the chat a duplicate is a duplicate; here it is the bot commenting twice under a member's post, in public.

So the row is inserted first and the insert either succeeds or violates the primary key. The delivery that wins owns the post; the one that collides stops. Checking "have we answered this?" and then answering is two steps with a gap, and two deliveries can both pass the check before either records anything. The database is the only thing here that can decide atomically.

The cost, stated plainly: a claim taken by a delivery that then crashes is never released, and that post is never answered. Silence is the right way to fail here — a missed comment is invisible, a doubled one is not.

The filter

It drops posts where commenting is obviously wrong rather than merely unhelpful: empty, under four words, promotional phrasing, any link, and — first and most important — anything the bot itself wrote.

It deliberately does not decide whether a post is a question. The brief says "skip non-questions"; the check-in notes say the bot should respond to course reviews, member achievements and expressions of concern by pointing at a lesson. Most of those are not questions. A question-mark rule would drop exactly the posts the bot exists to engage with, and would do it silently.

Topical fit is left where it already works. A post the knowledge base cannot serve falls below DECIPLE_SCOPE_FLOOR, which is calibrated against measured scores; a question-mark heuristic would not be.

Agreed with the mentor on 2026-09-11, so this is settled rather than pending. test_a_post_does_not_have_to_be_a_question holds the line: a change that makes it fail is a change to the decision, not to the code.

Open questions

Each one is used in exactly one place in disciple.py, so each is a one-line correction.

  1. Crisis posts — should the bot ever reply in public? The most important question here, and not a technical one. Today it stays silent and emails the team immediately. The alternative is a public comment with crisis resources, which reaches a distressed member in seconds rather than whenever somebody opens the inbox — but it also replies to a distressed member's post with a bot, in front of everyone. That is a community decision.
  2. The exact X-Disciple-Event string for a new post. The docs name the event "New Post" but do not print the header value. POST_EVENT is new_post and STRICT_EVENT is off, so an unrecognised event is logged and handled anyway rather than dropped — a wrong guess with strict on would silently discard every real post, which is the worse of the two failures. Confirm the string, set STRICT_EVENT = True, and comment deliveries stop at the door instead of relying on the author check behind it.
  3. Whether the signature is bare hex or sha1= prefixed. Their docs cite GitHub's scheme, which prefixes it; no example delivery is shown. Both are accepted, so this needs no answer to work — it is here so that whoever sees a real delivery can delete the branch that turned out to be unused.
  4. Is there an endpoint to read one post back? None is documented. fetch_post still assumes GET /posts/{id} and nothing calls it: the webhook payload carries the post's full text, so the bot has never needed a second round trip. Delete it or confirm it — do not leave it as the one guess that looks confirmed because it sits beside four that are.
  5. Rate limits on commenting, and whether a bot account is subject to different ones than a member.
  6. The portal subdomain and the bot's author id. Both are values rather than questions, but nothing works without them: the base URL is https://<portal>.disciplemedia.com/v1, and DECIPLE_DISCIPLE_BOT_AUTHOR is the numeric id of the account the bot comments as.

Seeing it work, without an account

python scripts/disciple_local_test.py

Nothing needs configuring. It stands up a stand-in for Disciple that records what gets posted to it, starts the bot pointed at that, and sends four signed deliveries: an ordinary post, the same post again, a post written by the bot itself, and one with a forged signature. Only the first should produce a comment, and the script fails if more than one does.

It exists because this is the surface with no way to try it. The chat can be opened in a browser and the handover email has send_test_handover.py; this had nothing, so "the adapter works" rested entirely on unit tests of the pieces rather than on anyone having watched the pieces run together.

What it proves is the half that is ours: the signature check, the payload mapping, the claim that makes a redelivery harmless, the filter that stops the bot answering itself, and the comment body. What it cannot prove is that Disciple accepts that body — see the open questions above.

Switching it on

The code is already correct against the documentation. What is missing is credentials — set four values in .env:

DECIPLE_DISCIPLE_BASE_URL=https://<portal>.disciplemedia.com/v1
DECIPLE_DISCIPLE_API_KEY=…
DECIPLE_DISCIPLE_WEBHOOK_SECRET=…
DECIPLE_DISCIPLE_BOT_AUTHOR=…

Point Disciple's webhook at POST /disciple/webhook. It returns 503 while the secret is unset and 401 for a bad signature — deliberately different, because 401 sends somebody hunting for the right credential when the truth is that this deployment never had one.