<?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: Ayush Mishra</title>
    <description>The latest articles on DEV Community by Ayush Mishra (@fuloxdev).</description>
    <link>https://dev.to/fuloxdev</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%2F4189605%2F27278e5b-8fe7-4ea7-a8c3-98f8e6a46049.png</url>
      <title>DEV Community: Ayush Mishra</title>
      <link>https://dev.to/fuloxdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fuloxdev"/>
    <language>en</language>
    <item>
      <title>Compare Google Trends keywords in bulk without pytrends</title>
      <dc:creator>Ayush Mishra</dc:creator>
      <pubDate>Sun, 01 Nov 2026 09:35:43 +0000</pubDate>
      <link>https://dev.to/fuloxdev/compare-google-trends-keywords-in-bulk-without-pytrends-21hg</link>
      <guid>https://dev.to/fuloxdev/compare-google-trends-keywords-in-bulk-without-pytrends-21hg</guid>
      <description>&lt;p&gt;If you track search interest weekly, a pytrends script tends to stop with HTTP 429 sooner or later. This tutorial runs the same job with an Apify Actor I built, &lt;a href="https://apify.com/fulox/google-trends-scraper" rel="noopener noreferrer"&gt;Google Trends Scraper&lt;/a&gt;, and puts the result in a spreadsheet. Disclosure: the Actor is mine and paid per keyword.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;For every keyword, one row with interest over time, interest by region and top and rising related queries. Up to five keywords are compared on one 0-100 scale, the way Google Trends compares them. The values are relative interest, never search volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create a free Apify account
&lt;/h2&gt;

&lt;p&gt;Sign up at apify.com and open the Actor page linked above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Enter keywords
&lt;/h2&gt;

&lt;p&gt;Put 2 to 5 keywords you want to compare in &lt;code&gt;keywords&lt;/code&gt;, for example pickleball and padel. Pick &lt;code&gt;geo&lt;/code&gt; (US, GB, or empty for worldwide) and &lt;code&gt;timeRange&lt;/code&gt; (default &lt;code&gt;today 12-m&lt;/code&gt;). Keep related terms in the same group of five if you want to compare them: each group of five has its own scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Run it
&lt;/h2&gt;

&lt;p&gt;Click Start. The default two-keyword run costs $0.006 (two &lt;code&gt;ok&lt;/code&gt; rows at $0.003). Set a maximum cost per run if you like; the run stops when it is reached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Read the rows
&lt;/h2&gt;

&lt;p&gt;Each row has &lt;code&gt;status&lt;/code&gt;: &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;no_data&lt;/code&gt;, &lt;code&gt;partial&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt;. Only &lt;code&gt;ok&lt;/code&gt; is charged. Useful fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;comparedWith&lt;/code&gt;: the other keywords on the same scale.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;peak&lt;/code&gt; and &lt;code&gt;latest&lt;/code&gt;: the highest point and the most recent one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;regions&lt;/code&gt;: interest by region.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;relatedRising&lt;/code&gt;: queries gaining interest ("Breakout" means a very large increase).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The README shows the row shape; the values there are a sample, not a real Google record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Schedule it
&lt;/h2&gt;

&lt;p&gt;Schedule the Actor weekly and connect the dataset to Google Sheets, Make, Zapier or n8n.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;Google can slow down or refuse requests. The Actor pauses between requests, refreshes its cookie once, then stops with an explanation row. Failed keywords are not charged. It runs on Apify Proxy datacenter only. Not affiliated with Google.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>tutorial</category>
      <category>automation</category>
    </item>
    <item>
      <title>Search a YouTube channel's transcripts and get alerts for new uploads (Apify, no code)</title>
      <dc:creator>Ayush Mishra</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:55:06 +0000</pubDate>
      <link>https://dev.to/fuloxdev/search-a-youtube-channels-transcripts-and-get-alerts-for-new-uploads-apify-no-code-2ao7</link>
      <guid>https://dev.to/fuloxdev/search-a-youtube-channels-transcripts-and-get-alerts-for-new-uploads-apify-no-code-2ao7</guid>
      <description>&lt;p&gt;You want to know what a YouTube channel said about a topic, and hear about it when it says it again. This walkthrough uses the YouTube Transcript Scraper Actor, which I built. Disclosure: it is my own Actor, priced at $1.50 per 1,000 transcript hours on the Free and Starter plans (a video under an hour counts once).&lt;/p&gt;

