Skip to content

Writing a plugin

Most of Podlibre is plugins. Importing a feed, reducing noise, transcribing, writing show notes, publishing — each one is a folder with a manifest and some Python, using the same API you are about to use.

This page takes you from nothing to a plugin that appears in the workflow and does something. It should take an afternoon. If it takes longer, that is a bug in the architecture and we want to hear about it.

Before you start

You need Podlibre installed, Python 3.12 or newer, and about twenty minutes. You do not need to know Qt, threads, SQL, or anything about how Podlibre draws its window. Plugins cannot draw. They describe what they want on screen and Podlibre builds it.

1. Start from the template

Open podlibre-plugin-template and press Use this template. You get your own repository containing a working plugin: a manifest, one command, one screen, a translation catalogue and a test.

my-plugin/
├── plugin.toml          the manifest: what you provide, where it appears, what you need
├── plugin.py            the code
├── views/
│   └── main.toml        what your screen looks like
├── locales/
│   ├── en.po            your strings
│   └── fr.po
├── tests/
│   └── test_plugin.py
├── AGENT.md             for AI assistants: the API, the manifest schema, the house rules
├── CLAUDE.md            the same, for Claude Code
└── README.md

Clone it, then point Podlibre at it:

git clone https://codeberg.org/you/my-plugin.git
cd my-plugin
podlibre --plugin-dir .

Podlibre loads your plugin from that folder and reloads it when you save. There is no build step.

2. Say what you are

The manifest is the whole of your plugin's relationship with Podlibre. Nothing is discovered by scanning your code.

[plugin]
id      = "mood_tagger"      # unique; also the name of your folder
name    = "Mood Tagger"
version = "0.1.0"
api     = "1"                # the plugin API you were written against

[permissions]
files = ["episode"]          # you may read and write inside the open episode's folder
# no network, no model, no transcription: you did not ask, so you cannot

Ask for the least you need. The user sees this list before installing you, and again whenever you reach for something new.

3. Add a step to the workflow

An activity is one thing a podcaster does. Declaring one puts you in the workflow, which is where people look for work to do — not in a menu they have to remember.

[[activities]]
id    = "tag_mood"
scope = "episode"            # application | show | episode
phase = "enrich"             # which group of the workflow you belong to

Your title and hint come from your own translation catalogue, under activity.tag_mood.title and activity.tag_mood.hint. Podlibre never needs your strings in its own catalogue.

4. Say where you appear

[[surfaces]]
kind     = "screen"          # a full page, opened from your workflow step
activity = "tag_mood"
view     = "views/main.toml"

And the view, which is a description rather than a drawing:

[[widgets]]
kind = "heading"
text = "@mood.heading"            # @ means "look this up in my catalogue"

[[widgets]]
kind    = "choice"
id      = "mood"
label   = "@mood.label"
options = ["@mood.calm", "@mood.urgent", "@mood.funny"]

[[widgets]]
kind    = "button"
label   = "@mood.save"
command = "mood_tagger.save"      # calls your function, with the form's values
variant = "primary"

The vocabulary of widgets is listed in Surfaces. It covers forms, lists, progress and text. It does not cover arbitrary drawing — that is the trade for never being able to freeze the application.

5. Write the code

from podlibre import plugin

@plugin.command("mood_tagger.save")
def save(ctx, mood):
    ctx.episode.set_field("episode_mood", mood)
    ctx.notify("@mood.saved")

ctx is everything you are allowed to touch. It carries the open show and episode, the capabilities your manifest asked for, a way to start a task, and a way to say something to the user. It carries nothing you did not ask for: if you did not request network, there is no ctx.net, and the mistake shows up the first time you run instead of in a user's bug report.

6. Take your time, visibly

Anything that might take longer than a moment is a task. A task reports progress, can be stopped by the user, and appears in the Tasks screen with everything else.

@plugin.command("mood_tagger.tag_everything")
def tag_everything(ctx):
    episodes = ctx.show.episodes()
    with ctx.task("@mood.tagging", total=len(episodes)) as t:
        for n, episode in enumerate(episodes, start=1):
            if t.cancelled:
                return                      # the user pressed Stop; leave cleanly
            episode.set_field("episode_mood", guess(episode))
            t.step(n)

Never loop without checking t.cancelled. It is the only thing standing between a user and a process they cannot get rid of.

Writing several fields at once

Each set_field is a request to Podlibre and a transaction in the show file. Writing a dozen fields on an episode that way is a dozen of each, and importing a back catalogue turns that into thousands. When you know the fields together, send them together:

ctx.episode.set_fields(episode_id=episode["id"], values={
    "summary": episode["summary"],
    "pub_date": episode["pub_date"],
    "source_guid": episode["guid"],
})

One round trip, one transaction, and either all of them land or none do. ctx.show.set_fields does the same for the show's own metadata. Use set_field for a single value a user just changed; use set_fields whenever you are filling something in.

7. Add a field others can use

A field you declare becomes part of the model. Mark it shared and any other plugin can read it — an exporter can put it in the RSS feed without having heard of you.

[[fields]]
key    = "episode_mood"
on     = "episode"
type   = "choice"
values = ["calm", "urgent", "funny"]
shared = true
rss    = "podcast:mood"      # where it goes on the way out, if anywhere

8. Translate yourself

locales/en.po      the source language
locales/fr.po      and any others you or your users write

Strings written as @some.key in a manifest or a view are looked up in your catalogue. In code, use ctx.t("some.key"). Your plugin is translated independently of Podlibre, so a translator can help you without touching the application.

9. Test without a window

Your plugin is ordinary Python with no toolkit in it, so it tests like ordinary Python.

from podlibre.testing import fake_context

def test_it_saves_the_mood():
    ctx = fake_context(episode={"title": "Pilot"})
    save(ctx, mood="calm")
    assert ctx.episode.field("episode_mood") == "calm"

10. Share it

Push your repository and tell people its address. Podlibre installs a plugin from a folder, an archive, or a repository URL, and shows the user what you asked for before anything runs.

Working with an AI assistant

The template's AGENT.md and CLAUDE.md describe the plugin API, the manifest schema and the conventions this page teaches. An assistant pointed at a fresh clone has what it needs to write a plugin with you rather than guess at the framework. The usual advice applies: read what it writes, and check that the manifest asks only for what the plugin actually uses.

Where to look next