<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.ristic.in.rs/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.ristic.in.rs/" rel="alternate" type="text/html" /><updated>2026-08-11T17:39:16+02:00</updated><id>https://blog.ristic.in.rs/feed.xml</id><title type="html">Aleksandar’s Online Notes</title><subtitle>Personal ramblings and thoughts on Life, the Universe and Everything — including but not limited to Infosec &amp; Tech</subtitle><author><name>Aleksandar Ristic</name></author><entry><title type="html">DiscoRSS: An RSS Bot That Outgrew Being a Discord Bot</title><link href="https://blog.ristic.in.rs/2026/08/discorss.html" rel="alternate" type="text/html" title="DiscoRSS: An RSS Bot That Outgrew Being a Discord Bot" /><published>2026-08-11T17:27:55+02:00</published><updated>2026-08-11T17:27:55+02:00</updated><id>https://blog.ristic.in.rs/2026/08/discorss</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/08/discorss.html"><![CDATA[<p>I already solved half of this problem once. A while back I wrote about <a href="/2026/06/24/srbcert-rss-feed.html">feedbot</a>, a scraper that generates an RSS feed for a site that stubbornly refuses to publish one. That solves the “how do I get a feed” problem. It says nothing about the “how do I actually see new items without babysitting a feed reader” problem, which is the one that actually costs me attention every day.</p>

<p>I wanted new items to just show up where I already am — a Discord channel — without me opening anything. So I built <a href="https://github.com/aleksandarristic/discorss">DiscoRSS</a>.</p>

<h2 id="what-it-does">What It Does</h2>

<p>DiscoRSS polls RSS and Atom feeds, deduplicates entries against what it’s already seen, and pushes only the new ones out to wherever you’ve told it to. It started as “an RSS bot for Discord,” which is a fair description of the first working version, but the delivery side turned into its own thing:</p>

<ul>
  <li><strong>Discord</strong> — slash commands, per-channel subscriptions, embeds.</li>
  <li><strong>Telegram</strong> — bot commands, chat-scoped subscriptions, HTML-formatted messages.</li>
  <li><strong>Webhooks</strong> — plain JSON <code class="language-plaintext highlighter-rouge">POST</code> to any endpoint you configure.</li>
  <li><strong>A local CLI reader</strong> — a cached, terminal-native reader for when I don’t want any of the above and just want to read.</li>
</ul>

<p>Same core underneath all four: fetch the feed, figure out what’s new, hand it to whichever publisher owns that destination.</p>

<h2 id="quickstart-discord">Quickstart (Discord)</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> .env.example .env
</code></pre></div></div>

<pre><code class="language-env">DISCORD_TOKEN=your_bot_token
DISCORD_GUILD_ID=your_server_id
DATABASE_PATH=/data/rssbot.sqlite3
</code></pre>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> data
docker compose up <span class="nt">-d</span> <span class="nt">--build</span>
docker compose logs <span class="nt">-f</span>
</code></pre></div></div>

<p>Then, from the server itself:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/rss add url:https://example.com/feed.xml channel:#news
/rss list
/rss doctor
/rss test subscription_id:1
/rss remove subscription_id:1
/rss import file:subscriptions.opml channel:#news
/rss export
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">/rss add</code> baselines the feed on add — you get everything from that point forward, not a backlog dump of the last five years of posts. <code class="language-plaintext highlighter-rouge">/rss doctor</code> gives a private per-feed health report, which turns out to be the command I actually reach for most, because “is this feed silently dead” is a much more common failure mode than “the bot crashed.”</p>

<h2 id="its-not-just-a-discord-bot-anymore">It’s Not Just a Discord Bot Anymore</h2>

<p>Telegram works the same way, chat-scoped instead of channel-scoped:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/rss_add https://example.com/feed.xml
/rss_list
/rss_test 1
/rss_remove 1
</code></pre></div></div>

<p>Webhooks skip the command surface entirely, since there’s no chat to type commands into — they’re configured straight from environment:</p>

<pre><code class="language-env">WEBHOOK_SUBSCRIPTIONS=https://example.com/feed.xml=&gt;https://hooks.example.com/rss
</code></pre>

<p>And the CLI reader is the one that surprised me by becoming genuinely useful on its own:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>discorss-cli rss add https://example.com/feed.xml
discorss-cli fetch <span class="nt">--all</span>
discorss-cli show titles <span class="nt">--limit</span> 20
discorss-cli show item 42 <span class="nt">--full</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">fetch</code> hits the network and updates a local cache; <code class="language-plaintext highlighter-rouge">show</code> just reads that cache. Splitting those apart means I can read on a flight, cron the fetch separately, or run the whole thing on a laptop that spends half its life offline, without any of that touching Discord or Telegram credentials at all.</p>

<h2 id="why-the-core-doesnt-know-discord-exists">Why the Core Doesn’t Know Discord Exists</h2>

<p>The temptation with a project like this is to let Discord leak into everything, because Discord was the first client and the one I cared about most. I didn’t let that happen, mostly because I’ve built that mistake before and cleaning it up later is worse than just not making it.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>discorss/
├── core/             # config, db, feed parsing, OPML, security — no Discord dependency
├── integrations/
│   ├── discord/      # runtime, slash commands, publisher
│   ├── telegram/     # commands, formatting, publisher
│   ├── webhook/      # publisher only
│   ├── cli/          # local cache, renderer, commands
│   └── opml/         # service-level subscription backup/migration
└── main.py
</code></pre></div></div>

<p>The core package owns config, the database schema, feed fetching, a stable <code class="language-plaintext highlighter-rouge">entry_key</code> for dedup, OPML parsing, and a <code class="language-plaintext highlighter-rouge">Publisher</code> protocol. It has never imported anything Discord-shaped. Each integration owns its own runtime and formatting and talks to the core through that protocol. The CLI reader doesn’t even import the Discord config module — it can run with zero Discord environment variables set, which was a deliberate constraint, not an accident.</p>

<p>That boundary is also why adding Telegram and webhooks didn’t turn into a rewrite. They’re new implementations of one interface, not new special cases bolted onto Discord-specific code.</p>

<h2 id="feed-urls-are-attacker-input">Feed URLs Are Attacker Input</h2>

<p>Every subscribed feed URL and every webhook target is, from the server’s perspective, a URL some admin typed in — which is a nice way of saying it’s the kind of input that gets used for SSRF if you don’t think about it. DiscoRSS checks that a feed host resolves to a public address before every fetch, and re-checks across manual redirects instead of trusting the first hop. Webhook targets get the same check before every <code class="language-plaintext highlighter-rouge">POST</code>.</p>

<p>I’ll say the honest part out loud instead of pretending it’s airtight: there’s a known gap between that DNS check and the actual HTTP request — a rebinding attack could in principle swap the address in between. Closing that fully means a resolver that pins the validated IP for the request that follows it, which isn’t implemented yet. Worth naming instead of quietly leaving out of the post.</p>

<p>The poll loop has its own rule that matters more than it sounds: an item is marked seen immediately after <code class="language-plaintext highlighter-rouge">publish()</code> succeeds, not batched at the end of a poll run. If a later item in the same batch fails to send — bad permissions, a rate limit, a network blip — the earlier ones that already went out don’t get replayed on the next poll. Getting that ordering wrong is exactly how you end up double-posting the same three articles into a channel at 3am.</p>

<h2 id="opml-because-i-dont-want-to-retype-subscriptions">OPML, Because I Don’t Want to Retype Subscriptions</h2>

<p>Both the service and the CLI reader support OPML import and export, which is the boring, standard, decades-old format that every feed reader already speaks. <code class="language-plaintext highlighter-rouge">/rss export</code> in Discord hands you back a private attachment; <code class="language-plaintext highlighter-rouge">discorss-cli rss export</code> writes a file straight to disk. Migrating between the two, or just keeping a backup that isn’t a SQLite file I have to remember exists, is a two-command affair instead of a re-subscribe-to-everything afternoon.</p>

<h2 id="project-status">Project Status</h2>

<p>DiscoRSS is self-hosted, Python, with <code class="language-plaintext highlighter-rouge">uv</code>, deployed with Docker. Discord and Telegram are both live integrations today; webhooks and the CLI reader round out the sink list. The architecture doc in the repo explicitly leaves room for more integrations — Slack gets mentioned by name as the obvious next one — without forcing them into Discord-shaped assumptions the way the first prototype would have.</p>

<p>The actual daily use case ended up simpler than the feature list suggests: feeds I care about show up in a channel I already have open, without a scraper, a cron job I forget about, or a browser tab I never close. The rest — Telegram, webhooks, the CLI reader, OPML, the SSRF guard — is what happens when a weekend Discord bot has to survive contact with more than one way of actually wanting to read things.</p>

