<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Motzumoto</title>
    <description>The latest articles on DEV Community by Motzumoto (@motzumoto).</description>
    <link>https://dev.to/motzumoto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4153258%2Ff8700edd-96ae-434e-b19d-fad11c24183e.jpg</url>
      <title>DEV Community: Motzumoto</title>
      <link>https://dev.to/motzumoto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/motzumoto"/>
    <language>en</language>
    <item>
      <title>my bot logged "fetch failed" 450 times a day for a month and the error told me nothing</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/motzumoto/my-bot-logged-fetch-failed-450-times-a-day-for-a-month-and-the-error-told-me-nothing-59oa</link>
      <guid>https://dev.to/motzumoto/my-bot-logged-fetch-failed-450-times-a-day-for-a-month-and-the-error-told-me-nothing-59oa</guid>
      <description>&lt;p&gt;for about a month my bot logged &lt;code&gt;TypeError: fetch failed&lt;/code&gt; around 450 times a day. i ignored it. it told me nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  what was happening
&lt;/h2&gt;

&lt;p&gt;Akiko's AI features call a worker container on the same box. it's slow. summarizing a plain 20 message conversation took 55 seconds, and real ones queue behind other requests and take longer.&lt;/p&gt;

&lt;p&gt;i'd given the summarize call a one hour timeout with an AbortController. it didn't matter. Node's fetch (undici) has its own &lt;code&gt;headersTimeout&lt;/code&gt; and &lt;code&gt;bodyTimeout&lt;/code&gt; of 300 seconds that have nothing to do with your abort signal. anything over five minutes got killed, and the one hour timeout was a story i'd told myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  why it took a month
&lt;/h2&gt;

&lt;p&gt;the error showed up as a bare &lt;code&gt;fetch failed&lt;/code&gt;. i was logging errors with &lt;code&gt;String(err)&lt;/code&gt;, which drops &lt;code&gt;err.cause&lt;/code&gt;. the cause is the only thing that tells &lt;code&gt;ECONNREFUSED&lt;/code&gt; apart from &lt;code&gt;UND_ERR_HEADERS_TIMEOUT&lt;/code&gt;. about 7,400 errors, all identical, none of them actionable.&lt;/p&gt;

&lt;p&gt;there's a helper now that keeps the cause. trust the cause, not the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;a module level undici &lt;code&gt;Agent&lt;/code&gt; with &lt;code&gt;headersTimeout: 0&lt;/code&gt; and &lt;code&gt;bodyTimeout: 0&lt;/code&gt;, passed as the &lt;code&gt;dispatcher&lt;/code&gt; on the long calls. any new long running endpoint needs it too, or the default quietly caps it at 300 seconds.&lt;/li&gt;
&lt;li&gt;the summarizer model has a 4096 token window and i was feeding it up to 20 raw conversation rows. one request was 5,899 tokens. it now clamps input to about 2,400 tokens, newest first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;a day after the fix, 545 summarize calls, all 200.&lt;/p&gt;

&lt;p&gt;one more thing. the worker's &lt;code&gt;/health&lt;/code&gt; answers in about a millisecond even when inference is badly backed up. a green healthcheck told me nothing about whether requests were working :3&lt;/p&gt;

&lt;p&gt;Akiko's free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>debugging</category>
      <category>discord</category>
    </item>
    <item>
      <title>i told my bot to create a role and give it to me. it gave me a different one.</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/motzumoto/i-told-my-bot-to-create-a-role-and-give-it-to-me-it-gave-me-a-different-one-29of</link>
      <guid>https://dev.to/motzumoto/i-told-my-bot-to-create-a-role-and-give-it-to-me-it-gave-me-a-different-one-29of</guid>
      <description>&lt;p&gt;i asked Akiko to create a role called &lt;code&gt;bleh 3&lt;/code&gt; and give it to me. she created &lt;code&gt;bleh 3&lt;/code&gt; and gave me &lt;code&gt;bleh 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;it wasn't a fluke. every time, she granted the role that was created before the one i asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  why
