MoreRSS

site iconXe IasoModify

Senior Technophilosopher, Ottawa, CAN, a speaker, writer, chaos magician, and committed technologist.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Xe Iaso

Life pro tip: a Steam Deck can be a bluetooth speaker

2026-02-05 08:00:00

Bluetooth headphones are great, but they have one main weakness: they can only get audio streams from a single device at a time. Facts and Circumstances™️ mean that I have to have hard separation of personal and professional workloads, and I frequently find myself doing both at places like coworking spaces.

Often I want to have all of these audio inputs at once:

  • Notifications from games on my Steam Deck (mostly FFXIV)
  • Notification sounds from Slack at work
  • Music on my personal laptop
  • Anything else from my phone

When I'm in my office at home, I'll usually have all of these on speaker because I'm the only person there. I don't want to disturb people at this coworking space with my notification pings or music.

Turns out a Steam Deck can act as a BlueTooth speaker with no real limit to the number of inputs! Here's how you do it:

  • Open Bluetooth settings on the Steam Deck and device you want to pair.
  • Look for the name of your laptop on the Steam Deck or Steam Deck on your laptop. This may require you to "show all devices" as usually the UI wants to prevent you from pairing a laptop to another computer because this normally doesn't make sense.
  • Pair the two devices together and confirm the request on both sides.
  • Select your Steam Deck as a speaker on your laptop.
  • Max out the volume on the laptop and control the volume on the deck.

This is stupidly useful. It also works with any Linux device, so if you have desktop Linux on any other machines you can also use them as speakers. I really wish this was a native feature of macOS and Windows. It's one of the best features of desktop Linux that nobody knows about.

Cadey is coffee
Cadey

Sorry if this is a bit worse than my usual writing style, I have a big life event coming up and preparations for it are having secondary side effects that have made focusing deep enough for good writing hard. It'll get better! I'm just kinda stressed, sorry.

Did Zendesk get popped?

2026-02-04 08:00:00

I don't know how to properly raise this, but I've gotten at least 100 emails from various Zendesk customers (no discernible pattern, everything from Soundcloud to GitLab Support to the Furbo Pet Camera).

Is Zendesk being hacked?

I'll update the post with more information as it is revealed.

Backfilling Discord forum channels with the power of terrible code

2026-01-27 08:00:00

Hey all! We've got a Discord so you can chat with us about the wild world of object storage and get any help you need. We've also set up Answer Overflow so that you can browse the Q&A from the web.

Today I'm going to discuss how we got there and solved one of the biggest problems with setting up a new community or forum: backfilling existing Q&A data so that the forum doesn't look sad and empty.

All the code I wrote to do this is open source in our glue repo. The rest of this post is a dramatic retelling of the thought process and tradeoffs that were made as a part of implementing, testing, and deploying this pull request.

Ready? Let's begin!

Thinking about this from an AI Big Data™ perspective

There's a bunch of ways you can think about this problem, but given the current hype zeitgeist and contractual obligations we can frame this as a dataset management problem. Effectively we have a bunch of forum question/answer threads on another site, and we want to migrate the data over to a new home on Discord. This is the standard "square peg to round hole" problem you get with Extract, Transform, Load (ETL) pipelines and AI dataset management (mostly taking your raw data and tokenizing it so that AI models work properly).

So let's think about this from an AI dataset perspective. Our pipeline has three distinct steps:

  • Extracting the raw data from the upstream source and caching it in Tigris.
  • Transforming the cached data to make it easier to consume in Discord, storing that in Tigris again.
  • Loading the transformed data into Discord so that people can see the threads in app and on the web with Answer Overflow.

When thinking about gathering and transforming datasets, it's helpful to start by thinking about the modality of the data you're working with. Our dataset is mostly forum posts, which is structured text. One part of the structure contains HTML rendered by the forum engine. This, the "does this solve my question" flag, and the user ID of the person that posted the reply are the things we care the most about.

