<?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: Marco Montanari</title>
    <description>The latest articles on DEV Community by Marco Montanari (@sirmmo).</description>
    <link>https://dev.to/sirmmo</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%2F695621%2F9d15ef5a-91f6-449f-a579-00314ac8a6bf.jpeg</url>
      <title>DEV Community: Marco Montanari</title>
      <link>https://dev.to/sirmmo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sirmmo"/>
    <language>en</language>
    <item>
      <title>stairway: from a chat idea to a running Claude Code experiment on my own server</title>
      <dc:creator>Marco Montanari</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:37:09 +0000</pubDate>
      <link>https://dev.to/sirmmo/stairway-from-a-chat-idea-to-a-running-claude-code-experiment-on-my-own-server-2eoh</link>
      <guid>https://dev.to/sirmmo/stairway-from-a-chat-idea-to-a-running-claude-code-experiment-on-my-own-server-2eoh</guid>
      <description>&lt;p&gt;Most of my side projects start the same way: I'm on my phone, talking an idea through with Claude, and at&lt;br&gt;
some point the conversation is good enough that I want to &lt;em&gt;try it&lt;/em&gt;. Then the momentum dies. I have to get to&lt;br&gt;
a laptop, SSH into my server, make a folder, start Claude Code, and paste in a summary of everything we just&lt;br&gt;
discussed.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/sirmmo/stairway" rel="noopener noreferrer"&gt;stairway&lt;/a&gt;&lt;/strong&gt;: a small MCP server that lets the chat do that&lt;br&gt;
step itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Ok, let's try it. Create an experiment called &lt;code&gt;rss-digest&lt;/code&gt; and brief it with what we said."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A few seconds later there's a folder on my server, a git repo in it, and a Claude Code session already working&lt;br&gt;
on the brief. The session shows up in claude.ai/code, so I can follow it from my phone.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;stairway is one Python file, standard library only (Python 3.8+), about 1,700 lines. It exposes these MCP&lt;br&gt;
tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;create_experiment(name, prompt)&lt;/code&gt; creates &lt;code&gt;&amp;lt;root&amp;gt;/&amp;lt;name&amp;gt;&lt;/code&gt;, runs &lt;code&gt;git init&lt;/code&gt;, and starts
&lt;code&gt;claude --remote-control &amp;lt;name&amp;gt; "&amp;lt;prompt&amp;gt;"&lt;/code&gt; inside a detached &lt;code&gt;screen&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;send_prompt(name, prompt)&lt;/code&gt; passes more instructions to that running session, mid-conversation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attach_folder(path)&lt;/code&gt; lets Claude use an &lt;em&gt;existing&lt;/em&gt; project, but only after I press &lt;strong&gt;Approve&lt;/strong&gt; on
stairway's status page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;experiment_status&lt;/code&gt;, &lt;code&gt;open_editor&lt;/code&gt;, &lt;code&gt;stop_experiment&lt;/code&gt;, &lt;code&gt;forget_experiment&lt;/code&gt; and friends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Around those tools there are three ways to work on an experiment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;claude.ai/code&lt;/strong&gt;, because every session runs with remote control on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A browser VS Code&lt;/strong&gt;, started on demand per experiment and proxied by stairway. The first terminal joins
the Claude session, and every other terminal is a normal shell in the folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Telegram group.&lt;/strong&gt; Add the bot, approve the group, &lt;code&gt;/new picnic-planner &amp;lt;brief&amp;gt;&lt;/code&gt;, and from then on you
talk to Claude by mentioning the bot, while the humans discuss what it builds. Its replies, permission
requests and files come back to the group.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The parts I found interesting
&lt;/h2&gt;
&lt;h3&gt;
  
  
  An OAuth 2.1 server with no storage
&lt;/h3&gt;

&lt;p&gt;My first version used a token in the URL. claude.ai reached the server and got a valid &lt;code&gt;initialize&lt;/code&gt;&lt;br&gt;
response… and then went looking for OAuth anyway (&lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt;, dynamic client&lt;br&gt;
registration at &lt;code&gt;/register&lt;/code&gt;), and gave up when those returned 404.&lt;/p&gt;