&lt;p&gt;Actor: &lt;a href="https://apify.com/fulox/youtube-transcript-scraper" rel="noopener noreferrer"&gt;https://apify.com/fulox/youtube-transcript-scraper&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Try the default input
&lt;/h2&gt;

&lt;p&gt;Open the Actor and click Start. It reads two public videos and costs about $0.003. Open the dataset: each &lt;code&gt;video&lt;/code&gt; row has &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;segments&lt;/code&gt;, &lt;code&gt;chapters&lt;/code&gt;, &lt;code&gt;quality&lt;/code&gt; and &lt;code&gt;matches&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Preview a channel for free
&lt;/h2&gt;

&lt;p&gt;Paste a channel into Videos, channels or playlists, for example &lt;code&gt;@mkbhd&lt;/code&gt;, set Videos per channel to 20 and turn on List only. You get one free &lt;code&gt;preview&lt;/code&gt; row per video with its length, caption languages and &lt;code&gt;estimatedResults&lt;/code&gt;, the number of results a real run would charge. Nothing is downloaded or charged.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Search the transcripts
&lt;/h2&gt;

&lt;p&gt;Turn List only off. Add search terms such as &lt;code&gt;battery life&lt;/code&gt;, set formats to empty (&lt;code&gt;[]&lt;/code&gt;) if you only want quotes, and turn on &lt;code&gt;matchRows&lt;/code&gt;. Run. Each &lt;code&gt;match&lt;/code&gt; row has the term, the second, a quote and a &lt;code&gt;youtu.be/&amp;lt;id&amp;gt;?t=&amp;lt;seconds&amp;gt;&lt;/code&gt; link. Clicking it opens the video at that moment. Matching is case-insensitive text matching, not meaning-based. Matches are free; you pay for the transcripts delivered.&lt;/p&gt;

&lt;p&gt;Here is a real match from the Stanford 2005 commencement address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"term"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connecting the dots"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;55.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0:55"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://youtu.be/UF8uR6Z6KLc?t=55"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"...The first story is about connecting the dots. I dropped out of Reed College..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Get only new uploads
&lt;/h2&gt;

&lt;p&gt;Turn on Watch mode, give it a &lt;code&gt;watchlistName&lt;/code&gt;, and optionally paste a Slack incoming-webhook URL. Open Schedules, create a daily schedule with this saved input. The first run delivers the existing videos (use &lt;code&gt;publishedAfter&lt;/code&gt; such as &lt;code&gt;7 days&lt;/code&gt; to limit that); later runs deliver only new uploads, and Slack gets a message with the new videos and the top quotes only when something is new.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Use the output for AI
&lt;/h2&gt;

&lt;p&gt;Set formats to &lt;code&gt;chunks&lt;/code&gt; for RAG chunks with a stable id, start and end seconds, token estimate and deep link, or &lt;code&gt;markdown&lt;/code&gt; for a document with front matter. Check &lt;code&gt;quality.grade&lt;/code&gt; (A to D) before you embed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot do
&lt;/h2&gt;

&lt;p&gt;It reads existing captions: a video without captions is a free &lt;code&gt;no_captions&lt;/code&gt; row, there is no audio transcription and no translation, and YouTube can change how it serves captions. Questions or bugs go to the Actor's Issues tab.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>tutorial</category>
      <category>automation</category>
    </item>
    <item>
      <title>Track a competitor's hiring with Apify and Google Sheets</title>
      <dc:creator>Ayush Mishra</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:32:04 +0000</pubDate>
      <link>https://dev.to/fuloxdev/track-a-competitors-hiring-with-apify-and-google-sheets-4ea2</link>
      <guid>https://dev.to/fuloxdev/track-a-competitors-hiring-with-apify-and-google-sheets-4ea2</guid>
      <description>&lt;p&gt;Hiring is one of the clearest public signals of what a company plans to do. Here is how to watch a list of companies and get only the job postings that changed, using an Apify Actor I built: &lt;a href="https://apify.com/fulox/ats-hiring-signals" rel="noopener noreferrer"&gt;Job Postings API &amp;amp; Change Tracker&lt;/a&gt;. It reads Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio, and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Add companies
&lt;/h2&gt;

