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.
- 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.
- The exact
X-Disciple-Eventstring for a new post. The docs name the event "New Post" but do not print the header value.POST_EVENTisnew_postandSTRICT_EVENTis 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, setSTRICT_EVENT = True, and comment deliveries stop at the door instead of relying on the author check behind it. - 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. - Is there an endpoint to read one post back? None is documented.
fetch_poststill assumesGET /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. - Rate limits on commenting, and whether a bot account is subject to different ones than a member.
- 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, andDECIPLE_DISCIPLE_BOT_AUTHORis 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.