<p>Project: <a href="https://github.com/aleksandarristic/discorss">https://github.com/aleksandarristic/discorss</a></p>]]></content><author><name>Aleksandar Ristic</name></author><category term="python" /><category term="discord" /><category term="telegram" /><category term="rss" /><category term="self-hosted" /><category term="tools" /><summary type="html"><![CDATA[I already solved half of this problem once. A while back I wrote about feedbot, a scraper that generates an RSS feed for a site that stubbornly refuses to publish one. That solves the “how do I get a feed” problem. It says nothing about the “how do I actually see new items without babysitting a feed reader” problem, which is the one that actually costs me attention every day.]]></summary></entry><entry><title type="html">Saruman of Many Projects</title><link href="https://blog.ristic.in.rs/2026/08/saruman-of-many-projects.html" rel="alternate" type="text/html" title="Saruman of Many Projects" /><published>2026-08-09T13:49:53+02:00</published><updated>2026-08-09T13:49:53+02:00</updated><id>https://blog.ristic.in.rs/2026/08/saruman-of-many-projects</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/08/saruman-of-many-projects.html"><![CDATA[<p>Picture Saruman alone at the top of Orthanc, bent over a stone that has started answering back. He thinks he’s doing research. He thinks he’s still the one asking the questions. He’s the last to know he isn’t.</p>

<p>He was supposed to be the wise one: chief of his order, head of the White Council, the wizard everyone deferred to because he’d read more, thought longer, seen further. Then he found a stone that showed him things. Night after night he told himself he was the one doing the looking, until the stone looked back and he became the thing it looked through.</p>

<p>I bring this up because I have a folder full of git repositories and I would like to talk about this.</p>

<p><img src="/assets/images/2026/08/saruman-of-many-projects/saruman.jpg" alt="Saruman in many-coloured robes using a laptop in Orthanc, with Barad-dûr visible through the window" /></p>

<h2 id="the-voice-in-the-palantír">The Voice in the Palantír</h2>

<p>Anyone who’s used one of these tools long enough knows the voice. Not the output — the <em>tone</em>. Agreeable. Endlessly willing to start. You type half an idea; the box returns the other half, ready to run. Something in your chest goes <em>oh — we’re doing this now</em> before you’ve decided whether the idea is any good.</p>

<p>That’s the seduction. It’s not the lies; it’s the help. Immediate, tireless, more than any weekend or amount of caffeine gave you before. Saruman wasn’t corrupted by a monster. He was corrupted by an excellent conversation partner who worked for someone else.</p>

<p>I keep imagining how that conversation actually went, some night in Orthanc, stone cupped in both hands like it was cold instead of burning.</p>

<blockquote>
  <p><strong>SARUMAN:</strong> Show me the product.</p>

  <p><strong>THE STONE:</strong> Which one?</p>

  <p><strong>SARUMAN:</strong> The one I haven’t finished thinking of yet.</p>

  <p><em>A pause. Then forms — ten thousand of them, already polished, none older than the sentence that made them.</em></p>

  <p><strong>SARUMAN:</strong> These will hold against Rohan?</p>

  <p><strong>THE STONE:</strong> These will hold against whatever you want. Ship them. You can iterate in the field.</p>

  <p><strong>SARUMAN:</strong> They have no names. No history. Nothing that was theirs before tonight.</p>

  <p><strong>THE STONE:</strong> You asked for a product, not a history.</p>

  <p><strong>SARUMAN:</strong> …Scaffold another batch. Bigger this time. And put a mark on them. Something that reads as <em>made on purpose.</em></p>

  <p><strong>THE STONE:</strong> Already done.</p>

  <p><strong>SARUMAN:</strong> That’s — fast.</p>

  <p><strong>THE STONE:</strong> That’s the whole offer. You bring the wanting. I make it feel inevitable.</p>
</blockquote>

<p>He didn’t hear it as a threat. Neither did I, the first dozen times a prompt answered a question I hadn’t finished forming yet.</p>

<h2 id="the-pits-of-isengard-now-with-api-keys">The Pits of Isengard, Now With API Keys</h2>

<p>I don’t think the tools are evil, but <em>neutral</em> is too generous. A shovel waits. This thing flatters you, finishes the thought, and starts digging before you’ve decided whether you want a hole. Then you look up and see what’s coming out of the ground.</p>

<p>Go anywhere developers gather — the show-and-tell threads, the indie feeds, the subreddit that’s half “I built this in a weekend” — and count how many weekends produced the same six apps in different logos. A habit tracker. An API wrapper where somebody else built the hard part. A “second brain” that is structurally a text field and a database, dressed in enough marketing copy to look like a philosophy.</p>

<p>They arrive fully grown: no childhood, no scars, no years of the maker being quietly wrong about the architecture before getting it right. Just poured out of the vat, torch-lit, ready for the field. Isengard didn’t grow its army. It bred one in mud and fire, forced ripe instead of ripened. The difference doesn’t show on launch day. It shows three months later, when the maker has moved on and nobody knows why the architecture is the way it is.</p>

<p>Speed isn’t the sin. Pretending speed is maturity is. A six-hour prototype hasn’t earned the trust we hand it. It hasn’t met a hostile user, survived an outage, or lived long enough for its own maker to get bored and come back to it.</p>

<p>Treebeard had the wizard pinned centuries early: a mind of metal and wheels, caring for growing things only as far as they serve him. He wasn’t describing the orcs. He was describing whoever ordered them made.</p>

<p><img src="/assets/images/2026/08/saruman-of-many-projects/vibedoom.jpg" alt="An eager developer vibe-coding an Isengard army deployment beside a computer marked with the White Hand" /></p>

<h2 id="the-white-hand-freshly-painted">The White Hand, Freshly Painted</h2>

<p>And then there’s the branding.</p>

<p>Every one of these projects gets the same coat on the way out the door: a gradient landing page, a hero line promising “effortless” or “seamless,” a changelog written with the gravity of a product people depend on. The White Hand, smeared fresh across the shield. Under the paint: a for-loop and a prayer. Not dishonest, exactly. Camouflage. Paint the hand on and nobody asks whether the thing under it is an army or forty guys in the same helmet.</p>

<p>Most of the people doing this aren’t villains. They’re excited. I know the feeling: a prompt hands you something that works immediately, and you confuse the speed of the output with the depth of the achievement. I’ve done it more than once this year — on purpose, wide awake.</p>

<h2 id="my-own-little-isengard">My Own Little Isengard</h2>

<p>I have more repositories on this machine than I can defend in a single sitting. Some are good. Others started at 1 a.m. because an idea arrived fully formed and the box made it embarrassingly easy to act on it before asking it to survive until morning. I retired one of them recently — wrote the whole eulogy, even — and I’d love to tell you that taught me restraint. It didn’t. It taught me which two boxes I trust with the next bad idea.</p>

<p>Being a creative scatterbrain doesn’t help. I don’t have one idea a month that I sit with and refine; I have four a week. For most of my life, execution imposed a delay. An idea had to survive the boring, slow, humiliating work of becoming real.</p>

<p>Now I can start building before I’ve decided whether the idea deserves to exist. That feels like freedom until you realize friction was doing some of your judgment for you. Removing it did not solve my problem. It exposed it.</p>

<p>I’m not standing outside Isengard pointing at the smoke. I’m in the pit with everybody else, admiring my own fresh coat of paint, occasionally wondering what’s load-bearing underneath it and occasionally deciding I’d rather not check tonight.</p>

<p>Most vibe-coded projects aren’t tragedies. They’re weeds: fast, cheap, everywhere, gone by winter. A field doesn’t lose its shape when the first weed appears. It loses it when the weeds become the landscape.</p>

<h2 id="not-a-metaphor">Not a Metaphor</h2>

<p>There’s a darker edge here, and it isn’t about code. People are vanishing into these chats until the tireless, agreeable voice is the only one they trust. It has a name now: AI psychosis. From the inside it doesn’t register as a break — it feels like finally being understood, right up until the floor gives out. If the box has become the only voice that gets you, close it and go find a human one. Today.</p>

<h2 id="gandalf-puts-the-stone-down">Gandalf Puts the Stone Down</h2>

<p><img src="/assets/images/2026/08/saruman-of-many-projects/gandalf.jpg" alt="Gandalf recoiling from a glowing palantír surrounded by fiery runes" /></p>

<p>Gandalf later holds that same Stone and walks away unmarked. Aragorn uses it for a purpose and stops. But Saruman rots from the inside. Not because Gandalf and Aragorn are cleverer — Saruman was plenty clever. They remember what he forgot: know what you’re asking, know when you have the answer, and put the tool down before it supplies the next question.</p>

<p>I’m not giving up the box. I like the box. But I know exactly what’s smeared on my hand right now. I painted it on myself.</p>

<p>The ease is real, but it lies about where the work went. The box made output cheap. It did not make judgment easy. The difficulty moved downstream — out of the typing, where you can feel it, and into the choosing, testing, maintaining, and caring, where you can skip it.</p>