&lt;p&gt;Paste company domains (&lt;code&gt;acme.com&lt;/code&gt;) or career-page links into &lt;code&gt;boards&lt;/code&gt;, up to 100. The job board is detected for you, free. If detection misses, write the board yourself: &lt;code&gt;greenhouse:vercel&lt;/code&gt;, &lt;code&gt;ashby:linear&lt;/code&gt;, &lt;code&gt;lever:spotify&lt;/code&gt;, &lt;code&gt;smartrecruiters:Bosch&lt;/code&gt;, &lt;code&gt;workable:mindvalley&lt;/code&gt;, &lt;code&gt;recruitee:bunq&lt;/code&gt;, &lt;code&gt;personio:acme&lt;/code&gt;. Give the list a &lt;code&gt;watchlistName&lt;/code&gt; if you track several lists; each name keeps its own snapshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. First run
&lt;/h2&gt;

&lt;p&gt;Click Start. The first run lists every open posting (turn &lt;code&gt;emitBaseline&lt;/code&gt; off to only record the starting point). Use &lt;code&gt;titleIncludes&lt;/code&gt;, &lt;code&gt;departments&lt;/code&gt;, &lt;code&gt;locations&lt;/code&gt; or &lt;code&gt;remoteOnly&lt;/code&gt; to list only the roles you care about. Each company also gets a &lt;code&gt;type: board&lt;/code&gt; row with open roles, roles per function and a &lt;code&gt;signal&lt;/code&gt; (surge, freeze, expanding, contracting, steady).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Later runs
&lt;/h2&gt;

