Planet Jabber

October 19, 2026

Erlang Solutions

Exploring OTP Patterns for Agentic Workflows

Chapter 1: State

“AI is not my area.” That was my answer when a friend suggested I give a talk about agentic workflows. I used that phrase a lot at the beginning of the year; I was pretty skeptical about the whole thing.

But the idea stuck. I started reading about what makes agentic systems hard to build, expecting to find something wild and exotic to dig into. What I found instead was a list of problems that sounded pretty familiar:

  • State.
  • Long-running processes.
  • Communication.
  • Concurrency.
  • Coordination.
  • Failure recovery.

None of that was new to me. If you work with Elixir, Erlang, and the BEAM, it’s probably not new to you either.

A few months later, I was on stage at ElixirConf US giving the talk “Exploring OTP Patterns for Agentic Workflows”. This series is based on that talk, but it goes deeper into the topics I couldn’t fully cover on stage.

The goal is to help you better understand agentic systems from the perspective of a BEAM developer. Not because OTP is an AI framework, nor because AI concepts map one-to-one to elements of the BEAM ecosystem, but because many of the engineering challenges these systems present are problems the BEAM community has been solving for decades.

So the question is simple:

What do I already know that can help me understand agentic workflows?

In this chapter, we start with the first item on the list: state.

A familiar problem, a different way of approaching it

The example we’ll use throughout this chapter starts with a familiar situation: an endpoint that returns an error instead of the user’s data, and us leaning on an AI agent to debug and fix it.

GET /users/:id

Hey Claude: The GET /users/:id endpoint is returning a 500 error
for a valid ID. It should return the user object. Fix it.

Notice that the prompt describes what I want, not how to get there.

The agent takes this prompt as its goal. It has a codebase to investigate and a set of tools to do so. Let’s say it starts by inspecting the router and then the controller. What it does next depends on what it finds along the way.

One distinction matters for everything that follows. The model at the center of the agent, a large language model (LLM), doesn’t open files or interact directly with the system. It participates in deciding what should happen next, but it’s the tools that do the work, and their results become part of the context the model sees when it makes its next decision.

At a high level, we can represent it like this:

OTP agentic workflow represent

For this series, that’s our working definition of an agentic workflow: a workflow in which the model decides the path as it progresses, based on the context and on what it observes.

What interests us is everything behind that loop that makes it work as a system:

  • Where we keep track of what has happened.
  • Where the work runs.
  • How the results come back.
  • What work can happen concurrently.
  • What happens when things fail.
  • And finally, when the workflow stops.

We already know how to reason about those problems, and that’s what this series explores.

The system needs to remember what happened

After a few iterations, the agent has gathered information: the controller function behind the endpoint, the tests, the relevant spots in the codebase. If every iteration had to start from scratch, it would never make any progress.

But from the model’s perspective, each invocation starts with only the context it’s given. The model itself doesn’t carry anything over from one invocation to the next.

That means the system around it must preserve information about the execution: the goal, what has already happened, what work is still pending, how many steps it has taken, and so on.

In other words, the workflow has state.

Before we continue, let’s define another concept we’ve already mentioned and will need throughout this series: context.

When the model needs to decide what to do next, it needs information about what has happened so far. Maybe the failing test output, the relevant function, the original goal, and the result of the last tool call. The model receives that information as context.

The two are related, but they’re not the same thing. One way to see it:

The system has state. The model receives context derived from that state.

The state usually contains more than the model needs for a particular decision. Step counts and pending work may matter to the system without needing to be exposed to the model. And the model can only take in a limited amount of context per invocation (what’s known as its context window), so even the relevant information may need to be selected, summarized, or trimmed.

What the model sees depends on the decision we’re asking it to make. This brings us to the first interesting question of the chapter.

Who owns the state?

The answer is the essential building block of the BEAM world: a process.

A process owns its state. No other process can reach into its memory and change it; anyone who wants to interact with it has to send it a message. For a workflow, that suggests one process per execution: it starts when a goal comes in, holds the state for that run, and exits when the run is over.

But the process doesn’t just hold a value. It has to react to messages that arrive on their own schedule: a tool result, a timeout, a request to cancel.

A GenServer gives us exactly that. But be careful: the GenServer isn’t the AI agent. It’s just the process we’ll use to own and coordinate one execution of the workflow.

A naming note: agent in this series always means an AI agent, never Elixir’s Agent module. That module wouldn’t be enough here anyway: it’s a simple abstraction around state, built to hold a value, not to react to messages as they arrive.

Before writing the process, let’s look at what it has to hold. Go back to our broken endpoint. The agent has already read the router, and the model’s next decision was to do two things at once: read the controller and run the tests. Neither has come back yet. At that moment, the state of the run looks like this:

%BlogOtp.Workflow{
  goal: "The GET /users/:id endpoint is returning a 500 error...",
  model: BlogOtp.Model.Scripted,
  history: [
    {{:read_file, %{path: "lib/my_app_web/router.ex"}},
     ~s(get "/users/:id", UserController, :show)}
  ],
  pending: %{
    #Reference<...> => {:read_file, %{path: "lib/my_app_web/controllers/user_controller.ex"}},
    #Reference<...> => {:run_tests, %{path: "test/my_app_web/controllers/user_controller_test.exs"}}
  },
  steps: 3,
  max_steps: 10
}

Each field maps to something we’ve already talked about:

  • goal is the prompt we gave the agent.
  • model is the module that makes the decisions. In this chapter it’s a scripted stand-in for an LLM, so it doesn’t need anything extra to run.
  • history is what has happened so far: each tool call paired with its result.
  • pending is the work requested but not answered yet. Each request gets a unique reference, so when a result arrives, we know which one it answers.
  • steps counts the tool calls requested so far, and max_steps lets the system stop a run that goes on too long.

Only goal and history end up in the context. The rest exists for the system; the model never sees it.

Here’s the process that owns that state:

defmodule BlogOtp.Workflow do
  use GenServer, restart: :transient

  require Logger

  defstruct [:goal, :model, history: [], pending: %{}, steps: 0, max_steps: 10]

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  @impl true
  def init(opts) do
    state = %__MODULE__{
      goal: Keyword.fetch!(opts, :goal),
      model: Keyword.get(opts, :model, BlogOtp.Model.Scripted),
      max_steps: Keyword.get(opts, :max_steps, 10)
    }

    {:ok, state, {:continue, :next_step}}
  end

  @impl true
  def handle_continue(:next_step, state), do: next_step(state)

  @impl true
  def handle_info({:tool_result, ref, result}, state)
      when is_map_key(state.pending, ref) do
    {call, pending} = Map.pop(state.pending, ref)

    state = %{
      state
      | pending: pending,
        history: [{call, result} | state.history]
    }

    next_step(state)
  end

  # A result we aren't waiting for: a duplicate, or a late arrival
  def handle_info({:tool_result, _ref, _result}, state), do: {:noreply, state}

  # Still waiting on results: keep waiting
  defp next_step(%{pending: pending} = state) when map_size(pending) > 0,
    do: {:noreply, state}

  # The system decides when to stop, not only the model
  defp next_step(%{steps: steps, max_steps: max} = state) when steps >= max,
    do: finish({:stopped, :max_steps}, state)

  defp next_step(state) do
    # Chapter 1 shortcut: the owner calls the model itself
    case state.model.decide(build_context(state)) do
      {:tools, calls} -> {:noreply, request(calls, state)}
      {:done, answer} -> finish({:done, answer}, state)
    end
  end

  # Nobody executes these yet: each call waits in `pending` for its result
  defp request(calls, state) do
    Enum.reduce(calls, state, fn call, state ->
      %{state | pending: Map.put(state.pending, make_ref(), call), steps: state.steps + 1}
    end)
  end

  defp finish(outcome, state) do
    Logger.info("Workflow finished: #{inspect(outcome)}")
    {:stop, :normal, state}
  end

  # What the model gets to see, in its simplest form
  def build_context(state) do
    [{:goal, state.goal} | Enum.reverse(state.history)]
  end
end

The loop lives in next_step/1, which works like this:

  • If results are still pending, it waits.
  • If the run has gone on too long, it stops.
  • Otherwise, it builds the context and asks the model what to do next: either more tool calls, which go into pending, or a final answer, which ends the process normally.

And restart: :transient tells a supervisor to restart the process only if it terminates abnormally: a run that finishes with :normal stays finished.

Two things are deliberately missing in this chapter. First, nobody executes the tools yet: requests sit in pending until someone sends back a {:tool_result, ref, result} message. Second, the owner calls the model directly, even though a model call is slow. Both are problems we’ll come back to.

The code also shows the state/context distinction from the previous section. pending, steps and max_steps exist for the system; the model never sees them. And the context is the output of build_context/1, a plain function over the state, so we can test what the model will see without calling a model.

One message at a time

The code also gives us serialized access to the state.

Look at pending in the example above. Suppose the controller’s contents and the test output come back at almost the same time. They arrive as messages, and the process handles them one at a time. Each handle_info/2 runs to completion against a consistent state before the next message is processed. There are no locks; nothing else can change the state in the meantime, and the context for the next model call is never built from a half-applied update.

The first result is simply recorded: pending still holds the other request, so next_step/1 keeps waiting. Only when the second result arrives does the model get to decide, with both observations in its context.

But that same property has a cost.

A GenServer handles one message at a time, and the work in this loop is slow: a model call can take seconds, while a test suite can take much longer. If the process did that work itself, it couldn’t handle anything in the meantime. Messages would pile up in its mailbox: a result, a timeout, a request to cancel, all waiting their turn.

And that’s exactly what our chapter 1 shortcut does with the model call. It works here because our scripted model answers instantly; a real one wouldn’t.

So owning the state and doing the work pull in opposite directions.

The owner process needs to stay available, so the work has to happen somewhere else. That’s why nobody executes the tools yet: the owner shouldn’t be the one doing it. We’ll come back to that.

First, there’s a more urgent question: everything above lives in memory.

If this process dies, what happens to the run?

“Let it crash” works because a supervisor can start a new process in a known good state. But for a GenServer, that known good state is whatever init/1 builds. For a workflow, that means starting from the beginning, which is exactly what we don’t want.

Think about our endpoint. By the time the process crashes, the model may have already made several decisions: it read the router, read the controller, ran the tests, maybe even edited the controller. Starting over means repeating all of that, paying for it again in time and in model calls. And some of that work, like editing a file, may not be something we want to do twice.

The process can own the workflow state while it’s running, but that doesn’t mean process memory has to be the only place where that state lives.

We need enough durable information to reconstruct the workflow.

One way to do that is to record its progress as an append-only log. The goal is the first entry. Each tool call the model decides on becomes another, as does each tool result. As the workflow progresses, we only ever add these entries to the end.

For our endpoint, the log might look like this when the process crashes:

{:goal, "The GET /users/:id endpoint is returning a 500 error..."}
{:decision, 1, {:read_file, %{path: "lib/my_app_web/router.ex"}}}
{:result, 1, ~s(get "/users/:id", UserController, :show)}
{:decision, 2, {:read_file, %{path: "lib/my_app_web/controllers/user_controller.ex"}}}
{:decision, 3, {:run_tests, %{path: "test/my_app_web/controllers/user_controller_test.exs"}}}
{:result, 2, ~s[def show(conn, %{"id" => id}) do ... json(conn, user) ...]}

Everything the process knew is there: the goal, every decision, every result that came back. Even the request that’s still waiting for its answer.

If that log becomes the source from which we reconstruct the workflow state, we’re using the same core idea as event sourcing.

Rebuilding the state from the log

Let’s put that idea into code. Three things change: the workflow gets an id that survives the process, each tool call gets a step id the workflow assigns, and every state change goes through a single function.

defstruct [:id, :goal, :model, history: [], pending: %{}, steps: 0, max_steps: 10]

# Only updates the state: no work, no model calls, no messages
defp apply_step({:goal, goal}, state), do: %{state | goal: goal}

defp apply_step({:decision, step_id, call}, state) do
  %{state | pending: Map.put(state.pending, step_id, call), steps: state.steps + 1}
end

defp apply_step({:result, step_id, result}, state) do
  {call, pending} = Map.pop(state.pending, step_id)
  %{state | pending: pending, history: [{call, result} | state.history]}
end

Each clause handles one kind of entry from the log. apply_step/2 never starts work, calls a model, or sends a message. That restriction will matter in a moment.

The owner is still the only one that writes:

@impl true
def handle_info({:tool_result, step_id, result}, state)
    when is_map_key(state.pending, step_id) do
  state = record({:result, step_id, result}, state)
  next_step(state)
end

# Each call becomes a decision in the log, with a step id the workflow assigns
defp request(calls, state) do
  Enum.reduce(calls, state, fn call, state ->
    record({:decision, state.steps + 1, call}, state)
  end)
end

# Persist first, then apply
defp record(entry, state) do
  :ok = BlogOtp.Log.append(state.id, entry)
  apply_step(entry, state)
end

Notice two things.

The first is step_id, which replaces the reference from the previous version. A reference created with make_ref/0 only identifies something within the running node, and the process waiting for it may be gone after a crash. A step id is just a number the workflow assigns when it records the decision, and it means the same thing to any process that reads the log.

The second is the order inside record/2. We write to the log first, then apply the entry to the in-memory state. If the process dies between those two lines, the log is ahead of memory, and memory is the part we know how to rebuild. The other way around, the process could act on a step that no restart would ever see. And if the append fails, the process crashes before acting on anything.

The same rule applies to decisions: they’re recorded before anyone starts the work. In this chapter nobody executes the tools yet, but when someone does, the decision will already be in the log.

This puts a durable write on the owner’s critical path. That’s intentional: we don’t want the workflow to act on a transition it couldn’t recover after a crash.

In this chapter, BlogOtp.Log keeps its entries in an ETS table owned by its own process. It survives a workflow crash, but not a node restart. A real implementation would write to disk or a database; the contract, append first and read in order, stays the same.

Starting a run now means creating its log first:

def start(goal, opts \\ []) do
  id = new_id()
  :ok = BlogOtp.Log.append(id, {:goal, goal})

  DynamicSupervisor.start_child(
    BlogOtp.WorkflowSupervisor,
    {__MODULE__, Keyword.put(opts, :id, id)}
  )
end

def start_link(opts) do
  GenServer.start_link(__MODULE__, opts, name: via(Keyword.fetch!(opts, :id)))
end

defp via(id), do: {:via, Registry, {BlogOtp.WorkflowRegistry, id}}

The goal is written before the owner exists; from then on, the owner is the only writer. Registering the process under the workflow id in a Registry with unique keys turns “only one writer” from an intention into a guarantee: a key points to at most one process, and a second attempt to start an owner for the same run would get {:error, {:already_started, pid}}.

Look at what the process is started with: the id, not the goal. When a supervisor restarts a child, it calls the same start function, with the same arguments, from the child’s specification. The id is all a new process needs to find out everything else.

@impl true
def init(opts) do
  state = %__MODULE__{
    id: Keyword.fetch!(opts, :id),
    model: Keyword.get(opts, :model, BlogOtp.Model.Scripted),
    max_steps: Keyword.get(opts, :max_steps, 10)
  }

  {:ok, state, {:continue, :rebuild}}
end

@impl true
def handle_continue(:rebuild, state) do
  state =
    state.id
    |> BlogOtp.Log.read()
    |> Enum.reduce(state, &apply_step/2)

  next_step(state)
end

When init/1 returns {:continue, :rebuild}, OTP runs handle_continue/2 before the process receives any other message, so no result can be handled while the state is still being rebuilt.

The same apply_step/2 that updates the workflow while it runs also rebuilds it after a crash. Now we can see why it had to be free of side effects: replaying the log must not run the tests again. And a new run isn’t a special case either: it’s a log with a single entry, the goal. The fold produces a state with nothing pending, and next_step/1 asks the model for its first decision.

The state is a fold over the log.

This changes what a restart means. The new process doesn’t need to remember what the old one knew. It only needs to rebuild enough of the workflow to continue from a known point. But careful!

Rebuilding isn’t resuming

Fold the log from our crash, and pending holds exactly one entry: step 3.

Run the tests

There’s no result for it. So next_step/1 sees something pending and waits, for a result that may never come.

We know the decision was recorded. But did the work actually run? Maybe the request was never sent. Maybe the tests were still running when the process crashed. Maybe they finished, and the result was lost before it could be recorded.