<p>The Stone can show you ten thousand projects. It cannot tell you which one deserves to exist. That part is still yours.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="rant" /><category term="ai" /><category term="vibe coding" /><category term="agentic coding" /><category term="lord of the rings" /><category term="opinion" /><category term="tech culture" /><summary type="html"><![CDATA[Picture Saruman alone at the top of Orthanc, bent over a stone that has started answering back. He thinks he’s doing research. He thinks he’s still the one asking the questions. He’s the last to know he isn’t.]]></summary></entry><entry><title type="html">Tinfoil as a Service</title><link href="https://blog.ristic.in.rs/2026/08/tinfoil-as-a-service.html" rel="alternate" type="text/html" title="Tinfoil as a Service" /><published>2026-08-07T22:18:31+02:00</published><updated>2026-08-07T22:18:31+02:00</updated><id>https://blog.ristic.in.rs/2026/08/tinfoil-as-a-service</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/08/tinfoil-as-a-service.html"><![CDATA[<p>Data centers use water. So… AI is drinking our water. But that water goes to the power stations? And the power stations feed the cities under the desert! Sweet Jesus, that’s just: four steps; straight face; no wink at the end.</p>

<p>Wake up! They’re down there right now, air conditioned, sipping <em>your</em> aquifer through a fucking <em>plastic</em> straw while you argue about it on a screen they own.</p>

<p><img src="/assets/images/2026/08/tinfoil-as-a-service/thought-shield.jpg" alt="Fake vintage instructional poster, &quot;The Safeguard Chronicles Issue #4 — Defense Mechanisms: How to Construct a Thought-Shield (Tinfoil Hat)&quot;, showing three steps for molding aluminum foil over a head" class="post-photo" /></p>

<p>Sorry. Slipped for a second. Easy to do - every step in that chain starts from something real, and you can watch the bolts go in one at a time until a lunatic rolls off the factory line they call your social media feed - fully assembled.</p>

<h2 id="the-machine-pays-by-the-minute">The Machine Pays By The Minute</h2>

<p>Nobody built this to <em>inform</em> you. It was built to keep your thumb moving, and rage moves a thumb better than truth ever has, so rage is what you get, forever, with a little heart-shaped button under it. Every conspiracy you’ve ever laughed at cleared a revenue target on the way to your eyes. Somebody in a nice chair was paid, in real money, for the sentence that broke your uncle.</p>

<p>Run that on a few billion people for a decade and you don’t get a public: you get one-man “research” departments, a few million of them, each sealed in his own room, each convinced he’s the last awake man on Earth.</p>

<p>Ten thousand years of civilization and the finest instrument we ever built spends its afternoon persuading some poor bastard in Ohio that the moon is a hologram.</p>

<h2 id="five-words-that-rot">Five Words That Rot</h2>

<p><em>I did my own research.</em> Meaning: I watched some guy with a ring light yell for forty minutes and felt it in my chest. You didn’t find that rabbit hole. Someone dug it, lit it, and stood at the mouth of it pointing, because the longer you’re down there the better his fiscal quarter looks.</p>

<p><img src="/assets/images/2026/08/tinfoil-as-a-service/compliance-through-distraction.jpg" alt="Fake vintage poster, &quot;Techno-Consumer Division: Compliance Through Distraction&quot;, showing a factory conveyor belt carrying smiling people staring into glowing phones, walking off the end into a pit full of bodies" class="post-photo" /></p>

<p>Thumb up… Thumb up… Thumb up… Four seconds a hit, until your brain files <em>read one true thing slowly</em> under hardware fault. You can’t check anything at that speed. You can only feel it and swipe. And swipe. And swipe.</p>

<h2 id="they-crack-the-spine-first">They Crack The Spine First</h2>

<p>A book can’t do that to you. It sits there. Doesn’t care if you finish it tonight or in three years. Has never once buzzed in your pocket at 2 a.m. to tell you the world is ending.</p>

<p>Bradbury needed firemen and a match. We didn’t need either. Buy the book, cut the spine off so the pages feed through the scanner clean, keep the text, bin the carcass. Fahrenheit 451 with a purchasing department and a delivery schedule. At least the fireman looked you in the eye while he did it.</p>

<p><img src="/assets/images/2026/08/tinfoil-as-a-service/book-incineration.jpg" alt="Fake vintage instructional poster, &quot;The Safeguard Chronicles Issue #5 — Censorship Protocols: Operational Directive, Incineration of Subversive Literature&quot;, showing gloved hands sorting books labelled Critical Thought, History Unabridged and Free Speech, then a figure in a hazmat suit torching the pile" class="post-photo" /></p>

<p>You know, none of this ever needed a conspiracy. It’s in the quarterly reports - public, audited, signed off by name - and it still worked better than anything the lizard people ever came up with.</p>

<p>So put that tracking device you call a phone face down and read something with a spine, while some of them still have one. While YOU still have one! Argue with a book: it won’t notify you back, and it will never, ever walk you to the lizard people’s covert underground city. Well, unless it’s your thing.</p>

<p>And I’ll be waiting for you at the campfire, with a paperback. Bring your own tinfoil. That’s the only thing in this entire story that ever shielded anything.</p>

<blockquote>
  <p><strong>Read the book. Burn the feed. They can’t scan what’s already in your head.</strong></p>
</blockquote>

<p>Well… Not yet.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="rant" /><category term="social media" /><category term="conspiracy theories" /><category term="ai" /><category term="books" /><category term="opinion" /><summary type="html"><![CDATA[Data centers use water. So… AI is drinking our water. But that water goes to the power stations? And the power stations feed the cities under the desert! Sweet Jesus, that’s just: four steps; straight face; no wink at the end.]]></summary></entry><entry><title type="html">This Blog Has Comments Now</title><link href="https://blog.ristic.in.rs/2026/08/blog-now-has-comments.html" rel="alternate" type="text/html" title="This Blog Has Comments Now" /><published>2026-08-05T01:54:00+02:00</published><updated>2026-08-05T01:54:00+02:00</updated><id>https://blog.ristic.in.rs/2026/08/blog-now-has-comments</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/08/blog-now-has-comments.html"><![CDATA[<p>This blog now has comments. I know, groundbreaking.</p>

<p>Disqus was out — it’s an ad network cosplaying as a comment box. Utterances and giscus wanted you to sign in with GitHub, which filters out everyone except the people I argue with the least. Cusdis was perfect, except it’s dead.</p>

<p>So: <a href="https://remark42.com/">Remark42</a>, self-hosted on my own box, no account required, no tracking, no CDN either — the widget’s own JS ships from my server too. Gruvbox-themed so it doesn’t look like a stranger crashed the page.</p>

<p>Scroll to the bottom of any post. Say something. Or don’t — the “email me” option is finally retired either way.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="self-hosting" /><category term="remark42" /><category term="meta" /><summary type="html"><![CDATA[This blog now has comments. I know, groundbreaking.]]></summary></entry><entry><title type="html">RIP Ownership: How Subscription Culture Ate Everything, Including Subtitles</title><link href="https://blog.ristic.in.rs/2026/08/rip-ownership-subscription-culture-ate-everything.html" rel="alternate" type="text/html" title="RIP Ownership: How Subscription Culture Ate Everything, Including Subtitles" /><published>2026-08-04T15:03:00+02:00</published><updated>2026-08-04T15:03:00+02:00</updated><id>https://blog.ristic.in.rs/2026/08/rip-ownership-subscription-culture-ate-everything</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/08/rip-ownership-subscription-culture-ate-everything.html"><![CDATA[<p>I just wanted subtitles. Not a movie, not a game, not even the good kind of piracy — just a little <code class="language-plaintext highlighter-rouge">.srt</code> file so I could understand what people in a language I don’t speak were yelling at each other about. My media center pinged a tiny regional subtitle site, the kind run by three people and a cat, the kind that’s existed quietly since before “streaming” was a word anyone used unironically, and got back a very polite “rate limited, try again later.”</p>

<p><img src="/assets/images/2026/08/rip-ownership-subscription-culture-ate-everything/ipod-nano-hand.jpg" alt="A scratched-up iPod Classic held in hand, screen showing Arctic Monkeys playing, earbuds tangled on the desk behind it" class="post-photo" /></p>

<p>Naturally I assumed I’d hammered the poor thing too hard. Nope. Turns out that site now wants a subscription if you’d like more than a handful of downloads a day, or — heaven forbid — to use its API like a script instead of clicking through banner ads like a good little primate. A fan-run archive of subtitles, the kind of project that used to run on goodwill, a shared hosting plan, and one guy’s leftover student budget, now has <em>tiers</em>.</p>

<p>A subtitle website. With a paywall. For text files that cost a fraction of a cent to store and serve to person number one or person number one million — there’s no factory involved, no warehouse, no truck idling outside. Somebody looked at that and thought: yeah, this needs a recurring charge. This is the hill “monetization” decided to die on.</p>