&lt;/h2&gt;

&lt;p&gt;she has tools like &lt;code&gt;create_role&lt;/code&gt; and &lt;code&gt;add_role&lt;/code&gt;. when i ask for both in one message, the model plans both calls in a single response. so the second call's arguments get written before the first call has run. at that point the newest role ID the model has seen is the previous one, so that's the one it hands to &lt;code&gt;add_role&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bleh 2&lt;/code&gt; got a fresh ID and &lt;code&gt;add_role&lt;/code&gt; was handed the ID of the earlier &lt;code&gt;bleh&lt;/code&gt;. &lt;code&gt;bleh 3&lt;/code&gt; got a fresh ID and &lt;code&gt;add_role&lt;/code&gt; was handed &lt;code&gt;bleh 2&lt;/code&gt;'s.&lt;/p&gt;

&lt;p&gt;running the calls in order doesn't help. the arguments were already written.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;create_role&lt;/code&gt; now reports the ID it really made, as structured data and not by parsing a sentence. the engine keeps a list of things created in the current batch. if another role or channel action in the same batch points at something else, it gets refused with the real ID in the error, so the model retries with the right one.&lt;/p&gt;

&lt;p&gt;i refuse instead of quietly swapping in the new ID. "create role A, then take role B off me" is a real request that points at an existing role, and redirecting it would do the wrong thing, which is the exact bug i was fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  the real problem was bigger
&lt;/h2&gt;

&lt;p&gt;while i was in there i counted her tools. six. none of them could look anything up on a server. no way to ask "what is this role's ID". a model that needs an ID and can't ask doesn't leave it blank. it writes the most believable number it can find.&lt;/p&gt;

&lt;p&gt;in one test run it used the ID of a role from a run 48 minutes earlier, a user ID that was just 1234567890123456789, and a channel from 2022 that it never created. it sent a message there, made an invite, and deleted the channel.&lt;/p&gt;

&lt;p&gt;i'd written five guards for five different ways of inventing an ID, and each one found a new way. so i stopped guarding and added three lookup tools. guards stop wrong answers. a lookup removes the reason to guess.&lt;/p&gt;

&lt;p&gt;if your bot is making up IDs, give it a way to look them up :3&lt;/p&gt;

&lt;p&gt;Akiko's free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>ai</category>
      <category>typescript</category>
      <category>debugging</category>
    </item>
    <item>
      <title>my bot's system prompt was cut off in the middle on every message</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/motzumoto/my-bots-system-prompt-was-cut-off-in-the-middle-on-every-message-1o2e</link>
      <guid>https://dev.to/motzumoto/my-bots-system-prompt-was-cut-off-in-the-middle-on-every-message-1o2e</guid>
      <description>&lt;p&gt;Akiko's persona file is 10,353 characters. the cap i put on the system prompt was 9,000. i didn't do that math until she started rambling.&lt;/p&gt;

&lt;p&gt;the symptom was weird. she'd answer a simple question with a paragraph about rain on a window. separately i wanted to check that premium servers' custom system prompts were actually being applied. and memory seemed to come and go.&lt;/p&gt;

&lt;h2&gt;
  
  
  one bug
&lt;/h2&gt;

&lt;p&gt;before every request the system prompt gets trimmed to a max length by slicing off the end. the prompt builder assembles roughly 17,000 characters. the persona alone is over the cap, so the cut landed in the middle of the persona.&lt;/p&gt;

&lt;p&gt;everything after the cut disappeared on every single turn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the part of the persona that tells her not to narrate atmosphere&lt;/li&gt;
&lt;li&gt;the ground rules&lt;/li&gt;
&lt;li&gt;the brevity rule&lt;/li&gt;
&lt;li&gt;the block that tells her where she is&lt;/li&gt;
&lt;li&gt;the premium server's custom prompt&lt;/li&gt;
&lt;li&gt;all injected memory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;that's three complaints from one cause. the custom prompt wiring was fine the whole time. it was just always past the cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  how i found it
&lt;/h2&gt;