&lt;p&gt;So stairway is its own small OAuth 2.1 authorization server: discovery documents, dynamic client&lt;br&gt;
registration, PKCE (S256), refresh tokens. The twist is that &lt;strong&gt;nothing is stored&lt;/strong&gt;. Client IDs, access tokens&lt;br&gt;
and refresh tokens are all HMAC-signed blobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;_b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauth.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A registered client is just &lt;code&gt;{"k": "client", "r": [redirect_uris], "n": name}&lt;/code&gt;, signed. Only the short-lived&lt;br&gt;
authorization codes live in memory. As a bonus, rotating the one server secret revokes every grant at once.&lt;/p&gt;
&lt;h3&gt;
  
  
  Typing into a TUI, reliably
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;claude --remote-control&lt;/code&gt; gives you an interactive session that's also visible on claude.ai. To pass it more&lt;br&gt;
prompts, stairway types into the &lt;code&gt;screen&lt;/code&gt; it runs in. A naive &lt;code&gt;screen -X stuff "text\r"&lt;/code&gt; breaks on multi-line&lt;br&gt;
prompts, since each newline submits. It also mangles &lt;code&gt;^&lt;/code&gt; and &lt;code&gt;\&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is a bracketed paste: load the text into screen's paste buffer from a file, wrapped in&lt;br&gt;
&lt;code&gt;ESC[200~ … ESC[201~&lt;/code&gt;, paste it, then press Enter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\033&lt;/span&gt;&lt;span class="s"&gt;[200~&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\033&lt;/span&gt;&lt;span class="s"&gt;[201~&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;subprocess&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;screen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readbuf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;subprocess&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;screen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paste&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;_stuff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code sees it as one paste, multi-line and with every character intact, and queues it if it's busy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting answers &lt;em&gt;out&lt;/em&gt;: Claude Code hooks
&lt;/h3&gt;

&lt;p&gt;For Telegram I needed Claude's replies, and scraping a TUI is miserable. Claude Code's hooks solve this. The&lt;br&gt;
&lt;code&gt;Stop&lt;/code&gt; hook payload includes &lt;code&gt;last_assistant_message&lt;/code&gt;, so each session starts with:&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="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"curl -s --data-binary @- 'http://127.0.0.1:8765/hooks/&amp;lt;name&amp;gt;/stop?k=&amp;lt;key&amp;gt;'"&lt;/span&gt;&lt;span class="p"&gt;}]}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Notification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"curl -s --data-binary @- '…/notify?k=&amp;lt;key&amp;gt;'"&lt;/span&gt;&lt;span class="p"&gt;}]}]&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;p&gt;stairway relays the reply to the bound Telegram group, as a reply to the message that asked. Permission&lt;br&gt;
prompts arrive as "⏳ needs your permission", which the group can answer with &lt;code&gt;/approve&lt;/code&gt; or &lt;code&gt;/deny&lt;/code&gt;. An&lt;br&gt;
&lt;code&gt;--append-system-prompt&lt;/code&gt; tells Claude which &lt;code&gt;curl&lt;/code&gt; sends a file it made to the group, so charts and images&lt;br&gt;
just show up there.&lt;/p&gt;
&lt;h3&gt;
  
  
  The red bars
&lt;/h3&gt;

&lt;p&gt;Inside &lt;code&gt;screen&lt;/code&gt;, Claude Code's UI was covered in solid red bars. Capturing the raw output showed why: Claude&lt;br&gt;
draws with 24-bit colors (&lt;code&gt;ESC[38;2;153;153;153m&lt;/code&gt;), and screen 4.x doesn't understand them. It reads &lt;code&gt;38&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;2&lt;/code&gt;, &lt;code&gt;153&lt;/code&gt;, … as &lt;em&gt;separate&lt;/em&gt; attributes, and some of those numbers mean "red background". Setting&lt;br&gt;
&lt;code&gt;COLORTERM&lt;/code&gt; or &lt;code&gt;FORCE_COLOR&lt;/code&gt; didn't help. Starting the session with Claude Code's ANSI theme did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--settings '{"theme": "dark-ansi"}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's set per process, so my own Claude Code theme stays as it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  A browser VS Code without dependencies
&lt;/h3&gt;

&lt;p&gt;code-server listens on a unix socket per experiment, and stairway proxies &lt;code&gt;/code/&amp;lt;name&amp;gt;/&lt;/code&gt; to it with a&lt;br&gt;
deliberately dumb byte pipe. It forwards the request head, then shovels bytes both ways with &lt;code&gt;select()&lt;/code&gt; until&lt;br&gt;
either side closes. Websockets just work, and plain requests get &lt;code&gt;Connection: close&lt;/code&gt; so a keep-alive&lt;br&gt;
connection never gets mixed up between routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security, honestly
&lt;/h2&gt;

&lt;p&gt;stairway's purpose is to let an AI agent run commands on your machine when asked. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one strong secret, OAuth with your explicit consent, and HTTPS in front&lt;/li&gt;
&lt;li&gt;existing folders and Telegram groups do nothing until you approve them on the status page&lt;/li&gt;
&lt;li&gt;choose each experiment's Claude Code &lt;code&gt;permission_mode&lt;/code&gt; deliberately; &lt;code&gt;default&lt;/code&gt; asks before acting&lt;/li&gt;
&lt;li&gt;run it as a dedicated user, ideally on a box you're happy to give an agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo has a &lt;a href="https://github.com/sirmmo/stairway/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security doc&lt;/a&gt; with the full&lt;br&gt;
threat model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sirmmo/stairway.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;stairway
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# set STAIRWAY_TOKEN, STAIRWAY_PUBLIC_URL, ...&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; ./.env&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a&lt;span class="p"&gt;;&lt;/span&gt; python3 stairway.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put it behind HTTPS and add &lt;code&gt;https://&amp;lt;host&amp;gt;/mcp&lt;/code&gt; as a custom connector in claude.ai. Then ask Claude to&lt;br&gt;
create your first experiment. You'll need Claude Code logged in with a claude.ai subscription on the server,&lt;br&gt;
because remote control requires it. GNU &lt;code&gt;screen&lt;/code&gt; is required, and code-server and a Telegram bot token are&lt;br&gt;
optional.&lt;/p&gt;

&lt;p&gt;I built it in a morning of pair-programming with Claude Code, which was a fitting way to make a tool for&lt;br&gt;
starting Claude Code sessions. It's MIT licensed. Issues and PRs are welcome:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/sirmmo/stairway" rel="noopener noreferrer"&gt;github.com/sirmmo/stairway&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What would you want your chat to be able to spin up?&lt;/p&gt;

</description>
      <category>claude</category>
      <category>mcp</category>
      <category>ai</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Markette: an open-source, self-hosted tool to write once and publish everywhere</title>
      <dc:creator>Marco Montanari</dc:creator>
      <pubDate>Sun, 11 Oct 2026 07:27:21 +0000</pubDate>
      <link>https://dev.to/sirmmo/markette-an-open-source-self-hosted-tool-to-write-once-and-publish-everywhere-5bmd</link>
      <guid>https://dev.to/sirmmo/markette-an-open-source-self-hosted-tool-to-write-once-and-publish-everywhere-5bmd</guid>
      <description>&lt;p&gt;I publish to a lot of places: Bluesky, Mastodon, X, LinkedIn, a couple of Telegram channels, a blog. I also help a few organizations that do the same, each with its own accounts and its own people. The existing tools were either SaaS with per-channel pricing, or self-hosted but limited to a handful of networks with a scheduler that was little more than a cron job.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://markette.org" rel="noopener noreferrer"&gt;Markette&lt;/a&gt;&lt;/strong&gt;: write a post once, adapt it for each network, schedule it at the right moment, and see what worked. It is open source (AGPL-3.0) and you can run it on your own server with one &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This article is a tour of what it does and how it is built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it publishes to
&lt;/h2&gt;

&lt;p&gt;24 destinations, behind a single provider contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Social:&lt;/strong&gt; X, Bluesky, Threads, Mastodon (any instance), LinkedIn (people and company pages), Facebook Pages, Instagram, YouTube, Reddit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging:&lt;/strong&gt; Telegram, Slack, Discord, WhatsApp, WeChat, QQ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blogs and sites:&lt;/strong&gt; WordPress, Drupal, Ghost, Medium, DEV (and any Forem community), Hugo (it commits a page bundle to your git repo)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Newsletters:&lt;/strong&gt; Brevo and Listmonk, where each post becomes an email campaign&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything else:&lt;/strong&gt; a signed JSON webhook, for n8n, Make, Zapier or your own code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, this article was written in Markette and published through its DEV integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write once, tailor per channel
&lt;/h2&gt;

&lt;p&gt;A post fans out into one &lt;em&gt;target&lt;/em&gt; per channel. Every target can override the text, media and options: a short version for X, a longer one with a first comment for LinkedIn, a title and tags for the blog. Character counters count the way each network does (X weighs links as 23 characters, Bluesky counts graphemes), and every target is validated against its network's rules before anything is scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scheduler you can trust
&lt;/h2&gt;

&lt;p&gt;Scheduling is the part I cared about most. Each target gets its own time, chosen by one of these modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;now&lt;/strong&gt; or an &lt;strong&gt;exact&lt;/strong&gt; time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;queue:&lt;/strong&gt; the next free weekly slot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;best time:&lt;/strong&gt; the best-scoring free hour for &lt;em&gt;that&lt;/em&gt; channel, keeping a minimum gap from other posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Celery beat process ticks every 15 seconds and claims due targets with &lt;code&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt;, so several workers never publish the same thing twice. Errors are classified as &lt;em&gt;retryable&lt;/em&gt; (rate limits, 5xx, network: exponential back-off, honouring &lt;code&gt;Retry-After&lt;/code&gt;), &lt;em&gt;auth&lt;/em&gt; (the channel is flagged for reconnection) or &lt;em&gt;permanent&lt;/em&gt;. A worker that dies mid-publish leaves the target as "failed, check before retrying" instead of retrying blindly and risking a duplicate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best time to post, from your own data
&lt;/h2&gt;

&lt;p&gt;The best-time engine keeps a 7×24 grid (weekday × hour) of scores for every channel. It starts from a per-platform prior, so a new channel gets sensible defaults, then blends in the engagement rate of your own posts from the last 180 days. The more posts land in an hour, the more your data wins over the prior. The UI tells you whether a grid comes from defaults, a blend, or your data alone.&lt;/p&gt;

&lt;p&gt;Metrics are pulled 1 h, 6 h, 24 h, 3 d, 7 d and 30 d after publishing, plus a daily audience count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional AI, with any provider
&lt;/h2&gt;

&lt;p&gt;AI is optional and goes through OpenRouter or any OpenAI-compatible API (OpenAI, Azure, vLLM, Ollama…). It can suggest times (checked against the engine and the calendar), plan a campaign over a period, and rewrite one text for each network's length and tone. Every call is logged with its tokens and cost, and organizations can have monthly limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built for teams and agencies
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Organizations contain workspaces, one per brand or client, with roles from owner to viewer&lt;/li&gt;
&lt;li&gt;Channels belong to the organization and can be shared across workspaces&lt;/li&gt;
&lt;li&gt;Teams can restrict channels and require approval before posts are scheduled&lt;/li&gt;
&lt;li&gt;Single sign-on with any OpenID Connect provider, per organization&lt;/li&gt;
&lt;li&gt;Optional Stripe billing if you host it for others&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  More ways in
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP server:&lt;/strong&gt; connect Claude, or any MCP client, and ask it to draft, validate and schedule posts. Every call runs as you, with your roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome extension:&lt;/strong&gt; share the page, a selection, a link or an image straight to the composer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android and iOS apps&lt;/strong&gt;, with a share sheet.&lt;/li&gt;
&lt;li&gt;A documented &lt;strong&gt;REST API&lt;/strong&gt; with personal tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Django 6 + Django REST Framework, Celery, PostgreSQL, Redis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Angular 21, served by nginx or GitHub Pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile:&lt;/strong&gt; Capacitor&lt;/li&gt;
&lt;li&gt;Channel credentials are encrypted at rest (Fernet), and secrets are write-only in the API&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/markette-org/markette-deploy.git
&lt;span class="nb"&gt;cd &lt;/span&gt;markette-deploy &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# set the keys and your domain&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;api python manage.py createsuperuser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/markette-org/markette-deploy/blob/main/SELF_HOSTING.md" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt; explains where to find the keys for every network.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Website: &lt;a href="https://markette.org" rel="noopener noreferrer"&gt;markette.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/markette-org" rel="noopener noreferrer"&gt;github.com/markette-org&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues, pull requests and new providers are very welcome. I'd love to hear which network you'd want next, and how you handle publishing to many places today.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>django</category>
      <category>angular</category>
    </item>
    <item>
      <title>pg_i18n: translatable columns in PostgreSQL without rewriting your app</title>
      <dc:creator>Marco Montanari</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:53:43 +0000</pubDate>
      <link>https://dev.to/sirmmo/pgi18n-translatable-columns-in-postgresql-without-rewriting-your-app-b51</link>
      <guid>https://dev.to/sirmmo/pgi18n-translatable-columns-in-postgresql-without-rewriting-your-app-b51</guid>
      <description>&lt;p&gt;Every project I have worked on eventually needed a second language for some text stored in the database. Product names, category labels, descriptions, menu entries. And every time, the same thing happened: the schema was already there, the API was already reading and writing those columns as plain strings, and nobody wanted to touch either.&lt;/p&gt;

&lt;p&gt;So the pragmatic hack appeared: someone started writing JSON into the text column.&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="err"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-----------------------------------&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Chair&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chair"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"it"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sedia"&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;p&gt;Half the rows plain strings, half of them JSON objects, and the application still treating everything as a string. It works until someone asks for a proper list of Italian product names.&lt;/p&gt;

&lt;p&gt;I wrote &lt;a href="https://github.com/sirmmo/pg_i18n" rel="noopener noreferrer"&gt;pg_i18n&lt;/a&gt; to make this state of affairs a feature instead of a bug. It is pure SQL and PL/pgSQL, no compiled code, and it installs either as a PostgreSQL extension or as a script you load with &lt;code&gt;psql&lt;/code&gt;. It works on PostgreSQL 9.5 and up, tested on 14, 16 and 17.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading and writing one language
&lt;/h2&gt;

&lt;p&gt;The core is two functions that understand both shapes of the column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 'Chair'                       -&amp;gt; Chair     (plain string, returned as-is)&lt;/span&gt;
&lt;span class="c1"&gt;-- {"en":"Chair","it":"Sedia"}   -&amp;gt; Sedia&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i18n_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Sedia rossa'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&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;-- 'Chair' -&amp;gt; {"en": "Chair", "it": "Sedia rossa"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain string is taken to be in the default language, so writing Italian to it promotes it to a JSON object instead of losing the English. Reading falls back as far as you want: requested language, then default language, then whatever exists. Or not at all, if you set &lt;code&gt;i18n.fallback = 'none'&lt;/code&gt; and want a NULL or an empty string for a language that is not there.&lt;/p&gt;

&lt;p&gt;Everything exists for both &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;jsonb&lt;/code&gt; columns, and the explicit forms are &lt;code&gt;IMMUTABLE&lt;/code&gt;, so they go into expression indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i18n_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i18n_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%sedia%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part I actually needed: the app does not change
&lt;/h2&gt;

&lt;p&gt;The API reads and writes plain strings and it is not going to be rewritten this quarter. Fine. Rename the table and put a view in its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;products_i18n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_wrap_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{name,description}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view exposes &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; as plain strings in the session language. &lt;code&gt;INSTEAD OF&lt;/code&gt; triggers write back into the JSON, touching only the current language. The application keeps running its old queries against &lt;code&gt;products&lt;/code&gt;. The only thing it needs is the language, and that can be set per connection, per transaction, or once per database role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;api_it&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inserts store &lt;code&gt;{"it": "..."}&lt;/code&gt;, updates change only Italian and leave the other languages alone, &lt;code&gt;RETURNING&lt;/code&gt; gives back the translated row, defaults on omitted columns still apply. I was surprised how little the application notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up: migrating to jsonb
&lt;/h2&gt;

&lt;p&gt;Once every writer goes through the functions or the view, the text columns can become real &lt;code&gt;jsonb&lt;/code&gt; with a constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;i18n_migration_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;--  col_type | total | nulls | plain | translated | other_json&lt;/span&gt;
&lt;span class="c1"&gt;--  text     | 12040 |    15 |  9871 |       2154 |          0&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_migrate_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{name,description}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain strings become &lt;code&gt;{"en": "..."}&lt;/code&gt;, the column type changes, and a &lt;code&gt;CHECK&lt;/code&gt; makes sure only translation objects get in from now on. The same queries keep working because PostgreSQL picks the &lt;code&gt;jsonb&lt;/code&gt; overloads by column type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling the gaps automatically
&lt;/h2&gt;

&lt;p&gt;This is where it got fun. With the languages in the row, "which rows are missing German" is a cheap question, and answering it with a machine translation is a good first draft for product names and labels.&lt;/p&gt;

&lt;p&gt;PostgreSQL cannot call HTTP APIs portably, so the split is: the database detects rows missing a configured language and puts them on a queue table, a small Python worker outside the database calls the provider and writes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_auto_enable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{en,it,de}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deepl'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'furniture product names, keep brand names untranslated'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PG_I18N_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deepl &lt;span class="nv"&gt;DEEPL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... ./pg_i18n_worker.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Providers are DeepL, Google Cloud Translation, and OpenRouter, which means any LLM with a prompt that includes the per-column hint above. The rules that keep it safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only missing languages are requested, and only what is still missing at write-back time is written. A human translation entered while a job is in flight wins;&lt;/li&gt;
&lt;li&gt;a changed source text does not retranslate existing languages;&lt;/li&gt;
&lt;li&gt;claims use &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, so you can run several workers;&lt;/li&gt;
&lt;li&gt;two views, &lt;code&gt;i18n_coverage&lt;/code&gt; and &lt;code&gt;i18n_missing_translations&lt;/code&gt;, show what is done and what is still open.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Detecting the language of what was inserted
&lt;/h3&gt;

&lt;p&gt;One more real-world problem: the API knows nothing about languages, so a plain string is assumed to be English, and someone in an Italian session pastes an English text that lands under &lt;code&gt;it&lt;/code&gt;. With detection enabled on a column, the worker asks the provider what language the text actually is. If it disagrees with the key the text was stored under, the text moves to the right key and the other languages are filled from it. A language outside your configured set stays under its own key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inserted, {en,it} configured:  'Bonjour'
after the worker:              {"en": "Hello", "fr": "Bonjour", "it": "Ciao"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where it fits, and where it does not
&lt;/h2&gt;

&lt;p&gt;It fits when translations belong inside the row and the application is not going to change: legacy databases with mixed content, string-only APIs, catalogues, CMS labels, multi-tenant apps where each role has its own language.&lt;/p&gt;

&lt;p&gt;It does not fit long documents that need per-language versioning and an approval workflow. There a translations table or a translation management system is the better tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sirmmo/pg_i18n
&lt;span class="nb"&gt;cd &lt;/span&gt;pg_i18n &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nb"&gt;install
&lt;/span&gt;psql &lt;span class="nt"&gt;-d&lt;/span&gt; mydb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'CREATE EXTENSION pg_i18n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or &lt;code&gt;psql -d mydb -f i18n.sql -f i18n_auto.sql&lt;/code&gt; on a managed database where you cannot install extensions. The test suite runs against throwaway Docker containers, including an end-to-end run of the worker with an offline provider.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://ingmmo.com/pg_i18n/" rel="noopener noreferrer"&gt;https://ingmmo.com/pg_i18n/&lt;/a&gt; · Source: &lt;a href="https://github.com/sirmmo/pg_i18n" rel="noopener noreferrer"&gt;https://github.com/sirmmo/pg_i18n&lt;/a&gt; · MIT.&lt;/p&gt;

&lt;p&gt;I would like to hear how other people have handled the "JSON in a text column" situation, and which provider you would want next.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>i18n</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