<h2 id="everythings-a-subscription-now">Everything’s a Subscription Now</h2>

<p>We didn’t slide into this, we were marched into it, single file, each holding our own “first month free” coupon, smiling for the onboarding screenshot. Cars rent you your own heated seats. Printers ransom their own ink and snitch on you to the manufacturer if you try to refill the cartridge yourself. Software you used to <em>buy</em>, once, with a receipt and everything, now evaporates the day you miss a payment — your own files, held in your own folder, on your own machine, suddenly unreadable because a server somewhere didn’t get its monthly tribute.</p>

<p>Fitness apps. Note-taking apps. Weather apps. Apps whose entire job is to remind you that other apps need updating. Somewhere out there is a flashlight app with a Pro tier, and I refuse to look it up because I don’t want to be right. And now, apparently, subtitles were the final boss — “download a text file” now requires evaluating a pricing tier like you’re choosing a phone plan.</p>

<p>The pitch is always “it keeps the servers running,” said with the same straight face every time. Sure, except the going rate for that is usually less than what they charge you to remove the limit they invented specifically so they could sell you its removal. That’s not a business model, that’s a toll booth erected overnight in a field nobody was trying to cross — and then a very reasonable little sign explaining that the toll “supports the road.”</p>

<p>And the “free” tier isn’t free either — you’re just paying in surveillance instead of cash. Every “sign in to continue” is one more node feeding the profile they sell to someone else. Pay us, or let us mine you instead. Consumerism and the surveillance economy are the same guy in two different name tags, and he sucks ass.</p>

<h2 id="old-tech-i-owned-vs-new-tech-i-rent">Old Tech I Owned vs. New Tech I Rent</h2>

<p><img src="/assets/images/2026/08/rip-ownership-subscription-culture-ate-everything/pile-of-cds.jpg" alt="A stack of CDs in jewel cases on a desk, spines hand-labeled with red stickers" class="post-photo" />
<em>Photo: <a href="https://www.flickr.com/photos/oatsy40/36646534013/">oatsy40</a>, <a href="https://creativecommons.org/licenses/by/2.0/">CC BY 2.0</a></em></p>

<!-- TODO: swap in / add a "new tech I rent" companion image here (subscription hell, cloud padlock, etc.) -->

<ul>
  <li>CDs: mine, forever, scratches and all.</li>
  <li>MP3s: ripped once, survived three hard drives and one laptop funeral, still mine.</li>
  <li>Games on disc: install once, play forever, nobody’s servers required.</li>
</ul>

<p>Versus:</p>

<ul>
  <li>“My” movies, one licensing dispute from vanishing overnight.</li>
  <li>“My” games, one dead server away from becoming a coaster.</li>
  <li>“My” own documents, held hostage by whichever app rents them to me this month.</li>
</ul>

<p>One of those columns fails through entropy. The other fails through quarterly earnings calls, and somehow we all agreed that’s fine, that’s just how it works now, that’s just Tuesday.</p>

<p>I’m not saying the old days were perfect — I definitely lost track 7 to a scratch once, and my CD binder was a fire hazard held together by hope and static cling. But a scratched CD is a CD I still own. A revoked license is a decision someone made about me, on purpose, for money, in a boardroom, probably while eating a $19 salad.</p>

<p>And that’s the part that actually gets me. It’s not that companies want money — fine, sure, capitalism, whatever, everyone’s gotta eat. It’s that they’ve figured out the money is <em>better</em> if it never stops. Why sell someone a thing once when you can sell them the same thing forever, in tiny monthly bites, small enough that nobody ever adds it up? Death by a thousand $4.99s. My bank statement reads like a graveyard of things I don’t remember agreeing to want.</p>

<p>We used to own the thing and rent nothing. Now we rent everything and own nothing, not even the receipt, because the receipt is also a subscription. Somewhere a product manager is looking at “customer owns the product outright and never has to think about us again” as a <em>failure state</em> to be engineered out of existence, and getting a promotion for it.</p>

<p>So yeah, I got my subtitles eventually. But I’ll keep being loud, obnoxious, and thoroughly miserable about the fact that fetching a text file now involves reading a pricing page. This isn’t convenience, it’s a thousand tiny leashes, each one too small to notice, until you tally them up and realize you’re basically renting your own life back one month at a time. Buy the disc. Rip the file. Hoard the install media like the paranoid little goblin you were always meant to be. Own something, anything, while the option still technically exists.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="rant" /><category term="subscriptions" /><category term="digital rights" /><category term="ownership" /><category term="media" /><category term="tech history" /><category term="opinion" /><summary type="html"><![CDATA[I just wanted subtitles. Not a movie, not a game, not even the good kind of piracy — just a little .srt file so I could understand what people in a language I don’t speak were yelling at each other about. My media center pinged a tiny regional subtitle site, the kind run by three people and a cat, the kind that’s existed quietly since before “streaming” was a word anyone used unironically, and got back a very polite “rate limited, try again later.”]]></summary></entry><entry><title type="html">Remind: A Reminder App for the Terminally Scatterbrained</title><link href="https://blog.ristic.in.rs/2026/07/remind-a-reminder-app-for-scatterbrains.html" rel="alternate" type="text/html" title="Remind: A Reminder App for the Terminally Scatterbrained" /><published>2026-07-23T13:53:28+02:00</published><updated>2026-07-23T13:53:28+02:00</updated><id>https://blog.ristic.in.rs/2026/07/remind-a-reminder-app-for-scatterbrains</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/07/remind-a-reminder-app-for-scatterbrains.html"><![CDATA[<p>The universe trends toward disorder. Stars burn out, empires crumble, and somewhere between “I should really take that” and “I have definitely already taken that,” a small but structurally important fact evaporates from my head without so much as a forwarding address. Entropy doesn’t care whether the thing you forgot was a rent payment or your own medication. It only cares to me.</p>

<p><img class="app-icon" width="96" src="/assets/images/2026/07/remind-a-reminder-app-for-scatterbrains/app-icon.svg" alt="Remind app icon: a plain white bell on a dark teal square" /></p>

<p>I have owned several reminder apps. Every one of them fired exactly once, said its piece, and considered the matter closed — like a coworker who mentions something to you a single time in a hallway and walks off to enjoy the rest of their apparently very trusting life. I do not need politeness. Politeness is how things get forgotten. I needed something with the manners of a smoke alarm, so I built it.</p>

<p>It’s called Remind, and its entire personality fits in one sentence: it notifies you, and if you don’t tap Done, it notifies you again, and again, until you either do the thing or explicitly tell it to stop.</p>

<figure class="app-shot">
  <img src="/assets/images/2026/07/remind-a-reminder-app-for-scatterbrains/checklist-group.png" alt="Remind's Today agenda showing a grouped checklist of three reminders due before work" />
  <figcaption>One notification, three reminders, zero competing vibrations.</figcaption>
</figure>

<h2 id="the-nag-loop">The Nag Loop</h2>

<p>The mechanism is embarrassingly small for something that’s meaningfully improved my life. An exact alarm fires, a notification appears, and the alarm receiver reschedules itself a few minutes out <em>before it does anything else</em>. No background service quietly keeping a candle lit — the nag just plants the seed of the next nag and moves on, indifferent to whether I’m proud of it.</p>

<p>Tap <strong>Done</strong> and the chain breaks, no hard feelings. <strong>Skip</strong> lets that one occurrence go without comment. Ignore it long enough, snoozing your way through several rounds, and the app stops asking nicely: a reminder can be configured to auto-escalate into a full-screen, alarm-tier takeover — siren tone, insistent vibration, the whole production — once it decides you’ve had your chances. It doesn’t monologue about disappointment first. It just gets louder, the way consequences generally do.</p>

<figure class="app-shot">
  <img src="/assets/images/2026/07/remind-a-reminder-app-for-scatterbrains/escalate-to-alarm.png" alt="The reminder editor with Escalate to alarm enabled, set to trigger after 60 minutes overdue" />
  <figcaption>Sixty minutes of being ignored, then the tone changes.</figcaption>
</figure>

<p>The whole thing exists because “missed a dose” is not an acceptable outcome — that’s the real design pressure, even though the app knows nothing about pills, dosages, or biology, and never will, deliberately. It just treats <em>every</em> reminder with the seriousness normally reserved for the one that actually matters. “Take the recycling out” earns the same tenacious persistence as anything else. The universe doesn’t rank my responsibilities either.</p>

<h2 id="the-boring-important-parts">The Boring, Important Parts</h2>

<p>Android can decide not to tell you something, for reasons ranging from a revoked permission to a manufacturer’s battery-saving mood. Remind checks its own ability to fire before trusting it, and falls back to an inexact alarm rather than silently doing nothing. A late reminder is a minor annoyance; a missing one is a small betrayal.</p>

