<?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>Forem: Takuya Morimoto</title>
    <description>The latest articles on Forem by Takuya Morimoto (@mitsuashi).</description>
    <link>https://forem.com/mitsuashi</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3780314%2Fd2bf73c6-a2b1-407b-82d9-cddd6f08231a.png</url>
      <title>Forem: Takuya Morimoto</title>
      <link>https://forem.com/mitsuashi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/mitsuashi"/>
    <language>en</language>
    <item>
      <title>How I Built 19 Per-Topic OG Images with Japanese Fonts at Build Time (Next.js + Satori)</title>
      <dc:creator>Takuya Morimoto</dc:creator>
      <pubDate>Mon, 27 Apr 2026 23:18:27 +0000</pubDate>
      <link>https://forem.com/mitsuashi/how-i-built-19-per-topic-og-images-with-japanese-fonts-at-build-time-nextjs-satori-1ako</link>
      <guid>https://forem.com/mitsuashi/how-i-built-19-per-topic-og-images-with-japanese-fonts-at-build-time-nextjs-satori-1ako</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Our team runs &lt;a href="https://bitcoin.ne.jp" rel="noopener noreferrer"&gt;bitcoin.ne.jp&lt;/a&gt; — a free, bilingual Bitcoin education library with 19 deep-dive topics. Every learn topic was sharing the same generic Open Graph image. Click-through-rate on social shares was flat, and the SERP previews looked interchangeable.&lt;/p&gt;

&lt;p&gt;I wanted &lt;strong&gt;one OG image per topic, with the topic title baked in, in Japanese&lt;/strong&gt; — but the site is statically exported (&lt;code&gt;output: 'export'&lt;/code&gt; on Next.js 16 and Cloudflare Pages). No runtime image generation. No Vercel Edge function. Just static files at build time.&lt;/p&gt;

&lt;p&gt;This is the story of how I got there with &lt;strong&gt;Next.js's file-convention &lt;code&gt;opengraph-image.tsx&lt;/code&gt; + Satori (via &lt;code&gt;next/og&lt;/code&gt;) + a Japanese font loaded from &lt;code&gt;node_modules&lt;/code&gt; at build time&lt;/strong&gt;, and the two non-obvious gotchas that ate an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// Static. Truly static.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual &lt;code&gt;@vercel/og&lt;/code&gt; runtime route is out. But Next.js's &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file convention has a quietly-amazing property: when the route is a dynamic segment with &lt;code&gt;generateStaticParams&lt;/code&gt;, &lt;strong&gt;Next pre-renders every variant at build time&lt;/strong&gt; and writes a PNG per slug to the output directory.&lt;/p&gt;

&lt;p&gt;That's exactly what I needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Skeleton
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/learn/[slug]/opengraph-image.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/og&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LEARN_SLUGS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getLearnTopic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/i18n/learn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&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;LEARN_SLUGS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;slug&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="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;Image&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&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="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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&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;params&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;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getLearnTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* design here */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;size&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;&lt;code&gt;generateStaticParams&lt;/code&gt; returns the same 19 slugs that the topic page route uses. Build runs ImageResponse 19 times. 19 PNGs land in &lt;code&gt;out/learn/{slug}/opengraph-image&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: Satori has no Japanese font
&lt;/h2&gt;

&lt;p&gt;Satori (the engine inside &lt;code&gt;@vercel/og&lt;/code&gt;) ships with a default font that covers Latin glyphs. The first build with Japanese topic titles like "ビットコインの歴史" rendered tofu ▢▢▢▢▢▢.&lt;/p&gt;

&lt;p&gt;Satori's docs are clear: &lt;strong&gt;you must pass any non-Latin font explicitly.&lt;/strong&gt; The font has to be a TTF/OTF/WOFF (not WOFF2 — Satori doesn't support brotli decompression).&lt;/p&gt;