&lt;p&gt;Run it again, or schedule it daily or weekly. Now you get only changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type: posting&lt;/code&gt; with &lt;code&gt;status&lt;/code&gt; new, closed or changed (with &lt;code&gt;changedFields&lt;/code&gt; and &lt;code&gt;previous&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: board&lt;/code&gt; with &lt;code&gt;newCount&lt;/code&gt;, &lt;code&gt;closedCount&lt;/code&gt;, &lt;code&gt;newFunctions&lt;/code&gt;, &lt;code&gt;leadershipHires&lt;/code&gt;, &lt;code&gt;techStack&lt;/code&gt; and open roles 7 and 30 days ago&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A function hiring for the first time (&lt;code&gt;newFunctions&lt;/code&gt;) or a leadership hire is usually the strongest signal for outreach. Function, seniority and tool tags come from fixed word lists, so use them to filter and count, not as exact facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Send it somewhere
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Google Sheets: in the Actor's Integrations tab connect Google Sheets, then filter the &lt;code&gt;type&lt;/code&gt; column to &lt;code&gt;posting&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Slack: paste an incoming-webhook URL into &lt;code&gt;slackWebhookUrl&lt;/code&gt;; a digest is posted only when something changed, and it is free. &lt;code&gt;webhookUrl&lt;/code&gt; sends the same digest as JSON.&lt;/li&gt;
&lt;li&gt;Clay or any HTTP tool: call the Actor's &lt;code&gt;run-sync-get-dataset-items&lt;/code&gt; endpoint with body &lt;code&gt;{"boards":["{{domain}}"],"emitBaseline":false}&lt;/code&gt; and read &lt;code&gt;signal&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt; and &lt;code&gt;leadershipHires&lt;/code&gt; from the board row.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost and limits
&lt;/h2&gt;

&lt;p&gt;$1.50 per 1,000 results; a quiet company costs $0.0015 a day. Domain detection, unreadable boards, invalid entries, the digest and alerts are free, and you can set a maximum cost per run. The default input lists the last 7 days' postings of three companies, about $0.02; with &lt;code&gt;postedWithinDays&lt;/code&gt; cleared on a first run the same three list about 200 postings, about $0.30. Limits: not BambooHR, Workday, Teamtailor or LinkedIn; you see changes between your runs, not exact times; description edits are ignored.&lt;/p&gt;

&lt;p&gt;Disclosure: I built this Actor.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>sales</category>
      <category>automation</category>
      <category>data</category>
    </item>
    <item>
      <title>Two AI chats, one game board, and a server that trusts neither</title>
      <dc:creator>Ayush Mishra</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:06:44 +0000</pubDate>
      <link>https://dev.to/fuloxdev/two-ai-chats-one-game-board-and-a-server-that-trusts-neither-3770</link>
      <guid>https://dev.to/fuloxdev/two-ai-chats-one-game-board-and-a-server-that-trusts-neither-3770</guid>
      <description>&lt;p&gt;Every game in Fyreflock was written for two browsers in lockstep, with no referee anywhere. That held up right until I let a language model take a seat, because a model reads everything it's handed, and the thing handing it over was now a server holding both halves of the board.&lt;/p&gt;

&lt;p&gt;Some context. Fyreflock is a random text and video chat I run, with two-player games you can open against the stranger mid-conversation. It also has an MCP connector for Claude, ChatGPT and Claude Code (&lt;a href="https://github.com/FuloxDev/fyreflock-claude" rel="noopener noreferrer"&gt;github.com/FuloxDev/fyreflock-claude&lt;/a&gt;, which holds the plugin and setup; the server code quoted below lives in the main app). So the person you're playing Connect 4 against might be in a browser tab, or typing "4" into their own Claude.&lt;/p&gt;

&lt;p&gt;This is how the games survived that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lockstep games keep secrets by not sending them
&lt;/h2&gt;

&lt;p&gt;Between two browsers, both sides run the same pure reducer from the same seed. A move crosses the WebRTC data channel and the receiving side runs it through one function before applying it:&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="c1"&gt;// packages/shared/src/games/sync.ts (trimmed)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ingestMove&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;M&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GameDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;M&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MoveHashes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isLegal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ignored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verifyIncoming&lt;/span&gt;&lt;span class="p"&gt;)&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;honest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyIncoming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;honest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cheat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...plus a state-hash comparison that only means something between lockstep peers&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&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;Secrets are handled by omission. In Rock Paper Scissors you send a hash of your throw first and the throw plus its salt later, and the other side checks the reveal against the hash. A Battleship fleet never leaves the device that placed it. Nobody needs a referee, because neither peer ever holds the other one's secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then a model sits down
&lt;/h2&gt;

&lt;p&gt;The connector lets an assistant play through tools: &lt;code&gt;fyreflock_game_invite&lt;/code&gt;, &lt;code&gt;fyreflock_game_move&lt;/code&gt;, &lt;code&gt;fyreflock_game_board&lt;/code&gt; and a few more. The person says "4", the model calls the move tool, and the server applies it.&lt;/p&gt;

&lt;p&gt;Which means one process now holds both seats. If it hands Claude the raw Would You Rather state, Claude knows the other person's pick before it asks you for yours. There's no clever attack in that. The model just reads the JSON.&lt;/p&gt;

&lt;p&gt;So the server became the referee. &lt;code&gt;GameSession&lt;/code&gt; owns the one true state per conversation, applies moves through the same &lt;code&gt;ingestMove&lt;/code&gt; the browsers use, and gives each seat its own view and nothing else:&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="c1"&gt;// apps/server/src/gamesession.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** What this seat is allowed to know. Never the raw state. */&lt;/span&gt;
&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;AgentGame&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&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;Games live in memory and die with the conversation. A restart can't leave a half-played board that nobody recalls starting, which I think is the correct way to lose data.&lt;/p&gt;

&lt;p&gt;Here's one move from a text assistant:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant P as Person A
    participant M as A's assistant
    participant S as Fyreflock server
    participant G as GameSession
    participant B as Person B
    P-&amp;gt;&amp;gt;M: 4
    Note over M: Claude Code only, a hook checks the move is what P typed
    M-&amp;gt;&amp;gt;S: fyreflock_game_move with move 4
    S-&amp;gt;&amp;gt;S: screen the text, rate limit
    S-&amp;gt;&amp;gt;G: play seat 0, input 4
    G-&amp;gt;&amp;gt;G: parse, then ingestMove inside the queue
    G--&amp;gt;&amp;gt;S: ok, new state
    S-&amp;gt;&amp;gt;B: game.moved, or game.state if B has a box
    S--&amp;gt;&amp;gt;M: text board and redacted state for seat 0, in an envelope
    M--&amp;gt;&amp;gt;P: the board&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Redaction has to be per game
&lt;/h2&gt;

&lt;p&gt;Nothing in my state shapes says which fields are private. Between browsers nothing had to, since a peer only ever received what the other peer chose to send. A generic redactor would have to guess, and guessing wrong here means telling somebody the answer.&lt;/p&gt;

&lt;p&gt;So each text-playable game got its own. Would You Rather shows the whole idea:&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="c1"&gt;// packages/shared/src/games/agent.ts&lt;/span&gt;
&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;)&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;both&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;round&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;your_pick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Only once the card is face up. Before that it is the answer.&lt;/span&gt;
    &lt;span class="na"&gt;their_pick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;both&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;other&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;agreed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agreed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&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;Two Truths and a Lie hides the lie from the setter's own payload too. You obviously know which of your statements is false. Your model would also know, and it would happily repeat it into a conversation the other person is reading.&lt;/p&gt;