I made a bucket for this (in typical recovering former SRE fashion it's named for a completely different project) with snapshots enabled, and then got cracking. Tigris snapshots will let me recover prior state in case I don't like my transformations.

Gathering the dataset

When you are gathering data from one source in particular, one of the first things you need to do is ask permission from the administrator of that service. You don't know if your scraping could cause unexpected load leading to an outage. It's a classic tragedy of the commons problem that I have a lot of personal experience in preventing. When you reach out, let the administrators know the data you want to scrape and the expected load– a lot of the time, they can give you a data dump, and you don't even need to write your scraper. We got approval for this project, so we're good to go!

To get a head start, I adapted an old package of mine to assemble User-Agent strings in such a way that gives administrators information about who is requesting data from their servers along with contact information in case something goes awry. Here's an example User-Agent string:

tigris-gtm-glue (go1.25.5/darwin/arm64; https://tigrisdata.com; +qna-importer) Hostname/hoshimi-miyabi.local
        

This gives administrators the following information:

  • The name of the project associated with the requests (tigris-gtm-glue, where gtm means "go-to-market", which is the current in-vogue buzzword translation for whatever it is we do).
  • The Go version, computer OS, and CPU architecture of the machine the program is running on so that administrator complaints can be easier isolated to individual machines.
  • A contact URL for the workload, in our case it's just the Tigris home page.
  • The name of the program doing the scraping so that we can isolate root causes down even further. Specifically it's the last path element of os.Args[0], which contains the path the kernel was passed to the executable.
  • The hostname where the workload is being run in so that we can isolate down to an exact machine or Kubernetes pod. In my case it's the hostname of my work laptop.

This seems like a lot of information, but realistically it's not much more than the average Firefox install attaches to each request:

User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0
        

The main difference is adding the workload hostname purely to help debugging a misbehaving workload. This is a concession that makes each workload less anonymous, however keep in mind that when you are actively scraping data you are being seen as a foreign influence. Conceding more data than you need to is just being nice at that point.

If you're given the whole webapp, you use the whole webapp

One of the other "good internet citizen" things to do when doing benign scraping is try to reduce the amount of load you cause to the target server. In my case the forum engine is a Rails app (Discourse), which means there's a few properties of Rails that work to my advantage.

Fun fact about Rails: if you append .json to the end of a URL, you typically get a JSON response based on the inputs to the view. For example, consider my profile on Lobsters at https://lobste.rs/~cadey. If you instead head to https://lobste.rs/~cadey.json, you get a JSON view of my profile information. This means that a lot of the process involved gathering a list of URLs with the thread indices we wanted, then constructing the thread URLs with .json slapped on the end to get machine-friendly JSON back.

This made my life so much easier.

Shoving it in Tigris

Now that we have easy ways to get the data from the forum engine, the next step is to copy it out to Tigris directly after ingesting it. In order to do that I reused some code I made ages ago as a generic data storage layer kinda like Keyv in the node ecosystem. One of the storage backends was a generic object storage backend. I plugged Tigris into it and it worked on the first try. Good enough for me!

Either way: this is the interface I used:

// Interface defines the calls used for storage in a local or remote datastore.
        // This can be implemented with an in-memory, on-disk, or in-database storage
        // backend.
        type Interface interface {
            // Delete removes a value from the store by key.
            Delete(ctx context.Context, key string) error
        
            // Exists returns nil if the key exists, ErrNotFound if it does not exist.
            Exists(ctx context.Context, key string) error
        
            // Get returns the value of a key assuming that value exists and has not expired.
            Get(ctx context.Context, key string) ([]byte, error)
        
            // Set puts a value into the store that expires according to its expiry.
            Set(ctx context.Context, key string, value []byte) error
        
            // List lists the keys in this keyspace optionally matching by a prefix.
            List(ctx context.Context, prefix string) ([]string, error)
        }
        

By itself this isn't the most useful, however the real magic comes with my JSON[T] adaptor type. This uses Go generics to do type-safe operations on Tigris such that you have 90% of what you need for a database replacement. When you do any operations on a JSON[T] adaptor, the following happens:

  • Key names get prefixed automatically.
  • All data is encoded into JSON on write and decoded from JSON on read using the Go standard library.
  • Type safety at the compiler level means the only way you can corrupt data is by having different "tables" share the same key prefix. Try not to do that! You can use Tigris bucket snapshots to help mitigate this risk in the worst case.

In the future I hope to extend this to include native facilities for forking, snapshots, and other nice to haves like an in-memory cache to avoid IOPs pressure, but for now this is fine.

As the data was being read from the forum engine, it was saved into Tigris. All future lookups to that data I scraped happened from Tigris, meaning that the upstream server only had to serve the data I needed once instead of having to constantly re-load and re-reference it like the latest batch of abusive scrapers seem to do.

Massaging the data

So now I have all the data, I need to do some massaging to comply both with Discord's standards and with some arbitrary limitations we set on ourselves:

  1. Discord needs Markdown, the forum engine posts are all HTML.
  2. We want to remove personally-identifiable information from those posts just to keep things a bit more anonymous.
  3. Discord has a limit of 2048 characters per message and some posts will need to be summarized to fit within that window.

In general, this means I needed to take the raw data from the forum engine and streamline it down to this Go type:

type DiscourseQuestion struct {
            Title string          `json:"title"`
            Slug  string          `json:"slug"`
            Posts []DiscoursePost `json:"posts"`
        }
        
        type DiscoursePost struct {
            Body     string `json:"body"`
            UserID   string `json:"userID"`
            Accepted bool   `json:"accepted"`
        }
        

In order to make this happen, I ended up using a simple AI agent to do the cleanup. It was prompted to do the following:

  • Convert HTML to Markdown: Okay, I could have gotten away using a dedicated library for this like html2text, but I didn't think about that at the time.
  • Remove mentions and names: Just strip them out or replace the mentions with generic placeholders ("someone I know", "a friend", "a colleague", etc.).
  • Keep "useful" links: This was left intentionally vague and random sampling showed that it was good enough.
  • Summarize long text: If the text is over 1000 characters, summarize it to less than 1000 characters.

I figured this should be good enough so I sent it to my local DGX Spark running GPT-OSS 120b via llama.cpp and manually looked at the output for a few randomly selected threads. The sample was legit, which is good enough for me.

Once that was done I figured it would be better to switch from the locally hosted model to a model in a roughly equivalent weight class (gpt-5-mini). I assumed that the cloud model would be faster and slightly better in terms of its output. This test failed because I have somehow managed to write code that works great with llama.cpp on the Spark but results in errors using OpenAI's production models.

I didn't totally understand what went wrong, but I didn't dig too deep because I knew that the local model would probably work well enough. It ended up taking about 10 minutes to chew through all the data, which was way better than I expected and continues to reaffirm my theory that GPT-OSS 120b is a good enough generic workhorse model, even if it's not the best at coding.

Avoiding everything being a generic pile of meh

From here things worked, I was able to ingest things and made a test Discord to try things out without potentially getting things indexed. I had my tool test-migrate a thread to the test Discord and got a working result.

To be fair, this worked way better than expected (I added random name generation and as a result our CEO Ovais, became Mr. Quinn Price for that test), but it felt like one thing was missing: avatars. Having everyone in the migrated posts use the generic "no avatar set" avatar certainly would work, but I feel like it would look lazy. Then I remembered that I also have an image generation model running on the Spark: Z-Image Turbo. Just to try it out, I adapted a hacky bit of code I originally wrote on stream while I was learning to use voice coding tools to generate per-user avatars based on the internal user ID.

This worked way better than I expected when I tested how it would look with each avatar attached to their own users.

In order to serve the images, I stored them in the same Tigris bucket, but set ACLs on each object so that they were public, meaning that the private data stayed private, but anyone can view the objects that were explicitly marked public when they were added to Tigris. This let me mix and match the data so that I only had one bucket to worry about. This reduced a lot of cognitive load and I highly suggest that you repeat this pattern should you need this exact adaptor between this exact square peg and round hole combination.

Making the forum threads look like threads

Now that everything was working in development, it was time to see how things would break in production! In order to give the façade that every post was made by a separate user, I used a trick that my friend who wrote Pluralkit (an accessibility tool for a certain kind of neurodivergence) uses: using Discord webhooks to introduce multiple pseudo-users into one channel.

I had never combined forum channels with webhook pseudo-users like this before, but it turned out to be way easier than expected. All I had to do was add the right thread_name parameter when creating a new thread and the thread_id parameter when appending a new message to it. It was really neat and made it pretty easy to associate each thread ingressed from Discourse into its own Discord thread.

The big import

Then all that was left was to run the Big Scary Command™ and see what broke. A couple messages were too long (which was easy to fix by simply manually rewriting them, doing the right state layer brain surgery, deleting things on Discord, and re-running the migration tool. However 99.9% of messages were correctly imported on the first try.

I had to double check a few times including the bog-standard wakefulness tests. If you've never gone deep into lucid dreaming before, a wakefulness test is where you do something obviously impossible to confirm that it does not happen, such as trying to put your fingers through your palm. My fingers did not go through my palm. After having someone else confirm that I wasn't hallucinating more than usual I found out that my code did in fact work and as a result you can now search through the archives on community.tigrisdata.com or via the MCP server!

I consider that a massive success.

Conclusion: making useful forums

As someone who has seen many truly helpful answers get forgotten in the endless scroll of chats, I wanted to build a way to get that help in front of users when they need it by making it searchable outside of Discord. Finding AnswerOverflow was pure luck: I happened to know someone who uses it for the support Discord for the Linux distribution I use on my ROG Ally, Bazzite. Thanks, j0rge!

AnswerOverflow also has an MCP server so that your agents can hook into our knowledge base to get the best answers. To find out more about setting it up, take a look at the "MCP Server" button on the Tigris Community page. They've got instructions for most MCP clients on the market. Worst case, configure your client to access this URL:

https://community.tigrisdata.com/mcp
        

And bam, your agent has access to the wisdom of the ancients.

But none of this is helpful without the actual answers. We were lucky enough to have existing Q&A in another forum to leverage. If you don't have the luxury, you can write your own FAQs and scenarios as a start. All I can say is, thank you to the folks who asked and answered these questions– we're happy to help, and know that you're helping other users by sharing.

Join our Discord community

Connect with other developers, get help, and share your projects. Search our Q&A archives or ask a new question. Join the Discord.

Tormentmaxxing 'simple requests'

2026-01-15 08:00:00

I don't like being interrupted when I'm deep in flow working on things. When my flow is interrupted, it can feel like my focus was violently stolen from me and the mental context that was crystalline falls apart into a thousand pieces before it is lost forever. With this in mind, being asked to do a "quick" 5 minute task can actually result in over an hour of getting back up to speed.

This means that I sometimes will agree to do things, go back into flow (because if I get back into flow almost instantly I'm more likely to not lose any context), forget about them, and then look bad as a result. This is not ideal for employment uptime.

When you work at a startup, you don't do your job; you project the perception of doing it and ensure that the people above you are happy with what you are doing. This is a weird fundamental conflict and understanding this at a deep level has caused a lot of strange thoughts about the nature of the late-stage capitalism that we find ourselves in.

Tormentmaxxing it

However, it's the future and we have tools like Claude Code. As much as I am horrified by the massive abuses the AI industry is doing to the masses with abusive scraping, there are real things that the tools the AI industry can do today. The biggest thing they can do is just implement those "quick requests" because most of them are on the line of:

  • Delete this paragraph from the readme please.
  • This thing is confusing, can you reword or remove it?
  • You forgot to xyz.

Nearly 90% of these are in fact things that tools the AI industry has released can do today. I could just open an AI coding agent and tell it to go to town, but we can do better.

Claude Code has custom slash command support. In Claude Code land, slash commands are prompt templates that you can hydrate with arguments. This means you can just describe the normal workflow process and have the agent dutifully go about and get that done for you while you focus on more important things.

Here's what those commands look like in practice:

Please make the following change:

$ARGUMENTS

When you are done, do the following:

  • Create a Linear issue for this task.
  • Create a branch based on the changes to be made and my github username (eg: ty/update-readme-not-mention-foo).
  • Make a commit with the footer Closes: (linear issue ID) and use the --signoff flag.
  • Push that branch to GitHub.
  • Create a pull request for that branch.
  • Make a comment on that pull request mentioning ${CEO_GITHUB_USERNAME}.

When all that is done, please reply with a message similar to the following:

> Got it, please review this PR when you can: (link).

So whenever I get a "quick request", I can open a new worktree in something like Conductor, copy that Slack message verbatim, then type in:

/quick-request add a subsection to the README pointing people to the Python repository (link) based on the subsections for Go and JavaScript

From there all I have to do is hit enter and then go back to writing. The agent will dutifully Just Solve The Thing™️ using GLM 4.7 via their coding plan. It's not as good as Anthropic's models, but it works well enough and has a generous rate limit. It's good enough, and good enough is good enough for me.

I realize the fundamental conflict between what I work on with Anubis and this tormentmaxxing workflow, but if these tools are going to exist regardless of what I think is "right", is decently cheap, and is genuinely useful, I may as well take advantage of this while the gravy train lasts.

Remember: think smarter, not harder.

I made a simple agent for PR reviews. Don't use it.

2026-01-11 08:00:00

My coworkers really like AI-powered code review tools and it seems that every time I make a pull request in one of their repos I learn about yet another AI code review SaaS product. Given that there are so many of them, I decided to see how easy it would be to develop my own AI-powered code review bot that targets GitHub repositories. I managed to hack out the core of it in a single afternoon using a model that runs on my desk. I've ended up with a little tool I call reviewbot that takes GitHub pull request information and submits code reviews in response.

reviewbot is powered by a DGX Spark, llama.cpp, and OpenAI's GPT-OSS 120b. The AI model runs on my desk with a machine that pulls less power doing AI inference than my gaming tower pulls running fairly lightweight 3D games. In testing I've found that nearly all runs of reviewbot take less than two minutes, even at a rate of only 60 tokens per second generated by the DGX Spark.

reviewbot is about 350 lines of Go that just feeds pull request information into the context window of the model and provides a few tools for actions like "leave pull request review" and "read contents of file". I'm considering adding other actions like "read messages in thread" or "read contents of issue", but I haven't needed them yet.

To make my life easier, I distribute it as a Docker image that gets run in GitHub Actions whenever a pull review comment includes the magic phrase /reviewbot.

The main reason I made reviewbot is that I couldn't find anything like it that let you specify the combination of:

  • Your own AI model name
  • Your own AI model provider URL
  • Your own AI model provider API token

I'm fairly sure that there are thousands of similar AI-powered tools on the market that I can't find because Google is a broken tool, but this one is mine.

How it works

When reviewbot reviews a pull request, it assembles an AI model prompt like this:

Pull request info:
        
        <pr>
        <title>Pull request title</title>
        <author>GitHub username of pull request author</author>
        <body>
        Text body of the pull request
        </body>
        </pr>
        
        Commits:
        
        <commits>
        <commit>
        <author>Xe</author>
        <message>
        chore: minor formatting and cleanup fixes
        
        - Format .mcp.json with prettier
        - Minor whitespace cleanup
        
        Assisted-by: GLM 4.7 via Claude Code
        Reviewbot-request: yes
        Signed-off-by: Xe Iaso <[email protected]>
        </message>
        </commit>
        </commits>
        
        Files changed:
        
        <files>
        <file>
        <name>.mcp.json</name>
        <status>modified</status>
        <patch>
        @@ -3,11 +3,8 @@
             "python": {
               "type": "stdio",
               "command": "go",
        -      "args": [
        -        "run",
        -        "./cmd/python-wasm-mcp"
        -      ],
        +      "args": ["run", "./cmd/python-wasm-mcp"],
               "env": {}
             }
           }
        -}
        \ No newline at end of file
        +}
        </patch>
        </file>
        </files>
        
        Agent information:
        
        <agentInfo>
        [contents of AGENTS.d in the repository]
        </agentInfo>
        

The AI model can return one of three results:

  • Definite approval via the submit_review tool that approves the changes with a summary of the changes made to the code.
  • Definite rejection via the submit_review tool that rejects the changes with a summary of the reason why they're being rejected.
  • Comments without approving or rejecting the code.

The core of reviewbot is the "AI agent loop", or a loop that works like this:

  • Collect information to feed into the AI model
  • Submit information to AI model
  • If the AI model runs the submit_review tool, publish the results and exit.
  • If the AI model runs any other tool, collect the information it's requesting and add it to the list of things to submit to the AI model in the next loop.
  • If the AI model just returns text at any point, treat that as a noncommittal comment about the changes.

Don't use reviewbot

reviewbot is a hack that probably works well enough for me. It has a number of limitations including but not limited to:

  • It does not work with closed source repositories due to the gitfs library not supporting cloning repositories that require authentication. Could probably fix that with some elbow grease if I'm paid enough to do so.
  • A fair number of test invocations had the agent rely on unpopulated fields from the GitHub API, which caused crashes. I am certain that I will only find more such examples and need to issue patches for them.
  • reviewbot is like 300 lines of Go hacked up by hand in an afternoon. If you really need something like this, you can likely write one yourself with little effort.

Frequently asked questions

When such an innovation as reviewbot comes to pass, people naturally have questions. In order to give you the best reading experience, I asked my friends, patrons, and loved ones for their questions about reviewbot. Here are some answers that may or may not help:

Does the world really need another AI agent?

Probably not! This is something I made out of curiosity, not something I made for you to actually use. It was a lot easier to make than I expected and is surprisingly useful for how little effort was put into it.

Is there a theme of FAQ questions that you're looking for?

Nope. Pure chaos. Let it all happen in a glorious way.

Where do we go when we die?

How the fuck should I know? I don't even know if chairs exist.

Has anyone ever really been far even as decided to use even go want to do look more like?

At least half as much I have wanted to use go wish for that. It's just common sense, really.

If you have a pile of sand and take away one grain at a time, when does it stop being a pile?

When the wind can blow all the sand away.

How often does it require oatmeal?

Three times daily or the netherbeast will emerge and doom all of society. We don't really want that to happen so we make sure to feed reviewbot its oatmeal.

How many pancakes does it take to shingle a dog house?

At least twelve. Not sure because I ran out of pancakes.

Will this crush my enemies, have them fall at my feet, their horses and goods taken?

Only if you add that functionality in a pull request. reviewbot can do anything as long as its code is extended to do that thing.

Why should I use reviewbot?

Frankly, you shouldn't.

2026 will be my year of the Linux desktop

2026-01-02 08:00:00

TL;DR: 2026 is going to be The Year of The Linux Desktop for me. I haven't booted into Windows in over 3 months on my tower and I'm starting to realize that it's not worth wasting the space for. I plan to unify my three SSDs and turn them all into btrfs drives on Fedora.

I've been merely tolerating Windows 11 for a while but recently it's gotten to the point where it's just absolutely intolerable. Somehow Linux on the desktop has gotten so much better by not even doing anything differently. Microsoft has managed to actively sabotage the desktop experience through years of active disregard and spite against their users. They've managed to take some of their most revolutionary technological innovations (the NT kernel's hybrid design allowing it to restart drivers, NTFS, ReFS, WSL, Hyper-V, etc.) then just shat all over them with start menus made with React Native, control-alt-delete menus that are actually just webviews, and forcing Copilot down everyone's throats to the point that I've accidentally gotten stuck in Copilot in a handheld gaming PC and had to hard reboot the device to get out of it. It's as if the internal teams at Microsoft have had decades of lead time in shooting each other in the head with predictable results.

To be honest, I've had enough. I'm going to go with Fedora on my tower and Bazzite (or SteamOS) on my handhelds.

I think that Linux on the desktop is ready for the masses now, not because it's advanced in a huge leap/bound. It's ready for the masses to use because Windows has gotten so much actively worse that continuing to use it is an active detriment to user experience and stability. Not to mention with the price of ram lately, you need every gigabyte you can get and desktop Linux lets you waste less of it on superfluous bullshit that very few people actually want.

Cadey is coffee
Cadey

Oh, and if I want a large language model integrated into my tower, I'm going to write the integration myself with the model running on hardware I can look at.

At the very least, when something goes wrong on Linux you have log messages that can let you know what went wrong so you can search for it.