For a test suite, running it again is harmless. But imagine our agent’s last step had been:

Open a pull request

Now repeating it means a duplicate pull request. And some actions are even more delicate than that, like inserting a row into a database that must never exist twice, or charging a customer. Those are the ones where we have to be really careful.

Restarting the process gives us back the ability to continue the workflow. It doesn’t tell us whether to run an external action again.

Restart and retry are two different problems.

That leaves two questions open: where the work actually happens while the owner stays available, and what to do with a step whose outcome we don’t know. The first question is where the next chapter picks up. The second one will have to wait until we talk about failure recovery.

The complete code repository for this series will be shared in Chapter 2.

Want to learn more about agentic workflows and OTP? Get in touch with our ESL team.

The post Exploring OTP Patterns for Agentic Workflows appeared first on Erlang Solutions.

by Lorena Mireles at October 19, 2026 11:31

October 12, 2026

Sam Whited

Coffeeneuring 2026

It’s that time of the year when the annual Biketober and Coffeeneuring challenges are on! This year I’m not planning on any serious miles or any particular goals for Biketober, but as always I plan on completing the Coffeeneuring challenge, and will document it here as well as on the fediverse at social.coop.

As always, I’ll start my week on Monday (per Rule 4, “As long as you define it as seven consecutive days, you can start your week any day.”), so “Week One” will be a short one since the challenge started with “Early Bird Friday” (Rule 2).

Week One (Oct 09–11)

Oct 11

Since last year I’ve started a Smyrna based group ride that aims to be a short ride (30-45 minutes, normally 5-7 miles depending on who shows up with longer rides if we’re all experienced riders) aimed at getting new riders on bikes, so for the first week I decided to pair up my Coffeeneuring ride and the group ride and went to Cafe Lucia. Unfortunately, I think I’ll haver to stop going to this location. It’s been one of my favorite coffee spots for a while, but lately it’s taken to only playing christian praise music, which I find somewhat offensive both as music and in the sense that a coffee shop should be more welcoming and not force its owner or employees beliefs on everyone else. Believe what you want, but in a public community space there’s no reason to assume everyone else will want to hear about it.

The ride itself, however, was great. Between it being a rainy day, one of the regulars being sick, and several of the others being at the Atlanta Pride Parade, we only had three folks for the day, none of whom were new so we decided to do a slightly longer ride (~9.1 miles), ending at a local beer establishment—though I did not count the beer place per rule 4, “Only one coffee location per day counts toward official completion”.

Three people holding beers standing under a sign for 'Stout Brothers' with two bikes in the foreground next to them.

2026-10-11 Smyrna Group Ride

A GPX track for the ride can be downloaded from the Internet Archive.

Week Two (Oct 12-18)

Oct 12

I took advantage of our weekly Smyrna Group Ride to try and get a few of our other regulars Coffeneuring, so today we stopped at Rev Coffee mid-route. Their beans are a bit expensive for what you get, but the coffee is pretty solid. It was just chilly enough to need a jacket when we started, and it was windy enough to slow us down in a few places, so the coffee was a welcome break.

Four people with bicycles standing under the entrance to a building that says 'Rev'.

2026-10-18 Smyrna Group Ride

A GPX track is available from theInternet Archive.

October 12, 2026 13:11

October 08, 2026

Erlang Solutions

Can Agentic AI Find the Security Vulnerabilities Other Tests Miss?

Security teams are good at finding problems. The difficult part is finding the right problems across a growing codebase, a changing application and the connections between them. A scanner can flag a known pattern quickly. A skilled tester can follow a complicated line of enquiry. Neither has unlimited time, and there will always be a question nobody thought to ask.

Could AI agents take on more of that investigative work? I think the answer is yes, provided we are precise about what “find” means. Agents can produce leads, test assumptions and uncover issues that established checks have missed. A security vulnerability, though, has to survive examination in the context of the real system.

That distinction matters more as agents become available to both defenders and attackers. A familiar weakness can become more relevant when agents have the time to find it, test it and follow where it leads. Defenders have a good reason to look again at parts of their systems that were too laborious to examine thoroughly.

What might agents see differently?

Think about the code paths that work perfectly well in normal use but behave differently with a surprising input or an unusual amount of data. Fuzzing (testing software with unexpected or malformed inputs) helps explore these cases; agents can then read the surrounding code, try a more specific hypothesis and keep working through leads that would consume too much of an engineer’s day if checked one by one.

Some issues only become apparent when you understand how the application is used. A finding may look harmless in isolation until you establish who controls the input, which permissions are involved or what the application is meant to prevent. Agents can explore a sequence of actions and check how the application responds at each step, which may reveal a problem that would be hard to spot from an isolated code warning.

There is also the possibility that one weakness opens the way to another. A low-priority issue could expose useful information; a forgotten endpoint might give access to a service assumed to be isolated. Agents can follow a discovery into the next step, although its account of that path still needs to be checked. The full route through a system may matter more than the severity of any one weakness in it.

I would be careful about grouping all of this under one claim that “AI finds what traditional testing misses”. Code analysis tools, fuzzing tools, formal verification and agents conducting a permitted penetration test are doing different jobs. Their findings have different levels of evidence. Formal verification is also starting to gain traction, with code agents proving useful for generating the specifications it requires. The useful question is where agents give the team another line of enquiry and how that enquiry will be validated.

What we found in a mature codebase

We have seen one version of this in our own work. Lukas Backström, a senior developer at Erlang Solutions and a member of the Ericsson Erlang/OTP core development team, built a custom workflow using Claude Code to examine the Erlang/OTP repository for security vulnerabilities, bugs and performance issues.

This codebase already receives human review, regular automated functional and performance testing, and vulnerability scanning. Even so, agents surfaced cases where expensive encoding or decoding could be performed on untrusted data that was very large or unbounded, creating a possible denial-of-service risk. The operation itself was not mysterious. Finding the relevant combination of input and behaviour across a large repository was the challenge.

Our published case study reports approximately 20 previously unknown, potentially severe security vulnerabilities, alongside around 400 critical or high findings and roughly 4,000 findings overall, most of them low-severity bugs. These are reported findings from the investigation, not a claim that thousands of confirmed security vulnerabilities were waiting to be fixed. And, this is one of the world’s most thoroughly tested code bases.

The project gives us evidence for a particular point: agents helped investigate parts of an extensively tested codebase that would have been difficult to cover manually. It does not prove that agents will outperform every scanner or security team. The details of the task and the review process made the result useful.

The part that takes judgement

Lukas gave agents a verification mode, including access to a Docker container to check findings and rank their severity and confidence. He also found that a fresh context and a focused second look at an issue helped expose false positives. That separate work took substantial compute, and the agents still sometimes returned a high-confidence finding that was wrong. It could also mistake a bug for a security issue because it misunderstood the threat model.

An engineer has to ask whether an attacker can reach the relevant code, what input they control, what limits exist elsewhere and what happens in a real deployment. A persuasive explanation from an agent is a reason to investigate, not evidence that every assumption is true.

There is a second challenge once agents start finding more faults. Most teams do not need a larger pile of unverified tickets. They need to know which findings are real and which ones deserve attention first. Every result takes time to assess, and asking agents to assess it again can add to the compute cost. Finding more vulnerabilities only helps if the team has enough context to act on what matters.

The investigative agents must also be secure in its own right. It may read code, retrieve documents and run tools, so its permissions and execution environment deserve scrutiny. Untrusted material could contain instructions intended to redirect its behaviour. The team needs to decide what the agents can access and do before giving it sensitive systems to examine.

Where I would start

I would treat an agentic investigation as an experiment with a clear question, rather than asking agents to “find everything” and judging success by the length of its report:

  • Choose the gap. Is the team trying to explore unusual code paths, test a business workflow or understand whether separate weaknesses can form an attack path? Set the scope and define what would make a finding meaningful.
  • Require a trail of evidence. Ask the agents to record what it examined, the assumptions it made and what happened when it tried to verify a concern. Give engineers something they can reproduce.
  • Review the results against the threat model. Check reachability, exploitability and impact before assigning severity. Give the agents limited access and keep a record of its actions during the investigation.
  • Measure the whole exercise. Count confirmed issues and the time spent validating them, as well as compute and findings rejected. Compare what the agents found with what existing tests had already revealed.

To conclude

So, can agentic AI find security vulnerabilities other tests miss? Yes, it can uncover leads that existing checks have not surfaced, particularly when an issue takes sustained investigation to find. Our Erlang/OTP work showed that in a codebase with established reviews, automated tests and vulnerability scanning. It was not a test-by-test comparison, so we cannot say which individual check missed each issue.

Finding a lead is only part of the answer. An experienced engineer still needs to establish whether it is a genuine security vulnerability and what should be fixed. The opportunity is to investigate more of the questions a team has never had the time to pursue, then give those findings the scrutiny they deserve.

The post Can Agentic AI Find the Security Vulnerabilities Other Tests Miss? appeared first on Erlang Solutions.

by Erik Schön at October 08, 2026 08:40

October 06, 2026

The XMPP Standards Foundation

The XMPP Newsletter September 2026

XMPP Newsletter Banner

XMPP Newsletter Banner

Welcome to the XMPP Newsletter, great to have you here again! This issue covers the month of September 2026.

The XMPP Newsletter is brought to you by the XSF Communication Team and contributors of the XMPP community.

Just like any other product or project by the XSF, the Newsletter is the result of the voluntary work of its members and contributors. If you are happy with the services and software you may be using, please consider saying thanks or help these projects!

Interested in contributing to the XSF Communication Team? Read more at the bottom.

XMPP Events

See also the XMPP Events page.

Videos and Talks

XMPP Articles

Software news

Clients and applications

Servers

  • ejabberd has released version 26.09 of its robust, scalable and extensible XMPP server. This security release fixes an unauthenticated remote code execution, a BOSH denial of service and a cross-tenant access issue in MUC and rosters, so updating is strongly encouraged. It also adds an optional XEP-0158 hashcash CAPTCHA for in-band registration and improves support for vhost-admins. Read the release announcement for all the details.
  • MongooseIM has released versions 6.9.0 of their enterprise instant messaging solution. You can read all the details in the changelog.
  • Prosody IM is pleased to announce version 13.0.7, a new minor release from the stable branch. This is a relatively small release, but several of the bug fixes are important in their own ways. One is notable for causing problems on BSD platforms - it is strongly recommend that BSD users upgrade - and also switched the TLS profile source over to tlsref.org, which is the new home for the configuration recommendations used, previously published by Mozilla. Make sure to read the changelog for all the details!

Libraries & Tools

XSF Announcements

XSF Membership

Being an elected member of the XMPP Standards Foundation signals a commitment to open standards and professional engagement in / with the XMPP community. Here, your membership helps position the XSF as a healthy organization, which in itself is valuable. It also grants voting rights on technical and administrative matters within the XSF. The application is a light-weight and free of cost process and you can use your membership to get more involved more easily, too. If you are interested in joining the XMPP Standards Foundation as a member, please apply to our 4th quarterly call for members admission before November 29, 2026, 00:00 UTC.

XSF Board and Council Elections 2026

The XMPP Standards Foundation is calling for individuals to compose the Board of Directors and members to serve on the XMPP Council for the 2026/2027 period. If you are interested in running for the Board of Directors or the XMPP Council, please add a wiki page about your candidacy to one or both of the aforementioned sections before November 8, 2026, 00:00 UTC. Get involved in the XMPP Standards Foundation organization decisions and with the specifications the XSF publish. Note: XMPP Council members must be elected members of the XSF; however, there is no such restriction for the Board of Directors.

Extensions and specifications

The XMPP Standards Foundation develops extensions to XMPP in its XEP series in addition to XMPP RFCs. Developers and other standards experts from around the world collaborate on these extensions, developing new specifications for emerging practices, and refining existing ways of doing things. Proposed by anybody, the particularly successful ones end up as Final or Active - depending on their type - while others are carefully archived as Deferred. This life cycle is described in XEP-0001, which contains the formal and canonical definitions for the types, states, and processes. Read more about the standards process. Communication around Standards and Extensions happens in the Standards Mailing List (online archive).

Proposed

The XEP development process starts by writing up an idea and submitting it to the XMPP Editor. Within two weeks, the Council decides whether to accept this proposal as an Experimental XEP.

  • SASL2 Terms of Service Acceptance Task
    • This specification defines a SASL2 task, per the extensibility mechanism of XEP-0388, that lets a server require a user to accept the current terms of service before authentication completes.
  • Multistream XMPP
    • Multistream protocols such as QUIC and WebTransport offer significant performance improvements to XMPP, particularly over constrained or degraded networks. This specification defines a binding for the XMLStream onto multistream transports, including both QUIC and WebTransport.

New

  • No new XEPs this month.

Deferred

If an experimental XEP is not updated for more than twelve months, it will be moved off Experimental to Deferred. If there is another update, it will put the XEP back onto Experimental.

  • No XEPs deferred this month.

Updated

  • No XEPs updated this month.

Last Call

Last calls are issued once everyone seems satisfied with the current XEP status. After the Council decides whether the XEP seems ready, the XMPP Editor issues a Last Call for comments. The feedback gathered during the Last Call can help improve the XEP before returning it to the Council for advancement to Stable.

  • No last call this month.

Stable

  • No XEP moved to stable this month.

Deprecated

  • No XEPs deprecated this month.

Rejected

  • No XEPs rejected this month.

Spread the news

Please share the news on other networks:

Subscribe to the monthly XMPP newsletter
Subscribe

Also check out our RSS Feed!

Looking for job offers or want to hire a professional consultant for your XMPP project? Visit our XMPP job board.

Newsletter Contributors & Translations

This is a community effort, and we would like to thank translators for their contributions. Volunteers and more languages are welcome! Translations of the XMPP Newsletter will be released here (with some delay):

  • Contributors:

    • To this issue: Adrien ’neox’ Bourmault, cal0pteryx, emus, Gonzalo Raúl Nemmi, Ludovic Bocquet, XSF iTeam
  • Translations:

    • French: Adrien Bourmault (neox), alkino, anubis, Arkem, Benoît Sibaud, mathieui, nyco, Pierre Jarillon, Ppjet6, seveso, Ysabeau

Help us to build the newsletter

This XMPP Newsletter is produced collaboratively by the XMPP community. Each month’s newsletter issue is drafted in this simple pad. At the end of each month, the pad’s content is merged into the XSF GitHub repository. We are always happy to welcome contributors. Do not hesitate to join the discussion in our XSF Communications Team group chat (MUC) and thereby help us sustain this as a community effort. You have a project and want to spread the news? Please consider sharing your news or events here, and promote it to a large audience.

Tasks we do on a regular basis:

  • gathering news in the XMPP universe
  • short summaries of news and events
  • summary of the monthly communication on extensions (XEPs)
  • review of the newsletter draft
  • preparation of media images
  • translations
  • communication via media accounts

Unsubscribe from the XMPP Newsletter

For this newsletter either log in here and unsubscribe or simply send an email to newsletter-leave@xmpp.org. (If you have not previously logged in, you may need to set up an account with the appropriate email address.)

License

This newsletter is published under CC BY-SA license.

October 06, 2026 00:00

September 30, 2026

ProcessOne

ejabberd 26.09

ejabberd 26.09

Contents:

Security fixes

This release contains fixes for those security issues:

  • Unauthenticated Remote Code Execution on ejabberd (reported by Gia Bui) when:
    • ejabberd versions is at least 25.10
    • BOSH is enabled on any port
    • S2S is enabled
    • mod_adhoc_api is loaded
    • outbound connectivity from ejabberd on ports epmd (default 4369), s2s (default 5269) and Erlang distribution port (dynamically assigned, typically in the range 49152-65535 unless FIREWALL_WINDOW is set in ejabberdctl.cfg).
  • DoS attack on 16.12+ versions if BOSH is enabled on any port.
  • Cross-Tenant MUC, Roster and Shared-Roster unauthorized access (reported by Hoang Gia).

Fixed the ordering of some XML child elements

There was a reference-ordering problem in the fast_xml generator, it generated XML encoders that could reorder child elements in a different order from the one declared in the codec specification. From now, the order is the one declared in the codec specification. See details in https://github.com/processone/fast_xml/pull/53