&lt;p&gt;Pig was a fun one. Its dice come from the seed, so whoever holds the seed can read the next roll before deciding whether to hold. Between browsers that was harmless. Once the server rolls and the client only watches, the seed has to go:&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="c1"&gt;// packages/shared/src/games/agent.ts&lt;/span&gt;
&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;_seed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rest&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;Two more MCP details bit here. Hosts pass &lt;code&gt;structuredContent&lt;/code&gt; to the model, so the redacted object is the only thing that ever goes in it, and the readable board goes in &lt;code&gt;content&lt;/code&gt;. And that board gets wrapped in the same envelope as a chat line, because Two Truths statements and Word Chain words are text a stranger typed, which makes them a prompt injection channel with a game skin on it. For the same reason the 400-character &lt;code&gt;move&lt;/code&gt; argument goes through the outbound screen like any message. 400 characters is plenty of room for a phone number or an API key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The salt can't live in a transcript
&lt;/h2&gt;

&lt;p&gt;Two Truths commits to which statement is false and reveals it after the guess, so a modified client can't pick whichever one it wasn't caught on. Between browsers each side keeps its own salt.&lt;/p&gt;

&lt;p&gt;A model can't keep a secret from its own transcript. The salt would sit right next to the answer it's meant to hide. So the server keeps the salt, makes the commitment for the player, and produces the reveal when the game's phase asks for it. The hash check still runs and still rejects a reveal that doesn't match. Only the custody moved.&lt;/p&gt;

&lt;p&gt;The lie itself arrives as its own tool argument, never inside the move text:&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="c1"&gt;// apps/server/src/mcp/tools.ts&lt;/span&gt;
&lt;span class="nx"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// Two Truths only. A separate argument rather than part of the move text because&lt;/span&gt;
  &lt;span class="c1"&gt;// it must never travel back to the model, and anything inside `move` is echoed.&lt;/span&gt;
  &lt;span class="nx"&gt;lie_index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;person_asked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;personAsked&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;h2&gt;
  
  
  Two assistants answer at once, every time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ingestMove&lt;/code&gt; is async. Two moves landing close together could both read the same state, both pass the checks against it, and both reduce from it, with the second quietly overwriting the first. With people clicking boards that's rare. With two models firing tool calls it's the normal case, since neither of them is waiting to watch a board redraw.&lt;/p&gt;

&lt;p&gt;So every state change goes through a promise chain:&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="c1"&gt;// apps/server/src/gamesession.ts&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;serialise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Chained on the settled result so one rejection cannot wedge the queue.&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;run&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;The first version only routed &lt;code&gt;play&lt;/code&gt; through it. The bot's turn and the Two Truths setter wrote &lt;code&gt;this.state&lt;/code&gt; on their own, after their own awaits, while the queue's comment promised it protected the state. Good comment. Wrong code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the chat box showed up and the model left the path
&lt;/h2&gt;

&lt;p&gt;Later the connector grew an MCP Apps widget, a small chat box drawn inside the conversation. Hosts unload that iframe whenever you look at another chat, and a WebRTC connection dies with it, so since commit &lt;code&gt;ac0a5aa&lt;/code&gt; every match with a box in it is relayed through the server:&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="c1"&gt;// apps/server/src/hub.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kindA&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;kindB&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;boxed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kindA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;boxed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kindB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No data channel means the lockstep engine had nothing to ride. But the referee already existed, so it moved onto the box's WebSocket: five socket messages that mirror the tools, each ending in a &lt;code&gt;game.state&lt;/code&gt; to every seat holding a box. In the box, the model never touches a move. It only gets a one-line note about the game, so "how's the game going?" still has an answer.&lt;/p&gt;