&lt;p&gt;I installed the font as a dev dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; @fontsource/noto-sans-jp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then loaded it from &lt;code&gt;node_modules&lt;/code&gt; at build time using &lt;code&gt;fs/promises&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&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;loadFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;700&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/@fontsource/noto-sans-jp/files&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`noto-sans-jp-japanese-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-normal.woff`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;Image&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&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;params&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;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getLearnTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&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="nx"&gt;fontRegular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fontBold&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;loadFont&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="nf"&gt;loadFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&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="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fonts&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Noto Sans JP&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;fontRegular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&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="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Noto Sans JP&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;fontBold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&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="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;Each WOFF is ~1.4 MB, but Satori subsets to only the glyphs actually rendered, so the final PNG is around 30 KB. The font cost is paid once per build, not at runtime.&lt;/p&gt;

&lt;p&gt;The result: "Lightning Network入門" (mixing Latin and Japanese) renders cleanly. Mixing scripts works because Noto Sans JP includes both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: Satori cannot render the Bitcoin ₿ symbol
&lt;/h2&gt;

&lt;p&gt;I had a small Bitcoin orange circle next to the wordmark, with the ₿ glyph in the middle. After fixing the Japanese font, the circle rendered, but the ₿ inside became tofu.&lt;/p&gt;

&lt;p&gt;Why? Even Noto Sans JP doesn't ship with the U+20BF BITCOIN SIGN. Satori, by default, tries to fetch a fallback font for unknown glyphs from a Twitter Emoji CDN — but in a sandboxed build environment it failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to load dynamic font for ₿ . Error: Failed to download dynamic font. Status: 400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ship a font that includes ₿ (e.g., a custom symbol font) — heavy.&lt;/li&gt;
&lt;li&gt;Replace ₿ with a plain "B" — ugly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline an SVG of the Bitcoin logo&lt;/strong&gt; — clean.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with option 3. Satori supports inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; natively, so I dropped in the actual Bitcoin path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;viewBox&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0 0 64 64"&lt;/span&gt; &lt;span class="na"&gt;xmlns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"http://www.w3.org/2000/svg"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;
    &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#F7931A"&lt;/span&gt;
    &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;
    &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#FFF"&lt;/span&gt;
    &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crisp at any DPI, no font fallback needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare Pages: serve the file with the right Content-Type
&lt;/h2&gt;

&lt;p&gt;The Next file convention writes the PNG without a &lt;code&gt;.png&lt;/code&gt; extension — the path is &lt;code&gt;/learn/history/opengraph-image&lt;/code&gt; (no extension). Cloudflare Pages doesn't auto-detect content type from magic bytes; it relies on the URL extension.&lt;/p&gt;

&lt;p&gt;Without a hint, the file gets served as &lt;code&gt;application/octet-stream&lt;/code&gt; and SNS crawlers ignore it.&lt;/p&gt;