&lt;p&gt;i ran the real prompt builder outside the bot and printed where each block started and ended. once you see block offsets sitting next to a 9000 line, it's obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix, and the part where it came back
&lt;/h2&gt;

&lt;p&gt;i moved the blocks that have to survive (brevity rule, custom prompt, current context) above the persona so they sit in the protected head, and left the stuff that can be cut at the tail. then i raised the cap from 9000 to 14000 so the full persona fit.&lt;/p&gt;

&lt;p&gt;it came back. the persona grew past the new cap, memory got squeezed out again, and i raised the cap a second time, to 21,500. that's when i admitted that raising the number isn't a fix, it's just a longer fuse.&lt;/p&gt;

&lt;p&gt;now the head of the prompt has its own budget, and going over it throws an error instead of cutting quietly. if i add to the persona and blow the budget, something fails loudly and i have to cut something. memory gets first claim on whatever room is left over.&lt;/p&gt;

&lt;p&gt;truncating a prompt never throws on its own. it just quietly makes your bot a different bot. if you slice a prompt anywhere, print the offsets once, put the things you can't lose at the top, and make going over budget an error :3&lt;/p&gt;

&lt;p&gt;Akiko's free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>ai</category>
      <category>debugging</category>
      <category>llm</category>
    </item>
    <item>
      <title>my dashboard had 24 raid settings and the bot ignored 11 of them</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:59:21 +0000</pubDate>
      <link>https://dev.to/motzumoto/my-dashboard-had-24-raid-settings-and-the-bot-ignored-11-of-them-576o</link>
      <guid>https://dev.to/motzumoto/my-dashboard-had-24-raid-settings-and-the-bot-ignored-11-of-them-576o</guid>
      <description>&lt;p&gt;i build Akiko's dashboard and the bot in two separate repos. they deploy separately and share no code. turns out that's a good way to end up with settings that save, show up on the dashboard, and do nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  how it happens
&lt;/h2&gt;

&lt;p&gt;the dashboard builds each module's form straight from the database table. every column becomes a field, minus a list of ones i've hidden by hand. the bot then reads whichever columns it feels like.&lt;/p&gt;

&lt;p&gt;if the bot stops reading one, nothing complains. the dashboard keeps rendering it, the save goes through, the row updates. the bot just never looks.&lt;/p&gt;

&lt;p&gt;nothing ties "the dashboard offers this" to "the bot reads this" except me remembering.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i found
&lt;/h2&gt;

&lt;p&gt;it started as one automod bug and kept going.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the account age and avatar checks had a punishment box that was free text. anything other than the literal word &lt;code&gt;ban&lt;/code&gt; quietly became a kick.&lt;/li&gt;
&lt;li&gt;the message filters offered "escalate repeat offenders" and the filter code never mentioned either column.&lt;/li&gt;
&lt;li&gt;the link filter and mention spam had infraction columns the dashboard labelled and the code hardcoded over.&lt;/li&gt;
&lt;li&gt;raid heat had 24 settings. 11 of them had no reader at all. a few more only changed the colour of the graph.&lt;/li&gt;
&lt;li&gt;auto-roles, which i wrote about last time, turned out to be the entire feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;two of the hints were worse than missing. one said panic mode auto-locks during coordinated raids. it posts an embed.&lt;/p&gt;

&lt;h2&gt;
  
  
  the check
&lt;/h2&gt;

&lt;p&gt;before writing a description for any setting, i run this per field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;column_name&amp;gt;"&lt;/span&gt; src/features src/core | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;zero hits means it's decoration. writing a hint for a dead field is the real mistake, because now the bug has documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  fixing it was the scary part
&lt;/h2&gt;

&lt;p&gt;wiring up a dead setting is a behaviour change. the raid heat enforcement fields had been sitting on defaults nobody chose, so switching them on would have started handing out timeouts in servers that never asked for them.&lt;/p&gt;