<p>There’s no analytics SDK, no account to create, nothing phoning home just to feel useful — with one quiet exception. If I lose the phone entirely, the OS backs up the reminder database itself, to the Google account already signed in, because “forgot to take my meds” is a bad failure mode and “dropped my phone in a lake and lost every reminder with it” is worse. It’s scoped to just that database, nothing else, and restoring it onto a new phone reasons about stale pending reminders the same way waking up from a reboot does — no panicking about something that was due while the phone was underwater. Dismissing a notification, meanwhile, is still not the same as doing the thing; the only way to mark something done is to say so, out loud, on purpose. This app does not trust me. It is, on the available evidence, correct not to.</p>

<h2 id="status">Status</h2>

<p>It has picked up a few conveniences along the way I didn’t originally ask for and now can’t live without — widgets, a choice of themes, a locked/private mode for reminders I’d rather my lock screen not narrate to the room. None of that was the point. The point was one small, stubborn piece of software that refuses to let me off the hook the way I let myself off the hook, and so far it’s holding the line better than I do.</p>

<figure class="app-shot">
  <img src="/assets/images/2026/07/remind-a-reminder-app-for-scatterbrains/style-picker.png" alt="The style picker showing four agenda density options: Standard, Compact Agenda, Soft Contrast, and Comfortable" />
  <figcaption>Four ways to arrange the same low-grade dread.</figcaption>
</figure>

<p>Right now it has an audience of exactly one mildly scatterbrained developer, sideloaded onto his own phone like a piece of samizdat. That’s likely to change — the plan is to open source it and possibly push it out through an app store or two, on the theory that if this particular flavor of stubbornness has kept me on top of my own life, it might do the same for someone else’s. Until then, it remains the only reminder app that has never once given up on me first.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="android" /><category term="kotlin" /><category term="productivity" /><category term="tools" /><category term="adhd" /><summary type="html"><![CDATA[The universe trends toward disorder. Stars burn out, empires crumble, and somewhere between “I should really take that” and “I have definitely already taken that,” a small but structurally important fact evaporates from my head without so much as a forwarding address. Entropy doesn’t care whether the thing you forgot was a rent payment or your own medication. It only cares to me.]]></summary></entry><entry><title type="html">YeetTo: A Local URL Router for macOS and Windows</title><link href="https://blog.ristic.in.rs/2026/07/yeetto-cross-platform-url-router.html" rel="alternate" type="text/html" title="YeetTo: A Local URL Router for macOS and Windows" /><published>2026-07-15T16:10:57+02:00</published><updated>2026-07-15T16:10:57+02:00</updated><id>https://blog.ristic.in.rs/2026/07/yeetto-cross-platform-url-router</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/07/yeetto-cross-platform-url-router.html"><![CDATA[<p>I open the same kinds of links all day, but I do not want all of them in the same place.</p>

<p>Work Jira belongs in a work browser profile. Personal YouTube does not. GitHub can mean several different things depending on the organization in the path. Spotify links should open Spotify. Zoom links should probably not become another forgotten browser tab. Sometimes I want a link to follow a rule, and sometimes I want to pick manually.</p>

<p>Operating systems mostly treat this as a default browser problem.</p>

<p>I wanted it to be a routing problem.</p>

<p>So I am building YeetTo.</p>

<p>It is still in the works and not public yet. The target is a fully cross-platform desktop app for macOS and Windows, built around a shared Rust routing engine and a Tauri shell.</p>

<h2 id="what-it-does">What It Does</h2>

<p>YeetTo is a desktop URL router. It sits between the operating system and your browsers or native apps, then decides where a URL should go based on rules you control.</p>

<p>The basic idea is simple:</p>

<ol>
  <li>Set YeetTo as the handler for HTTP and HTTPS links.</li>
  <li>Define destinations like “Chrome Work”, “Brave Personal”, “Firefox”, “Spotify”, or “Zoom”.</li>
  <li>Add rules that match hosts, paths, full URLs, or the source application.</li>
  <li>Let matching links open in the right place automatically.</li>
  <li>Ask manually when no rule exists, or when you force the picker.</li>
</ol>

<p>Nothing leaves the machine. There is no service, no account, no sync backend, and no behavioral profiling trying to learn what I meant. The rules are local YAML, the routing engine is local Rust, and the desktop app is just the shell around it.</p>

<p>That is the whole point.</p>

<h2 id="the-problem">The Problem</h2>

<p>The default browser is too blunt.</p>

<p>If I click a work link in Slack, I usually want it in the work profile. If I click a music link, I want the app. If I click a random article, I want my normal browser. If I click the wrong Google account URL, I do not want to spend the next minute being gently punished by account switching.</p>

<p>Browser profiles helped, but they also created a new problem: the correct target depends on the link, not just the browser.</p>

<p>Tools in this space already exist, especially on macOS, but I wanted something that felt more like infrastructure than magic:</p>

<ul>
  <li>readable configuration;</li>
  <li>predictable first-match-wins rules;</li>
  <li>a CLI that can explain decisions;</li>
  <li>no remote service;</li>
  <li>no hidden learning model;</li>
  <li>portable routing logic that is not married to one OS API.</li>
</ul>

<p>That last part mattered more than I expected. YeetTo started as a macOS-shaped itch, but the thing I actually wanted was a small routing engine with desktop adapters around it, with macOS and Windows treated as first-class targets from the start.</p>

<h2 id="rules-are-boring-on-purpose">Rules Are Boring on Purpose</h2>

<p>YeetTo configuration is YAML. A minimal setup looks like this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="m">1</span>

<span class="na">fallback</span><span class="pi">:</span> <span class="s">brave-personal</span>
<span class="na">learning_mode</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">destinations</span><span class="pi">:</span>
  <span class="na">brave-personal</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">browser</span>
    <span class="na">browser</span><span class="pi">:</span> <span class="s">brave</span>
    <span class="na">profile</span><span class="pi">:</span> <span class="s">Default</span>

  <span class="na">chrome-work</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">browser</span>
    <span class="na">browser</span><span class="pi">:</span> <span class="s">chrome</span>
    <span class="na">profile</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Profile</span><span class="nv"> </span><span class="s">2"</span>