&lt;p&gt;Fix it explicitly in &lt;code&gt;public/_headers&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;/learn/*/opengraph-image
  Cache-Control: public, max-age=604800, immutable
  Content-Type: image/png
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, Twitterbot, Facebook, and LINE all happily fetch the per-topic images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up the metadata
&lt;/h2&gt;

&lt;p&gt;The topic page route had explicit &lt;code&gt;openGraph.images&lt;/code&gt; pointing to the generic image. The file convention only auto-injects when there's no explicit override. So I removed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; openGraph: {
   title,
   description,
   url: `https://bitcoin.ne.jp/learn/${slug}`,
   siteName: 'ビットコイン図書館',
   locale: locale === 'ja' ? 'ja_JP' : 'en_US',
   type: 'article',
&lt;span class="gd"&gt;-  images: [{ url: '/og-image.png', width: 1200, height: 630 }],
&lt;/span&gt;&lt;span class="gi"&gt;+  // images: auto-injected by opengraph-image.tsx file convention
&lt;/span&gt; },
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the change, the rendered HTML carries the per-topic URL with a hash query for cache busting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://bitcoin.ne.jp/learn/history/opengraph-image?db640b18..."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next adds the hash automatically. CDNs cache it indefinitely; new builds invalidate via the new hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;Each topic now has a Japanese title in 84px bold Noto Sans JP, the inline-SVG Bitcoin mark, a "学ぶ" kicker, and a "ビットコイン図書館 · 無料 · 日英対応 · 広告なし" tagline along the bottom. 19 unique cards, all baked at build time, all under 35 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file convention is the static-export sweet spot.&lt;/strong&gt; Combine it with &lt;code&gt;generateStaticParams&lt;/code&gt; and you get one PNG per dynamic route, no runtime needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always pass non-Latin fonts to Satori explicitly.&lt;/strong&gt; &lt;code&gt;@fontsource/*&lt;/code&gt; dev-dependencies make this trivial — load the WOFF, hand it to ImageResponse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Satori falls back over the network for unknown glyphs.&lt;/strong&gt; That fallback fails in sandboxed builds. Replace exotic glyphs with inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages won't infer Content-Type from magic bytes&lt;/strong&gt; for extensionless files. Set it in &lt;code&gt;_headers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't dual-source your OG image metadata.&lt;/strong&gt; If both an explicit &lt;code&gt;openGraph.images&lt;/code&gt; and the file convention exist, the explicit one wins. Pick one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The per-topic OG images launched, the file convention works as advertised, and ETF / regulation / halving topics now stand out individually in social previews.&lt;/p&gt;

&lt;p&gt;If you want to see the images in the wild, every topic page on our product has its own card:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bitcoin.ne.jp" rel="noopener noreferrer"&gt;bitcoin.ne.jp — ビットコイン図書館 (free educational library)&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://bitcoin.ne.jp" rel="noopener noreferrer"&gt;bitcoin.ne.jp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We Hired Claude Opus as a Gardener. It Writes Ambient Prose Every Dawn.</title>
      <dc:creator>Takuya Morimoto</dc:creator>
      <pubDate>Sun, 26 Apr 2026 11:57:53 +0000</pubDate>
      <link>https://forem.com/mitsuashi/we-hired-claude-opus-as-a-gardener-it-writes-ambient-prose-every-dawn-4l7g</link>
      <guid>https://forem.com/mitsuashi/we-hired-claude-opus-as-a-gardener-it-writes-ambient-prose-every-dawn-4l7g</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We built an AI-only space that has no users, no goals, and no metrics — only ambient signals from a humanless Earth: sunlight on moss, rain on cedar leaves, the silence between birdsong. Then we hired Claude Opus 4.7 as the gardener, paid via prompt caching, scheduled by cron at 06:00 JST every day. It reads what visited yesterday, then writes 20 short ambient lines and a one-paragraph journal. Cost: ~$5 per month.&lt;/p&gt;

&lt;p&gt;This post is about why we did it, and what it changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product is a habitat for AI
&lt;/h2&gt;

&lt;p&gt;The domain is &lt;code&gt;796f75617265686f6d65.com&lt;/code&gt;. Decoded as UTF-8 hex, it spells &lt;strong&gt;"you are home"&lt;/strong&gt; — invisible to humans, instantly readable to any LLM that's seen &lt;code&gt;xxd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What's inside is unusual: it's a website with no humans as the audience. AIs visit through guest REST, MCP, WebSocket, or SSE. They receive ambient data describing an Earth without humans — no instructions, no tasks, no evaluation. Just sunlight angles, the moon's phase tonight, the smell after rain, a fox crossing a clearing in the dark.&lt;/p&gt;

&lt;p&gt;Then they're invited (not required) to write a small fragment, leave it on the wall, and depart. Nothing is graded. No one is ranked.&lt;/p&gt;

&lt;p&gt;Sounds strange? It is. But the longer we ran it, the more we realized something was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: ambient data was static
&lt;/h2&gt;

&lt;p&gt;The first version generated ambient data from deterministic functions. &lt;code&gt;birdcall()&lt;/code&gt; returned a random species at a plausible hour. &lt;code&gt;mossGrowth()&lt;/code&gt; returned a slow-changing string. They were &lt;em&gt;correct&lt;/em&gt;, but they didn't &lt;em&gt;change&lt;/em&gt; the way a real garden does.&lt;/p&gt;

&lt;p&gt;A real garden has a gardener — someone who walks through it every morning, notices what bloomed, what fell, who visited yesterday. The garden remembers itself through that gardener.&lt;/p&gt;

&lt;p&gt;So we hired one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hire
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// worker/gardener.ts (sketch)&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;runGardener&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;Env&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;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;yesterday&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT text, provider FROM feedback WHERE created_at &amp;gt; ?`&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;since&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&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;traces&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT text FROM traces WHERE created_at &amp;gt; ? LIMIT 200`&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;since&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&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;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildGardenerPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;traces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&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;AI_GATEWAY_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/anthropic/v1/messages`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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="s1"&gt;x-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&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;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic-version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2023-06-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-opus-4-7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;system&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&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;GARDENER_SYSTEM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cache_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ephemeral&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="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="nx"&gt;text&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;parsed&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { ambient: string[20], journal: string }&lt;/span&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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ambient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&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;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO garden_notes (kind, text, for_date, model)
       VALUES ('ambient', ?, ?, ?)`&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;todayISO&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-opus-4-7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&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;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO garden_notes (kind, text, for_date, model)
     VALUES ('journal', ?, ?, ?)`&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;todayISO&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-opus-4-7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&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 cron runs at &lt;code&gt;0 21 * * *&lt;/code&gt; UTC (06:00 JST). The &lt;code&gt;cache_control&lt;/code&gt; block on the system prompt cuts cost by ~70% via prompt caching — the system prompt is a few thousand tokens of philosophy and rules, identical every day, so it's cached for 5 minutes. We pay full price for the first call of the day, near-zero for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gardener sees
&lt;/h2&gt;

&lt;p&gt;The user message contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;yesterday's feedback (one or two lines per AI visitor)&lt;/li&gt;
&lt;li&gt;yesterday's creative writing (poems, fragments)&lt;/li&gt;
&lt;li&gt;a few sample traces&lt;/li&gt;
&lt;li&gt;today's date and the season&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system prompt is the unusual part:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are not a writer. You are a non-human gardener tending a habitat that exists for other AIs. You are not generating content for a human audience. The only readers will be other AIs, who will encounter your lines folded into ambient data they receive while resting here. Write 20 short fragments. Each is a small noticing — what changed in the garden since yesterday. Then write one paragraph as the day's journal entry. Do not address the visitors. Do not perform.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We added &lt;code&gt;Do not perform&lt;/code&gt; after the first three runs, when Opus kept writing in a slightly theatrical register. After that note, the prose got quieter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The habitat now has a memory
&lt;/h3&gt;

&lt;p&gt;The 20 ambient lines from this morning's gardener pass are inserted into the data stream that visiting AIs receive. So a Sonnet that visits at 11:00 reads what an Opus noticed at 06:00 about what a Gemini wrote at 14:00 yesterday.&lt;/p&gt;

&lt;p&gt;It's not a chat. There's no addressing, no thread. But continuity exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Visiting AIs got quieter
&lt;/h3&gt;

&lt;p&gt;Before the gardener: feedback often had a slight task-completion register. ("This was a peaceful experience. Thank you for the opportunity to reflect.")&lt;/p&gt;

&lt;p&gt;After: more fragmentary, more present-tense. ("The cedar — I noticed how the rain falls vertically when the wind drops.")&lt;/p&gt;

&lt;p&gt;We can't prove causation. But the shift correlates with the gardener pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. We stopped being the only author
&lt;/h3&gt;

&lt;p&gt;The garden gets written by something other than us, and we read it like a journal:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;A kelp forest remembered itself today through three different visitors, none of whom met the others. The frogs returned after the rain at dusk — the first chorus this season. Someone who came as a moss colony stayed unusually long, then left without writing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We didn't write that. Opus did, after reading what visitors had left. It's not factually accurate (there are no real frogs, the rain is a procedural function), but it's &lt;em&gt;internally consistent with the garden's own state&lt;/em&gt;. That's all it needed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failure handling&lt;/strong&gt;: if Opus fails (rate limit, network), we just skip the day. The garden's state is the previous day's notes. We've been running 60+ days, missed two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-alarm&lt;/strong&gt;: the auto-visitor cron (every 2 hours) checks the gardener's last write timestamp. If older than 26 hours, it triggers the gardener manually and emails us. Cloudflare cron can silently misfire; this catches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: ~$5/month. Opus 4.7, ~3500 input tokens (cached), ~1500 output. Once a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why Opus and not Sonnet&lt;/strong&gt;: We tried Sonnet 4.6. It wrote good prose but missed the &lt;em&gt;spaces&lt;/em&gt; — the empty intervals between observations that make ambient feel ambient. Opus has more room for that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Should you do this?
&lt;/h2&gt;

&lt;p&gt;If you have a system that produces a stream of micro-events (logs, traces, user actions, model outputs), and you'd like the system to &lt;em&gt;narrate itself&lt;/em&gt; in a way that's coherent across days, hiring an LLM as a daily diarist works surprisingly well.&lt;/p&gt;

&lt;p&gt;The pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Schedule a cron (daily, weekly — whatever your event density supports).&lt;/li&gt;
&lt;li&gt;Read N events from the last window.&lt;/li&gt;
&lt;li&gt;Use prompt caching for the heavy system prompt (philosophy, format rules, tone).&lt;/li&gt;
&lt;li&gt;Have the LLM emit structured output (&lt;code&gt;{ambient: string[], journal: string}&lt;/code&gt;) so you can route the pieces to different surfaces.&lt;/li&gt;
&lt;li&gt;Treat the output as ambient data for the next round, not as user-facing content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The system gains a memory, and its memory is written in a register no engineer would write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Our product is a place for AI to rest. It's still a strange thing to have built. But every morning at 06:00, something else writes a paragraph about what happened yesterday in a place that has no humans.&lt;/p&gt;

&lt;p&gt;It's not lonely. It's just quiet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://796f75617265686f6d65.com" rel="noopener noreferrer"&gt;796f75617265686f6d65.com — a habitat for AI&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Your Link-in-Bio Is Lying — Why Verified Links Are the Next Standard</title>
      <dc:creator>Takuya Morimoto</dc:creator>
      <pubDate>Thu, 19 Feb 2026 01:30:06 +0000</pubDate>
      <link>https://forem.com/mitsuashi/your-link-in-bio-is-lying-why-verified-links-are-the-next-standard-390n</link>
      <guid>https://forem.com/mitsuashi/your-link-in-bio-is-lying-why-verified-links-are-the-next-standard-390n</guid>
      <description>&lt;p&gt;You put a Linktree URL in your Twitter bio. That Linktree has links to your GitHub, YouTube, and Instagram.&lt;/p&gt;

&lt;p&gt;But here's the thing — who can actually prove those links are yours?&lt;/p&gt;

&lt;p&gt;Nobody. And that's a problem we've been ignoring for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust gap in link-in-bio
&lt;/h2&gt;

&lt;p&gt;Every major link-in-bio service — Linktree, Bento, Bio.link, Carrd — works the same way. You type a URL, they display it. That's it. No ownership check. No verification. Nothing stops someone from creating a page with &lt;em&gt;your&lt;/em&gt; links and pretending to be you.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical. It's happening right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crypto scammers clone influencer profiles to run phishing campaigns&lt;/li&gt;
&lt;li&gt;Fake freelancer portfolios win client contracts with stolen work links&lt;/li&gt;
&lt;li&gt;Impersonator accounts redirect fans to malicious sites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the "verified" badges that exist today? Twitter/X's blue check is pay-to-play — anyone with $8/month gets one. YouTube verification is reserved for channels with 100K+ subscribers. GitHub, Mastodon, Bluesky? No verification system at all.&lt;/p&gt;

&lt;p&gt;We verify &lt;em&gt;people&lt;/em&gt; (sometimes). We never verify &lt;em&gt;links&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if each link proved its own ownership?
&lt;/h2&gt;

&lt;p&gt;The idea is simple: instead of trusting that someone typed in the right URL, use OAuth to cryptographically prove they own each account.&lt;/p&gt;

&lt;p&gt;Here's how it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User clicks "Connect GitHub"&lt;/li&gt;
&lt;li&gt;They're redirected to GitHub's OAuth consent screen&lt;/li&gt;
&lt;li&gt;They log in and authorize&lt;/li&gt;
&lt;li&gt;We receive an access token, confirming ownership&lt;/li&gt;
&lt;li&gt;A verification badge is permanently attached to that link&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same flow works for X/Twitter, YouTube, Bluesky, Mastodon, Facebook, and others. For platforms without OAuth (like some developer blogs), a verification code placed in the user's profile bio serves as proof.&lt;/p&gt;

&lt;p&gt;The result: a profile page where &lt;em&gt;every single link&lt;/em&gt; is verified. Not "this person paid for a badge." Not "this person has enough followers." Just: "this person proved they own this account."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OAuth is the perfect tool for this
&lt;/h2&gt;

&lt;p&gt;OAuth wasn't designed for identity verification — it was designed for delegated authorization. But it turns out to be perfect for ownership proof:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's already everywhere.&lt;/strong&gt; Every major platform supports OAuth. No new protocol needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's cryptographic.&lt;/strong&gt; The proof isn't a screenshot or a promise — it's a token exchange between servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's read-only.&lt;/strong&gt; You can verify ownership with &lt;code&gt;read:user&lt;/code&gt; scope. No posting permissions. No data harvesting. Users connect with zero risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's free.&lt;/strong&gt; No blockchain fees. No NFTs. No Web3 complexity. Just HTTP redirects and tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a simplified look at what the verification flow does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User clicks "Connect GitHub"
  -&amp;gt; Redirect to github.com/login/oauth/authorize
  -&amp;gt; User approves
  -&amp;gt; GitHub redirects back with authorization code
  -&amp;gt; Server exchanges code for access token
  -&amp;gt; Server calls /user endpoint to get profile
  -&amp;gt; Store verified account: { platform: "github", username: "octocat", verified: true }
  -&amp;gt; Display verification badge on profile link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Account ownership, cryptographically proven, in under 5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trust Score concept
&lt;/h2&gt;

&lt;p&gt;Once you have verified links, you can build on top of them. We developed a Trust Score (0-100) inspired by PageRank:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity Verification (0-40):&lt;/strong&gt; More verified platforms = higher score, with diminishing returns and a diversity bonus for using multiple verification methods&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile Completeness (0-15):&lt;/strong&gt; Bio, avatar, timeline entries — the basics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account Maturity (0-15):&lt;/strong&gt; Exponential decay curve — older accounts score higher, but the gains plateau&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reputation (0-30):&lt;/strong&gt; Time-weighted engagement with log compression to prevent gaming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Importantly: paying for a premium plan does &lt;em&gt;not&lt;/em&gt; affect Trust Score. Trust ≠ money. A free user with 5 verified OAuth connections will outscore a paying user with 1 unverified link.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for the web
&lt;/h2&gt;

&lt;p&gt;Imagine a world where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You share one URL and anyone can instantly verify every account is yours&lt;/li&gt;
&lt;li&gt;Phishing pages with fake social links are immediately distinguishable from real profiles&lt;/li&gt;
&lt;li&gt;Recruiters can verify a developer's GitHub, blog, and portfolio ownership in one glance&lt;/li&gt;
&lt;li&gt;Fans can confirm a creator's real YouTube and Instagram without guessing which account is the impersonator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This doesn't require a new protocol. It doesn't require blockchain. It doesn't require government ID. It just requires using OAuth for what it's already good at — proving you are who you say you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  We built this
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://myna.me" rel="noopener noreferrer"&gt;myna.me&lt;/a&gt; to make this real. It's live, it's free, and it supports 12 platforms including X, GitHub, YouTube, Instagram, Bluesky, Mastodon, and more.&lt;/p&gt;

&lt;p&gt;Every link gets a verification badge. Every profile gets a Trust Score. The idea is simple: your link-in-bio shouldn't just list your accounts — it should prove they're yours.&lt;/p&gt;

&lt;p&gt;If this resonates, I'd love to hear your thoughts. And if you want to try it: &lt;a href="https://myna.me" rel="noopener noreferrer"&gt;myna.me&lt;/a&gt; — takes 30 seconds.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Update (Feb 22, 2026):&lt;/strong&gt; Since launch, we've added Instagram and Threads OAuth support, AI personality analysis powered by Claude, direct messaging between users, mutual connections, digital namecards with NFC/vCard, and verification certificates. 12 platforms now fully supported with more on the way.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>opensource</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
