# School news with checks: self-critique and a gatekeeper **Guide 7 for the AI Agents workshop** LeX Consultancy B.V. · 20 September 2026 In guide 3 you built School news: one core message becomes a piece for the website, the parent app and social media. The texts were good, but they went out unchecked. This guide adds two things that the *Design patterns* appendix calls pattern 4 and 5: | Addition | Pattern | What it does | | --- | --- | --- | | **Checker** (LLM) | Self-critique | Reads the parent app message once more against the style guide and improves it | | **Gatekeeper** (Code) plus **IF/ELSE** | Gatekeeper | Checks hard rules with code: word count, forbidden words, hashtag, missing information. If everything is right, out it goes; otherwise to the communications officer with the reasons | The difference between those two is the heart of this guide. The Checker is a model reviewing a model: good for form and tone, but it can talk itself into things. The Gatekeeper is six lines of Python that cannot be talked into anything: 81 words is too many, full stop. Together they deliver what a communications officer wants: less checking work, and certainty that the rules that really matter really apply. This guide follows the cloud version of Dify of 20 September 2026. If your screen differs, follow what you see. The screenshots show the Dutch build of this workflow (Dutch block names such as Controleur and Poortwachter, Dutch texts); the screens and clicks are the same. **How to read this guide** | Form | Meaning | | --- | --- | | 1. 2. 3. | What you do: click, choose, open | | **Bold** | A button, menu or field as it appears on your screen | | Table *What you type* | Values you put in a form field | | Grey box *Type in* | Text you type or paste literally | | Orange box | A pitfall you would otherwise discover yourself | | Green box *Your choice* | A place where you can replace our example with your own material, style or prompt | --- ## The design card for this app Before we started clicking, this card was filled in (the blank card is in the Design card appendix). Fill in the same five boxes for your own app; the loose version of this filled-in card is in 00-design-card/examples. | 1. Who is it for | 2. What goes in | | --- | --- | | The communications officer who has to write every message three times (website, parent app, social) and has no time to check every version against the style guide. | Three fields: the core message (who, what, where, when), the class or year group, and the tone (informative, celebratory, urgent). | | 3. What comes out | 4. What it must stick to | | --- | --- | | If approved: three versions ready to post. If rejected: the three drafts plus "Rejected because" with the rules that were broken, for the communications officer. | The style guide from the knowledge base; the Checker (second LLM) rewrites the parent app version; the Gatekeeper (Code + IF/ELSE) measures word count, forbidden words, [FILL IN] and the hashtag. What does not pass the gate does not go out. | **5. How you know it works** | Test | What goes in | What must come out | | --- | --- | --- | | 1 | A complete message about sports day, celebratory | Approved, three versions within the word limits, with #ExampleCollege | | 2 | A message without a date | Rejected: contains [FILL IN] | | 3 (the hard one) | A message with "kindly" and "asap" | Rejected with the two forbidden words named | ## Preparation - The **School news** workflow from guide 3 is on your account and works. - You know what an LLM block and an Output block are (guides 1 and 3). - You do not need to be able to program. You paste the code; what it does is explained next to it. This is what School news looks like before you start: ![Starting point: the workflow from guide 3](screenshots/01-uitgangspunt.jpg) And this is what the tail looks like when you are done: ![The goal: Checker, Gatekeeper, IF/ELSE and two exits](screenshots/27-hele-keten.jpg) --- ## Part A. The Checker (self-critique) ### Step 1. Disconnect the Output from Social The new blocks go between Social and Output. So you first disconnect Output. 1. Open **School news** in **Studio** and click the **Social** block. 2. In the right-hand panel scroll to **NEXT STEP**. It says **Output**. ![Next Step of Social: Output](screenshots/02-next-step-output.jpg) 3. Click the three dots behind Output and choose **Disconnect**. (Not **Delete**: the block may stay, only the line goes.) ![Change, Disconnect, Delete](screenshots/03-disconnect-menu.jpg) ![Social now has no next step](screenshots/04-losgekoppeld.jpg) ### Step 2. Add the Checker 1. Under **NEXT STEP** click **Select next step** and choose **LLM**. ![The list of blocks](screenshots/05-nodes-lijst.jpg) ![The new, empty LLM block](screenshots/06-llm4-leeg.jpg) 2. **MODEL**: gpt-5-mini. **CONTEXT**: Knowledge Retrieval / result (the style guide). 3. Paste the prompt below into **SYSTEM**. For `{Context}` type a forward slash and choose **Context**; for `{Parent app/text}` choose the item **text** under **PARENT APP**. ``` You are the checker of Example College. Below is a draft message for the parent app. Assess it on these points from the style guide: at most 80 words; parents are addressed formally; one message, one date and one action; no abbreviations and no words from the list of words to avoid; no names of students; reading level B1 (short sentences, everyday words). Work in two steps. Step 1: briefly name what is wrong (or write: nothing). Step 2: give the improved version. Invent no facts; what is not in the draft stays [FILL IN]. Do not remove facts, dates or times that are in the draft; a deadline for signing up belongs to the action and stays. Do not change digits into words. As your answer give ONLY the improved text, without step 1, without a heading and without explanation. If the draft is already good, return it unchanged. Style guide: {Context} Draft message: {Parent app/text} ``` ![The prompt, with the menu after a forward slash](screenshots/07-controleur-prompt.jpg) 4. Rename the block to `Checker`. ![Checker ready: context and draft message are connected](screenshots/08-controleur-klaar.jpg) > "Work in two steps, but give only step 2." That is not a contradiction but a trick: the model assesses first (which improves the result) and you still get clean text back that you can pass on directly. If you leave step 1 in the answer, that explanation ends up in the parent app too. > In our first test the Checker did exactly what we asked, and that was too much: it read "one date" as "remove the sign-up deadline", and "Year 8" became "year eight". That is why the two sentences about facts and digits are in the prompt. Self-critique improves form; what may change in content is for you to limit. > **Your choice: what the Checker checks.** The first paragraph of the prompt is your checklist. Also want to check "is there a way to get in touch"? Or the tone per channel? Add it. Want the Checker on the website piece too? Copy the block (three dots on the block, **Duplicate**), connect Website/text as the draft and adjust the rules (200 words, eight-word headline). --- ## Part B. The Gatekeeper (code) ### Step 3. Add a Code block 1. At Checker: **NEXT STEP** → **Select next step** → under Transform: **Code**. ![The Code block with sample code](screenshots/09-code-standaard.jpg) 2. Click in the editor, select everything (Ctrl+A) and paste the code below. ``` def main(website: str, parent_app: str, social: str) -> dict: reasons = [] forbidden = ["by means of", "with regard to", "asap", "w.r.t.", "kindly", "parent(s)/carer(s)", "utilise"] texts = (("website", website, 230), ("parent app", parent_app, 90), ("social", social, 70)) for name, text, maximum in texts: count = len(text.split()) if count > maximum: reasons.append(f"{name}: {count} words, maximum {maximum}") if "[FILL IN]" in text: reasons.append(f"{name}: contains [FILL IN], information is missing") for word in forbidden: if word in text.lower(): reasons.append(f"{name}: contains the word to avoid '{word.strip()}'") if "#examplecollege" not in social.lower(): reasons.append("social: #ExampleCollege is missing") return { "ok": 0 if reasons else 1, "reasons": "\n".join(reasons) if reasons else "All rules satisfied.", } ``` ![The code is in place](screenshots/10-code-ingevuld.jpg) What the code does, line by line in plain words: | Line | Meaning | | --- | --- | | `forbidden = [...]` | The list of words that must never be in a text. This is the list from the style guide, made hard here | | `texts = (...)` | The three texts with their maximum in words. The maxima are slightly above the style guide (200, 80, 60), because the headline and the label WEBSITE count too | | `if count > maximum` | Too long: note a reason | | `if "[FILL IN]" in text` | The model did not know something: note a reason. This is the most important rule: a message with a gap never goes out | | `for word in forbidden` | If a forbidden word is in the text: note a reason | | `if "#examplecollege" not in ...` | The social post must have the school hashtag | | `return {...}` | Return two things: `ok` (1 = good, 0 = wrong) and `reasons` (the list, or "All rules satisfied.") | > Mind the indentation: in Python every space at the start of a line counts. Pasting with Ctrl+V works fine. Typing it over almost always goes wrong; do not do that. ### Step 4. Connect the input and the output 1. Under **INPUT VARIABLES** rename `arg1` to `website` and `arg2` to `parent_app`. Click **+** for a third variable and call it `social`. The names must be exactly the same as the names in the first line of the code. ![Input variables renamed, third one added](screenshots/11-invoer-hernoemd.jpg) 2. For each variable click **Set variable** and choose: | Variable | Choose | | --- | --- | | website | **WEBSITE / text** | | parent_app | **CHECKER / text** (the improved version, not the raw one) | | social | **SOCIAL / text** | ![The variable picker](screenshots/12-variabele-kiezen.jpg) ![All three connected](screenshots/13-invoer-gekoppeld.jpg) 3. Under **OUTPUT VARIABLES**: rename `result` to `ok` and set the type to **Number**. Click **+** and add `reasons`, type **String**. ![Choosing the type: Number](screenshots/14-uitvoer-type.jpg) 4. Rename the block to `Gatekeeper`. ![Gatekeeper ready](screenshots/15-poortwachter-klaar.jpg) > The output names (`ok`, `reasons`) must literally match the names in the `return` line of the code. One letter different and the workflow stops with an error about a missing output. > **Your choice: the hard rules.** Everything you can check with certainty belongs here and not in a prompt: length, forbidden words, required parts (hashtag, contact line, a date in the future), no personal email addresses, no phone numbers except reception's. Ask your communications officer: "Which mistake must never happen again?" That is your first rule. --- ## Part C. The gate: IF/ELSE and two exits ### Step 5. IF/ELSE 1. At Gatekeeper: **Select next step** → under Logic: **IF/ELSE**. ![The empty IF/ELSE block](screenshots/16-ifelse-leeg.jpg) 2. Under **IF** click **+ Add Condition** and choose **GATEKEEPER / ok**. ![The variable for the condition](screenshots/17-conditie-variabele.jpg) 3. Leave the comparison on **=** and type `1` as the value. ![The condition: ok = 1](screenshots/18-conditie.jpg) ### Step 6. Exit 1: Approved 1. Under **NEXT STEP** under **IF**: **Select next step** → **Output**. ![The new Output block](screenshots/19-output2-leeg.jpg) 2. Add three output variables: | Variable name | Set variable | | --- | --- | | `website` | **WEBSITE / text** | | `parent_app` | **CHECKER / text** | | `social` | **SOCIAL / text** | 3. Rename the block to `Approved`. ![Approved](screenshots/20-goedgekeurd.jpg) ### Step 7. Exit 2: to the communications officer 1. Click the **IF/ELSE** block. Under **ELSE** it still says **Select next step**; choose **Output**. ![IF has Approved, ELSE nothing yet](screenshots/21-ifelse-else.jpg) 2. Add four output variables. The reasons go at the top, so the officer immediately sees what is wrong. | Variable name | Set variable | | --- | --- | | `rejected_because` | **GATEKEEPER / reasons** | | `draft_website` | **WEBSITE / text** | | `draft_parent_app` | **CHECKER / text** | | `draft_social` | **SOCIAL / text** | 3. Rename the block to `To the communications officer`. ![The second exit](screenshots/22-naar-medewerker.jpg) > Two Output blocks may not have variables with the same name. If you call them `website` in both, Dify puts an orange "Duplicate variable" in the checklist and you cannot publish. Hence `draft_` in the second exit. ### Step 8. Clean up the old Output 1. Click the checklist icon at the top right (next to Test Run). You see which blocks are not right yet. ![The checklist: the old Output is connected to nothing](screenshots/23-checklist.jpg) 2. Click **Output** in the checklist. The old block is selected; it is hidden behind Checker. ![The old Output, hidden behind Checker](screenshots/24-oude-output.jpg) 3. In the panel click the three dots and then **Delete**. 4. Check that the checklist is empty (no number on the icon any more). ![After renaming to draft_: no duplicate variables any more](screenshots/25-concept-variabelen.jpg) ![The new tail of the workflow](screenshots/26-nieuwe-staart.jpg) --- ## Part D. Testing: once right, once wrong You always test a gatekeeper both ways. Testing only the good side proves nothing. ### Step 9. Test 1: a complete message 1. **Test Run**. Fill in: | Field on screen | What you type | | --- | --- | | What has happened... | `On Thursday 8 October at 19.30 there is a parents' evening for Year 8 in the main hall. Form tutors will talk about subject choices and the test week timetable. Parents can sign up until 5 October through the parent portal. Coffee and tea from 19.15.` | | For which class or year group? | `Year 8` | | Which tone fits this message? | **informative** | 2. **Start Run**. After about a minute the path via **IF** to **Approved** turns green. ![Test 1: the path runs via IF to Approved](screenshots/28-test1-resultaat.jpg) 3. Click **DETAIL**: status SUCCESS, run time and tokens. ![Detail of the run](screenshots/29-test1-detail.jpg) 4. Click **TRACING** and expand **GATEKEEPER**. Under OUTPUT is what the code returned. ![Tracing: every block with its time and tokens](screenshots/30-tracing.jpg) ![The Gatekeeper: ok 1, "All rules satisfied."](screenshots/31-poortwachter-ok.jpg) > Tracing is your friend. For every question "why did this come out?" you open the block here and see exactly what went in and what came out. This is also the screen on which you saw that the Checker had removed the sign-up deadline (see the orange box in step 2). ![The extended prompt of the Checker after test 1](screenshots/32-controleur-aangevuld.jpg) ### Step 10. Test 2: a message with gaps 1. **Test Run** again, now with a message without a date, without a time and with an abbreviation: | Field on screen | What you type | | --- | --- | | What has happened... | `There will be another parents' evening for Year 7 soon. The form tutors will tell you how things are going and what happens next year. Parents must sign up asap.` | | For which class or year group? | `Year 7` | | Which tone fits this message? | **informative** | ![Test 2: the input](screenshots/33-test2-invoer.jpg) 2. **Start Run**. Now the path runs via **ELSE** to **To the communications officer**, and the reasons are at the top of the result. ![Test 2: rejected, with four reasons](screenshots/34-test2-afgekeurd.jpg) Read the reasons: all three texts contain [FILL IN] (the models did not know the date and neatly did not invent one), and the social post contains "asap". That last one is instructive: the Social prompt asked to follow the style guide, and still the abbreviation from the core message slipped through. A prompt asks; the gatekeeper enforces. ### Step 11. Publish 1. **Publish** → **Publish Update**. Dify creates version 2 of the app; with **Name** you can give a version a name, and via the clock icon at the top right you can always go back to version 1. ![Published as version 2](screenshots/35-gepubliceerd.jpg) 2. Open the webapp and repeat test 2. The colleague sees the reasons at the top and below them the drafts to complete. ![The webapp: rejected, with the reasons and the drafts](screenshots/36-webapp-afgekeurd.jpg) The link has stayed the same (Dutch version): https://udify.app/workflow/jJclFyX9BlteuHYi --- ## Checking that it worked - The checklist at the top right shows no number - Test 1 ends in **Approved**, test 2 in **To the communications officer** - In **TRACING** Gatekeeper gives `ok: 1` for test 1 and `ok: 0` with at least one reason for test 2 - The parent app message in Approved comes from Checker (visible in the Output settings), not from Parent app - The webapp shows the reasons at the top for a rejected message ## When things go wrong | What you see | What is going on | | --- | --- | | Code block: error about `IndentationError` or `SyntaxError` | The indentation is damaged. Select everything, delete, paste again | | Code block: error about a missing output variable | The names under OUTPUT VARIABLES differ from `ok` and `reasons` in the `return` line | | Code block: `main() missing argument` | An input variable is named differently from the first line of the code, or is not connected | | IF/ELSE always goes to ELSE | The type of `ok` is String instead of Number, or you compare with `"1"` instead of `1` | | Checklist: "Duplicate variable of the output node" | Two Output blocks use the same variable name. Rename one (step 7) | | Checklist: "This step is not connected to anything" | The old Output block still exists. Delete it (step 8) | | The Checker removes information or changes digits | The prompt does not limit what may change in content. See the two sentences in step 2 | | Approved shows the raw parent app message | The variable `parent_app` in Approved points to Parent app/text instead of Checker/text | | The run takes more than two minutes | Four LLM blocks in a row; 60 to 90 seconds is normal. If it takes longer, look in Tracing which block hangs | ## Housekeeping and costs - **One run** now costs four model calls (Website, Parent app, Social, Checker) and about 7,500 tokens; with your own OpenAI key about one and a half cents, on Dify credits about 13. The Code block and IF/ELSE cost nothing. A sandbox account (200 credits) manages about fifteen runs; a Professional account (5,000 credits a month) is ample for a team. - **The forbidden list** lives in two places: in the style guide (for the models) and in the code (for the gate). If the style guide changes, change the code too. Forget that and you get rejections the models do not understand. - **Versions.** Every Publish Update is a new version. If a change goes wrong, you restore the previous version via the clock icon at the top right without rebuilding. - **Logs** also show which messages were rejected and why. That is exactly the information you need to improve the prompts of Website, Parent app and Social: every reason that occurs often is a sentence missing from a prompt. --- ## Make it yours From easy to hard. | Change | Where | Difficulty | What you get for it | | --- | --- | --- | --- | | Other forbidden words | Code, line `forbidden` | easy | The real list from your style guide is enforced | | Other word limits | Code, line `texts` | easy | Limits that fit your channels | | An extra check point for the Checker | Step 2, first paragraph of the prompt | easy | More checking work off your plate | | A Checker for the website piece | Duplicate the block, different input and rules | medium | The longest text checked too | | A rule "the date must be in the future" | Code, with `datetime` | medium | No more messages about yesterday | | A third exit: "doubtful" for minor violations | A second condition in IF/ELSE (ELIF) and a second counter in the code | medium | Small things through automatically, big ones to a person | | A human in the loop: post the message only after a click | **Human Input** block before Approved | hard | The most honest gatekeeper there is | | Self-critique in a loop until the gate opens (at most two rounds) | **Loop** block around Checker and Gatekeeper | hard | Fewer rejections, but double the cost | **Three assignments** 1. **Fill the list.** Ask your communications team for the five words or phrases they cross out most often. Put them in `forbidden` and run test 1 and test 2 again. Is test 1 now rejected too? Then your list is too broad (a word like "just" appears everywhere) and you need to be more precise. 2. **Break the gate.** Write a core message that passes the gate but is still wrong: a wrong date, an invented student name, a tone that does not fit. What does the Checker catch, what does the code catch, and what does nobody catch? For that last one you write a new rule, or you decide that a person keeps doing that. 3. **Measure it.** Run ten real messages from the past month through the app. Count: how many went straight through, how many were rejected, and how many of the rejections were justified? Those three numbers are your conversation with the leadership about whether this is worth it. **Checklist before you use your own material** - [ ] The forbidden list and the limits in the code match the style guide in the knowledge base - [ ] You have tested both sides: a message that must pass the gate, and one that must be stopped - [ ] It is agreed who picks up the rejected messages and within what time - [ ] The core messages colleagues enter contain no student names; the gate does not check for that - [ ] Someone also spot-checks approved messages; the gate checks form, not truth - [ ] You still have the previous version of the app (version history), in case the new one breaks something