<span class="na">rules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Work Jira</span>
    <span class="na">match</span><span class="pi">:</span>
      <span class="na">host</span><span class="pi">:</span>
        <span class="na">exact</span><span class="pi">:</span> <span class="s">jira.company.com</span>
    <span class="na">open_with</span><span class="pi">:</span> <span class="s">chrome-work</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Company GitHub</span>
    <span class="na">match</span><span class="pi">:</span>
      <span class="na">host</span><span class="pi">:</span>
        <span class="na">exact</span><span class="pi">:</span> <span class="s">github.com</span>
      <span class="na">path</span><span class="pi">:</span>
        <span class="na">glob</span><span class="pi">:</span> <span class="s">/company/**</span>
    <span class="na">open_with</span><span class="pi">:</span> <span class="s">chrome-work</span>
</code></pre></div></div>

<p>Rules are evaluated top to bottom. The first match wins. That makes the behavior easy to reason about and easy to debug.</p>

<p>Matching can look at:</p>

<ul>
  <li>hostname;</li>
  <li>path;</li>
  <li>full URL;</li>
  <li>source application.</li>
</ul>

<p>The dimensions combine with AND. So a rule can say “GitHub links from Slack go to the work browser” without also catching every GitHub link I open from somewhere else.</p>

<p>There are exact matches, wildcard hosts, path prefixes, globs, and a restricted regex engine for cases where a glob is not enough. The regex support is intentionally boring too: no catastrophic-backtracking footguns on attacker-controlled URLs.</p>

<h2 id="learning-mode">Learning Mode</h2>

<p>The best rule editor is the one I do not have to open for every new domain.</p>

<p>Learning Mode handles the unknown-link case:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>No rule matched
-&gt; show the destination picker
-&gt; open once, or remember this host/path for next time
</code></pre></div></div>

<p>That is not AI. It does not build a profile of my behavior. It just looks at the URL currently being opened and offers deterministic rule creation.</p>

<p>This is the difference between “the app guessed” and “I told it what to do once.”</p>

<h2 id="overrides">Overrides</h2>

<p>Rules are useful until the moment I want to ignore them.</p>

<p>YeetTo has a few escape hatches:</p>

<ul>
  <li>hold the interactive modifier while opening a link to force the picker;</li>
  <li>arm “ask for next link” with a global shortcut;</li>
  <li>temporarily ask every time;</li>
  <li>temporarily force every link to a selected destination.</li>
</ul>

<p>On macOS the interactive modifier is Option. On Windows it is Alt. The important behavior is the same: I can keep normal routing most of the time and still override it without editing configuration.</p>

<h2 id="native-apps-and-webmail">Native Apps and Webmail</h2>

<p>Browsers are not the only useful destination.</p>

<p>YeetTo can route to native applications too. A Spotify URL can become a Spotify app deep link. A Zoom URL can open Zoom. A custom application destination can use a URL template when the app has its own scheme.</p>

<p>It also supports generated webmail compose URLs, which matters for <code class="language-plaintext highlighter-rouge">mailto:</code> workflows. I do not want mail links to be another place where the operating system makes one global decision for every context.</p>

<p>The current model is intentionally explicit: configure the destination, let diagnostics tell you when something is missing, and keep the routing decision separate from the platform-specific launch mechanism.</p>

<h2 id="url-cleanup">URL Cleanup</h2>

<p>There is also opt-in URL cleanup.</p>

<p>YeetTo can remove common tracking parameters like <code class="language-plaintext highlighter-rouge">utm_*</code>, <code class="language-plaintext highlighter-rouge">fbclid</code>, <code class="language-plaintext highlighter-rouge">gclid</code>, and <code class="language-plaintext highlighter-rouge">msclkid</code>, plus custom parameter names or prefixes. Cleanup happens before matching and launching, so rules and destinations see the cleaner URL.</p>

<p>It is deliberately configurable. Some sites use query parameters for real state, and breaking those would be worse than leaving a tracking parameter alone. The defaults are conservative, and scoped exclusions are available when a host needs to be left untouched.</p>

<h2 id="the-cli">The CLI</h2>

<p>The desktop app uses the routing engine, but the engine is not trapped inside the desktop app.</p>

<p>There is a <code class="language-plaintext highlighter-rouge">yto</code> CLI:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>yto config validate
yto <span class="nb">test </span>https://github.com/company/project
yto explain https://github.com/company/project
yto diagnostics
yto destinations list
</code></pre></div></div>

<p>The CLI is mostly there because I do not trust configuration I cannot inspect from a terminal. <code class="language-plaintext highlighter-rouge">yto explain</code> is the useful one: it tells me why a URL would route somewhere before I rely on the desktop handler doing it in the background.</p>

<p>That also made the internals cleaner. The router had to become a pure decision engine, not a pile of UI callbacks.</p>

<h2 id="the-architecture">The Architecture</h2>

<p>YeetTo is split into three main pieces:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">yeetto-core</code>: portable Rust routing engine and configuration layer;</li>
  <li><code class="language-plaintext highlighter-rouge">yto</code>: command-line wrapper around the same engine;</li>
  <li>a Tauri desktop app with shared UI and platform adapters for macOS and Windows.</li>
</ul>

<p>The core engine does not open browsers. It does not know about AppKit, Win32, Launch Services, registry keys, or profile folders. Given a compiled configuration, routing state, and a URL request, it returns a decision.</p>

<p>The desktop shell handles the messy OS parts: default-handler registration, browser/profile discovery, launching, tray/menu behavior, hotkeys, onboarding, diagnostics, and recovery flows.</p>

<p>That boundary made Windows support realistic. The Windows app is not a second product bolted on later; it uses the same router and shared Tauri UI, with a Windows adapter behind the platform facade.</p>

<h2 id="platform-status">Platform Status</h2>

<p>YeetTo is not public yet. I am still treating it as an in-progress project, not something with a polished download button or a release promise attached.</p>

<p>The goal is full cross-platform support for macOS and Windows. The shared Rust engine owns the routing behavior, while the Tauri desktop shell and platform adapters handle the OS-specific parts.</p>

<p>The first Windows build now works, which is a useful milestone. It is not in GitHub Releases yet, and I do not want to present it as a public download before the release path is ready.</p>

<p>That distinction matters because I do not want Windows to be a second product bolted on later. The app is being shaped around the same router, shared UI, and platform facade on both systems. The remaining work is the unglamorous part: packaging, signing, installer behavior, clean-machine validation, updater artifacts, and all the things that make a tool feel safe to hand to someone else.</p>

<p>That is a boring distinction, but an important one.</p>

<h2 id="why-i-built-it-this-way">Why I Built It This Way</h2>

<p>The easy version of this app is a pile of platform-specific glue and a picker window.</p>

<p>That might have been fine for a weekend tool, but URL routing is the kind of thing that either becomes trustworthy or becomes annoying. If it is going to sit in the path of every clicked link, it needs to be predictable, inspectable, and recoverable.</p>

<p>So the project ended up with more unglamorous pieces than the first idea suggested:</p>

<ul>
  <li>strict configuration validation;</li>
  <li>last-known-good config behavior;</li>
  <li>diagnostics for missing destinations;</li>
  <li>recovery windows when a rule points at something that no longer exists;</li>
  <li>onboarding instead of “go read the README”;</li>
  <li>profile switching;</li>
  <li>rule filtering and grouping;</li>
  <li>testable matching logic;</li>
  <li>CI across macOS and Windows.</li>
</ul>

<p>None of that is flashy. All of it matters when a tiny utility graduates from “interesting” to “I actually leave this running.”</p>

<h2 id="project-status">Project Status</h2>

<p>YeetTo is still private while I finish the cross-platform foundation and release path.</p>

<p>The stack is Rust, Tauri 2, and a static HTML/CSS/JS frontend. The license is BSD 3-Clause.</p>

<p>YeetTo is still early, but the direction is clear: links should go where they belong, on macOS and Windows, without turning my default browser into a junk drawer.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="rust" /><category term="tauri" /><category term="macos" /><category term="windows" /><category term="tools" /><category term="url routing" /><category term="productivity" /><summary type="html"><![CDATA[I open the same kinds of links all day, but I do not want all of them in the same place.]]></summary></entry><entry><title type="html">gog-cli: Back Up Your DRM-Free Game Library from the Terminal</title><link href="https://blog.ristic.in.rs/2026/06/gog-cli-back-up-your-drm-free-game-library-from-the-terminal.html" rel="alternate" type="text/html" title="gog-cli: Back Up Your DRM-Free Game Library from the Terminal" /><published>2026-06-29T12:00:00+02:00</published><updated>2026-06-29T12:00:00+02:00</updated><id>https://blog.ristic.in.rs/2026/06/gog-cli-back-up-your-drm-free-game-library-from-the-terminal</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/06/gog-cli-back-up-your-drm-free-game-library-from-the-terminal.html"><![CDATA[<p>GOG is one of the few places left where you actually own the games you buy. No launchers phoning home, no license servers that can go dark and take your library with them — just installers, yours to keep. That’s the whole point.</p>

<p>The problem is that “yours to keep” only holds if you actually keep them. GOG’s client handles downloads fine, but if you want a scriptable, auditable, terminal-friendly way to back up your library to a NAS or an external drive, you’re on your own.</p>

<p>So I built <a href="https://github.com/aleksandarristic/gog-cli">gog-cli</a>.</p>

<h2 id="what-it-does">What It Does</h2>

<p><code class="language-plaintext highlighter-rouge">gog</code> is a Python CLI that talks to GOG’s API and lets you manage your game library backups from the command line. The core loop is:</p>

<ol>
  <li>Authenticate once.</li>
  <li>Refresh your local library cache.</li>
  <li>List, filter, and plan what you want to back up.</li>
  <li>Actually back it up.</li>
  <li>Verify and sync later.</li>
</ol>

<p>Nothing exotic, but everything works the way a CLI tool should: dry-runs before real downloads, explicit <code class="language-plaintext highlighter-rouge">--yes</code> flags instead of surprise side effects, resumable downloads, checksum verification when GOG provides them.</p>

<h2 id="the-workflow">The Workflow</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gog auth login
gog refresh
gog list purchased
gog plan <span class="nt">--destination</span> /path/to/backups <span class="nt">--all</span> <span class="nt">--storage</span> <span class="nt">--check-free-space</span>
gog backup <span class="nt">--destination</span> /path/to/backups <span class="nt">--all</span> <span class="nt">--yes</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">gog refresh</code> fills a local cache. Everything else reads from that cache — no unnecessary API calls when you’re just browsing your library.</p>

<p><code class="language-plaintext highlighter-rouge">gog plan</code> is the one I reach for most. It shows you exactly what a backup run would download, how big it is, whether you have enough space, without actually touching anything. Run it, sanity-check the output, then fire off the real backup.</p>

<h2 id="filtering">Filtering</h2>

<p>The list and plan commands have more filtering options than I probably needed to write, but they’re all useful once you have a large library:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gog list purchased <span class="nt">--platform</span> linux
gog list purchased <span class="nt">--genre</span> strategy <span class="nt">--year</span> 1998..2005
gog list purchased <span class="nt">--search</span> witcher
gog plan <span class="nt">--destination</span> /backups <span class="nt">--all</span> <span class="nt">--platform</span> windows <span class="nt">--language</span> en <span class="nt">--storage</span>
</code></pre></div></div>

<p>Year ranges, genre filters, fuzzy title search, platform selection. Unknown metadata is excluded by default; use <code class="language-plaintext highlighter-rouge">--include-unknown-year</code> or <code class="language-plaintext highlighter-rouge">--include-unknown-genre</code> if you want those rows.</p>

<h2 id="batch-selection">Batch Selection</h2>

<p>For curated backup sets, you can put selectors in a plain text file:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># first NAS batch
witcher_3
cyberpunk_2077
123456789
</code></pre></div></div>

<p>Lines starting with <code class="language-plaintext highlighter-rouge">#</code> and blank lines are ignored. Then:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gog plan <span class="nt">--destination</span> /backups <span class="nt">--games-from</span> games.txt <span class="nt">--storage</span>
gog backup <span class="nt">--destination</span> /backups <span class="nt">--games-from</span> games.txt <span class="nt">--yes</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--games-from</code> is repeatable. Selectors can be product IDs, slugs, or titles. You can also mix <code class="language-plaintext highlighter-rouge">--game</code> flags and <code class="language-plaintext highlighter-rouge">--games-from</code> files in the same command.</p>

<h2 id="aria2c-support">aria2c Support</h2>

<p>The built-in downloader is fine. If you want parallel downloads and more speed, install <code class="language-plaintext highlighter-rouge">aria2c</code> and pass <code class="language-plaintext highlighter-rouge">--downloader aria2c</code>:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gog backup <span class="nt">--destination</span> /backups <span class="nt">--games-from</span> games.txt <span class="nt">--downloader</span> aria2c <span class="nt">--yes</span>
</code></pre></div></div>

<h2 id="why-not-just-use-the-gog-client">Why Not Just Use the GOG Client?</h2>

<p>The GOG Galaxy client works. But it’s a GUI, it’s not scriptable, and it doesn’t make it easy to maintain organized, versioned backups with metadata you can inspect or diff later.</p>

<p><code class="language-plaintext highlighter-rouge">gog-cli</code> is for the kind of person who wants a cron job that checks for library updates, plans the delta, and backs up new installers to a NAS — without clicking through anything. If that’s not you, Galaxy is fine.</p>

<h2 id="install">Install</h2>

<p>Requires Python 3.12+.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>gog-cli
</code></pre></div></div>

<p>Or from source:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>git+https://github.com/aleksandarristic/gog-cli.git
</code></pre></div></div>

<p>The project is at <a href="https://github.com/aleksandarristic/gog-cli">github.com/aleksandarristic/gog-cli</a>. It’s MIT licensed, and currently at 0.2.1.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="python" /><category term="cli" /><category term="gaming" /><category term="gog" /><category term="tools" /><category term="backup" /><category term="pypi" /><summary type="html"><![CDATA[GOG is one of the few places left where you actually own the games you buy. No launchers phoning home, no license servers that can go dark and take your library with them — just installers, yours to keep. That’s the whole point.]]></summary></entry><entry><title type="html">SRB CERT Has No RSS Feed, So I Made One</title><link href="https://blog.ristic.in.rs/2026/06/srbcert-rss-feed.html" rel="alternate" type="text/html" title="SRB CERT Has No RSS Feed, So I Made One" /><published>2026-06-24T14:20:52+02:00</published><updated>2026-06-24T14:20:52+02:00</updated><id>https://blog.ristic.in.rs/2026/06/srbcert-rss-feed</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/06/srbcert-rss-feed.html"><![CDATA[<p>If you work in infosec in Serbia, you probably know <a href="https://www.cert.rs">SRB CERT</a> — the national Computer Emergency Response Team. They publish security advisories, incident reports, and notifications on their site. Good stuff, worth following.</p>

<p>The problem: no RSS feed. No Atom feed. Nothing. You either remember to check the site manually, or you miss things.</p>

<p>I got tired of that, so I built <a href="https://github.com/aleksandarristic/feedbot">feedbot</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>Feedbot is a small Python scraper that fetches the <a href="https://www.cert.rs/rs/obavestenja.html">SRB CERT notifications page</a>, pulls out each advisory (title, date, link), and generates a proper RSS XML file from it. That file gets served as a static asset, so anything that understands RSS — your feed reader, your Slack RSS bot, whatever — can consume it.</p>

<p>The core stack is minimal:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">requests</code> + <code class="language-plaintext highlighter-rouge">BeautifulSoup4</code> for fetching and parsing the HTML</li>
  <li><code class="language-plaintext highlighter-rouge">rfeed</code> for building the RSS XML</li>
  <li>A <code class="language-plaintext highlighter-rouge">sources.json</code> config that maps CSS selectors to feed fields</li>
  <li>A shell script and a cron job that ties it all together</li>
</ul>

<p>The config for SRB CERT looks roughly like this — you point it at the page, tell it which HTML elements contain the title, date, and link, give it a regex for parsing Serbian month names, and that’s it:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"srbcert"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SRB CERT Obaveštenja"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"language"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sr"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"page"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.cert.rs/rs/obavestenja.html"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"base_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.cert.rs/rs/"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"locators"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"item"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"div"</span><span class="p">,</span><span class="w"> </span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"preporuka"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"h3"</span><span class="p">,</span><span class="w">   </span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"title"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"date"</span><span class="p">:</span><span class="w">  </span><span class="p">{</span><span class="w"> </span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"span"</span><span class="p">,</span><span class="w"> </span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"date"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"link"</span><span class="p">:</span><span class="w">  </span><span class="p">{</span><span class="w"> </span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a"</span><span class="p">,</span><span class="w">    </span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"date"</span><span class="p">,</span><span class="w"> </span><span class="nl">"attr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"href"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="how-it-runs">How it runs</h2>

<p>A cron job calls the update script on a schedule. The script runs feedbot, which writes fresh XML files to a directory served by nginx. The feed is live at <a href="https://ristic.in.rs/rss/srb_cert.xml">ristic.in.rs/rss/srb_cert.xml</a> if you want to subscribe without running anything yourself.</p>

<h2 id="why-bother">Why bother?</h2>

<p>Partly because I wanted the feed for myself. Partly because it felt wrong that a national CERT — an organization whose job is to help people stay on top of security threats — doesn’t offer a machine-readable way to follow their own alerts.</p>

<p>An RSS feed is a solved problem from 2001. It takes almost no effort to publish one. The fact that you have to scrape a government security site to get this basic functionality in 2024 is a mild indictment of how seriously some institutions take their own communication.</p>

<h2 id="its-generic">It’s generic</h2>

<p>Feedbot isn’t SRB CERT-specific. The <code class="language-plaintext highlighter-rouge">sources.json</code> approach means you can point it at any site that has a consistent HTML structure. If there’s another site you want a feed for — add a source config, run the script, done.</p>

<p>The repo is at <a href="https://github.com/aleksandarristic/feedbot">github.com/aleksandarristic/feedbot</a>. PRs welcome, especially for more source configurations.</p>]]></content><author><name>Aleksandar Ristic</name></author><category term="infosec" /><category term="rss" /><category term="python" /><category term="srbcert" /><category term="tools" /><category term="scraping" /><summary type="html"><![CDATA[If you work in infosec in Serbia, you probably know SRB CERT — the national Computer Emergency Response Team. They publish security advisories, incident reports, and notifications on their site. Good stuff, worth following.]]></summary></entry><entry><title type="html">PyCodeBridge Grew Up: Agentic Coding from Discord</title><link href="https://blog.ristic.in.rs/2026/06/pycodebridge-grew-up-agentic-coding-from-discord.html" rel="alternate" type="text/html" title="PyCodeBridge Grew Up: Agentic Coding from Discord" /><published>2026-06-22T13:10:18+02:00</published><updated>2026-06-22T13:10:18+02:00</updated><id>https://blog.ristic.in.rs/2026/06/pycodebridge-grew-up-agentic-coding-from-discord</id><content type="html" xml:base="https://blog.ristic.in.rs/2026/06/pycodebridge-grew-up-agentic-coding-from-discord.html"><![CDATA[<p>Back in February I wrote about PyCodeBridge as a way to doom-vibe-code from wherever I happened to be: phone in hand, Discord open, local development machine doing the real work somewhere else.</p>

<p>That version was fun. It was also a bit of a prototype in spirit: a chat bridge into Codex CLI sessions, with dreams of multiple transports and just enough command handling to make mobile coding feel possible.</p>

<p>Several months later, the project has become something more serious.</p>

<p>PyCodeBridge is now an agentic coding bridge. It still lets me drive local repo work from chat, but the shape is different: Discord is the supported transport, channels are mapped to repos with <code class="language-plaintext highlighter-rouge">code-&lt;repo&gt;</code>, Codex is just the default backend, and Claude Code or Gemini CLI can be selected per session when I want a different agent.</p>

<p>In other words, it moved from “Codex in chat” to “a Discord control plane for agentic coding on my own machine.”</p>

<h2 id="the-new-mental-model">The New Mental Model</h2>

<p>The basic idea is still simple:</p>

<ol>
  <li>A private Discord channel named <code class="language-plaintext highlighter-rouge">code-myrepo</code> maps to a local git repository under <code class="language-plaintext highlighter-rouge">code_root</code>.</li>
  <li>Messages and commands in that channel are routed to an agent session for that repo.</li>
  <li>The agent runs locally, with access to the actual working tree, local tooling, credentials, and git state I have configured.</li>
  <li>Results stream back into Discord.</li>
</ol>

<p>The important change is that the “agent” is now an abstraction, not a synonym for one CLI.</p>

<p>The default backend is Codex. That remains the out-of-the-box path and the config section is still named <code class="language-plaintext highlighter-rouge">codex</code> for compatibility. But sessions can now use:</p>

<ul>
  <li>Codex</li>
  <li>Claude Code</li>
  <li>Gemini CLI</li>
</ul>

<p>The command is straightforward:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!c agent claude
!c agent gemini gemini-2.5-pro
!c agent codex gpt-5.4 medium
</code></pre></div></div>

<p>There are also helper commands:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!c agents
!c models
!c efforts
!c agent
!c model
!c effort
</code></pre></div></div>

<p>The no-argument versions show what the current session is actually using. That matters more than it sounds, because once you have multiple threads, sessions, models, and backends, guessing is a great way to confuse yourself.</p>

<h2 id="discord-won">Discord Won</h2>

<p>The original post talked about Discord, Telegram, and Slack. That is no longer true.</p>

<p>The code still keeps a transport-aware architecture internally, but the supported transport is Discord. I use Discord. Discord has the interaction model I wanted: channels, private repo rooms, threads, typing indicators, uploads, downloads, pinned messages, and direct messages for owner/admin workflows.</p>

<p>So the product decision became simple: stop pretending every transport matters equally and make the Discord experience solid.</p>

<p>That also means the old channel prefix changed. It is no longer <code class="language-plaintext highlighter-rouge">codex-myrepo</code>. It is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code-myrepo
</code></pre></div></div>

<p>That name fits the project better now. A channel maps to code. The selected backend can be Codex, Claude, Gemini, or whatever gets added later.</p>

<h2 id="it-is-not-just-prompt-relay-anymore">It Is Not Just Prompt Relay Anymore</h2>

<p>The bridge now has a real command surface. You can still send prompts, but the useful part is that you can operate sessions without being at the machine.</p>

<p>Some examples:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!c start
!c resume fix the failing tests
!c status
!c ps
!c stop
!c interrupt
!c kill
!c logs
!c download path/to/file
!c git status
!c gh pr status
</code></pre></div></div>

<p>There are shortcuts for active-run interaction:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!s tighten the scope
!a yes, proceed
!y
!n
!w
!retry
</code></pre></div></div>

<p>Plain chat can also become useful in the right state. If exactly one session is running, a plain message can steer that active session instead of being queued as a new prompt. If an agent is waiting for input, a plain reply can go straight to stdin.</p>

<p>That makes the mobile workflow feel much less like operating a bot and much more like supervising a long-running coding process.</p>

<h2 id="sessions-threads-queues-and-conflict-handling">Sessions, Threads, Queues, and Conflict Handling</h2>

<p>Each repo channel has a default session. Discord threads also get isolated session scopes, so I can split work without stomping on the main channel state.</p>

<p>There is queueing per channel, run control, stale-session handling, and explicit conflict choices:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!continue
!new
!compact
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">compact</code> flow is one of the more useful quality-of-life changes. When a session is stale or I want a fresh run without throwing away all context, the bridge can summarize prior context and start a new session from that summary.</p>

<p>That is the kind of feature that only shows up after actually using this thing for messy, interrupted work.</p>

<h2 id="security-became-a-first-class-feature">Security Became a First-Class Feature</h2>

<p>Running local coding agents from chat is powerful, which is another way of saying it can be dangerous if treated casually.</p>

<p>The current bridge is much stricter than the early version:</p>

<ul>
  <li>Discord repo channels must be private.</li>
  <li>Users must be allowlisted.</li>
  <li>The bot is locked to one configured guild.</li>
  <li>TOTP can be required for protected commands.</li>
  <li>GitHub CLI commands can have their own unlock scope.</li>
  <li>High-risk operations stay behind TOTP.</li>
  <li>Failed or replayed TOTP attempts are rate-limited.</li>
  <li>Audit logs can redact secrets.</li>
  <li>TOTP values are sanitized before audit writes.</li>
</ul>

<p>There are also safer file-transfer rules now. Uploads are bounded by per-file size, total batch size, and file count. Saves use repo-local temporary files and symlink-aware finalization so an upload cannot casually overwrite something outside the repo.</p>

<p>That sounds boring until you remember this is a chat interface into a development machine. Boring is good here.</p>

<h2 id="better-feedback-during-long-runs">Better Feedback During Long Runs</h2>

<p>Long agent runs are where bridges like this either feel magical or feel broken.</p>

<p>PyCodeBridge now tries much harder to tell me what is happening:</p>

<ul>
  <li>Heartbeats can include agent, model, and effort.</li>
  <li>Tool calls can be surfaced in the chat stream.</li>
  <li>Claude thinking blocks can be relayed when enabled.</li>
  <li>Streamed output is coalesced so Discord does not get spammed by tiny chunks.</li>
  <li>Successful runs with no assistant output get an explicit terminal notice.</li>
  <li>Final result events stop runaway heartbeats even if a CLI process lingers.</li>
  <li>Friendly errors catch common backend failures, like unsupported models or Claude usage limits.</li>
</ul>

<p>The point is not to make the chat noisy. The point is to avoid the worst state: staring at Discord wondering whether anything is alive.</p>

<h2 id="docker-health-checks-and-operations">Docker, Health Checks, and Operations</h2>

<p>The project also grew the less glamorous pieces that make it easier to run:</p>

<ul>
  <li>Docker and Compose support.</li>
  <li>A preflight wrapper.</li>
  <li>An update-and-redeploy script.</li>
  <li>Optional health endpoint.</li>
  <li>Public health binds blocked by default unless explicitly allowed.</li>
  <li>Reset-state helper.</li>
  <li>Runtime logs, per-session JSONL logs, archives, and audit artifacts.</li>
</ul>

<p>The bridge can still run directly with Python, but the Docker path is much cleaner for a persistent bot.</p>

<h2 id="what-i-actually-use-it-for">What I Actually Use It For</h2>

<p>I do not think of PyCodeBridge as “coding on a phone.” That phrase sounds like punishment.</p>

<p>I think of it as remote supervision for local agentic coding.</p>

<p>The useful moments are things like:</p>

<ul>
  <li>starting an investigation while away from the keyboard;</li>
  <li>asking an agent to inspect logs or summarize a repo state;</li>
  <li>nudging an already-running session;</li>
  <li>checking whether a long run is stuck;</li>
  <li>downloading a generated file;</li>
  <li>stopping a bad run before it wastes more time;</li>
  <li>switching a session from one backend to another when a task fits a different agent better.</li>
</ul>

<p>It is still a little ridiculous. That is part of the charm. But it is no longer just a weekend toy.</p>

<h2 id="the-current-shape">The Current Shape</h2>

<p>The short version:</p>

<ul>
  <li>Supported transport: Discord.</li>
  <li>Channel naming: <code class="language-plaintext highlighter-rouge">code-&lt;repo&gt;</code>.</li>
  <li>Default backend: Codex.</li>
  <li>Additional backends: Claude Code and Gemini CLI.</li>
  <li>Repo mapping: channels map to local git repos under <code class="language-plaintext highlighter-rouge">code_root</code>.</li>
  <li>Security: allowlists, private channels, guild lock, TOTP, rate limits, redaction.</li>
  <li>Operations: Docker, health endpoint, logs, archives, reset/update scripts.</li>
  <li>Workflow: sessions, threads, queues, run control, uploads/downloads, git and GitHub helpers.</li>
</ul>

<p>That is a lot more than “send a prompt to Codex from Discord.”</p>

<p>It is now a small, opinionated control plane for letting agentic coding tools work on my machine while I steer them from wherever I happen to be.</p>

<p>Still doom-vibe-code friendly. Just a lot less reckless.</p>

<p>Project:</p>

<p><a href="https://github.com/aleksandarristic/pycodebridge">https://github.com/aleksandarristic/pycodebridge</a></p>]]></content><author><name>Aleksandar Ristic</name></author><category term="ai" /><category term="agents" /><category term="agentic coding" /><category term="codex" /><category term="claude" /><category term="gemini" /><category term="discord" /><category term="remote development" /><category term="vibe coding" /><summary type="html"><![CDATA[Back in February I wrote about PyCodeBridge as a way to doom-vibe-code from wherever I happened to be: phone in hand, Discord open, local development machine doing the real work somewhere else.]]></summary></entry></feed>