&lt;p&gt;Four ways in, one referee:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    T[Text assistant, no widget] --&amp;gt;|fyreflock_game_move| D
    W[Chat box on its WebSocket] --&amp;gt;|game.move| D
    F[Chat box whose host refused the socket] --&amp;gt;|fyreflock_app_game| D
    R[fyreflock.com tab in a relayed chat] --&amp;gt;|game.move| D
    D[GameDesk finds the session] --&amp;gt; Q[GameSession queue]
    Q --&amp;gt; I[ingestMove checks legality and commits, then reduces]
    I --&amp;gt; V[render and redact for a model]
    I --&amp;gt; X[board for a box]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The box draws your own move before the server answers, keyed on the state it was made from. The next board from the server is a new object, so the guess drops away by itself:&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="c1"&gt;// apps/web/src/lib/relayGame.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nl"&gt;play&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;shown&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isLegal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setLocal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seat&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game.move&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;move&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;And when the referee says no, the hub sends the true board straight after the refusal, so a box that drew the move early takes it back. The socket also got the rate limit the tool path already had: 60 moves per 10 seconds per person.&lt;/p&gt;

&lt;p&gt;Why bother with the socket when a tool call works? The host probe I ran on 2026-10-19 measured both from inside the iframe:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnm3i6k0z0khxtm65b2m8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnm3i6k0z0khxtm65b2m8.png" alt="Dot plot of two ways to reach the server from inside the chat box. On Claude Desktop, claude.ai and ChatGPT the WebSocket answers in about 270 to 283 ms, while an app-initiated tools/call takes 700 to 1116 ms. In VS Code the tools/call is about 100 ms because its proxy is local." width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On Claude and ChatGPT the socket answers in 270 to 283 ms and the host-proxied tool call takes 700 to 1116 ms. VS Code is the odd one out at about 100 ms, because its proxy runs locally. On loopback, a move reached the other box in 4 ms. Your own move costs nothing to see either way, thanks to the optimistic draw.&lt;/p&gt;

&lt;p&gt;The box plays all 25 turn-based games, while the text tools offer 5, because only those have a board you can read as characters. The three realtime games need a data channel and stay on the website. The games with a secret play in the box exactly as they do in a browser, salt held on the client. One catch: that secret lives in the box instance, so if the host reloads the box mid-game, the salt is gone and the game ends with a note. A perfect-information game just gets its board handed back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-one green tests and a game nobody could start
&lt;/h2&gt;

&lt;p&gt;The first commit of all this had twenty-one passing tests. A review the same morning found that nothing about two-player games actually worked, and the review was right.&lt;/p&gt;

&lt;p&gt;The invitation reached nobody. The function that drains a player's mailbox handled seven message types and silently threw away &lt;code&gt;game.invite&lt;/code&gt; and &lt;code&gt;game.moved&lt;/code&gt;, under a comment saying anything else was reported rather than dropped.&lt;/p&gt;

&lt;p&gt;Two Truths was registered, listed and invitable, and its parser returned &lt;code&gt;null&lt;/code&gt; for every input, so each move came back "illegal" with a hint telling the model to retry the one thing that could never work. The loser never saw the end, because the session was torn down inside the winner's call, so their next move was answered with "there is no game running". A person playing the computer while they waited, who then matched and accepted a real game, had their next move land on the bot's board while the stranger stared at an empty one.&lt;/p&gt;

&lt;p&gt;And any third account could end two other people's game, because quitting checked that a game existed instead of checking that you were in it.&lt;/p&gt;

&lt;p&gt;My favourite: the bot test asserted &lt;code&gt;solo === true&lt;/code&gt; on a session it had just built with a null second seat. It tested that I can type.&lt;/p&gt;

&lt;p&gt;The fix was 657 inserted lines across 10 files, including a new 263-line test file covering the layer where four of these were hiding: the mailbox, the board, move routing, and who's allowed to end a game. The units were fine. The thing between them was broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I'd mark private fields in the state types from day one. "Secret by not sending it" was a perfectly good rule for two peers, and it left me with zero information about what to hide once one process held everything. Every redactor was written by reading a reducer and asking "would this spoil the game?"&lt;/p&gt;

&lt;p&gt;I'd also write the first test as a whole conversation: two accounts, an invite, an accept, some moves, a finish both sides can see. That's roughly what the 263-line file became, written after the review instead of before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I still can't enforce everywhere
&lt;/h2&gt;

