Which bio?¶
Occasionally, I'll get a ping on Matrix from someone asking for the source on my bio (or some other comment on it). The issue is, each time I have to ask them, "which bio did you see?". This often confuses people - what do you mean "which one"? Well, about that... I have over 25, and growing. And in this post, I'm going to explain how! Because it was kinda fun.
Wait, Matrix has bios?¶
For anyone uninitiated, yes, Matrix (sort of) has profile bios. MSC4440: Profile Biography via Global Profiles is an unstable proposal that was opened back in March of this year, but has since received pretty widespread adoption across popular clients like Gomuks, Sable, and Commet. It uses the relatively new extensible profiles from MSC4133: Extending User Profile API with Key:Value Pairs, which became part of the formal Matrix specification back in August, 2025, allowing global profiles to be extended with arbitrary information. In this case, it adds a new "bio" field, which allows for user-configurable biographies using plain text or HTML (markdown is usually rendered into HTML), which supporting clients can then show. For example:
It may look different in other clients, but you can approximate from there.
But how does it change?¶
Anyone who went and read the proposal might notice one glaring problem: profile biographies are static, meaning only one value can be present at a time. Changing it requires making a request to update the profile on the server... so how can I not know what bio someone is seeing? Surely I'd be able to just check my own bio?
Here's the fun part: don't tell the police, but my car may be running some modifications that aren't street approved.
To understand how I did this, you have to read the Matrix specification a little bit. In order to fetch a user's profile, your client has to send a request to GET /_matrix/client/v3/profiles/{userId}. This fetches the profile for the given user, which in this case will include the biography field. But, what does a server do if it doesn't know the profile for userId?
In this case, it's usually because the user belongs to another homeserver. So, to fetch the profile the client wants, the server will ask the other server for the profile with GET /_matrix/federation/v1/query/profile. if the other server provides the requested information, then the server can pass that data back to the requesting client.
The first iteration: static list¶
So, the problem seems fairly easy right? Just intercept requests to that endpoint and return a random field.
And that's what I did for a little bit, before I quickly realised the approach I took wasn't going to last. See, I had compiled a check into my server that made it build my profile data and then modify the biography field to contain a random string from a precompiled list. It looked something like this:
const RANDOM_BIO_ENTRIES: [&str, 4] = [
"foo",
"bar",
"bazz",
"asdf"
]
async fn get_profile_route(request: Request, services: Services) -> Result<Response> {
let mut profile = services.users.build_profile(&request.body.user_id).await?;
if request.body.user_id == ruma::user_id!("@nex:nexy7574.co.uk") {
profile.insert(
"gay.fomx.biography".into(),
serde_json::json!({
"m.text": [{
"body": RANDOM_BIO_ENTRIES.choose(&mut rand::rng()).into(),
"mimetype": "text/html"
}]
})
);
}
Ok(profile)
}
This would work if it weren't for the fact that I am indecisive as hell, and also like to put lyrics in my bio. I heavily use music to express myself, and some readers may have seen me putting lyrics from songs I like in places other than my bios, such as my main website's footer. So, what to do when I find a new song I love? Well, adding a verse from that song would require not only editing the source of my patchset, but also then recompiling and restarting my server. That whole ordeal can take more than 10 minutes in some cases, so this quickly became infeasible.
The second iteration: sourcing from my profile¶
It soon dawned on me: profiles are now key-value stores. JSON. And the server was already pulling in the profile data to begin with. So, I defined a quick schema for storing the list of bios I want to randomly choose from, and made it read from that. Since this was all going on inside the user service, I decided to move the logic for randomising the biography into that build call - this has the added benefit that even client-to-server requests will get a randomised bio too, meaning I too can see a randomised bio from my profile too. For that extra vanity flair or whatever.
So, the code was updated to look vaguely like this:
#[derive(serde::Deserialize)]
struct BioRepo {
entries: Vec<String>
}
// users service
impl service::Service {
async fn build_local_profile(&self, user_id: &UserId) -> BTreeMap<String, serde_json::Value> {
let mut profile = BTreeMap::new();
// some code here...
// Randomise bio
if let Some(repo_raw) = profile.get("uk.timedout.bio_repository") {
let repo: BioRepo = serde_json::from_value(repo_raw).unwrap();
// there was actual error handling I promise
profile.insert(
"gay.fomx.biography".into(),
serde_json::json!({
"m.text": [{
"body": repo.entries.choose(&mut rand::rng()).into(),
"mimetype": "text/html"
}]
})
);
}
profile
}
}
This worked great! I could just edit a JSON field in my profile now (which Gomuks lets me do in-app), and add/remove entries as my heart saw fit.
But then I encountered a new problem. You may have noticed that all of my biographies are returned as text/html. This allows me to style entries, like so (spoilers for the next iterations):
But, this bio is represented in the biography repository JSON like so (linebreaks added for readability purposes):
{
"entries": [
// ...
"<span data-mx-color=\"#0094FE\">Machine</span>... I will <span data-mx-color=\"#FE0000\">
cut you down</span>, <span data-mx-color=\"#FE0000\">break you apart</span>,
<span data-mx-color=\"#FE0000\">splay the gore of your profane form</span>
across the <span data-mx-color=\"#FF8000\">STARS</span>!
I will <span data-mx-color=\"#FE0000\">grind you down</span> until the very
<span data-mx-color=\"#FF8000\">SPARKS</span>
<span data-mx-color=\"#FE0000\">CRY FOR MERCY</span>! My hands shall
<span data-mx-color=\"#FE0000\">RELISH ENDING YOU</span>...
<span data-mx-color=\"#FE0000\"><strong>HERE</strong></span>! AND!
<span data-mx-color=\"#FE0000\"><strong>NOW</strong></span>!"
// ...
]
}
Now, that is a lot of text. You can imagine how big it can get when there's multiple entries with colours and other styling like that. Normally, this wouldn't be a problem. But for profiles, it is: profiles are limited to 64KiB, in total. This means my bio repository was fighting my other profile fields for space, which could have meant that it would be hard to update my displayname or avatar in the future. It also meant that whatever entry was selected was in the returned profile JSON twice, since every server downloading my profile got a full copy of the repository.
You might think the solution to that problem is easy though. "Just remove the repository field from the returned profile after you're done with it!" I hear you cry, in your head while reading this. But, consider this: since both federation and client requests reach this codepath, removing the field from the result would mean I can't see the repository! I'd have to keep a copy somewhere else!
Keep a copy somewhere else... now that sounds like a good idea...
The third iteration: sourcing from account data¶
Guess what other key-value store exists in Matrix? Account data!
Account data is typically used to store client configuration information - things like push rules, client settings, your ignored user list, your DM list, so on so forth. But, it also comes with a benefit: while it is shaped like profile data (in that it is just JSON), it is only visible to the authenticated user. That means it is only visible over the client-to-server API, which means not everyone asking for my profile gets my entire repository of bios too.
But account data has a bigger benefit: account data has no (formal) size limit. Realistically, since it never goes to other people's servers, I could just remove the size limit if there was one. I'm already modding my server, the world is my oyster. But, this means I can quite easily store my biography repository on the server, and sync it across all of my clients, all without ever leaking it to anyone actually requesting my bio.
So, with this in mind, I updated the profile builder function to look more like this:
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
struct BioRepository {
entries: Vec<String>,
}
// I think there's a way to do this via ruma, but I haven't invested enough
// time to bother reading the docs yet.
#[derive(Clone, Debug, Deserialize, Serialize)]
struct BioRepositoryWrapper {
content: BioRepositoryContent,
}
// users service
impl service::Service {
async fn build_local_profile(&self, user_id: &UserId) -> BTreeMap<String, serde_json::Value> {
let mut profile = BTreeMap::new();
// some code here...
// Randomise bio from account data
if let Ok(entries) = self
.services
.account_data
.get_global::<BioRepositoryWrapper>("uk.timedout.bio_repository")
{
profile.insert(
"gay.fomx.biography".into(),
serde_json::json!({
"m.text": [{
"body": entries.choose(&mut rand::rng()).into(),
"mimetype": "text/html"
}]
})
);
}
profile
}
}
Now, whenever my profile is requested, the server will build my profile, then read my account data to fetch my bio repository, and selects a random entry from it.
This worked great for ages. I was able to rotate less than 30 bios with ease, and it was quite a hit with anyone who looked at my profile. I got cool internet points!
But, you'll notice that I said "worked". Past tense.
In comes ring4440¶
When the biography proposal started gaining some support, ring4440 was created. This is a "webring", similar to ones you'll find in my website's footer, but instead with MSC4440 biographies (hence the name, ring4440). At the time of writing, there's 14 members (myself included), and that's what the <-, ring4440, and -> buttons were in my earlier image.
To join ring4440, one must put a link to their left and right neighbours, and a link to the ring4440 room. For most people, this wouldn't be a problem, just prepend some HTML their bio. But me? I have to be different, I have to be difficult, and I have to add entropy to everything.
So for a while my head spun a bit. How could I add this ring string without adding it to every single entry in my repository? Well, I went back to my roots: I hardcoded it! Sort of.
I didn't know who my neighbours were going to be when I was updating my server, so I opted to have another account data entry so that I could just set my neighbours later, and possible change them in the future without having to recompile.
This was convenient really, since I wanted to also know what entry was being returned each time I refreshed my profile, and how many entries I have in my repository. So, a template allows me to inject that too.
To do this, I updated the simple entry selection to do this:
async fn select_random_bio() -> Result<String> {
let template = entries
.choose(&mut rand::rng())
.cloned()
.ok_or_else(|| err!(error!(options=?entries, "could not choose a bio entry")))?;
let ring4440_str = match self
.services
.account_data
.get_global::<MSC4440RingConfigEvent>(
user_id,
"uk.timedout.msc4440_ring_config".into(),
)
.await
.and_then(|evt| {
evt.content
.ok_or_else(|| err!(Request(NotFound("not configured"))))
}) {
| Err(e) if e.is_not_found() => String::new(),
| Err(e) => return Err(e),
| Ok(conf) => {
format!(
"<a href=\"{}\">←</a> <a href=\"{}\">ring4440</a> <a href=\"{}\">→</a><hr>",
conf.before.matrix_uri(false),
ruma::room_alias_id!("#ring4440:zirco.dev").matrix_uri(true),
conf.after.matrix_uri(false)
)
},
};
let index = entries.iter().position(|s| **s == template).expect("woof").saturating_add(1);
Ok(format!(
"{ring4440_str}{template}<hr><p>Entry {index}/{}</p>",
entries.len(),
))
}
This does the following things:
- Picks a random biography entry and designates it as a "template".
- Loads my ring4440 account data, and either returns an empty string if it doesn't exist, or formats the buttons into a string.
- Returns a formatted string, prefixed with the ring4440 HTML (which may be an empty string), then the original template, and then suffixes the returned biography with which index it was at (and how many entries there are).
In prod I added a bit more flair, with a link to my website and the continuwuity logo, and now my bio looks like this:
And now, with every refresh of my profile, the text in the middle changes! Isn't that cool :D
But wait, the specification says servers might cache profiles?¶
You'd be right, person that totally read the specification I linked and isn't just nodding along. The definition for the server-to-server endpoint for fetching profiles DOES say:
Requesting servers MAY wish to cache the response to this query to avoid requesting the information too often.
However, no server currently does this. You might assume that's an oversight, but to my knowledge it's deliberate. There's currently no existing mechanism to tell other servers "hey, this user just updated their profile!", which means servers might cache others' profiles for a bit too long, and potentially then return stale data to clients asking for profiles. We can't have delays in my instant messaging protocol!
MSC4259: Profile Update EDUs for Federation aims to address this by adding an ephemeral data object servers can broadcast to tell others to invalidate their caches, but it doesn't look like it's getting much traction. Plus, with the likes of MSC4426: User Status Profile Fields and MSC4544: Rich Presences in Matrix being opened, it looks like the lack of caching is going to become a feature, rather than forgotten optimisation path. So while yes, servers may cache profiles, none of them currently do. This is also why every refresh of my profile returns a new bio: each client request translates to one federation request also.
Conclusion¶
And that's how I invented rotating biographies in Matrix. This isn't something that will be making it into upstream continuwuity since it isn't something any proposal standardises, however you're free to take inspiration and modify your own server to do similar things. I won't permalink it here, so this link may rot in the future, but the relevant changes are in my fork of continuwuity: https://forgejo.ellis.link/nex/continuwuity/src/branch/nex/mods/src/service/users/profile.rs. Just ctrl+f for "random", you should get close enough.
Disclaimer: if you do chose to modify your server, please make sure you extensively test your changes before deploying to prod. There's already too many vibecoded and poorly patched homeservers wreaking havoc on the open federation, and I'd rather not get another bug report.
Thanks for reading!