&lt;p&gt;i implemented all of it and put enforcement behind a new column that defaults to off.&lt;/p&gt;

&lt;p&gt;if you've got a dashboard and a bot in different repos, go grep your columns. i'd bet you've got a few too :3&lt;/p&gt;

&lt;p&gt;Akiko's free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>stripe told me my webhook was broken. i found the email 17 days later.</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:56:14 +0000</pubDate>
      <link>https://dev.to/motzumoto/stripe-told-me-my-webhook-was-broken-i-found-the-email-17-days-later-4kaj</link>
      <guid>https://dev.to/motzumoto/stripe-told-me-my-webhook-was-broken-i-found-the-email-17-days-later-4kaj</guid>
      <description>&lt;p&gt;this one's a bit embarrassing. Akiko has a premium tier that runs through Stripe, and for about ten weeks every webhook Stripe sent her dashboard failed. i had no idea.&lt;/p&gt;

&lt;p&gt;i only found out because of an email from Stripe that had been sitting unread for seventeen days.&lt;/p&gt;

&lt;h2&gt;
  
  
  what happened
&lt;/h2&gt;

&lt;p&gt;i retired the old Express backend on the dashboard and moved everything into Next.js. the Stripe keys lived in the root &lt;code&gt;.env&lt;/code&gt;. Next runs from the &lt;code&gt;web/&lt;/code&gt; folder under pm2, so it reads &lt;code&gt;web/.env&lt;/code&gt; instead, and that file had no Stripe variables at all.&lt;/p&gt;

&lt;p&gt;so every delivery hit the webhook route, found no secret, and threw. the catch block turned any error with "Stripe" in the message into a 400. a missing config looked exactly like a bad signature, to Stripe and to me poking at it with curl.&lt;/p&gt;

&lt;p&gt;roughly: last event stored June 3. Express retired June 11. first failed delivery July 22. Stripe disabled the endpoint on July 31 after 10 attempts over 9 days. i found it August 16.&lt;/p&gt;

&lt;p&gt;there was a second bug in the same file. six variables were written &lt;code&gt;NAME = value&lt;/code&gt; with spaces around the equals, which dotenv reads as a variable called &lt;code&gt;"NAME "&lt;/code&gt; with a trailing space. even with the right file loaded they'd have come back undefined.&lt;/p&gt;

&lt;h2&gt;
  
  
  then it got worse
&lt;/h2&gt;

&lt;p&gt;while i was checking what the dead webhook had broken, i looked at how premium ends. it ends one way. a &lt;code&gt;customer.subscription.deleted&lt;/code&gt; event flips &lt;code&gt;active&lt;/code&gt; to false, and that's the only code in the repo that does. no sweep, nothing else.&lt;/p&gt;

&lt;p&gt;so with the webhook dead, nobody's premium could end. it would just run forever.&lt;/p&gt;

&lt;p&gt;the data to catch this was right there. &lt;code&gt;expires_at&lt;/code&gt; was written in four places and read in none. there was even an index built for a query nobody had written.&lt;/p&gt;

&lt;p&gt;the two date columns also disagreed. &lt;code&gt;expires_at&lt;/code&gt; and &lt;code&gt;current_period_end&lt;/code&gt; are supposed to mirror each other, but old rows never got mirrored. two lapsed subscriptions had a past &lt;code&gt;expires_at&lt;/code&gt; and a NULL &lt;code&gt;current_period_end&lt;/code&gt;. the AI limits code checked &lt;code&gt;current_period_end&lt;/code&gt;, saw NULL, took that to mean a lifetime grant, and gave premium quotas to a subscription that ended in May.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix
&lt;/h2&gt;

&lt;p&gt;both gates now deny if either date is in the past. NULL in both still means lifetime, because comps and staff grants are made outside Stripe with no expiry and i didn't want to cut those off. an unparseable date fails open, since a malformed value is my bug and i'd rather not lock out someone who's paying.&lt;/p&gt;