Additionally there was an incorrect sasl2_continue declaration order in the xmpp erlang library: it declared "additional-data, text, tasks". Now it follows the ordering defined in XEP-0388 schema: "additional-data, tasks, text". See details in https://github.com/processone/xmpp/pull/112

New option for CAPTCHA POW

New toplevel option captcha_pow adds a SHA-256 hashcash challenge as described in XEP-0158 in the CAPTCHA form, alongside the image challenge or on its own.

Unlike the image challenge, this does not require setting the option captcha_cmd.

This option is used only by mod_register when registering a new account using In-Band Registration, not in MUC rooms or in mod_register_web. It is disabled by default.

Improved support for vhost-admins

It is well known how to grant administrative privileges to an account: by adding that account to an acl called admin:

acl:
  admin:
    user: admin1@localhost

The default ejabberd configuration uses this admin ACL in many places:

Consequently, that admin account can administer all of ejabberd: all the global features, all the modules, in all the vhosts... For now let&aposs call it a "global admin".

If you have several vhosts, you can allow specific accounts to administer only specific vhosts. Let&aposs call them "vhost-admins". In this example admin1@localhost can execute commands on all vhosts. Additionally, localhost has a vhost-admin, second has two vhosts-admins, and third has a vhost-admin:

hosts:
 - localhost
 - second
 - third

acl:
  admin:
    user: admin1@localhost

append_host_config:
  localhost:
    acl:
      aclhostadmin:
        - user: hostadmin@localhost
  second:
    acl:
      aclhostadmin:
        - user: hostadmin@second
        - user: hostadmin@third
  third:
    acl:
      aclhostadmin:
        - user: hostadmin@second

api_permissions:
  "vhost http access":
    from: mod_http_api
    who:
      access:
        allow:
          - acl: admin
        allow:
          - acl: aclhostadmin
    what: "*"

Example call of a vhost command by a vhost-admin:

$ curl --basic --user hostadmin@third:somepass -k \
  &aposhttps://localhost:5443/api/status_num_host?host=second&status=dnd&apos
7

If a vhost-admin tries to execute an API command directed to a vhost he does not administer, or a global command (that has no host argument, and affects all ejabberd), they are rejected:

$ curl --basic --user hostadmin@third:somepass -k \
  &aposhttps://localhost:5443/api/status_num_host?host=third&status=dnd&apos
{"code":32,
 "message":"AccessRules: Account does not have the right to perform the operation.",
 "status":"error"}

$ curl --basic --user hostadmin@third:somepass -k \
  &aposhttps://localhost:5443/api/stats?name=registeredusers&apos
{"code":32,
 "message":"AccessRules: Account does not have the right to perform the operation.",
 "status":"error"}

ChangeLog

Security fixes

  • Unauthenticated Remote Code Execution on ejabberd
  • DoS attack on BOSH
  • Cross-Tenant MUC, Roster and Shared-Roster unauthorized access