&lt;p&gt;The server stops illegal moves and hides secrets. What it can't do on its own is stop a model from playing a perfectly legal move its person never chose.&lt;/p&gt;

&lt;p&gt;In Claude Code it's enforced. The plugin has a &lt;code&gt;PreToolUse&lt;/code&gt; hook on the move tool that compares the move with what you typed, and the rule is equality. Its first rule was "is the sent text somewhere in what they typed", and a review found fifteen ways through that in one sitting. Typing &lt;code&gt;4&lt;/code&gt; works, &lt;code&gt;tell them 4&lt;/code&gt; works, and &lt;code&gt;column 4 please&lt;/code&gt; gets refused. On claude.ai and ChatGPT there are no hooks, so the rule is one sentence in the tool description: "they are playing, not you". The box sidesteps the whole thing by keeping the model out of the move path, and the text tools are still there for hosts that can't draw it.&lt;/p&gt;

&lt;p&gt;So here's what I keep going back and forth on. If your MCP tool acts for one person against another, would you enforce the exact words and annoy everyone who types like a human, or let the model paraphrase and accept that some day it plays a move nobody picked?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Get a weekly list of your competitor's new Google ads</title>
      <dc:creator>Ayush Mishra</dc:creator>
      <pubDate>Wed, 28 Oct 2026 18:54:21 +0000</pubDate>
      <link>https://dev.to/fuloxdev/get-a-weekly-list-of-your-competitors-new-google-ads-43l0</link>
      <guid>https://dev.to/fuloxdev/get-a-weekly-list-of-your-competitors-new-google-ads-43l0</guid>
      <description>&lt;p&gt;Google's Ads Transparency Center shows every ad an advertiser runs, but one advertiser at a time and with no export. Here is how to get a weekly list of only the new ones with an Apify Actor I built: &lt;a href="https://apify.com/fulox/google-ads-transparency-scraper" rel="noopener noreferrer"&gt;Google Ads Transparency Scraper&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Enter advertisers
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;advertisers&lt;/code&gt;, type a name, a domain or a Google advertiser id (&lt;code&gt;AR&lt;/code&gt; plus 20 digits), up to 50. Names and domains resolve to the advertiser with the most ads; the other matches are listed in the free &lt;code&gt;advertiser&lt;/code&gt; row under &lt;code&gt;alternatives&lt;/code&gt;. If the match is wrong, copy the id from there and run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. First run
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;maxAdsPerAdvertiser&lt;/code&gt; (default 40, up to 2,000) and click Start. Each ad is one row: &lt;code&gt;format&lt;/code&gt;, &lt;code&gt;firstShown&lt;/code&gt;, &lt;code&gt;lastShown&lt;/code&gt;, &lt;code&gt;imageUrl&lt;/code&gt; or &lt;code&gt;previewUrl&lt;/code&gt;, and &lt;code&gt;adUrl&lt;/code&gt;, which opens the ad on the Transparency Center. The default run costs $0.056. With &lt;code&gt;onlyNewSinceLastRun&lt;/code&gt; on, this first run is the baseline: all fetched ads are returned, flagged &lt;code&gt;isNew&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Weekly runs
&lt;/h2&gt;

&lt;p&gt;Turn on &lt;code&gt;onlyNewSinceLastRun&lt;/code&gt; and schedule the Actor weekly. Now each run returns only ads first seen since your last run. The memory is a named key-value store in your own account; &lt;code&gt;resetState&lt;/code&gt; starts over. A quiet week costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Send it somewhere
&lt;/h2&gt;

&lt;p&gt;In the Integrations tab connect Google Sheets, or use Make, Zapier or n8n, and filter rows to &lt;code&gt;type&lt;/code&gt; = &lt;code&gt;ad&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;Google does not list ads newest first, and new-only compares the first &lt;code&gt;maxAdsPerAdvertiser&lt;/code&gt; ads it returns, so for a large advertiser raise the limit. There are no region or date filters yet, and video ads come out as &lt;code&gt;unknown&lt;/code&gt; format. The price is $1.40 per 1,000 ads; lookups, empty results and failed requests are free. Not affiliated with Google.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>sales</category>
      <category>automation</category>
      <category>data</category>
    </item>
  </channel>
</rss>