&lt;p&gt;i also added a daily sweep that clears &lt;code&gt;active&lt;/code&gt; on anything whose own dates have passed. i ran it against a clone of the real table first. it revoked the two lapsed rows, left the lifetime grants alone, and the second run touched nothing.&lt;/p&gt;

&lt;p&gt;if you give people access because of an event, also give them an end date the gate actually reads. otherwise one missed webhook turns into free forever.&lt;/p&gt;

&lt;p&gt;go read your Stripe emails (i didn't :3)&lt;/p&gt;

&lt;p&gt;Akiko's free to add if you want to watch what i break next: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>stripe</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Your Discord bot's member cache is lying to you. Mine never timed out a single member.</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:49:58 +0000</pubDate>
      <link>https://dev.to/motzumoto/your-discord-bots-member-cache-is-lying-to-you-mine-never-timed-out-a-single-member-13i7</link>
      <guid>https://dev.to/motzumoto/your-discord-bots-member-cache-is-lying-to-you-mine-never-timed-out-a-single-member-13i7</guid>
      <description>&lt;p&gt;I set &lt;code&gt;largeThreshold: 50&lt;/code&gt; in my Discord bot's client config. The comment next to it said it saved memory with no functional loss. That was wrong, and it took a log search to find out how wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the setting really does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;largeThreshold&lt;/code&gt; is the &lt;code&gt;large_threshold&lt;/code&gt; field in Discord's gateway IDENTIFY payload. It is the member count above which the gateway stops sending offline members when a guild loads. Above it, a guild counts as "large" and the initial guild payload only carries members who are online.&lt;/p&gt;

&lt;p&gt;Nothing fills the gap later. My only full-roster fetch ran from the guild-join event, and that event only fires for brand new guilds. Guilds the bot was already in at startup never got it. So after every restart, the member cache held the online members plus whoever the bot had seen since.&lt;/p&gt;

&lt;p&gt;That means this line is not doing what it looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;member&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;guild&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;members&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not iterate every member. It iterates the members who happen to be online or recently active.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;I have a verification timeout sweep. If a member does not finish verification in time, the bot acts on them. I searched 200MB of production logs for the line it writes when it actions someone: &lt;code&gt;verification timeout action applied&lt;/code&gt;. It appeared zero times. It had never once actioned anyone.&lt;/p&gt;

&lt;p&gt;Two of the four servers using the gate were above the threshold, at 532 and 54 members. The 54 member server was only affected because I had set the threshold to 50. At Discord's default of 250 it would have worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Do not treat the cache as a roster. There are two honest options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep a durable record of who needs checking. I added a &lt;code&gt;pending_verification&lt;/code&gt; table and made it the source of candidates.&lt;/li&gt;
&lt;li&gt;Call the full member fetch explicitly before you iterate, and report a "not cached" count, so that "nobody matched" is distinguishable from "most of the server was never looked at".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The threshold itself is a real memory optimisation and I kept it. The mistake was letting several features read the cache as if it were complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap inside the trap
&lt;/h2&gt;

&lt;p&gt;The sweep's happy path returns early and quietly when it finds no candidates. So the absence of log output proved nothing for weeks. A check that stays silent when it finds nothing cannot tell you that it is blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Before you walk a guild's members, ask whether you need the offline ones. If yes, the cache is the wrong source.&lt;/li&gt;
&lt;li&gt;Make sweeps report what they examined, not only what they did.&lt;/li&gt;
&lt;li&gt;Comments that claim "no functional loss" deserve a test.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build Akiko, a Discord bot with AI chat that remembers you, plus economy, music, moderation and a web dashboard. Free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>node</category>
      <category>debugging</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Discord gives your bot exactly 100 slash commands. I hit 100 and found out from a failing test.</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:45:08 +0000</pubDate>
      <link>https://dev.to/motzumoto/discord-gives-your-bot-exactly-100-slash-commands-i-hit-100-and-found-out-from-a-failing-test-702</link>
      <guid>https://dev.to/motzumoto/discord-gives-your-bot-exactly-100-slash-commands-i-hit-100-and-found-out-from-a-failing-test-702</guid>
      <description>&lt;p&gt;Discord limits an app to 100 global slash commands. Subcommands and subcommand groups do not count toward it. My bot, Akiko, was sitting at exactly 100 when this happened. i've folded a few more commands into groups since, so she's at 95 now.&lt;/p&gt;

&lt;p&gt;I learned where the wall is the hard way. I added a &lt;code&gt;/settings&lt;/code&gt; command, the total became 101, and a test failed with &lt;code&gt;expected 101 to be less than or equal to 100&lt;/code&gt;. In production the same mistake shows up as Discord error code 30032 when you deploy your commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most useful thing in the repo is a boring test
&lt;/h2&gt;

&lt;p&gt;There is a test that builds the real list of commands that would be deployed globally and asserts the count is at most 100. Without it you find out at deploy time, on the live bot. The test's own docstring also lists the escape routes in order, and says to never loosen the bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The escape routes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Fold the command into an existing one as a subcommand group. &lt;code&gt;/report_setup&lt;/code&gt; became &lt;code&gt;/report setup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make it prefix-only, so it only works with the text prefix.&lt;/li&gt;
&lt;li&gt;Make it developer-only. Those deploy to a separate control guild instead of globally, and that scope had plenty of room. Global is the binding constraint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The scope rules are simple: global means visible, not prefix-only and not developer-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permission trap
&lt;/h2&gt;

&lt;p&gt;Discord ignores &lt;code&gt;default_permissions&lt;/code&gt; on subcommands. Permissions are set on the top-level command. So when I folded &lt;code&gt;/report_setup&lt;/code&gt; (admin only) into &lt;code&gt;/report&lt;/code&gt; (used by ordinary members to file reports), I could not require Manage Server on the setup subcommand alone. Putting the stricter permission on the whole command would have locked ordinary members out of &lt;code&gt;/report user&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The check for that one subcommand has to live in the command body.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Count your commands in a test, not in your head.&lt;/li&gt;
&lt;li&gt;Design command names as groups from day one. Every new top-level name spends a scarce slot.&lt;/li&gt;
&lt;li&gt;Never assume a permission set on a subcommand is enforced. Check it in code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build Akiko, a Discord bot with AI chat that remembers you, plus economy, music, moderation and a web dashboard. Free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>typescript</category>
      <category>api</category>
      <category>programming</category>
    </item>
    <item>
      <title>My Discord bot's auto-role setting said ON in 127 servers. It worked in 2.</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:44:34 +0000</pubDate>
      <link>https://dev.to/motzumoto/my-discord-bots-auto-role-setting-said-on-in-127-servers-it-worked-in-2-3g31</link>
      <guid>https://dev.to/motzumoto/my-discord-bots-auto-role-setting-said-on-in-127-servers-it-worked-in-2-3g31</guid>
      <description>&lt;p&gt;The dashboard showed the toggle on. The status command said enabled. No member had ever received a role.&lt;/p&gt;

&lt;p&gt;I found this while working on something else. A dashboard to-do claimed that auto-roles and my verification gate "both act on join and conflict", and asked me to add a warning about the clash. Before building the warning I went to check how the two interact.&lt;/p&gt;

&lt;p&gt;They don't interact, because auto-roles had no code path on join at all. The member-join handler never touched them. The only place that granted auto-roles was the handler that runs when a member passes a verification gate. So the feature worked exactly where a gate was enabled and nowhere else: 2 guilds out of the 127 that had auto-roles switched on with a role selected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing noticed
&lt;/h2&gt;

&lt;p&gt;Everything around the feature agreed with itself. The settings page read the row, the status command read the row, and the row said enabled. Nothing checked the outcome: whether a member actually ended up holding the role. A feature can be fully configured and never run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and one decision worth stealing
&lt;/h2&gt;

&lt;p&gt;Adding a grant on join is easy. The interesting part is what happens when a gate is on. A verification gate exists so a member holds no roles until they pass it. Granting on join would hand out exactly the roles the gate is there to withhold.&lt;/p&gt;

&lt;p&gt;So the join path checks the gate's own enabled flag and steps aside when the gate is on. The gate keeps ownership. The check reads that local flag instead of watching what the gate did, because the gate call is fire-and-forget and its outcome is not visible from the join handler. If the two ever disagree, the member gets nothing. That is deliberate: a missing role is a support message, a role handed to someone the gate meant to stop is an incident.&lt;/p&gt;

&lt;p&gt;The same planning function feeds both the join path and a backfill for existing members, so the prechecks cannot drift apart between the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Verify the premise of a bug report before building the fix. The spec described a conflict that did not exist. The real bug was a missing branch.&lt;/li&gt;
&lt;li&gt;"Enabled" in a settings page proves a row exists, not that the feature runs. Test the outcome.&lt;/li&gt;
&lt;li&gt;When two paths can grant the same thing, decide which one fails safe before you write either.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build Akiko, a Discord bot with AI chat that remembers you, plus economy, music, moderation and a web dashboard. Free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>typescript</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Giving a Discord bot long-term memory, and the two ways it went wrong</title>
      <dc:creator>Motzumoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:30:39 +0000</pubDate>
      <link>https://dev.to/motzumoto/giving-a-discord-bot-long-term-memory-and-the-two-ways-it-went-wrong-mkg</link>
      <guid>https://dev.to/motzumoto/giving-a-discord-bot-long-term-memory-and-the-two-ways-it-went-wrong-mkg</guid>
      <description>&lt;p&gt;I run a Discord bot called Akiko. The feature I care about most is that she remembers people across servers and DMs. Building it taught me more about what not to store than what to store. Two bugs stand out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory was 97% junk
&lt;/h2&gt;

&lt;p&gt;My first extractor pulled "facts" out of chat with a loose pronoun pattern. It looked fine in testing. In production, when I finally read the table, almost all of it was noise: fragments that matched the pattern but described nothing about the person.&lt;/p&gt;

&lt;p&gt;The fix had three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clean the existing rows down to the ones worth keeping (about 100 survived).&lt;/li&gt;
&lt;li&gt;Make the writer classify a candidate before it saves it, instead of saving everything that matched.&lt;/li&gt;
&lt;li&gt;Treat "not yet judged" as its own state (NULL) and retry the judgment on an hourly job, so a temporary failure never turns into a permanent bad row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson: a memory system that only ever adds will bury itself. The write path needs a filter as strict as the read path.&lt;/p&gt;

&lt;h2&gt;
  
  
  She remembered what people had not said
&lt;/h2&gt;

&lt;p&gt;The extractor also started storing absence as a fact, things like "User has not shared their birthday". That is worse than noise, because recall then surfaces it and the bot sounds like it is keeping a file on what you withheld.&lt;/p&gt;

&lt;p&gt;I first tried marking these rows as low priority, but recall never filtered on that flag, so it changed nothing. The real fix was an extractor rule that refuses negative statements, plus a recall-side filter as a second line of defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it something users can control
&lt;/h2&gt;

&lt;p&gt;Memory is only comfortable if the user can see and change it. Akiko has commands to list, add, delete and export what she has stored. There is also an import that takes a memory export from another AI and condenses it into clean facts, so a new user does not start from zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell anyone building this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read your memory table in production early. It will not look like your tests.&lt;/li&gt;
&lt;li&gt;Filter on the way in, not only on the way out.&lt;/li&gt;
&lt;li&gt;Never store the absence of information.&lt;/li&gt;
&lt;li&gt;Give users list, delete and export from day one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Akiko is free to add: &lt;a href="https://upload.motzumoto.com/devto" rel="noopener noreferrer"&gt;https://upload.motzumoto.com/devto&lt;/a&gt;&lt;br&gt;
Dashboard: &lt;a href="https://akiko.motzumoto.com" rel="noopener noreferrer"&gt;https://akiko.motzumoto.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>typescript</category>
      <category>postgres</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