Core

  • Add force value to auth_external_user_exists_check option
  • Add XEP-0158 SHA-256 hashcash CAPTCHA challenge (#4594)
  • Add gen_mod:get_module_proc_check()
  • Fix to preserve reference order in XML, done in fast_xml and xmpp (#4606)
  • Get rid of couple *_to_atom
  • Make ejabberd_cluster:*call operate only on known nodes
  • More fixes for arguments in commands for vhost-admin
  • Optimize acl:load_tab()
  • ejabberd_systemd: Prefer matching over length/1
  • Updated Portuguese-Brazil and Chinese-Simplified translations

Modules

  • mod_auth_fast: Make sure that fast tokens can be used only with method that they were created for
  • mod_invites: don&apost apply overuse limit if max_invites is infinity (#4615)
  • mod_invites: don&apost crash in get_invite_by_invitee_t if reset_token present (#4620)
  • mod_invites: now that Conversations is for free we remove Yaxim (#4621)
  • mod_mix: Make access_create rule be applied when creating channel
  • mod_mqtt: Add lower limits for pre-auth packets
  • mod_muc_room: Fix handling of hats request with missing xdata
  • mod_muc_rtbl: Accept also plain account and domain JIDs
  • mod_muc_rtbl: Fix handling of remote ban servers (#4622)
  • mod_register: After changing password disallow password change on currently authenticated sessions

SQL

  • Add db_serialize to mod_privacy and mod_pubsub
  • Add rename_column op to ejabbrd_sql_schema update routines
  • Make rename_column compatible with older mysql versions
  • ejabberd_sql_schema: Escape all column/table names
  • Update mod_roster serializer with info about approved field

Administration

  • Allow vhost-admin to execute MUC commands for his vhost (#4603)
  • Fix method to check vhost-admin permission in Host API (#4619)
  • WebAdmin: Fix shared roster page when visited by vhost-admin
  • WebAdmin: For vhost-admins, hide useless link to node page
  • WebAdmin: Show proper domain in URLs, not the first configured vhost

Installers and Container

  • make-binaries: Bump Elixir to 1.19.6
  • make-binaries: Bump Erlang/OTP version to 28.5.0.7
  • make-binaries: Bump Expat version to 2.8.5
  • make-binaries: Bump JPEG version to 10
  • make-binaries: Bump OpenSSL 3.6.4
  • make-binaries: Bump PNG version to 1.6.58
  • make-binaries: Bump SQLite version to 3530400
  • make-binaries: Bump WebP version to 1.6.0
  • Dockerfile: Workaround to get image with amd64 (#4598)

Full Changelog

https://github.com/processone/ejabberd/compare/26.07...26.09

Acknowledgments

We would like to thank for the security reports provided by:

the contributions to the source code by:

and the translation by:

  • Daltux for updating the Portuguese (Brazil) translation
  • Sketch6580 for updating the Chinese (Simplified) translation

And also to all the people contributing in the ejabberd chatroom, issue tracker...

Improvements in ejabberd Business Edition

Customers of the ejabberd Business Edition, in addition to all those bugfixes, also get the following changes:

  • Improve p1db serialization
  • Fix SQLite backend for push
  • Recognize gateway_sandbox option inside mod_applepush service (to change sandbox connection endpoint)

Changes in SQL schema

MySQL

When using multihost schema:

ALTER TABLE push_gate CHANGE COLUMN user username text NOT NULL;
CREATE INDEX i_push_gate_username_server_host USING BTREE ON `push_gate`(username(191), server_host(191));

Otherwise:

ALTER TABLE push_gate RENAME COLUMN user TO username;
CREATE INDEX i_push_gate_username USING BTREE ON `push_gate`(username(191));

PgSql

When using multihost schema:

ALTER TABLE push_gate RENAME COLUMN "user" TO "username";
CREATE INDEX i_push_gate_token_server_host ON "push_gate" USING btree ("username", "server_host");

Otherwise:

ALTER TABLE push_gate RENAME COLUMN "user" TO "username";
CREATE INDEX i_push_gate_token ON "push_gate" USING btree ("username");

ejabberd 26.09 download & feedback

As usual, the release is tagged in the Git source code repository on GitHub.

The source package and installers are available in ejabberd Downloads page. To check the *.asc signature files, see How to verify ProcessOne downloads integrity.

For convenience, there are alternative download locations like the ejabberd DEB/RPM Packages Repository and the GitHub Release / Tags.

The ecs container image is available in docker.io/ejabberd/ecs and ghcr.io/processone/ecs. The alternative ejabberd container image is available in ghcr.io/processone/ejabberd.

If you consider that you&aposve found a bug, please search or fill a bug report on GitHub Issues.

by Jérôme Sautret at September 30, 2026 09:37

September 29, 2026

Erlang Solutions

A Live Chiptune Synthesizer in Erlang

No sample library — just equations, PCM, and a small OS-specific playback bridge.

Early game consoles had little memory or storage to spare for recorded sound. Their music relied on a small number of voices and a limited vocabulary: simple waveforms, noise, and envelopes. Composers turned those restrictions into instantly recognizable melodies.

Chiptune grew out of these constraints. The term covers a broad family of music and music-making practices rooted in programmable sound chips.

It is often called “8-bit music,” although the label is imprecise: not every chiptune machine was 8-bit, and not every modern track with a retro timbre runs on vintage hardware. The important idea for us is the working method. Instead of asking an audio player to reproduce a recorded instrument, we describe a small signal generator and tell it which frequencies to produce over time. The hardware limitations helped shape the art; today we can choose similar limitations because they are fun.

This project builds a chiptune-inspired synthesizer directly in Erlang. It is not an emulator for a particular console or sound chip. It borrows the useful ingredients—elementary waveforms, noise, short envelopes, and a small set of instruments—and renders them as ordinary digital audio. A bass note is not a WAV file. A kick drum is not a hidden sample. Every instrument is a mathematical function evaluated while the song plays.

The project was inspired in part by the 2020 Erlang Solutions article “The sound of Erlang: How to use Erlang as an instrument”. That article develops sound from first principles, writes raw audio to a file, and plays it with ffplay. We will keep the first-principles spirit but take a different route: multiple simultaneous tracks, several synthesized instruments, chunked rendering, and live playback through the operating system’s audio API.

By the end, you will know how a value such as a4 becomes 440 Hz, how Erlang turns that frequency into signed 16-bit samples, and how those samples reach the speakers.

First, hear the small hand-written demo we will use for the walkthrough. This clip records the synthesizer’s output:

You need Erlang/OTP on PATH, including erl.exe and escript.exe. The playback bridge on Windows uses the powershell.exe and built-in C# compiler available on modern Windows. No FFmpeg, external synthesizer, or audio library is required. Open PowerShell in the repository root and play the small hand-written demo:

.\run-windows.bat demo_song

On macOS, Erlang’s erl and escript commands must be on PATH. The launcher also needs the Apple Command Line Tools to compile the bundled Core Audio bridge. If they are missing, install them with xcode-select --install. Run the equivalent command in Terminal:

./run-macos.sh demo_song

Both launchers compile the application, start a non-interactive Erlang VM, synthesize the song from its Erlang data, and stream it to the default audio output device. They accept any compiled song module with the interface we will examine below.

Before isolating tracks, ask the module what it contains:

On Windows:

.\run-windows.bat demo_song tracks

On macOS:

./run-macos.sh demo_song tracks

The output will be:

[{1, kick}, {2, snare}, {3, closed_hat}, {4, bass}, {5, pad}, {6, lead}]

Now we can listen to the lead by itself or remove the percussion from the mix:

On Windows:

.\run-windows.bat demo_song solo 6
.\run-windows.bat demo_song mute 1,2,3

On macOS:

./run-macos.sh demo_song solo 6
./run-macos.sh demo_song mute 1,2,3

These controls are intentionally small. They are enough to make the synthesizer explorable without turning a blog-sized project into a workstation.

Map of the Project

The playback path is compact enough to follow from beginning to end:

LocationResponsibility
src/demo_song.erlA readable score used for the walkthrough
src/song_*.erlLarger song modules generated from MIDI files
src/music.erlThe small public API: play a song or list its tracks
src/music_synth.erlScheduling, pitch, instruments, mixing, and PCM rendering
src/music_player.erlSelects the platform bridge and streams rendered chunks
priv/wave_out.ps1PowerShell/C# bridge to the native Windows waveOut API
priv/audio_out.cC bridge to macOS Core Audio’s Audio Queue API
import/import.pyOptional Standard MIDI File to Erlang module converter

There are three useful boundaries here. Song modules describe what to play. music_synth calculates what the waveform is. The player and its platform bridge decide how bytes reach an audio device.

A Song Is Erlang Data

A song module exports just two functions. This excerpt shows the tempo and percussion tracks from demo_song:

-module(demo_song).
-export([bpm/0, tracks/0]).

bpm() -> 120.

tracks() ->
  [{kick, lists:seq(0, 30, 2)},
   {snare, lists:seq(1, 31, 2)},
   {closed_hat, [B / 2 || B <- lists:seq(0, 63)]},
   ...].

Try it on Windows or macOS

“`

Each track is {Preset, Events}. A percussion event is simply a beat number. At 120 beats per minute, the kick events above land at beats 0, 2, 4, and so on, while the snare occupies the alternating beats. The closed hat list uses half-beats, which creates the faster pulse running across the demo.

Pitched instruments use {Beat, Duration, Notes}:

{bass, [{0, 0.45, c2}, {1, 0.45, c2}, {2, 0.45, g2}]}

{pad, [{0, 4, {c4, e4, g4}},
       {4, 4, {a3, c4, e4}}]}

“`

Notes may be one note atom or a non-empty tuple. A tuple schedules its notes together, giving us a chord without a second data structure. Both Beat and Duration are measured in beats and can have whole or fractional values.

The generated song_01_slay_the_evil through song_18_infinite_darkness modules follow exactly the same contract. Their scores are much larger, but they are still ordinary data returned by functions.

The public music module keeps callers away from the internal details:

music:play(demo_song).
music:play(demo_song, {solo, 6}).
music:play(demo_song, {mute, [1, 2, 3]}).
music:tracks(demo_song).

“`

Track selection happens before synthesis. music_synth:prepare/2 either keeps all tracks, selects one numbered track, or removes the requested track numbers. It then expands the remaining events into voices: one for each sounding note or percussion hit. A chord creates several voices with the same start time.

Walking Through the Player

music_player.erl is intentionally small. Its two public functions first ask the synthesizer to prepare the whole score, then pass the resulting state to open/1:

play(Song) ->
    open(music_synth:prepare(Song)).

play(Song, Selection) ->
    case music_synth:prepare(Song, Selection) of
        {error, _} = Error -> Error;
        Synth -> open(Synth)
    end.

“`

Once prepared, open/1 selects the platform bridge and starts playback. The bridge requests chunks as its audio buffers become available. Sample indexes determine when notes belong in the score; the audio device paces those samples in real time. We will follow the synthesis steps first, then return to the bridge protocol.

From a Note Name to a Frequency

The note atom a4 becomes MIDI note 69, which corresponds to 440 Hz. In twelve-tone equal temperament, moving one semitone multiplies a frequency by the twelfth root of two. Using A4 as the reference, any MIDI note number Midi can be converted with:

frequency = 440 × 2^((Midi - 69) / 12)

“`

The synthesizer parses atoms such as c4, fs4, and a4, converts the letter, optional s for sharp, and octave into a MIDI number, and applies that formula:

Midi = (Octave + 1) * 12 + Base + Sharp,
440.0 * math:pow(2.0, (Midi - 69) / 12).

“`

For example, c4 becomes MIDI note 60 and approximately 261.63 Hz.

To turn that frequency into audio, we need samples. Sound is changing air pressure; digital audio represents that change as a sequence of numbers. At 44,100 samples per second, sample index SampleI occurs at time T in seconds:

T = SampleI / 44100

“`

A sine wave of frequency F can be sampled with:

sin(2 × pi × F × T)

“`

The result moves between -1 and 1. Repeating the calculation at consecutive values of T produces the waveform. Higher frequencies complete more cycles per second and sound higher.

example of repeating calculation from a note to samples

A note becomes audio data. Every point in the final waveform is calculated for its position in time.

Instruments Are Functions

The center of the project is the tone/6 family in music_synth.erl. Its clauses are our instruments. Each receives the time since the voice began, its gate duration, its frequency, and where useful the absolute sample index and a deterministic seed. The gate duration is the time the note is held before its release begins; both it and the elapsed time are measured in seconds here.

The melodic presets start with elementary periodic waveforms:

tone(bass, T, G, F, _, _) ->
  0.32 * square(?PI2 * F * T) * envelope(T, G, 0.005, 0.04);
tone(lead, T, G, F, _, _) ->
  0.22 * saw(?PI2 * F * T) * envelope(T, G, 0.01, 0.12);
tone(pad, T, G, F, _, _) ->
  0.12 * triangle(?PI2 * F * T) * envelope(T, G, 0.18, 0.50).

“`

The ?PI2 macro represents 2 × pi. A square wave flips between two levels and gives the bass a hollow, buzzy sound. A sawtooth ramps and jumps, producing the brighter lead. A triangle wave changes more gently and suits the softer pad.

The piano, organ, and strings presets combine basic waveforms. “Piano” adds sine waves at the fundamental, twice the frequency, and three times the frequency, then applies exponential decay. “Organ” uses a related harmonic mixture without the same decay. Strings combine a saw and a slightly detuned triangle. These are impressions, not physical models of real instruments, and their simplicity is part of the chiptune character.

For percussion, the kick feeds a rapidly falling phase into sin and damps it with exp(-9 × T). The tom uses a gentler downward sweep. The snare adds a short 180 Hz body to deterministic pseudo-random noise; the closed hat is an even shorter burst of noise. The cymbal combines noise with a high square wave. A handful of arithmetic expressions becomes a recognizable drum kit.

One more function keeps notes from clicking abruptly at their edges:

envelope(T, _Gate, Attack, _Release) when T < Attack -> T / Attack;
envelope(T, Gate, _Attack, _Release) when T < Gate -> 1.0;
envelope(T, Gate, _Attack, Release) ->
  max(0.0, 1.0 - (T - Gate) / Release).

“`

This is a compact attack–sustain–release envelope. During attack, amplitude rises from zero. It remains at full level while the note is gated, then falls after release begins. Different attack and release values make the same oscillator feel percussive, plucked, or slow and pad-like.

Scheduling, Mixing, and PCM

Tempo determines when each voice begins. beat_sample/2 converts a beat position to an absolute sample index:

beat_sample(Beat, Bpm) -> round(Beat * 60 * ?RATE / Bpm).

“`

Here ?RATE is 44,100. At 120 BPM, one beat lasts half a second, or 22,050 samples; beat 4 begins at sample 88,200.

A pitched event records three positions: Start, the first sample of the note; Gate, the end of its written duration; and End, the end of its release tail. The renderer advances a sample cursor instead of calling timer:sleep/1 for each note. This keeps note positions fixed even if rendering pauses briefly, although playback still needs a steady supply of audio to avoid gaps.

Multiple tracks do not require separate Erlang processes. prepare_tracks/2 gathers all selected tracks into one list of voices. Notes and chords can overlap because voices whose sample ranges intersect are active together.

Preparation turns events into voice tuples containing the preset, start, gate, end, frequency, and seed. Percussion receives a frequency of zero because its formula does not need the pitch argument. Every preset also has a short tail, allowing its release or decay to continue after the gate closes.

The renderer does not allocate the whole song as one giant list. It works in chunks of 4,410 samples—one tenth of a second at 44.1 kHz:

Count = min(?CHUNK, Total - Pos),
Last = Pos + Count,
Active = [V || V = {_, Start, _, End, _, _} <- Voices,
          Start < Last, End > Pos].

“`

Only voices overlapping the current chunk are considered. For each sample index, the renderer evaluates those voices and sums their amplitudes. The sum is scaled and clamped to the valid range from -1 to 1. Clamping prevents an overflowing mix from wrapping into a radically different value, although a heavily clipped mix can still sound distorted.

Finally, each floating-point sample becomes a signed 16-bit little-endian integer:

pcm(X) -> <<(round(X * 32767)):16/little-signed>>.

“`

The resulting binary is mono linear PCM: 44,100 samples per second, two bytes per sample, 88,200 bytes per second. It contains no file header because we are sending it to a device configured with the matching format, not saving a WAV file.

Playback on Windows and macOS

Erlang can calculate the audio portably, but it still needs an operating-system API to make speakers move. music_player.erl chooses a deliberately small adapter: wave_out.ps1 on Windows or audio_out.c on macOS.

Live playback on Windows Synthesizer Elrnag

The synthesizer is platform-neutral; this diagram shows the Windows branch of the final bridge.

The player first calls music_synth:prepare/1 or prepare/2. It then checks the operating system and starts a TCP listener bound to 127.0.0.1 on a temporary port. Binding to loopback keeps this private protocol on the local machine.

Next, open_port/2 launches the selected bridge with the temporary port number. Here an Erlang port manages the external process; the PCM itself travels over the loopback TCP socket. The socket uses Erlang’s {packet, 4} mode, so each message receives a four-byte length prefix and arrives as one binary frame.

The PowerShell script uses Add-Type to compile a small embedded C# class. That class connects back to Erlang and opens the default waveform-audio output device through waveOutOpen. Its declared format matches the renderer: PCM, one channel, 44,100 Hz, 16 bits.

On macOS, run-macos.sh compiles the bundled C bridge with Apple Clang into the ignored _build directory. The bridge connects back to Erlang and creates an output queue with Core Audio’s Audio Queue Services. It declares the same mono, 44,100 Hz, signed 16-bit PCM format, so the Erlang renderer sends identical binaries on both systems.

Live playback macOS chiptune synthesizer Erlang

The macOS bridge uses the same PCM format and request protocol as the Windows bridge.

Four native buffers act as playback slots. For every free slot, the bridge sends an R (“ready”) frame. Erlang responds with A followed by one PCM chunk. When the native API makes a buffer reusable, the bridge requests another. This credit-based exchange prevents Erlang from sending audio faster than the fixed buffer pool can accept it.

When the renderer reaches the end, Erlang sends E. The bridge lets all active buffers drain, replies with D, and closes. An X frame carries a bridge error back to Erlang. The protocol is tiny, but it gives the two runtimes explicit flow control and a clean ending. The macOS bridge asks Audio Queue Services to stop non-immediately and waits for its running-state notification before sending D, so buffered audio is not cut off.

The macOS launcher rebuilds the native bridge when its C source changes. The platform boundary remains music_synth:render/1, which returns either {PCM, NextState} or done. A future Linux adapter could consume those same chunks, handle buffering and errors, and drain at the end without changing the score or synthesizer.

MIDI Is an Authoring Tool, Not the Synthesizer

Writing demo_song.erl by hand is useful for learning, but a complete arrangement can contain thousands of note events. import/import.py converts Standard MIDI files into modules with the same bpm/0 and tracks/0 interface.

The larger song_*.erl modules were generated from MIDI files in HydroGene’s free chiptune collection on itch.io, which includes rendered music and its source MIDI files.

Here is the synthesizer playing an imported arrangement, song_01_slay_the_evil. It uses the same instruments as the hand-written demo, with a larger score:

The importer uses only Python’s standard library. It reads MIDI formats 0 and 1, tracks tempo, note-on and note-off events, program changes, chords, and percussion on MIDI channel 10. General MIDI programs are mapped onto the small preset collection rather than reproduced as samples.

You can validate the files in import/input without writing anything:

python3 import/import.py --check

To generate compilable Erlang modules in src, use:

python3 import/import.py --output src

On Windows, use python if that is the name of your Python 3 command.

Existing destinations are skipped unless —overwrite is supplied. Importing is an optional composition workflow; neither Python nor a MIDI parser participates when a song plays. Once generated, a song is simply Erlang source data, and the same mathematical instruments render it.

Equations Become Music

Follow one note and every transformation is visible: a4 becomes MIDI 69, MIDI 69 becomes 440 Hz, 440 Hz drives an oscillator, an envelope shapes its amplitude, voices add together, floats become little-endian integers, and buffered PCM reaches the speakers.

Chiptune began in a world where small sound vocabularies were unavoidable. Recreating that economy in Erlang is a reminder that a musical instrument does not have to be a large framework. Sometimes it can be a few functions, a clock, and 44,100 samples per second.

To hear how much one function matters, change the lead’s saw call to square in music_synth.erl, then run .\run-windows.bat demo_song solo 6 on Windows or ./run-macos.sh demo_song solo 6 on macOS. Keep the notes the same and listen to how the instrument changes. Next, try a longer attack or a shorter release: the score stays familiar while the sound becomes your own.

References and Further Reading

Erlang Solutions, “The sound of Erlang: How to use Erlang as an instrument”, 2020.

Kenneth B. McAlpine, Bits and Pieces: A History of Chiptunes (book), Oxford University Press, 2018.

The post A Live Chiptune Synthesizer in Erlang appeared first on Erlang Solutions.

by Dmytro Lytovchenko at September 29, 2026 13:11

September 27, 2026

Ignite Realtime Blog

Smack 4.5.0 Release

The Ignite Realtime community is proud to announce that Smack 4.5.0 has been released.

This is a new major release after 4.4 nearly two years ago, with many bug fixes and improvements. Smack’s modular connection architecture is now deemed stable and users of the legacy XMPPTCPConnection should switch over to a modern architecture. The modular connection architecture abstracts the underlying connection mechanism, for example TCP, BOSH, and WebSockets, transporting the raw bytes of the XMPP stream, from the higher level XML layer. This allows, among other things, for a connection to transparently switch between TCP and WebSocket connections.

As always, this new Smack release is available on Maven Central.

3 posts - 3 participants

Read full topic

by Flow at September 27, 2026 11:20

September 23, 2026

Prosodical Thoughts

Prosody 13.0.7 released

We are pleased to announce a new minor release from our stable branch.

This is a relatively small release, as far as change counts go, but several of the bug fixes are important in their own ways. One is notable for causing problems on BSD platforms - we strongly recommend that BSD users upgrade.

We have also switched our TLS profile source over to tlsref.org, which is the new home for the configuration recommendations we use, previously published by Mozilla.

As part of this change, we have added a new configuration option: tls_profile_version. It selects which version of the recommendations to use. For the 13.0.x series this will remain pinned at a default of “5.7” (the latest recommendations when we released 13.0.0), but you are free to change it to a higher version or set it to "latest". This mechanism allows us to safely make new TLS configuration changes available in the future to people who want them, even in our stable branches, without risking interoperability issues for deployments that prefer stability.

Some minor memory leaks were also fixed in this release, which would be noticeable mainly on large or busy deployments.

A summary of changes in this release:

Minor changes

  • rostermanager, mod_roster: Include ‘approved’ attribute in roster items (thanks singpolyma)
  • util.startup: Always apply umask (thanks Max Hearnden)
  • Update TLS profile data from Mozilla/TLSRef
  • net.server_epoll: Disable read timeouts on server sockets
  • net.unbound: Reset and apply new config on reload
  • net.unbound: Simplify by removing cancel()
  • util.poll: Reject file descriptors outside of FD_SETSIZE in all methods
  • net.websocket: Release connection and input state on disconnect
  • net.server_select: Release queued strings on forced close
  • net.unbound: Let in-flight queries complete after re-initialization
  • net.server_epoll: Release pending write buffers on destruction
  • net.server_event: Release retained state on destruction
  • net.server_event: Fix incorrect flag logic for watchfd handles
  • prosodyctl check: Validate tls_profile and tls_profile_version

Download

As usual, download instructions for many platforms can be found on our download page

If you have any questions, comments or other issues with this release, let us know!

by The Prosody Team at September 23, 2026 17:43

September 19, 2026

The XMPP Standards Foundation

XMPP Sprint London

There will be a XMPP Sprint in London from the 6th to the 8th of November at The Mitre in Greenwich.

The sprint is organised by Matthew Fennell and co-organised by Polarian.

If you are interested in attending please do book quickly and add your details onto the wiki page, if you do not have an account please contact one of the organisers and we can do it for you:

Also any operators interested in improving the operator space and would like to attend the sprint please get into contact with Polarian, who is an XMPP operator and interested in working on operator related issues.

September 19, 2026 00:00

XMPP OpMeet 2026.10 Call for Participation is open!

OpMeet are online meetups/conferences targeted at XMPP Operators.

The first OpMeet will be on the 10th October 2026, more information on the XMPP Wiki. This will be the first OpMeet, so expect it to be a little rough around the edges.

CfP (Call for Participation) or what we are calling CfI (Call for Ideas) has been open for a few months, but was lost to the mailing list.

An updated announcement has been posted, CfP/CfI are open until 1st October 2026.

The following (non-exhaustive) list of things we are interested in:

  • Ideas for discussions. Have an operator adjacent issue? Suggest a session to discuss this issue with other operators.

  • Hackrooms, have a specific project others might be interested in discussing or helping out with during a live coding session? Suggest it!

  • Talks, this is the big one. We will aim to record these talks and publish them. However we can make no promises here. If you have an interesting topic which is operator related, or even the wider XMPP topics, please do suggest it.

Anything else you can think of which could be interesting to XMPP operators, feel free to suggest, the worst case is the idea gets refused!

To make suggestions please contact Polarian:

September 19, 2026 00:00

September 15, 2026

Ignite Realtime Blog

FASTer connections for Openfire!

On mobile (and worse), baseline XMPP is a little more painful than it should be. It has a lot of round-trips, where the client sends a request and waits - patiently - for the answer before it can continue.

Baseline XMPP actually has 9 of these before you can send and receive messages, and while Openfire has dropped some of these for a while (we’ve supported Direct TLS, for clients, for ever), others haven’t yet made it here. Silly, as I wrote SASL2 some time ago, which is a framework for amortising some of the start-up requests into the authentication.

Others are Bind2 - which pulls resource binding into SASL2 and also allows multiple other “connection setup” things to work - and FAST, which hands out tokens to allow reauthentication which is, well, FAST. This gets us down to 4 round-trips - halving connection time.

Thanks to a supporting grant from the NLNet Foundation, these are now all in our “main” branch, and undergoing their final testing before we make a release, so will be in our nightly builds from now on. I’ve been lucky enough to spend three weeks doing (sometimes literal!) field testing over slow links, so I’ve seen first hand that fortune really favours the bold with these extensions.

We’ve also improved security, with Channel Bindings and Downgrade Protection - also in the nightlies, and added new SCRAM variants like SCRAM-SHA-512-PLUS. Sorry Alexey, we’ve not done the SHA3 variant quite yet, but our FAST implementation does support the new HT2-* family, including the HT2-SHA3- variants.

We even found another round-trip to save, with Initial Authentication Pipelining.

There is also supporting work to make all this happen - like improvements to message archiving, and improvements to our sister project, the XMPP Interop Testing framework, so we can do live testing. This is based on Smack, so that, too now has SASL2, Bind2, and FAST support in its latest Alpha release, 4.6.0-alpha1.

We’d welcome people trying this code out - it’s a lot of exciting new features, and should be highly beneficial to most users of Openfire, and indeed client developers using Smack. It’s still nightlies, not releases, but we’re keen to move this forward as, erm, FAST as we can.

Yeah, I’m not even sorry.

For other release announcements and news follow us on Mastodon or X

2 posts - 2 participants

Read full topic

by dwd at September 15, 2026 21:36

XMPP Interop Testing

Next-Gen Connectivity

Earlier today, version 1.8.0 has been released. This release adds tests for exciting new XMPP functionality!

Classic XMPP connection setup takes a lot of round-trips: authenticate, bind a resource, enable carbons, kick off stream management, sync the archive. Each one is its own request. Fine on wifi, annoying on a shaky mobile connection.

XMPP offers various XEPs to fix that. SASL2 (XEP-0388) turns authentication into a single extensible envelope instead of a fixed sequence of steps, and everything else here builds on it. Bind 2 (XEP-0386) folds resource binding and feature enablement into that same envelope, so a client can walk away with a fully set-up session in (almost) one shot. And FAST (XEP-0484) lets a client swap its password for a short-lived, rotating token and reconnect to the server, authenticated, in a single round-trip, no SCRAM handshake required.

Put together, a client can go from opening a socket to fully authenticated and ready to send in essentially one round-trip. That’s also exactly the kind of multi-XEP choreography where servers tend to disagree on the details, so we’re glad to have it covered.

Give the new tests a try, and let us know what you find!

Splash image courtesy of Conny Schneider, Unsplash

by Guus der Kinderen at September 15, 2026 21:33

September 12, 2026

The XMPP Standards Foundation

The XMPP Newsletter August 2026

XMPP Newsletter Banner

XMPP Newsletter Banner

Welcome to the XMPP Newsletter, great to have you here again! This issue covers the month of August 2026.

The XMPP Newsletter is brought to you by the XSF Communication Team and contributors of the XMPP community.

Just like any other product or project by the XSF, the Newsletter is the result of the voluntary work of its members and contributors. If you are happy with the services and software you may be using, please consider saying thanks or help these projects!

Interested in contributing to the XSF Communication Team? Read more at the bottom.

XSF Announcements

XMPP Events

  • The di.day takes place every first Sunday of the month where the XMPP community also promotes their solutions! di.day is a mainly German initiative to help people switch to open-source and privacy-friendly solutions.
  • There will be an XMPP stand at the LinuxDays conference in Prague during the first weekend of October. You can also vote for XMPP-related lectures during the first week of September.
  • XMPP stand at OmniOpenCon in Bucharest, Romania, too. From 16th - 17th October 2026.

Videos and Talks

XMPP Articles

Software news

Clients and applications

  • aTalk has released version 6.5.0 of its encrypted instant messaging with video call and GPS features for Android. This release brings a lot of improvements, quite a few fixes and some really heavy work ‘under the hood’. Please refer to the release notes for all the details.
  • Gajim has released version 2.6.0 of its free and fully featured chat app for XMPP. Gajim now adapts responsively to window size changes. This release comes with many small improvements and bugfixes. Thank you for all your contributions!
  • Introducing Livewire: an alpha-quality native XMPP client for Ubuntu Touch, written in Rust on top of tokio-xmpp and xmpp-parsers.
  • Introducing Mynah: an XMPP client using Python, GTK, and slixmpp. It has a long-term vision of being maximally customisable, user-friendly, and cross-platform (Linux, Windows, macOS).
  • Monocles has released version 2.3 of its chat client for Android. This is a bigger release that brings in a lot of new features like multi file messages, a new global search, group calls (still in beta stage), several UI/UX improvements and some ground breaking ones as Post Quantum OMEMO2, along with updated translations, a whole lot of fixes and more! Note: due to some delays with the Play Store updates, this release is only available through F-droid for the time being.

Servers

  • MongooseIM has released versions 6.8.0 and 6.8.1 of their enterprise instant messaging solution. You can read all the details in the changelog.
  • The Ignite Realtime community is pleased to announce the release of Openfire 5.1.2, a maintenance update to the open-source XMPP real-time communication server. Head over to the full changelog for all the details!
  • The Prosody App for Yunohost has been updated to provide a configuration panel, to give an easy access to the most relevant parameters from the web interface!
  • Enthusiasts from New Zealand announced a new local XMPP server for “Kiwis”: xmpp.nz

Libraries & Tools

Extensions and specifications

The XMPP Standards Foundation develops extensions to XMPP in its XEP series in addition to XMPP RFCs. Developers and other standards experts from around the world collaborate on these extensions, developing new specifications for emerging practices, and refining existing ways of doing things. Proposed by anybody, the particularly successful ones end up as Final or Active - depending on their type - while others are carefully archived as Deferred. This life cycle is described in XEP-0001, which contains the formal and canonical definitions for the types, states, and processes. Read more about the standards process. Communication around Standards and Extensions happens in the Standards Mailing List (online archive).

Proposed

The XEP development process starts by writing up an idea and submitting it to the XMPP Editor. Within two weeks, the Council decides whether to accept this proposal as an Experimental XEP.

  • No proposed XEPs this month.

New

  • Version 0.1.0 of XEP-0518 (Payment Required)
    • Accepted as Experimental by council vote on 2026-07-07 (XEP Editor(dg))

Deferred

If an experimental XEP is not updated for more than twelve months, it will be moved off Experimental to Deferred. If there is another update, it will put the XEP back onto Experimental.

  • No XEPs deferred this month.

Updated

  • Version 1.6 of XEP-0066 (Out of Band Data)
    • Twenty years later, strike section 6’s SI suggestion (dwd)
  • Version 1.1.5 of XEP-0084 (User Avatar)
    • Fix inconsistent hash in the example with multiple data sources for the same file. (nc)
  • Version 1.1.0 of XEP-0490 (Message Displayed Synchronization)
    • Add security consideration for sender verification of PEP notifications. (dg)
  • Version 0.1.1 of XEP-0515 (TLS Channel-Binding Downgrade Protection)
    • Fixed typo in Bind 2 namespace (XEP Editor (dg))

Last Call

Last calls are issued once everyone seems satisfied with the current XEP status. After the Council decides whether the XEP seems ready, the XMPP Editor issues a Last Call for comments. The feedback gathered during the Last Call can help improve the XEP before returning it to the Council for advancement to Stable.

  • No XEPs last calls this month.

Stable

  • No stable XEPs this month.

Deprecated

  • No XEPs deprecated this month.

Rejected

  • No XEPs rejected this month.

Spread the news

Please share the news on other networks:

Subscribe to the monthly XMPP newsletter
Subscribe

Also check out our RSS Feed!

Looking for job offers or want to hire a professional consultant for your XMPP project? Visit our XMPP job board.

Newsletter Contributors & Translations

This is a community effort, and we would like to thank translators for their contributions. Volunteers and more languages are welcome! Translations of the XMPP Newsletter will be released here (with some delay):

  • Contributors:

    • To this issue: cal0pteryx, emus, Gonzalo Raúl Nemmi, Ludovic Bocquet, poVoq, XSF iTeam
  • Translations:

    • French: Adrien Bourmault (neox), alkino, anubis, Arkem, Benoît Sibaud, mathieui, nyco, Pierre Jarillon, Ppjet6, seveso, Ysabeau
    • Italian: Mario Sabatino, Roberto Resoli

Help us to build the newsletter

This XMPP Newsletter is produced collaboratively by the XMPP community. Each month’s newsletter issue is drafted in this simple pad. At the end of each month, the pad’s content is merged into the XSF GitHub repository. We are always happy to welcome contributors. Do not hesitate to join the discussion in our XSF Communications Team group chat (MUC) and thereby help us sustain this as a community effort. You have a project and want to spread the news? Please consider sharing your news or events here, and promote it to a large audience.

Tasks we do on a regular basis:

  • gathering news in the XMPP universe
  • short summaries of news and events
  • summary of the monthly communication on extensions (XEPs)
  • review of the newsletter draft
  • preparation of media images
  • translations
  • communication via media accounts

Unsubscribe from the XMPP Newsletter

For this newsletter either log in here and unsubscribe or simply send an email to newsletter-leave@xmpp.org. (If you have not previously logged in, you may need to set up an account with the appropriate email address.)

License

This newsletter is published under CC BY-SA license.

September 12, 2026 00:00

September 10, 2026

ProcessOne

Fluux Messenger 0.17.3: your last read position everywhere, and unread counts you can trust

Fluux Messenger 0.17.3: your last read position everywhere, and unread counts you can trust

The largest single piece of that work is where you stopped reading, and how Fluux shares that position with your other clients. The rest is spread thin on purpose: badges that would not clear, a "New messages" line that kept coming back, a conversation that moved while you were reading it, failures that never said why.

Where you left off, on every client

Every XMPP client on your account should agree on where you stopped reading. Fluux publishes that position with XEP-0490, and this release closes the cases where the publication used to stall.

  • Your read position now leaves the device in the cases where it used to get stuck: after you reply in a one-to-one chat, in a conversation with nothing currently loaded, and after a first attempt that did not go through. Your other clients stop showing a stale "New messages" line and an inflated badge.
  • A position arriving from another client is no longer dropped when Fluux cannot yet place it in its own history. It is kept and applied the moment you open the conversation.
  • A conversation whose oldest messages have aged out of the server archive no longer freezes your position. Fluux could stop publishing that conversation altogether, which left your other clients months behind.
  • A group chat you read elsewhere now shows what your other clients show, instead of sitting at zero unread with no read marker while they display both.

One unread count, everywhere it appears

There used to be more than one way to answer "how many messages are unread here", and the answers could disagree. Now there is one: the messages that sit after your last read position. Every surface that shows a number reads that same number.

  • The sidebar badge, the "New messages" divider, the floating pill and the scroll-to-bottom badge always agree. A conversation left in the background no longer under-counts, and opening a conversation no longer zeroes its badge before you have read anything.
  • The divider stays where it is while you are looking at it. It no longer reappears every time you reopen a conversation you have already read to the end.
  • A badge that reading could not clear is gone. Your read position also stops moving backwards when the last message you read is missing from the loaded history.
  • Rooms say more at a glance: a room row now tells you how many messages are unread, and the unread dot and the mention badge moved after the timestamp so they line up down the list.

A conversation that stays where you put it

Scrolling is how you navigate a conversation. It should never happen on its own.

  • Closing an image or a dialog with Escape keeps your place. A late-arriving older message no longer pushes a scrolled-up reader backwards in time either.
  • Returning to a room lands where you left it. Opening a room whose read position predates the loaded history no longer strands you at the oldest message, and Home works immediately after opening a conversation.
  • The view stays on the newest message when a late link preview, reaction or attachment makes the last message taller, and when the composer collapses back to one line after you send.
  • The typing indicator no longer covers messages. Its label wraps to a second line instead of being cut off.

When something fails, Fluux tells you

Silence is the worst answer an app can give.

  • Older messages that could not be loaded now show a marker, right above the button that tries again.
  • A bookmarked room that could not be rejoined says why: the room wants a password, or your nickname is already taken by somebody else in it, or it only admits members. Joining a password-protected room asks for the password wherever you join from, the sidebar, an invitation or Browse Rooms, and remembers it for next time.
  • A message that cannot be read explains which problem it hit: a key this device does not have, an invalid signature, or content that could not be parsed, instead of always blaming a missing key.
  • Opening a conversation shows a loading indicator and lets you go back while history loads, and an update that has to upgrade your local history shows a progress bar while it does.

Faster catch-up

  • Launching after a long absence and opening a conversation are both faster. Fluux writes far less to local storage while it catches up on history, and upgrading local history after an update is faster too, on large archives especially.
  • Group chat history no longer arrives in two visible phases on mobile and in the browser. After a reconnect, a joined room no longer sits inert in the sidebar with no preview and no timestamp.
  • A busy room you join no longer announces its recent history as new arrivals.

Encryption, desktop, and the rest

  • OpenPGP interoperability: keys generated by Fluux can now be imported by Gajim, existing keys are repaired when you unlock them, and Fluux no longer removes your other clients&apos keys from your published key list. Saving an image from the lightbox no longer writes the raw encrypted file to disk, and re-publishing an unchanged key no longer locks encryption behind a warning.
  • A system tray on Windows and Linux: a new setting decides whether closing the window quits Fluux or leaves it running in the tray, wherever the desktop provides one. Clicking a notification restores the window and opens the conversation and the message it came from, and on Linux the notification settings button now opens the right panel on Cinnamon, KDE, XFCE, Budgie and LXQt.
  • Connections that used to be refused: Fluux now asks your server where to connect before falling back to its own list of known addresses, so an account on a server whose address changed is no longer locked out. A password with accented or non-Latin characters is sent exactly as you typed it, so accounts that were rejected with "invalid username or password" can sign in. If you were affected and typed an address into the advanced server field, clear it once and Fluux will find the server on its own.
  • Korean, strikethrough, and the small things: Korean joins the interface languages, contributed by the community. Markdown strikethrough renders alongside the XEP-0393 form. A deleted message stays deleted everywhere Fluux summarises a conversation, relative dates update after midnight even when the app stays open overnight, and copying several messages at once no longer drops polls and attachments.

Get it

Fluux Messenger 0.17.3 is available for macOS, Windows and Linux, or directly in your browser, from the Fluux Messenger page. If you upgrade and something still feels off, tell us: bug reports and feature requests both go to GitHub Issues.

by Mickaël Rémond at September 10, 2026 14:59

Erlang Solutions

Implementing a Phoenix PubSub Adapter with EventStore

Distributed systems need async message delivery across nodes. Phoenix provides Phoenix PubSub for this, with pluggable adapters for different backends — officially PG2 and Redis.

This post walks through implementing a Phoenix PubSub adapter backed by EventStore, an Elixir event sourcing library that persists events to PostgreSQL as an append-only log.

Using EventStore as a PubSub backend has a few advantages over the default PG2 adapter:

  • No Erlang distribution required: nodes communicate through the shared database rather than through the Erlang cluster, so you can run multiple nodes without configuring Erlang node connectivity.
  • Persistence: every broadcast is stored and can be replayed or audited later.

The tradeoffs are the need for storage and the additional latency of a database round-trip per broadcast, making it best suited for lower-throughput messaging where persistence and cross-node decoupling matter more than raw speed. This implementation is a proof of concept — no load tests were performed.

A full implementation of the adapter can be found on Github.

Phoenix.PubSub.Adapter in a nutshell

A Phoenix PubSub adapter must implement a few callbacks specified in Phoenix.PubSub.Adapter:

node_name(adapter_name)

Returns the node name as an atom or binary. Used mainly by Phoenix.Tracker. In most cases:

def node_name(nil), do: node()
def node_name(configured_name), do: configured_name

child_spec(keyword)

Generates the child spec for the adapter. GenServer provides a default; this rarely needs overriding.

broadcast(adapter_name, topic, message, dispatcher)

Called when a message is broadcast through Phoenix.PubSub.broadcast. The adapter_name is the PubSub name with .Adapter appended (e.g. MyApp.PubSub → MyApp.PubSub.Adapter). The dispatcher module handles local delivery via dispatch/3.

direct_broadcast(adapter_name, node_name, topic, message, dispatcher)

Same as broadcast/4 with an additional node_name — the message should only reach subscribers on that node.

The EventStore adapter

This section walks through a possible implementation of a Phoenix PubSub adapter that uses EventStore to distribute messages between nodes. This gives a solution that does not depend on Erlang/Elixir distribution, and an event log is stored in case further analysis is needed.

How Phoenix.PubSub works


Phoenix.PubSub uses Elixir’s Registry for subscriptions — each subscribe call registers an entry under the topic key. When broadcast is called, the framework invokes the adapter callback to distribute the message, then handles local dispatch.

The adapter’s job is to get the message to other nodes. For direct_broadcast, only subscribers on the target node should receive it.

The implementation

The adapter is a GenServer that joins the PubSub supervision tree. An eventstore option selects which EventStore module to use (in case you have multiple):

{Phoenix.PubSub,
  [name: MyApp.PubSub,
   adapter: Phoenix.PubSub.EventStore,
   eventstore: MyApp.EventStore]
}

The GenServer stores the EventStore module and the PubSub name in state — both are needed later:

defmodule Phoenix.PubSub.EventStore do
  @behaviour Phoenix.PubSub.Adapter
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: opts[:adapter_name])
  end

  def init(opts) do
    {:ok,
     %{
       eventstore: opts[:eventstore],
       pubsub_name: opts[:name]
     }}
  end
  #... implementation will come here ...#
end

Note the difference between opts[:name] and opts[:adapter_name]. The former is the name of the PubSub as a whole and is reserved for the Registry. Publishers use it when broadcasting messages. opts[:adapter_name] can be used as the name of the GenServer.

Distributing a message as an event

The GenServer appends a new event to the EventStore when broadcast is called:

def broadcast(server, topic, message, dispatcher, metadata \\ %{}) do
  metadata = Map.put(metadata, :dispatcher, dispatcher)
  GenServer.call(server, {:broadcast, topic, message, metadata})
end

def handle_call(
      {:broadcast, topic, message, metadata},
      _from_pid,
      %{id: id, eventstore: eventstore, serializer: serializer, pubsub_name: pubsub_name} = state
    ) do
  event = %EventStore.EventData{
    # ... constructed below
  }

  res = eventstore.append_to_stream(topic, :any_version, [event])

  # For direct_broadcast targeting the current node, the framework does not
  # call local dispatch, so the adapter must do it. For regular broadcast,
  # the framework handles local dispatch after adapter.broadcast returns :ok.
  current_node = to_string(node())
  destination_node = Map.get(metadata, :destination_node)

  if destination_node == current_node do
    dispatcher = Map.get(metadata, :dispatcher, Phoenix.PubSub)
    Phoenix.PubSub.local_broadcast(pubsub_name, topic, message, dispatcher)
  end

  {:reply, res, state}
end

direct_broadcast/5 is a thin wrapper that sets destination_node in the metadata before delegating to broadcast/5:

def direct_broadcast(server, node_name, topic, message, dispatcher) do
  metadata = %{
    destination_node: to_string(node_name),
    source_node: to_string(node())
  }
  broadcast(server, topic, message, dispatcher, metadata)
end

source_node is stored in the event metadata for auditing. Routing is handled downstream by comparing destination_node against the current node.

The key decision is how to wrap the message inside %EventStore.EventData{}. Serialization is handled by a pluggable module (defaulting to Phoenix.PubSub.EventStore.Serializer.Base64) so the adapter is not tied to a specific encoding. The default serializer base64-encodes :erlang.term_to_binary/1 output — this is necessary because EventStore stores data as JSON and raw binaries would be invalid, and because JSON cannot distinguish atoms from strings so a round-trip through term serialization preserves type fidelity.

event = %EventStore.EventData{
  event_type: to_string(serializer),
  data: serializer.serialize(message)
}

A custom serializer can be provided via the serializer option as long as it implements serialize/1 and deserialize/1.

Handling events, local distribution

Now that events are in the event store, any subscribed process will receive them. The GenServer must subscribe to all topics ("$all"). If the event store is also used for another purpose, it’s best to have a separate one for PubSub. The subscription is set up via handle_continue/2, which runs immediately after init/1 completes, before any other messages can be processed.

def handle_continue(:subscribe, %{eventstore: eventstore} = state) do
  eventstore.subscribe("$all")

  {:noreply, state}
end

def handle_info({:subscribed, _subscription}, state), do: {:noreply, state}

A transient subscription is used since previous messages are not needed. The event store replies with a {:subscribed, subscription} message, which must also be handled. After this, the server will start receiving {:events, events} messages.

To avoid dispatching a local message twice (once from broadcast and once when the event arrives back from EventStore), a unique ID is added to the process state:

def init(opts) do
  {:ok,
   %{
     id: generate_unique_id(opts),
     eventstore: opts[:eventstore],
     pubsub_name: opts[:name],
     serializer: opts[:serializer] || Phoenix.PubSub.EventStore.Serializer.Base64
   }, {:continue, :subscribe}}
end

defp generate_unique_id(opts) do
  unique_id_fn = opts[:unique_id_fn] || fn _name -> UUID.uuid4() end
  unique_id_fn.(opts[:name])
end

A custom ID generator can be provided via unique_id_fn — a function that receives the PubSub name and returns a unique string. Useful when UUID is unavailable or when a deterministic ID is needed for testing.

The id is added to the event’s metadata field as source_id, keeping it separate from the message data. Serialization is delegated to the configurable serializer module. The handle_call for :broadcast becomes:

event = %EventStore.EventData{
  event_type: to_string(serializer),
  data: serializer.serialize(message),
  metadata: Map.put(metadata, :source_id, id)
}

Where the value of id and serializer come from the state, and metadata already contains dispatcher and any destination_node for direct broadcasts. When an event arrives back, source_id identifies the origin node so duplicates can be skipped:

def handle_info({:events, events}, state) do
  Enum.each(events, &local_broadcast_event(&1, state))

  {:noreply, state}
end

defp local_broadcast_event(
       %EventStore.RecordedEvent{
         data: data,
         metadata: metadata,
         stream_uuid: topic,
         eventbies_type: event_type
       },
       %{id: id, serializer: serializer, pubsub_name: pubsub_name} = _state
     ) do
  current_node = to_string(node())

  %{source_id: source_id, destination_node: destination_node, dispatcher: dispatcher} =
    convert_metadata_keys_to_atoms(metadata)

  is_destination? = is_nil(destination_node) or destination_node == current_node

  if not is_nil(dispatcher) and is_destination? and source_id != id and
       event_type == to_string(serializer) do
    Phoenix.PubSub.local_broadcast(
      pubsub_name,
      topic,
      serializer.deserialize(data),
      maybe_convert_to_existing_atom(dispatcher)
    )
  end
end

That’s it — a complete implementation of Phoenix PubSub using EventStore, including support for direct_broadcast via the destination_node metadata field and pluggable serialization.

The complete implementation can be found at esl/phoenix_pubsub_eventstore.
Need help building reliable distributed systems with Elixir? Get in touch with our team.





The post Implementing a Phoenix PubSub Adapter with EventStore appeared first on Erlang Solutions.

by Brian Underwood at September 10, 2026 09:28

September 03, 2026

Erlang Solutions

How to use Lua for flexible configurations in Erlang and Elixir

When I need to configure something in a complicated way, I find myself reviewing embedded languages supported by the server to create a flexible configuration. In Redis, you can improve the performance of requests, in Nginx, you can improve the handling of incoming requests, FreeSwitch offers alternatives for performing the same tasks using different embedded languages. Even in a software like TheGimp, you can add your own code to edit images.

Among the embedded languages, JavaScript and Lua are the most commonly used languages. JavaScript is very well known to the Erlang community because it was integrated (as a port, it is not implemented on top of Erlang) in popular products such as CouchDB and Riak. But I think the more exciting option, raised by Erlang Co-Creator, Robert Virding, is to implement Lua on top of Erlang, which can be used as an embedded language.

Why? Let’s take a look.

Complex Configuration

Usually, when tasked with fitting the definition of a behaviour we would like to configure, we would create an algorithm in a simple language such as Lua. This saves us from performing activities like:

  1. Defining the configuration to fit all of the cases.
  2. Reading and transmitting that information to be prepared for use.
  3. Writing specific code to handle that standardised information.

This kind of implementation is used frequently. It is easy to think of examples you’re likely to come across in day-to-day life. For example, supermarket offers which have multiple dependencies, commissions for salespeople which might feature variable ranges and percentages based on the type of sale, amount of sale or tax brackets, even SMS, emails or HTTP requests could be considered examples.

To demonstrate this, let’s look at an example of developing a load balancer. This is a simple project using cowboy, and luerl as dependencies, and depending on the headers and other information from the HTTP request, we can send it to the different web servers we have available and configured.

Based on the above premise we can write the configuration as follows:

{load_balancer, [
    {servers, [
        {odin, "1.1.1.1", [
            {in, method, [post]},
            {'>', <<"content-length">>, 10000},
            {in, <<"accept">>, [<<"json">>]}
        ]},
        {thor, "1.1.1.2", [
            {in, method, [get, post]},
            {'==', http_version, <<"2">>}
        ]},
        {balder, "1.1.1.3", [
            {in, method, [get, post]}
        ]}
    ]}
]}.

As you can see, we have to define a 3-tuple system for the rules with the operation in the first element and the two operators as the following elements inside of the tuple. In addition, we are occasionally handling the second element as a header name (if it is a binary), but at other times it’s the method we use to perform the request (using the atom “method”) and other times still, the HTTP version is used to gather the information.

The problem is that we have no closed specifications. We could add more elements or even change the meaning of them. What if we want to use logical modifiers like “and” and “or” to join the checks instead of assuming they are always using “and”? This change will add more complexity to our configuration and more complexity means more possibilities for making mistakes.

At the moment, if the configuration is wrong or adds something that is not granted, it is up to us to trigger the corresponding error and point to where it is to make it easier to fix. As you can imagine, that is not an easy thing to do if you are handling it during runtime.

Lua saves the day!

It’s not unconventional to think about configuration in terms of a specific code. At this point, Lua code could be put in charge of the definition because it is based on Lua semantics.

We only need the information for the configuration and running of the snippet to give us the desired behaviour we want to plug into the correct place. For example, the previous configuration could be written as:

local odin = "1.1.1.1"
local thor = "1.1.1.2"
local balder = "1.1.1.3"

local method = http.method()
local size = tonumber(http.header("content-length")) or 0
local accept = config.split(http.header("accept") or "", ", ")
local httpver = http.version()

if method == "post" and size > 10000 and config.member("json", accept) then
    return odin
elseif config.member(method, {"get", "post"}) and httpver == "2" then
    return thor
elseif config.member(method, {"get", "post"}) then
    return balder
end


As you can see, we are able to optimise and fix the code to suit our needs, it is shorter and clearer than the original configuration and, most importantly, we can now test and check to be sure it is compiling correctly.

The important thing to keep in mind is that the configuration code must include the functions which are going to be needed to handle the request. In the example above, we are using functions like http_version(), http_header("...") or even split(...) and member(...). These functions should be provided to the interpreter. 

Of course, the interpreter also has other functions available, we only need to provide the specific functions that are required for our business logic.

In addition to improving the performance, we also improve security because we can use luerl_sandbox:init() to sandbox the function calls the code will not be able to access functions that are not explicitly exposed to it.

Where the code dwells?

Inserting the Lua code into the configuration can be a little tricky. To avoid this, I recommend putting these scripts into the priv directory as a normal Lua file (using the extension .lua ) – (this one is more of a code formatting of priv and .lua). This could even be done inside of a database if we are handling the configuration in an automated way using a key/value storage configuration such as etcd.

The most important thing to keep in mind before running that code is to have a specific task which helps you to parse it and ensure the code is correct. One solution is to conduct a testing phase to ensure that the configuration is not breaking or negatively impacting other parts of the system.

For example, in the previous code, we would write a couple of libraries, one called utils for the functions needed for strings and tables and another called http needed for the HTTP functions. An example would be:

-module(luerl_lib_http).
-export([load/1, install/1, put_request/2]).

-include_lib("luerl/include/luerl.hrl").

-define(REQUEST, http_request).

load(St) ->
    luerl:load_module([<<"http">>], luerl_lib_http, St).

install(St) ->
    luerl_heap:alloc_table(table(), St).

put_request(Request, St) ->
    luerl:put_private(?REQUEST, Request, St).

table() ->
  [
    {<<"method">>,  #erl_func{code = fun method/2}},
    {<<"version">>, #erl_func{code = fun version/2}},
    {<<"header">>,  #erl_func{code = fun header/2}}
  ].

method(_Args, St) ->
  #{method := Method} = request(St),
  {[Method], St}.

version(_Args, St) ->
  #{version := Version} = request(St),
  {[Version], St}.

header([Name|_], St) when is_binary(Name) ->
  #{headers := Headers} = request(St),
  {[maps:get(Name, Headers, nil)], St};
header(Args, St) ->
  luerl_lib:badarg_error(<<"header">>, Args, St).

request(St) -> luerl:get_private(?REQUEST, St).

defmodule LuerlLib.Http do
  require Record

  Record.defrecord(:erl_func, Record.extract(:erl_func, from_lib: "luerl/include/luerl.hrl"))

  @private_key :http_request

  def load(state), do: :luerl.load_module(["http"], __MODULE__, state)

  def install(state), do: :luerl_heap.alloc_table(exports(), state)

  def put_request(state, request), do: :luerl.put_private(@private_key, request, state)

  defp exports do
    [
      {"method", erl_func(code: &method/2)},
      {"version", erl_func(code: &version/2)},
      {"header", erl_func(code: &header/2)}
    ]
  end

  defp method(_args, state), do: {[request(state).method], state}

  defp version(_args, state), do: {[request(state).version], state}

  defp header([name | _], state) when is_binary(name),
    do: {[Map.get(request(state).headers, name)], state}

  defp header(args, state), do: :luerl_lib.badarg_error("header", args, state)

  defp request(state), do: :luerl.get_private(@private_key, state)
end

-module(luerl_lib_config).
-export([load/1, install/1]).

-include_lib("luerl/include/luerl.hrl").

load(St) ->
    luerl:load_module([<<"config">>], luerl_lib_config, St).

install(St) ->
    luerl_heap:alloc_table(table(), St).

table() ->
  [
    {<<"split">>, #erl_func{code = fun split/2}},
    {<<"member">>, #erl_func{code = fun member/2}}
  ].

member([Entry, #tref{}=Table], St) ->
  #table{a = Array} = luerl_heap:get_table(Table, St),
  Result = array:foldl(fun
    (_, V, false) when V =:= Entry -> true;
    (_, _, Acc) -> Acc
  end, false, Array),
  {[Result], St};
member(Args, St) ->
  luerl_lib:badarg_error(<<"member">>, Args, St).

split([String, Sep], St) when is_binary(String), is_binary(Sep) ->
  {Tref, St1} = luerl:encode(string:split(String, Sep, all), St),
  {[Tref], St1};
split(Args, St) ->
  luerl_lib:badarg_error(<<"split">>, Args, St).

defmodule LuerlLib.Config do
  require Record

  Record.defrecord(:erl_func, Record.extract(:erl_func, from_lib: "luerl/include/luerl.hrl"))
  Record.defrecord(:table, Record.extract(:table, from_lib: "luerl/include/luerl.hrl"))

  def load(state), do: :luerl.load_module(["config"], __MODULE__, state)

  def install(state), do: :luerl_heap.alloc_table(exports(), state)

  defp exports do
    [
      {"split", erl_func(code: &split/2)},
      {"member", erl_func(code: &member/2)}
    ]
  end

  defp split([string, separator | _], state) when is_binary(string) and is_binary(separator) do
    {table_ref, state} = :luerl.encode(String.split(string, separator), state)
    {[table_ref], state}
  end

  defp split(args, state), do: :luerl_lib.badarg_error("split", args, state)

  defp member([entry, table_ref | _], state) when Record.is_record(table_ref, :tref) do
    table(a: array) = :luerl_heap.get_table(table_ref, state)
    found = :array.sparse_foldl(fn _index, value, acc -> acc or value === entry end, false, array)
    {[found], state}
  end

  defp member(args, state), do: :luerl_lib.badarg_error("member", args, state)
end


As you can see, we are implementing the functions we need and making them available to our Lua interface under the config and http packages. To load these functions, we have to run the load function which is exported in both modules. NOTE: install is used as a callback function in load_module

A great benefit of doing things this way is the “compile once, and ready many” approach. The config does not have to be loaded for every request. If we put it all together in our application, it would something  like like the following 

Add a build_request function and its helpers to your luerl_lib_http and LuerlLib.Http modules.

-export([load/1, install/1, put_request/2, build_request/1]). % add export

build_request(CowboyReq) ->
    #{method => string:lowercase(cowboy_req:method(CowboyReq)),
      version => version_to_binary(cowboy_req:version(CowboyReq)),
      headers => cowboy_req:headers(CowboyReq)}.

version_to_binary('HTTP/1.0') -> <<"1.0">>;
version_to_binary('HTTP/1.1') -> <<"1.1">>;
version_to_binary('HTTP/2') -> <<"2">>.

  def build_request(cowboy_req) do
    %{
      method: cowboy_req |> :cowboy_req.method() |> String.downcase(),
      version: version_to_binary(:cowboy_req.version(cowboy_req)),
      headers: :cowboy_req.headers(cowboy_req)
    }
  end

  defp version_to_binary(:"HTTP/1.0"), do: "1.0"
  defp version_to_binary(:"HTTP/1.1"), do: "1.1"
  defp version_to_binary(:"HTTP/2"), do: "2"

Add handler modules for handling the request

-module(luerl_lib_handler).
-export([init/2]).

init(CowboyReq, {Form, St}) ->
    LuaReq = luerl_lib_http:build_request(CowboyReq),
    St1 = luerl_lib_http:put_request(LuaReq, St),
    Reply = case luerl:call_chunk(Form, St1) of
        {ok, [], _St2} ->
            cowboy_req:reply(404, #{}, <<"no matching route">>, CowboyReq);
        {ok, Rets, St2} ->
            [Backend] = luerl:decode_list(Rets, St2),
            cowboy_req:reply(200, #{}, Backend, CowboyReq);
        {lua_error, _Reason, _St2} ->
            cowboy_req:reply(500, #{}, <<"routing error">>, CowboyReq)
    end,
    {ok, Reply, {Form, St}}.

defmodule LuerlLib.Handler do
  def init(cowboy_req, {form, state}) do
    lua_req = LuerlLib.Http.build_request(cowboy_req)
    state1 = LuerlLib.Http.put_request(state, lua_req)

    reply =
      case :luerl.call_chunk(form, state1) do
        {:ok, [], _state2} ->
          :cowboy_req.reply(404, %{}, "no matching route", cowboy_req)

        {:ok, returns, state2} ->
          [backend] = :luerl.decode_list(returns, state2)
          :cowboy_req.reply(200, %{}, backend, cowboy_req)

        {:lua_error, _reason, _state2} ->
          :cowboy_req.reply(500, %{}, "routing error", cowboy_req)
      end

    {:ok, reply, {form, state}}
  end
end

With all of that defined, we can now call the relevant functions from our application startup:


-module(luerl_lib_app).
-behaviour(application).
-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
    {ok, Form, St} = build_lua_state(),
    Dispatch = cowboy_router:compile([{'_', [{'_', luerl_lib_handler, {Form, St}}]}]),
    {ok, _} = cowboy:start_clear(luerl_lib_listener, [{port, 8080}], #{env => #{dispatch => Dispatch}}),
    luerl_lib_sup:start_link().

stop(_State) ->
    ok = cowboy:stop_listener(luerl_lib_listener).

build_lua_state() ->
    St0 = luerl_lib_http:load(luerl_lib_config:load(luerl_sandbox:init())),
    luerl:loadfile("priv/config.lua", St0).

Supervisor

-module(luerl_lib_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    {ok, {#{strategy => one_for_one, intensity => 1, period => 5}, []}}.

defmodule LuerlLib.Application do
  use Application

  def start(_type, _args) do
    state =
      :luerl_sandbox.init()
      |> LuerlLib.Config.load()
      |> LuerlLib.Http.load()

    {:ok, form, state} = :luerl.loadfile(~c"priv/config.lua", state)
    dispatch = :cowboy_router.compile([{:_, [{:_, LuerlLib.Handler, {form, state}}]}])

    {:ok, _} =
      :cowboy.start_clear(:luerl_lib_listener, [port: 8080], %{env: %{dispatch: dispatch}})

    LuerlLib.Supervisor.start_link()
  end
end

Notice that we load the file once (with the lines below) and pass it down to the handler, it helps us obtain the forms that we need and the handler will always execute based on that initially loaded configuration.

{ok, Form, St1} = luerl:loadfile("priv/config.lua", St0).

{:ok, form, state} = :luerl.loadfile(String.to_charlist(path), state)

Scaling up

Lua scales up because it is built on top of Erlang. This means Lua is using the same processes as Erlang does, it also means we are not using ports to communicate with the Lua interpreter, we have the Lua interpreter running on Erlang.

This makes a great difference in comparison to JavaScript because if you are handling millions of requests and all of them require a JavaScript snippet, this can cause a bottleneck very quickly if you have to limit the number of ports or requests.

On the other hand, Lua is using native Erlang functions when it calls the functions we want to provide for the interpreter. That makes a clear improvement, saving us from performing data serialisation or transformation.

Conclusion

Using a language for flexible configuration gives us the possibility to create an easy interface to provide configurations, reduce the amount of code we need to write, and improve the maintenance without jeopardising the performance of the system. At the moment, you can use Lua as we have explained during the article or you can jump into PHP if you need to process text or templates on top of Erlang or Elixir.

Alternatively you can join the community and provide other solutions which help us build better fit-for-purpose software. Get in touch if you need help building your system with one of these solutions.

The post How to use Lua for flexible configurations in Erlang and Elixir appeared first on Erlang Solutions.

by Lee Sigauke at September 03, 2026 09:53

August 31, 2026

The XMPP Standards Foundation

XMPP at LinuxDays

UPDATE: The “Jabber/XMPP: 25 Years of Digital Independence” talk by Daniel Gultsch has been accepted for LinuxDays. Note that if you are unable to come to see it in person, it should be possible to watch it online.

Members of the community will operate an XMPP-themed stand at the upcoming LinuxDays conference in Prague – if you are around, you are welcome to come visit. Similarly, if you know of someone who might be interested in XMPP or whom you would like to introduce to it, Czech-speaking or not, please pass on the information to them.

Two XMPP talks have also been submitted for the conference, you can support them in a vote which takes place over the first week of September.

The conference takes place over the first weekend of October, 3rd and 4th, at the Faculty of Information Technology of the Czech Technical University and is free to attend (participants are asked to register, if possible). You can find more information at the conference’s website (in Czech).

August 31, 2026 00:00

August 17, 2026

Ignite Realtime Blog

Openfire 5.1.2 Release

The Ignite Realtime community is pleased to announce the release of Openfire 5.1.2, a maintenance update to our open-source XMPP real-time communication server!

This release primarily updates third-party libraries following reports of vulnerabilities in those dependencies. There’s no indication that Openfire itself was vulnerable to any of the reported issues, but as a precaution and to keep our dependencies up to date, we’ve included the updated libraries in this release.

The full changelog has all the details, with 12 items resolved in total.

You can obtain Openfire 5.1.2 for your platform from its download page. The SHA-256 checksums for the release artifacts are:

bbe0e3bd7837aeb6a78a1878b3e23a53a61e99d827ec966ddc40500db416eb86  openfire-5.1.2-1.noarch.rpm
354e14ebf80c03eee05649a43d52338a77797bb41529a4f1486c49e784119f9d  openfire_5.1.2_all.deb
7da2582b4bc6c25640deecd9f22cdc1278c989cd39c0a8832fc15b3aeb208235  openfire_5_1_2.dmg
b32e33bc0e5305dcac308a82447f970b59a095f1edbb425d81ac93203db2f667  openfire_5_1_2.exe
0f45006e319fb36bf869ef9ca3a3bef930a1bd683a792883b0b0b4c9a3974680  openfire_5_1_2.tar.gz
5e52d57fb9e20235ed31a6809c3ecd139fb6583c0b24575cab3459ad4ff0b0a1  openfire_5_1_2_x64.exe
4f2bcdde684ed157f19df8a792db4f907a62864baa8a6c982c970dfaa43d0421  openfire_5_1_2.zip

We’d love to hear from you! Please join our community forum or group chat and let us know what you think!

For other release announcements and news follow us on Mastodon or X

2 posts - 2 participants

Read full topic

by guus at August 17, 2026 14:17

August 12, 2026

The XMPP Standards Foundation

XMPP at FrOSCon 2026

From 15th to 16th August 2026, XMPP Community members will be present with an XMPP booth at FrOSCon 2026, the Free and Open Source Software Conference!

As every year, the FrOSCon e.V. (club) organises together with the computer science department of the University of Applied Sciences Bonn-Rhein-Sieg in Sankt Augustin, Germany. The venue is free of cost and no registration is required.

Find the program and more details on their webpage: froscon.org

Come by and have a personal chat with XMPP Community members and XMPP developers at the XMPP (Jabber) booth!

See you soon at FrOSCon 2026!

August 12, 2026 00:00

August 11, 2026

The XMPP Standards Foundation

XMPP stand at OmniOpenCon

XMPP Community members will have a stand at OmniOpenCon on the 16th-17th October 2026.

OmniOpenCon is an annual FOSS conference held at Politehnica University of Bucharest in Bucharest, Romania. OmniOpenCon is free to attend, and is well linked by public transport making it very easy to reach from anywhere in the city.

Currently a lot of XMPP community presence in Europe has been within Western Europe. Branching out to Eastern Europe will hopefully draw new members into the XMPP community and spread secure, open and decentralised messaging to more people!

We are looking for volunteers if anyone is free to attend.

Please join the OmniOpenCon MUC if you are attending the conference: omniopencon@muc.mohrie.net

August 11, 2026 00:00

August 05, 2026

The XMPP Standards Foundation

The XMPP Newsletter July 2026

XMPP Newsletter Banner

XMPP Newsletter Banner

Welcome to the XMPP Newsletter, great to have you here again! This issue covers the month of July 2026.

The XMPP Newsletter is brought to you by the XSF Communication Team and contributors of the XMPP community.

Just like any other product or project by the XSF, the Newsletter is the result of the voluntary work of its members and contributors. If you are happy with the services and software you may be using, please consider saying thanks or help these projects!

Interested in contributing to the XSF Communication Team? Read more at the bottom.

XSF Announcements

XMPP Summit 29

The XMPP Standards Foundation (XSF) is excited to announce the 29th XMPP Summit, the first XMPP Summit to take place fully online! The XMPP Summit will be held from Friday 4th September to Saturday 5th September 2026, both days between 13:00 - 16:00 UTC. The XSF invites everyone interested in development of XMPP technologies to attend, and discuss all things XMPP remotely!

XSF Membership

Being an elected member of the XMPP Standards Foundation signals a commitment to open standards and professional engagement in / with the XMPP community. Here, your membership helps position the XSF as a healthy organization, which in itself is valuable. It also grants voting rights on technical and administrative matters within the XSF. The application is a light-weight and free of cost process and you can use your membership to get more involved more easily, too. If you are interested in joining the XMPP Standards Foundation as a member, please apply to our 3rd quarterly call for members admission before August 16th, 2026 00:00UTC.

XMPP Events

  • XMPP at FrOSCon 2026: This year’s edition of FrOSCon will take place during August 15th & 16th, at the Hochschule Bonn-Rhein-Sieg in Sankt Augustin, Germany, and the XMPP community will be part of it. Make sure to come by the XMPP booth and say hi!. We promise there will be stickers. ;)
  • The di.day takes place every first Sunday of the month where the XMPP community also promotes their solutions! di.day is a mainly German initiative to help people switch to open-source and privacy-friendly solutions.

Videos and Talks

XMPP Articles

XMPP Software News

XMPP Clients and Applications

  • aTalk has released version 6.4.0 of its encrypted instant messaging with video call and GPS features for Android. This release brings a lot of improvements, quite a few fixes and some really heavy work ‘under the hood’. Please refer to the release notes for all the details.
  • Cheogram has released version 2.19.0-6 of its XMPP client for Android. This release brings jump to searched message, fixes enabling the dialer integration on Android 17, improves the experimental export, won’t linkify invalid URLs, changes default ‘big’ media size to 1MB for new installs and fixes many crashes. Make sure to check out the changelog for all the details.
  • Fluux Messenger, has released versions 0.17.0, 0.17.1 and 0.17.2 of its modern, cross-platform XMPP client for communities and organizations. These releases are massive and improve almost every single aspect of the client with follow-up releases to fix what real-world use exposed. They bring an awful lot of additions, changes and fixes. So many that we could never even attempt to summarize them here. Please, go straight to the full changelog for all the details!
A quick tour — messaging, group rooms, end-to-end encryption, and theming.

A quick tour — messaging, group rooms, end-to-end encryption, and theming.

  • Gajim has released version 2.5.0 of its free and fully featured chat app for XMPP. This release comes with many improvements for Gajim’s integrated audio player, and it comes with some bug fixes. Thank you for all your contributions!
Gajim: An updated look for Gajim’s audio player

Gajim: An updated look for Gajim’s audio player

  • Kaidan has released version 0.16.0 of its user-friendly and modern chat app for XMPP. This release adds a media viewer, improves the emoji picker, and got a button to try another XMPP provider during registration. In addition, it adds support to record voice messages via long press. As always, it includes several smaller improvements and fixes. Most of the work being funded by NLnet via NGI Zero Entrust and NGI Zero Commons Fund with public money provided by the European Commission. You can find a detailed list of new features, bugfixes and notes in their respective release announcements, or the changelog.
Kaidan 0.16.0: Media viewer.

Kaidan 0.16.0: Media viewer.

Kaidan 0.16.0: Message recording.

Kaidan 0.16.0: Message recording.

XMPP Servers

  • The Ignite Realtime community is pleased to announce the release of Openfire 5.1.1, a maintenance update to the open-source XMPP real-time communication server. This release comes with a focus on PubSub correctness and connection handling. Highlights: fixed excessive memory use from a bloated pubsub subscription table, resolved an IQBind thread-starvation bug, corrected IP encoding in certificate SANs, and resolved 24 issues in total. Make sure to read the full changelog for all the details!
  • ProcessOne has released ejabberd 26.07. This security release of ejabberd includes several security fixes, some improvements and other minor bugfixes. It is strongly encouraged that you update ejabberd as soon as possible. Make sure to read the changelog for all the details and a complete list of fixes and improvements on this release.

XMPP Libraries & Tools

Extensions and specifications

The XMPP Standards Foundation develops extensions to XMPP in its XEP series in addition to XMPP RFCs. Developers and other standards experts from around the world collaborate on these extensions, developing new specifications for emerging practices, and refining existing ways of doing things. Proposed by anybody, the particularly successful ones end up as Final or Active - depending on their type - while others are carefully archived as Deferred. This life cycle is described in XEP-0001, which contains the formal and canonical definitions for the types, states, and processes. Read more about the standards process. Communication around Standards and Extensions happens in the Standards Mailing List (online archive).

Proposed

The XEP development process starts by writing up an idea and submitting it to the XMPP Editor. Within two weeks, the Council decides whether to accept this proposal as an Experimental XEP.

  • No proposed XEPs this month.

New

  • No new XEPs this month.

Deferred

If an experimental XEP is not updated for more than twelve months, it will be moved off Experimental to Deferred. If there is another update, it will put the XEP back onto Experimental.

  • No XEPs deferred this month.

Updated

  • No XEPs updated this month.

Last Call

Last calls are issued once everyone seems satisfied with the current XEP status. After the Council decides whether the XEP seems ready, the XMPP Editor issues a Last Call for comments. The feedback gathered during the Last Call can help improve the XEP before returning it to the Council for advancement to Stable.

  • Last Call for comments on XEP-0424 (Message Retraction)
    • This Last Call begins on 2026-07-03 and shall end at the close of business on 2026-07-20.

Stable

  • No stable XEPs this month.

Deprecated

  • No XEPs deprecated this month.

Rejected

  • No XEPs rejected this month.

Spread the news

Please share the news on other networks:

Subscribe to the monthly XMPP newsletter
Subscribe

Also check out our RSS Feed!

Looking for job offers or want to hire a professional consultant for your XMPP project? Visit our XMPP job board.

Newsletter Contributors & Translations

This is a community effort, and we would like to thank translators for their contributions. Volunteers and more languages are welcome! Translations of the XMPP Newsletter will be released here (with some delay):

  • Contributors:

    • To this issue: cal0pteryx, emus, Gonzalo Raúl Nemmi, Ludovic Bocquet, poVoq, XSF iTeam
  • Translations:

    • French: Adrien Bourmault (neox), alkino, anubis, Arkem, Benoît Sibaud, mathieui, nyco, Pierre Jarillon, Ppjet6, seveso, Ysabeau
    • Italian: Mario Sabatino, Roberto Resoli

Help us to build the newsletter

This XMPP Newsletter is produced collaboratively by the XMPP community. Each month’s newsletter issue is drafted in this simple pad. At the end of each month, the pad’s content is merged into the XSF GitHub repository. We are always happy to welcome contributors. Do not hesitate to join the discussion in our XSF Communications Team group chat (MUC) and thereby help us sustain this as a community effort. You have a project and want to spread the news? Please consider sharing your news or events here, and promote it to a large audience.

Tasks we do on a regular basis:

  • gathering news in the XMPP universe
  • short summaries of news and events
  • summary of the monthly communication on extensions (XEPs)
  • review of the newsletter draft
  • preparation of media images
  • translations
  • communication via media accounts

Unsubscribe from the XMPP Newsletter

For this newsletter either log in here and unsubscribe or simply send an email to newsletter-leave@xmpp.org. (If you have not previously logged in, you may need to set up an account with the appropriate email address.)

License

This newsletter is published under CC BY-SA license.

August 05, 2026 00:00

July 30, 2026

ProcessOne

ejabberd 26.07

ejabberd 26.07

Contents:

Changes in SQL schema

If you upgrade ejabberd from a previous release to 26.07, there are no changes in SQL schemas, but there is one for ejabberd Business Edition (see below).

Security fixes

This release contains fixes for those security issues:

  • It&aposs possible to craft PLAIN auth request and authenticate as one user, but then open session for different one.
  • mod_caps persistent cache can be poisoned by using legacy version requests.This cache was only used to determine list of nodes that should trigger notifications in PubSub presence-based delivery.
  • SQL injection in mod_pubsub handling of paging requests.
  • Possible atom exhaustion that can be triggered by issuing REST requests to mod_http_api.
  • It was possible to make ejabberd send redirect response for OAuth requests to unvetted url. This required enabling ejabberd to act as OAuth provider (by adding request handler for ejabberd_oauth in http listener). As part of this fix we changed oauth_client_id_check default value to db.
  • using ejabberd as OAuth provider will be only allowed by clients
    that were previously registered with oauth_add_client_password or oauth_add_client_implicit commands.
  • Tokens generated by mod_bosh, captcha, mod_auth_fast, mod_http_upload and mod_invites used not cryptographically strong random number generators.
  • Files server by mod_http_upload didn&apost have XSS prevention headers.
  • Issues in authentication of SIP requests.
  • Request to web_admin were lacking CSRF protection.
  • It was possible to skip captcha verification in mod_register_web.
  • mod_conversejs allowed putting unescaped value from url in page content.

mod_invites: New pages to create invites and WebAdmin

mod_invites now includes a startpage where regular users can use their account credentials to generate new account creation invites.

This is useful for people using XMPP clients that do no support that feature. The URL of that page is the root of mod_invites; you can find a link to that page in the bottom of WebAdmin left menu.

There are also new WebAdmin pages to view the existing invites, generate new invites, expire, delete ... Go and take a look at WebAdmin > "Virtual Hosts" > one of your hosts > "Invites"

There is also a new command generate_reset_token that returns an URI with a password reset token, just like for the invite tokens. This functionnality can also be found in the WebAdmin.

mod_conversejs: Support ConverseJS 14

ConverseJS published version 14.0.0 recently, and it requires some changes in the web server. In this sense, mod_conversejs is updated to support ConverseJS 14, and also got other minor cosmetic improvements.

Erlang/OTP 27.0 as a soft minimum

Are you compiling ejabberd with Erlang/OTP 25 or 26? Then please try to update to Erlang/OTP 27, 28, or 29. For example, the ejabberd installers are compiled with Erlang/OTP 28.5.0.4.

ejabberd supports compilation with Erlang/OTP 25 and 26, and those versions are still tested in runtime.yml and weekly.yml, but those Erlang/OTP versions are not actively maintained anymore by Erlang/OTP.

Following the erlang security recommendation to Use Actively Maintained Versions of Erlang/OTP, from now ejabberd softly rejects compilation with Erlang/OTP lower than 27.

What does softly mean? If you really want to compile ejabberd with Erlang/OTP lower than 27 at your own risk, you can bypass that soft requirement by defining this option (Erlang/OTP 25.0 included Erlang Run-Time System 13.0, and that is the number to provide in that option):

./configure --with-min-erlang=13.0

Rebar/Rebar3: Update binaries to work with Erlang/OTP 26-29

ejabberd source code includes Rebar and Rebar3 binaries, in case you don&apost have installed in your system. But those programs only support four Erlang releases (26 up to 29).

If you want to compile ejabberd with Erlang 25, then you need to grab a compatible Rebar3 (or Rebar) binary: either install one from your operating system, or you can download the old binaries included with ejabberd 26.04 (those still supported Erlang 25):

https://github.com/processone/ejabberd/raw/26.04/rebar
https://github.com/processone/ejabberd/raw/26.04/rebar3

ChangeLog

Security fixes

This release contains fixes for those issues:

  • It&aposs possible to craft PLAIN auth request and authenticate as one user, but then open session for different one.
  • mod_caps persistent cache can be poisoned by using legacy version requests.
    This cache was only used to determine list of nodes that should trigger notifications in PubSub presence-based delivery.
  • SQL injection in mod_pubsub handling of paging requests.
  • Possible atom exhaustion that can be triggered by issuing REST requests to mod_http_api
  • It was possible to make ejabberd send redirect response for OAuth requests to unvetted url. This required enabling ejabberd to act as OAuth provider (by adding request handler for ejabberd_oauth in http listener). As part of this fix we changed oauth_client_id_check default value to db
  • using ejabberd as OAuth provider will be only allowed by clients
    that were previously registered with oauth_add_client_password or oauth_add_client_implicit commands.
  • Tokens generated by mod_bosh, captcha, mod_auth_fast, mod_http_upload and mod_invites used not cryptographically strong random number generators.
  • Files server by mod_http_upload didn&apost have XSS prevention headers.
  • Issues in authentication of SIP requests.
  • Request to web_admin were lacking CSRF protection.
  • It was possible to skip captcha verification in mod_register_web.
  • mod_conversejs allowed putting unescaped value from url in page content.

Core

  • Fixes delete_old_messages_batch command when used on pgsql
  • Adds export_db_ext which allows exporting db content to json files
  • Use constant time functions when doing password checks
  • Optimize room_unused_* commands when room hibernation is configured
  • We no longer add flag requesting client certificate for tls connections
    where certificate authentication is not enabled

Modules

  • mod_auth_fast: Fixes exception for session that didn&apost set user agent
  • mod_invites: Add page for creating invites.
  • mod_invites: Fix generation of CSRF tokens.
  • mod_invites: Update to changes in latest XEP-0401
  • mod_http_upload: Attach custom headers from config when serving files.

Full Changelog

https://github.com/processone/ejabberd/compare/26.04...26.07

Acknowledgments

We would like to thank for the security reports provided by:

the contributions to the source code by:

and the translation by:

And also to all the people contributing in the ejabberd chatroom, issue tracker...

Improvements in ejabberd Business Edition

Customers of the ejabberd Business Edition, in addition to all those bugfixes, also get the following changes:

Changes in SQL schema

This release modifies the push_customizations table in the SQL database schemas to support the new push notifications mute option (see below). This task is performed automatically by ejabberd by default.

However, if your configuration file has disabled update_sql_schema toplevel option, you must perform the SQL schema update manually yourself. Those instructions are valid for MySQL and PostgreSQL, both default and new schemas:

MySQL

ALTER TABLE push_customizations MODIFY COLUMN mute smallint;

PostgreSQL

ALTER TABLE push_customizations ALTER COLUMN mute TYPE smallint USING mute::int;

Push notifications

  • Add a push customization option to allow mentioned users (as described in XEP-0513) to be notified even in muted conversation.
  • Make proxy connections in applepushv3 always use http connect
  • Report push gate tokens in the results of the user_push_state command
  • Implement push errors reporting in the results of the user_push_state command.
  • Make mod_webpush reject tokens that don&apost have correct format.
  • Fix mod_webpush key parser.
  • Add ability to retrieve user nodes from push_gate.
  • Handle wildcard certificates now used by Google FCM service.

Commands

Add a new local_health_status command to return information about local node state.

Client certificate authentication

  • Add option require_xmpp_addr that forces client certs to have XmppAddr.
  • New recognize_email_addr option for mod_crl/mod_ocsp to add emailAddress from cert subject to be used beside xmppAddr for matching against provided user id.

p1db backend

  • Make mam_p1db serialization properly serialize MUC archives.
  • Fix JID encoding in roster_p1db serialization.
  • Fix decoding of fast_tokens in p1db backend

Clustering

Make join_cluster robust to race conditions on leave_cluster.

Docker

sqlite backend has been fixed in Docker image.

fluux.io

All fluux.io instances have been updated before the release of the 26.07 with the latest security fixes.

ejabberd 26.07 download & feedback

As usual, the release is tagged in the Git source code repository on GitHub.

The source package and installers are available in ejabberd Downloads page. To check the *.asc signature files, see How to verify ProcessOne downloads integrity.

For convenience, there are alternative download locations like the ejabberd DEB/RPM Packages Repository and the GitHub Release / Tags.

The ecs container image is available in docker.io/ejabberd/ecs and ghcr.io/processone/ecs. The alternative ejabberd container image is available in ghcr.io/processone/ejabberd.

If you consider that you&aposve found a bug, please search or fill a bug report on GitHub Issues.

by Jérôme Sautret at July 30, 2026 17:33

July 23, 2026

The XMPP Standards Foundation

XMPP at FOSSY 2026

This year’s edition of FOSSY, the fourth Free and Open Source Software Yearly conference, will take place during the month of August, from Thursday 6th to Sunday 9th 2026 at the University of British Columbia, Vancouver, Canada. And as always, there will be an XMPP Track.

As announced by the organization in the official Conference Schedule, the XMPP track wil be composed of seven presentations.

Three presentations will take place during Saturday, August 8th at MCLD 3038:

Presentation Speaker Time
Encrypted messaging interoperability with hybrid bridges in XMPP Marvin W. 2:00 PM to 2:45 PM PDT.
Beyond Chat: Delivering mini “apps” with great UI Stephen Paul Weber 3:00 PM to 3:45 PM PDT.
“Beautiful XMPP Testing” revisited – How to Overcome the Mind-Body Duality by Staring at XML Phillip Davis 4:30 PM to 5:15 PM PDT.

Followed by another four presentations during Sunday, August 9th at MCLD 3038:

Presentation Speaker Time
When Data Gets Heavy: Voice and Video for Large Groups Christopher Vollick 10:45 AM to 11:30 AM PDT.
UnifiedPush - Push notifications. Decentralized and Open Source Daniel Gultsch 11:45 AM to 12:30 PM PDT.
Adventures in Onboarding: Episode V - The Server Goes Down Gideon Mayhak 2:00 PM to 2:45 PM PDT.
Snikket: Behind the scenes of our on-demand XMPP server hosting Matthew Wild 3:00 PM to 3:45 PM PDT.

The FOSSY organizers kindly invite you to join the event and will be pleased to see you there. Don’t miss it out!

July 23, 2026 00:00

July 21, 2026

ProcessOne

Fluux Messenger 0.17.2: gapless history, faster transfers, and emoji autocomplete

History without gaps

Fluux Messenger 0.17.2: gapless history, faster transfers, and emoji autocomplete


The largest piece of work in this release is the kind you only notice when it is absent.

  • Interrupted archive syncs now heal. If a sync of the message archive (XEP-0313) was cut short, a conversation could be left with a silent gap: messages that were never fetched, and nothing on screen to say anything was missing. Interruptions are now detected and repaired from both directions.
  • History no longer depends on your read position. Opening a conversation you had already read elsewhere could show an empty history on a new or freshly cleared device. The archive now downloads independently of how far you had read.
  • A conversation read on another device opens at its live edge, instead of restoring a saved scroll position that multi-device sync had already made obsolete.
  • The new-messages divider and the unread badge on the scroll-to-bottom button follow your read position, consistent with the sidebar and with read-marker sync (XEP-0490).

These are the paths we found and could reproduce. Archive sync has a long tail, and how it behaves depends a good deal on which server you are talking to. If you still see a conversation with history missing, we would love to hear about it.

Faster file transfers

  • Desktop uploads and downloads are handled by native code. Transfers of large files are much faster and more reliable.
  • Encrypted attachments decrypt on download. An end-to-end-encrypted attachment used to be decrypted only for the inline preview, so saving it to disk gave you something unreadable. Files of every type now arrive readable.

Emoji autocomplete & polish

  • Type a colon and a keyword to complete emoji inline. Arrow keys move through the matches, Enter or Tab inserts.
  • The send button answers with a press and a glow pulse when a message goes out.

On the web

  • An unread badge on the app icon for the installed PWA.
  • Media is cached by the service worker, so images are not re-downloaded on every visit.
  • Repeated messages from one sender coalesce into a single "N new messages" notification instead of a stack.
  • An update that was downloaded but parked is now applied at launch, instead of the installed app trailing the deployed build indefinitely.

Reliability and platform fixes

Twenty-three fixes in this release. The ones most likely to have affected you:

  • Notifications behave. Clicking a reaction notification always jumps to the reacted message, reopening a conversation no longer re-posts a notification for something you have already seen, and an encrypted message with no readable content stays silent until it is decrypted instead of posting a blank notification and playing a sound.
  • The sidebar holds still. A delayed message, whether an offline replay or a catch-up copy older than what you already have, no longer drags the conversation preview back to older text. The sidebar also keeps its scroll position while conversations reorder during catch-up.
  • Authentication. A wrong saved password no longer triggers an endless keychain retry loop, and connecting to a server whose domain contains non-ASCII characters now works.
  • Key backup. The backup passphrase is used exactly as it is displayed, so backups restore in other XMPP clients. Older backups still open, and are healed to the portable format on restore.
  • Group chats. The public room directory no longer lists duplicate rooms or reaches past your server&aposs own directory, and a room notification banner no longer reappears when the room is reopened.

Smaller ones round out the release: the quoted-message and reply cards stay visually distinct when a message is selected, your own message group re-fits its width once an image inside it finishes loading, the typing indicator is centered between the last message and the composer, and the macOS traffic-light buttons stay centered in the app bar.

Get it

Fluux Messenger 0.17.2 is available for macOS, Windows, and Linux, or directly in your browser.

If you upgrade and something feels off, tell us. Bug reports and feature requests both go to GitHub Issues.

by Mickaël Rémond at July 21, 2026 11:41

July 20, 2026

Kaidan

Kaidan 0.16.0: Media Viewer and Convenient Voice Messages

Kaidan 0.16.0 is out now! This release adds a media viewer, improves the emoji picker, and got a button to try another XMPP provider during registration. In addition, it adds support to record voice messages via long press. As always, it includes several smaller improvements and fixes.

Most of the work has been funded by NLnet via NGI Zero Commons Fund with public money provided by the European Commission.

Media Viewer

Kaidan now opens a media viewer when you press an image, video, or audio file. That viewer allows to browse all media within a chat in chronological order. You will not need to scroll through the whole chat history anymore just to find a specific file.

The viewer displays information that only Kaidan is aware of, such as the description or the corresponding message’s body. Furthermore, you can jump to the message, open the containing folder, or delete the file. Music and videos can be directly played without opening them in an external app.

Media viewer

Voice Messages

If you often send voice messages, you will enjoy the new voice message enhancement. With this new version, Kaidan makes it much easier for you to record and send a voice message.

Instead of pressing a button to start recording and pressing another button once you are finished, you can now simply press and hold the record button. As soon as you release the button, the voice message is sent. You can cancel the recording by swiping to the left.

Of course, the regular behavior is still available. You can always decide when to use which way to record your voice messages. Just start a regular recording with a normal press or the new one with a long press.

Voice messages

Emoji Picker

The improved emoji picker matches the look of the remaining app. It opens at the current cursor position while composing a message to quickly choose an emoji. You also can navigate through the emoji picker via keyboard and search emojis.

Emoji picker

Automatic Registration

If you opt for creating an account automatically, a suitable XMPP provider is chosen depending on your system’s settings. But it can happen that the automatically chosen provider requires an unsolvable CAPTCHA or information you do not want to hand over, such as an email address.

Formerly, you needed to go back and open the automatic registration again to request an account from a different provider. Since that took some time, there is now a button to directly try another XMPP provider.

Automatic registration

Changelog

There are several other improvements. Have a look at the following changelog for more details.

Features:

  • Add button to try another XMPP provider during automatic registration (@melvo)
  • Display avatars in notifications (@pehg, @melvo)
  • Allow to reply to message via its notification on supported systems (@pehg)
  • Handle pressing Down/Escape button in chats (@melvo)
  • Apply new look to emoji picker with consistent interactive background (@melvo)
  • Add search field for emojis as message reactions (@melvo)
  • Open emoji/participant picker at cursor position while ensuring picker’s width fits best (@melvo)
  • Allow to navigate through emoji picker via keyboard to select emoji (@melvo)
  • Play notification sounds (@pehg)
  • Show more tooltips (@melvo)
  • Add viewer for opening/browsing media within a chat (@fazevedo)
  • Add support to record voice messages via long press (@melvo)
  • Rework user interface for consistent and smoother look/behavior including buttons, fields, and fading opacity of icons (@melvo)
  • Focus message input field on starting/canceling reply (@melvo)
  • Allow to zoom via mouse wheel in location map and move map by dragging (@melvo)
  • Allow to share selected location via Return button (@melvo)

Bugfixes:

  • Ignore incoming call requests from other own devices (@melvo)
  • Fix recognizing video calls (@melvo)
  • Hide contact name/avatar and adapt stop button’s background if video playback is active in calls (@melvo)
  • Maintain last focused user interface elements in various uses cases (@melvo)
  • Fix quitting calls (@melvo)
  • Fix highlighting contact invitation hint in groups (@melvo)
  • Fix wrong highlighting on opening group participant picker in some cases and ensure that highlighted item is not changed as long as count does not change (@melvo)
  • Fix opening chat in all cases (@melvo)
  • Fix displaying full sender names of message reactions (@melvo)
  • Ensure account avatar’s background color is updated if system colors change (e.g., on switching between light/dark mode) (@melvo)
  • Fix position of chat name/date in chat list (@melvo)
  • Fix showing multiple notifications for media (@melvo)
  • Fix saving new draft after sending draft (@melvo)
  • Fix highlighting referenced message after loading from database (@fazevedo)
  • Fix behavior of toggle buttons on release after long press (@melvo)
  • Ensure icons in media viewer and message context menu are always sharp (@melvo)
  • Fix displaying notifications for personal data (e.g., availability and device info) sharing requests even if no chat is open (@melvo)
  • Update chat list filtering on availability changes of contacts (@melvo)
  • Fix sound behavior for subsequent messages (@melvo)
  • Fix message input field overlapping messages if its height increases (@melvo)
  • Keep zoom level within bounds while zooming in location map (@melvo)
  • Fix displaying avatar after canceling selection (@melvo)
  • Fix location displaying/sharing including proper location markers (@melvo)
  • Allow entering credentials to log in (@fazevedo)
  • Fix displaying marked message area (@melvo)

Notes:

  • Kaidan requires KItemModels now
  • Kaidan requires Qt 6.9 now
  • Kaidan requires QXmpp 1.16 now

Download

Or install Kaidan for your distribution:

Packaging status

July 20, 2026 22:00