k4 loads plugins from two places: the repository's (plugins/) and the
user's in ~/.config/k4/plugins/<id>/. This guide is for the latter:
you do not need to touch the repository to write one.
The complete example for this guide lives in ejemplos/hola/ and can be
copied as is:
cp -r ejemplos/hola ~/.config/k4/plugins/
quickshell ipc -p ~/.config/quickshell/k4/shell.qml call k4 pluginEnable hola
quickshell ipc -p ~/.config/quickshell/k4/shell.qml call k4.hola toggleYou may not need to read this. k4 installs a skill for coding agents (
agentes/skills/k4/, linked by./install), so Claude Code, Codex and anything else that reads~/.claude/skills/or~/.config/agents/skills/already know everything on this page. Asking for the plugin you want is a legitimate way to get one — this guide is here for when you want to know what it is doing, or when you are doing it yourself.
Use K4.MarkdownView for rich, selectable text instead of implementing a markdown
parser in a plugin. It sizes vertically to its content, so give it a width and
place it in your scrolling surface. Enable streaming for a live reply. Connect
copyRequested(text) to your clipboard action and linkActivated(url) to your
link handler. selectionStarted() lets a conversation suspend automatic scroll.
The native renderer includes code highlighting/copy controls, horizontal code
and table scrolling, task lists, nested lists, quotes and standard inline markup.
See Native markdown for defaults and limitations.
Use K4.SystemMonitor for CPU, memory, temperatures and network rates. It shares
the host sampler with the built-in System card. Acquire a uniquely named lease
with K4.SystemMonitor.sample("my-plugin.summary", true, false) while visible;
release it with both flags false on disable/destruction. Handle available
changing to true if your component is created before the host bridge is ready.
Use true for the third argument while showing its view Component in a Loader.
Detailed sampling adds supported GPU readings, filesystem capacity and process
activity. Missing or warming-up percentages/rates are -1, not zero; memory
capacities are GiB, and rate formatters use binary units. See
System telemetry for the complete contract.
To see what is published in the public registry:
python3 tools/plugins.py --search
python3 tools/plugins.py --search snakeEach entry prints its exact install command, with the commit already in it.
To publish yours, open a PR adding an entry to plugins/registro.json: id,
title, description, git repo, commit (the full 40-character SHA you want
published) and carpeta if the plugin does not live at the repo root.
A registry entry publishes a commit, not a branch. A branch moves after it is reviewed, so what someone installs next month would not be what was looked at — the point of pinning is that those two are the same thing. To publish a new version, open another submission with the new SHA.
The easier route is the Publish a plugin issue form. Opening it (or
editing it afterwards) makes a bot fetch that exact commit, validate it with
tools/plugins.py — without running any of your plugin — and comment
with what it found: what the manifest declares, which permissions it asks
for, which surfaces it occupies. It then labels the issue validado, or
revision-de-seguridad if the plugin asks for permissions, or
necesita-arreglos with the reason.
The bot never publishes. Only a maintainer applying the publicado label
does, and even then the entry is written only if the commit still matches
the one that was reviewed — otherwise approving would mean approving "that
repository", which is a promise nobody can keep.
Beyond permissions — which say which k4 API a plugin touches — the checker looks for patterns that make the code you end up running not the code someone reviewed. Each one carries why it matters and how to fix it, because a warning that doesn't say what to do gets ignored:
| Rule | What it looks for |
|---|---|
descarga-y-ejecuta |
downloading something and piping it to a shell |
sudo-sin-contrasena |
NOPASSWD, sudo -n, pkexec |
clon-sin-commit |
git clone of a remote without a pinned SHA |
qml-desde-texto |
Qt.createQmlObject, eval, new Function |
borra-a-lo-ancho |
recursive deletes with wildcards |
Only the first two block publication, and that split is deliberate. They are unambiguous: whatever the URL serves, or whatever runs as root, was never part of the commit anyone looked at. The rest can be perfectly reasonable in context, so they mark the submission for a person to read rather than stopping it — and even a maintainer's label cannot publish past a blocking rule.
Blocking applies to publishing, never to installing. If you bring your own plugin to your own machine, the bar tells you what fired and why, and the button says "Install anyway". Taking the choice away without explaining it would be worse than letting you make it having read the reason.
None of this is a security audit and the report says so. Static checks on one commit are what it is: a plugin runs inside the bar and can do whatever the bar can do.
And if someone handed you a repository directly:
python3 tools/plugins.py --install https://github.com/quien/su-plugin
python3 tools/plugins.py --install https://github.com/quien/su-plugin \
--commit 4f1c2ab... # that exact oneWithout --commit you get the tip of the default branch, which is fine for
your own code and worth thinking about for someone else's. Either way the
commit that landed is written down, so you can always answer "what exactly
do I have installed?".
It clones to a temporary directory, validates the whole thing there, and only then shows what it claims to be and which permissions it declares, and asks. Nothing reaches your plugin directory without passing the same exam the installed ones pass: there is no "half installed and broken". If the QML uses something the manifest does not declare, it is rejected before touching the disk.
It arrives off. Turning it on is a separate decision, made in Settings, looking at those same permissions.
python3 tools/plugins.py --installed # what you have, from where, at which commit
python3 tools/plugins.py --check # what no longer matches the registry
python3 tools/plugins.py --update snake # reinstall from its origin (tip)
python3 tools/plugins.py --update snake --commit 4f1c2ab... # or that one
python3 tools/plugins.py --remove snake # uninstall (--con-estado also
# deletes what it saved)--check answers the question the old installer could not: it compares
the commit each installed plugin actually came from against what the registry
publishes today, and tells you which ones have something new upstream — and
which ones were installed before any of this existed, so their commit is
simply unknown.
With the bar running, k4 pluginRefresh makes it re-read the catalog: what
you just installed appears and what you removed disappears without
restarting anything. And after updating an enabled one, k4 pluginReload <id> swaps the running code — the old one stays alive in the bar until you
say so.
Do not start from an empty directory:
tools/plugins.py --new mi-pluginThat writes a manifest and a plugin that already opens, and tells you the three commands that come next. The rest of this page explains what it wrote.
~/.config/k4/plugins/hola/
├── plugin.json
├── HolaPlugin.qml
└── HolaView.qml
plugin.json is the manifest:
{
"id": "hola",
"entry": "HolaPlugin.qml",
"version": "1.0.0",
"title": "Hello",
"description": "What it does, in one sentence — shown in Settings",
"host": ">=1.1.0",
"permissions": [],
"surfaces": ["island"]
}The keys above are the vocabulary of record. The Spanish keys a manifest could use until the rename (
permisos,superficies,icono,aplicacion,requiere) are still read as aliases, so a plugin written yesterday keeps validating today — but write English.
-
surfaces: what your plugin occupies, as opposed to what it touches.island(it has aview),pildora,ventana,ipc,centro(a block in the control centre). It is optional — a manifest without it still validates — but if you declare it, the validator checks it against what your QML actually does, the same way it already does withpermissions. Declaring lets Settings describe your plugin without loading it. -
id: lowercase, no spaces, and it must match the directory name. If it collides with one of the bar's plugins, yours loses. -
entry: the file that inherits fromK4.Plugin, inside the directory itself. -
host: the minimum bar version you need (>=x.y.z). -
icon: your icon, of one of these two kinds:- a Nerd Font codepoint as text,
"0xF011A"— find it withpython3 tools/glyphs.py <word>. It inherits the theme's color, so it looks like the rest of the bar and dims and tints along with it. - your own image,
"icono.png": a PNG or SVG file from your directory (no paths: your icon is yours). A PNG must be at least 64×64 — below that it looks blurry exactly where people look the most, and a pixelated icon makes a good plugin look bad — and weigh less than 1 MB. SVG has no minimum, that is what scaling is for.
It is validated at install time: an icon that does not exist, is too small or comes in a strange format is an installation error, not an empty little square in the application center. It shows up in Settings, in the application center and in the control center's shortcuts.
- a Nerd Font codepoint as text,
-
application:trueif yours is something that is opened and used — a game, a tool — and not an indicator or a service. With it you appear in the application center (SUPER+SHIFT+Space) and you can be pinned to the control center strip — where shortcuts are also reordered by dragging. You also appear when typing in the launcher (SUPER+Space), which remains a different drawer — the desktop applications' one — but finds both. -
permissions: which capabilities you use — see below. Empty if you only paint.
The plugin is the state: it lives always, island or no island. The view only paints, and only exists while the host presents the plugin.
One summoned view at a time deploys from the bar, and the one just
opened is the one on it: the host retires the previous summoned view
through its close() — whatever that verb must do on the way out
still happens — while its view fades out under the arriving one and
the island glides to the newcomer. priority still arbitrates
everything else: the pill, the hover views, the transients. The view
deploys on the edge and at the point its placement says (Settings →
Popups & Layout). The ends of an edge are corners: a placement flush at
either end attaches the view to two walls — flush along its edge and
the side wall. The bar can live in a corner itself (Settings → Island
→ alignment), where the rim carries its material into the turn. The
island keeps the pill, the hover views and the transitions between
them, and stays alive above the dim the host paints behind any
summoned view that is out; hover views dim nothing. A deployed view
closes through its own close() — Escape, the outside-click, its own
cross and verbs work as always.
The card also carries a copy button — the IPC command that opens the
surface, ready to paste in a terminal or hang on a key. It reads
summonCommand, because target and verb are the plugin's to say: the
convention is k4.<id> + toggle, and the exceptions (the terminal's
k4.term island) are exactly why it is declared and not guessed. A
colocable surface with no summonCommand shows no button.
Set independentIsland: true on K4.Plugin to have the host present your
existing view in its own island. Keep your usual active, viewLoaded, size,
keyboard flags and close() implementation; no custom window is required.
The main island stays in place and existing views remain open. This property
defaults to false; Hyprland Submap enables it by default.
For a colocable surface, Settings → Popups & Layout → Separate popup
lets the user enable or disable this behavior regardless of your default. The
override survives restarts and position changes, including Follow bar.
Transients may opt in through the API without receiving a popup settings card.
The configured edge and alignment are the preferred position. If occupied, the host tries corners clockwise (top-left → top-right → bottom-right → bottom-left), starting at the next corner along a non-corner edge. It avoids both the main island and other independent islands, including notification popups, on that monitor. With all corners occupied, it chooses the least overlap. Existing independent windows get first choice; free allocated positions stay stable until obstructed or their placement/size changes. Repositioning keeps the view alive.
Independent views are excluded from the main island's priority and replacement
rules. They open on the requested or focused monitor, do not dim the desktop,
and retain hover-exit, keyboard and cross-monitor dismissal behavior. Only the
newest eligible independent view grabs exclusive keyboard focus. Outside-click
catchers leave other islands clickable. K4.Isla still reports the main island:
use your view item's own dimensions, hover and focus for independent content.
See the presentation contract for details.
// HolaPlugin.qml
import QtQuick
import K4 as K4
K4.Plugin {
id: self
name: "hola" // The same ID as the manifest.
priority: 65
active: abierto // Request presentation right now.
islandWidth: 360
islandHeight: 100
property bool abierto: false
view: Component { HolaView { plugin: self } }
K4.Ipc {
target: "k4.hola"
function toggle(): void { self.abierto = !self.abierto }
function close(): void { self.abierto = false }
}
}// HolaView.qml
import QtQuick
import K4 as K4
Item {
required property var plugin
K4.Etiqueta {
anchors.centerIn: parent
text: "Hello from an external plugin"
}
}priority decides who gets the island when more than one surface wants it,
and the number is not decoration. The bar's native resting views sit at
50 and 55 — the clock and the player — and they activate on
Island.hovered.
So a panel below 50 cannot be closed. To reach your close button the user puts the pointer on the island; that turns the clock on; the clock outranks you; and your panel becomes the clock exactly as they were reaching for it. It is not a rare case: it is what happens every single time, and with the bar in Hidden space mode it happens sooner, because entering with the pointer is the first thing anyone does.
The map, so you can place yourself:
| 0 | native pill at rest |
| 40 | native volume HUD |
| 50 · 55 | native clock · player — the hover views. Be above these. |
| 59 | native notification toast |
| 60 · 61 · 63 · 66 | native control center · native sound · tray · settings |
| 72 · 80 | app center · launcher |
Above the resting views so you survive being touched; below the things the user opens on purpose if yours can open itself. A module that appears over what someone was already doing is rude, and a plugin that announces something is announcing it, not demanding the screen.
Between summoned views the ladder no longer chooses: opening one supersedes whoever holds the island — the host closes the previous one — so your priority only places you against the resting views and the transients. Be findable in the map anyway; the number still says what you are.
You ask for islandWidth and islandHeight, and you can change them
live: a plugin can be a 200×150 strip and turn into a big screen
depending on what it is doing.
islandWidth: modo === "mini" ? 200 : 980
islandHeight: modo === "mini" ? 150 : K4.Isla.altoMaximoFor colocable surfaces, Settings → Popups & Layout offers width and
height overrides independently. Auto follows the corresponding live
request; a custom pixel value takes precedence in both the main island and
separate-popup hosts without changing the plugin's property bindings.
Overrides persist by surface ID in the host's popupSizes setting. Each
card provides size and position resets; cards start collapsed and retain
their disclosure state while navigating the current Settings session.
The host constrains the actual view to the available screen. Lay out against
the view's actual width and height rather than assuming the requested size
was granted. K4.Isla.altoMaximo remains a useful height budget. If content
can grow without limit, put it in a K4.Rodillo.
Rules the bar enforces:
-
A plugin imports QtQuick and K4. Nothing else. From your directory there is no path to the internal services, and that is on purpose: the public API is the contract that does not break under your feet on update.
-
The root id should be
self: a view withrequired property var plugincan shadow an id with the same name. If you declare yourselfapplication, the bar will open you by callingabrir(). By default it uses yourtoggle(), which is what almost all of them already have; redefine it if you need something else — for example always opening instead of toggling. -
Processes, timers and IPC go as children of the
K4.Plugin, not of the view: the view is destroyed every time you lose the island.
In Qt Quick an animation does not stop because its item stopped being visible. It keeps running, and while it runs the whole scene keeps repainting — at the refresh rate, on every monitor, for as long as its condition holds.
That is not a warning from the manual. k4's own pill once had a five-pixel dot pulsing whenever there was something waiting to be opened:
SequentialAnimation on opacity {
running: hayPendientes // ← data, not visibility
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 900 }
NumberAnimation { to: 1; duration: 900 }
}The condition asks about data, never about being seen. With 61 items sitting there it had been true for weeks, so the bar rendered 122 frames per second while collapsed with nothing open — around 6% of a core, day and night, for a dot nobody was looking at. Fixing that one line took the bar to 0 fps and 0.0% at rest.
So an animation with loops: Animation.Infinite asks whether anyone can see
it:
running: hayAlgoQueContar && K4.Isla.aLaVistaK4.Isla.aLaVista is false while the island is retracted (the Hidden mode
in Settings), while a system dialog has it out of the way, and on a monitor
whose bar is not showing. It is published per screen, so with two
monitors the answer is "yes" when any of them shows it. With no bar behind it
— a --test run — it answers "yes", because an animation too many is easier
to notice than one that never starts.
Your view is destroyed when you lose the island, so an animation that lives
inside it goes away with it and costs nothing. The trap is everything drawn
where the view survives: what you contribute to the pill, your own
K4.Ventana, anything you hang in a place that is not yours.
The same reasoning covers anything that repeats without an end: a Timer with
repeat: true, a Canvas that repaints itself, an AnimatedImage. If it
runs forever, it needs a reason to be running now.
Your plugin knows where it lives. carpeta is its directory on disk, filled in
by the host when it creates you, and fichero(...) builds a path inside it:
K4.Process {
command: ["python3", fichero("tools/mine.py")]
}That is what lets you ship your own things — a script, a binary, a model, a
data file. Qt.resolvedUrl("assets/x.png") was already enough to paint an
image, but a Process wants a path, not a URL, and K4.Paths.raiz is the
bar's directory, not yours.
| Type | What for |
|---|---|
K4.Plugin |
the contract: island, priority, keyboard, view |
K4.Tema |
the palette (tinta, superficie, apagado…) and the fonts |
K4.Etiqueta |
text with the bar's defaults |
K4.Glifo |
a Nerd Font icon (find them with tools/glyphs.py) |
K4.Icono |
a desktop-theme icon, by name |
K4.Miniatura |
the live thumbnail of an open window, by its address |
K4.Interruptor |
the bar's switch; it notifies, it does not flip itself |
K4.Deslizador |
filled slider with label and value; 60 px labelled, 32 px unlabelled |
K4.Medidor |
a bar that measures and is not touched: volume, progress, how much of a quota is gone |
K4.Baldosa |
the control center's pressable card |
K4.Boton |
round one-glyph button |
K4.ActionButton |
compact text action; text, controlled selected, inherited enabled, clicked() |
K4.TextField |
shared single-line editor; caller owns labels, validation and commits |
K4.Rodillo |
a scrolling area that actually obeys the wheel, house scroll bar included |
K4.Desplazador |
the house scroll bar — thin, fades away; attach it to your own lists |
K4.Estela |
the house caret, with the trail k4term leaves — use it as cursorDelegate |
K4.Aparicion |
enters with a fade instead of popping |
K4.FocoInicial |
moves the cursor to your text field on open |
K4.Guardado |
Existing cargado/guardar contract; settings are shared, other state is local |
K4.PluginSettings |
Shareable preference values in config.json; loaded/save |
K4.PluginState |
Owner-local named state files; loaded/save, recoverable saveSections |
K4.Credential |
Memory-only credential value backed by the system keyring |
K4.Ipc |
your IPC target (k4.<id>) |
K4.Process |
external processes — requires the procesos permission |
K4.Terminal |
the house terminal: run a script where it best fits, open a window, or register an island provider through its host adapter — process launches require procesos; see the provider reference |
K4.Sonido |
a short sound — requires the sound permission |
K4.Feedback |
Standard UI cues: click() for activation, rate-limited tick() for adjustment; host settings govern playback |
K4.Fichero |
reading and writing files — requires ficheros |
K4.Pildora |
an indicator on the folded pill |
K4.Capsule |
the capsule growing toward a screen edge with your text |
K4.Submaps |
the submap Hyprland is in right now ("" for none). Names follow the parseable standard — Title: (k)Label (command) — which the house hyprland-submap plugin turns into its key-chip island; anything else shows as plain text |
K4.Paths |
Asset/helper paths and the display-only config location |
K4.IconoPlugin |
a plugin's icon: its image if it brings one, its glyph if not |
For live numeric pill indicators, pass an optional seventh slots argument to
K4.Pildora.registrar. Each slot supplies { text, samples, prefix }, with
prefix optional: a percentage can reserve samples: ["100%"], while network
download and upload use separate slots with stationary arrows and left-aligned
values directly beside them and a compact 3 px gap between slots. Reserve the
four-character outputs of K4.SystemMonitor.compactRate for network values.
The host's Island → Indicator icon size setting scales every status glyph
together (8–20 px, default 14 px), including agent indicators, without resizing text.
Slots without a prefix stay right-aligned. Update texto
and the complete slots array through actualizar, retaining the same samples
and prefixes. This keeps the folded, clock, and player views steady while values
change. See pill indicators for sizing and rate samples.
Use the shared controls for consistent keyboard focus and disabled states.
Give icon buttons, cards and switches an Accessible.name. Switches and choice
buttons report activation; keep the displayed value bound to its owner.
K4.Deslizador supports arrows and Home/End, exposes read-only dragging, and
uses a compact 32 px layout when etiqueta is empty (supply an accessible name).
K4.Rodillo brings focused descendants into view during Tab traversal.
Text fields need a persistent visible label and an explicit commit/cancel
policy; do not persist password drafts. See the control reference in
API.md.
Shared buttons, switches and pressable cards already provide click sounds;
sliders provide quieter, throttled ticks for user changes. Do not duplicate
those calls in your handlers. Custom controls may call K4.Feedback.click() or
K4.Feedback.tick() for user interaction, without an additional permission.
These fixed cues obey Settings → Island → Interaction sounds, system mute,
and the default output device. Background changes and hover stay silent.
Arbitrary sound playback still uses K4.Sonido and its declared permission.
See interaction sounds.
And what you need when yours grows past the island:
| Type | What for |
|---|---|
K4.Ventana |
a window of your own, apart from the island: a full-screen picker, something worth real space |
K4.PorPantalla |
one copy of yours per monitor — with two screens almost nothing wants to exist once |
K4.Cargador |
loads the expensive part only when needed and drops it when not |
K4.Apps |
the installed applications: name, icon, how to launch them |
K4.Sistema |
the loose ends: launching things, reading the environment, finding icons |
K4.MenuBandeja |
the menu a tray icon publishes |
K4.BloqueoSesion |
locking the session for real (ext-session-lock) |
K4.SuperficieBloqueo |
what is drawn while locked, one per monitor |
K4.Autenticacion |
checking that whoever is in front is who they claim, via PAM |
The last four are for replacing pieces of the bar — a lock screen of your
own, another tray — more than for an ordinary plugin. They are here because
the built-in modules use them and the rule is the same for everyone: if a
repository plugin can, an outside one can too. (K4.MenuBandeja is the one
exception to the first half: the built-in tray is native now —
services/TrayIsland.qml, always on — and the type stays so a replacement
tray can still read an application's menu.)
(K4.Puente also exists and is not for you: it is how the bar hands
the API what it needs. Only someone implementing the API on another host
touches it.)
And the live system data:
| Type | What it gives | Writing requires |
|---|---|---|
K4.Audio |
volume, mute | audio |
K4.Medios |
what is playing: title, artist, artwork, position | medios |
K4.Notificaciones |
the ones that arrive, with the llego() signal |
notificaciones |
K4.Red |
Wi‑Fi and Bluetooth — read-only, no exceptions | — |
K4.Escritorios |
which ones exist and which one you are on | — |
K4.Portapapeles |
the history — just reading already requires portapapeles |
portapapeles |
K4.Reloj |
the time, from the bar's single clock | — |
The line is drawn by the effect, not the module: looking at the volume does nothing to anyone, raising it does. The clipboard goes the other way because it holds passwords and tokens — there, the delicate part is reading. And connecting to a network or pairing a device does not open even with a permission: a mistake there costs your connectivity or hands your laptop to a stranger, and no plugin idea makes up for that.
K4.Medios.posicion only advances if someone is watching: call
seguirPosicion() when your view mounts and dejarPosicion() when you
drop it, or the timer does not run.
With those pieces your plugin has the SAME face as the bar without drawing
a rectangle: ejemplos/piezas/ is the showcase, copyable and runnable. And
a warning that saves you an afternoon: if you put your list in a plain
Flickable and its rows have hover or click, the wheel will not work —
a MouseArea accepts the wheel whether it handles it or not — and no error
will be raised. That is why K4.Rodillo exists.
For a game: K4.PluginState is the local save and the high score,
tecladoAlPasar: true gives you the keys while the pointer is over the
island and hands them back when it leaves, and a Timer is the tick. Use
grabKeyboard only for something you open, look at and close: a game stays
open, and exclusive focus would leave the whole desktop unable to type. Do
NOT use tecladoOpcional for keys you expect to just work — on-demand means
the compositor only grants them if you CLICK the surface, so a game opened
by shortcut never sees a keystroke.
And the second half of the keyboard, which is a separate problem: the layer
having the keys does not mean your view receives one. The island's root also
claims focus — that is where ESC lives — so claim it back with
K4.FocoInicial, and not only on open: with tecladoAlPasar the keys arrive
when the pointer enters, and by then FocoInicial has already given up.
property var foco: K4.FocoInicial { objetivo: raiz }
Component.onCompleted: foco.reclamar()
HoverHandler { onHoveredChanged: if (hovered) raiz.foco.reclamar() }Four more things every game needs, and none of them raises an error when missing:
function close()— the host closes the active module on ESC by calling it. Without it the key does nothing and your view becomes a trap.handlesBackgroundTap: truewith an emptyonBackgroundTapped— or a click on any gap in your view opens the control center.closeOnHoverExit: false— nothing must vanish when the pointer leaves mid-use.- Anything you put in
services/is NOT hot-reloaded:pluginReloadreloads your plugin directory only, so rule changes need the bar restarted.
The repo's own examples (ejemplos/) demonstrate these capabilities.
Use the drawing primitives available from QtQuick, such as AnimatedSprite,
SpriteSequence, ShaderEffect and Canvas. Features requiring another module
need a public K4 wrapper or a host service. Some existing examples and the current
checker still allow broader imports; migrate those callers rather than treating
the enforcement gap as permission for new private or platform imports.
A plugin does not have to live only inside its island.
Your settings, in Settings. Without this, two options forced you to invent a screen, a button to open it and a way to save them — and the user had to learn a new place for every plugin.
K4.Ajustes {
plugin: "hola"
grupo: "Hello"
opciones: [{ id: "saludar", nombre: "Greet on open",
desc: "Otherwise just show the counter",
glifo: 0xF1821 }]
valores: ({ saludar: self.saludar })
onCambiado: function (id, valor) { self.saludar = valor; self.apuntar() }
}YOU keep the values: the bar asks through valores and notifies through
cambiado. That way what is shown is always what you actually have saved,
and not a copy that desynchronizes on the first failed write.
A switch per option is the default; tipo unlocks the other two:
opciones: [
{ id: "modelo", tipo: "eleccion", nombre: "Model",
desc: "Which one answers", glifo: 0xF06A9,
alternativas: [{ codigo: "rapido", nombre: "Fast" },
{ codigo: "capaz", nombre: "Capable" }] },
{ id: "clave", tipo: "texto", secreto: true,
nombre: "API key",
desc: "Saved wherever you say; the bar does not keep it",
pista: "sk-…", glifo: 0xF0306 }
]opciones is live: change it and the section redraws. An empty list hides
the whole section, which is how you say "this does not apply right now" —
useful when your options depend on something you only learn later, like
whether a program is installed or whether there is network. It has to be a
binding for that, not a value computed once:
// Without the required program, the entire section stays hidden.
opciones: !Consola.esNuestra ? [] : [ /* … */ ]A choice shows its alternativas as chips and cambiado delivers the
chosen codigo. A text is a free field — a URL, a model, a key: pista is
the empty field's gray, secreto: true masks it with dots once typing
stops, and the value arrives on confirm — Enter or a click outside — not
keystroke by keystroke. With this, a plugin that talks to a service, an AI
or a CLI configures itself in Settings like everything else, without
inventing a screen of its own.
Your news, on the capsule itself. An indicator chip is small on
purpose; some things deserve to be read from across the room. While your
extension is set, the pill grows toward a screen edge carrying your
text, and folds back when it is null:
K4.Capsule {
plugin: "rec"
extension: grabando ? ({
lado: "right", texto: "Recording",
glifo: 0xF037E, color: K4.Tema.rojo,
maxLength: 300 // px the capsule may grow to
}) : null
}The width is not yours to fight for: the bar hugs your text with the
pill's own font and caps it at maxLength and at the room to the edge.
The pill's own content does not move, and while a deployed view owns the
island the extension folds with the pill. extension must be a binding
— a new object when your state changes — because mutating the old one
in place tells no one.
Your pages, in Settings. A row of options is a comment; sometimes the
work wants the whole screen. K4.Pagina contributes a page to Settings —
its sidebar, its search, its scroll — nested under a family or as a
section of your own:
K4.Pagina {
plugin: "hola"
name: "gretings" // unique within YOUR plugin
titulo: "Greetings"
glifo: 0xF02FC
desc: "How this plugin greets the desktop"
claves: ["hello", "salute"]
componente: Component { MiPagina {} }
}The componente is instantiated only while its page is on screen, in your
plugin's own context. Root it in a layout that reports implicitHeight
and the window sizes and scrolls it for you. The page leaves the sidebar
AND the search the moment your plugin is off — nobody renders a page
whose author is gone.
Your blocks, in the control centre. The centre that opens from the bar
is not a closed list: K4.Card puts one block of yours among the natives
— toggles, media, shortcuts — wherever the stored order says, with the
same width and the same editor. The user reorders and hides it in
Settings → Control centre like any native block; visibility is theirs,
not yours.
K4.Card {
plugin: "correo"
name: "unread" // unique within YOUR plugin
titulo: "Mail"
glifo: 0xF01EE
alto: 64 // px the card occupies
component: Component { MiFila {} }
detailTitle: "Mail"
detail: Component { MailDetails {} }
}alto is the height the card OCCUPIES — fixed, like the native blocks'
own heights — and the centre hands you exactly that room: fill it, don't
fight it (width: parent.width, height: parent.height on the root).
An optional detail stays plugin-owned but renders inside the centre. Call
openDetail() from the card; detailTitle labels the shared header, Back and
Escape return to the card list, and unloading the plugin closes the detail.
The centre knows the card as "<plugin>.<name>". A card-only plugin has
no view and never asks for the island's stage — it exists to put one
block in the centre and that is all. ejemplos/worldclock/ ships a
working card.
Your results, in the launcher. You answer when you can; if yours is expensive — a network query — you block nobody. Yours shows up below the system's applications: that panel is theirs, and you are there to be findable, not to compete.
K4.Lanzador {
plugin: "hola"
onBuscando: function (texto) {
resultados = texto.length < 2 ? []
: [{ id: "abrir", titulo: "Open Hello", desc: "…" }]
}
onElegido: function (id) { self.abierto = true }
}Three pieces turn the bar into part of the game, not just its frame.
Tinting the ambience. K4.Tema.tintar(tuId, color, fuerza, duracionMs)
tints the whole neutral scaffold — island, surfaces, tracks — and
everything painted with the theme recolors itself. The ink and the colors
with meaning (green, red…) are not touched, so text stays readable and an
alert stays an alert. Strength is capped at 0.45 by the host; duracionMs
0 means "until you call destintar(tuId)", and disabling your plugin
untints on its own. The current tint is readable back: K4.Tema.tinteDueno
names whoever holds it and K4.Tema.tinteColor is its colour — the
wallpaper palette uses exactly this to dress the bar from your image.
K4.Tema.tintar("mi-juego", "#26324f", 0.35, 4000) // Abyss tint, 4 seconds.Asking for gestures. K4.Isla.efecto(tuId, nombre, fuerza) moves the
island like a physical object: "sacudida" (a hit), "empujon" (something
heavy lands on it), "tiron" (something pulls at it, like a fish on the
line). The host animates and arbitrates: one gesture every half second at
most. The rare effect impresses because the bar is sober the rest of the
time — ask for it at the moment that matters and let it breathe.
Painting outside. K4.Isla.rect gives the island's real on-screen
geometry ({ x, y, ancho, alto }, the primary one if there are several);
with several monitors, K4.Isla.rectEn(name) gives each screen's own, and
a K4.Ventana picks its monitor with pantalla.
With a transparent K4.Ventana above everything and that rect, anything
can peek over the edge, fall off the bar or stroll across it — a waving
hand, the pet climbing down. ejemplos/efectos/ ships all three pieces
working, hand included.
And since the bar no longer lives only at the top — the edge is chosen in
Settings — K4.Isla.posicion says which one it is ("arriba" or
"abajo"). The host takes care of its side (anchoring, flipped silhouette,
gestures pointing into the screen); your side is reading rect and
posicion instead of assuming up is up.
Sliding it along the edge. The island is not nailed to the center
either: the user picks its alignment in Settings, and a plugin can move it
TEMPORARILY with K4.Isla.colocar(tuId, fraccion, duracionMs) — 0 flush
left, 1 flush right, animated with the same spring as opening. It returns
to the user's base on its own: on timeout, with soltar(tuId), or when
your plugin is disabled. It is for what a scene lasts — the island dodging
a hit, playing paddle, stepping aside to show something behind — not for
staying: the permanent position belongs to the user. K4.Isla.colocacion
tells the effective fraction right now.
And K4.Isla to know whether you are on display: abierta,
ocupadaPor, raton, altoMaximo. Read-only — who holds the island is
decided by the host comparing priorities, which is the only way two plugins
do not fight over the screen. Use it to skip animating and polling when
nobody is watching.
Taking the stage while the mouse rests. A hover view is not a separate
API — it is the ladder plus one readable fact. K4.Isla.raton says
whether the mouse is on the pill, and binding active to it takes the
stage exactly the way the clock does:
K4.Plugin {
priority: 51 // pick your slot, see below
active: habilitado && K4.Isla.raton
islandWidth: 300; islandHeight: 84
view: Component { PeekView { plugin: self } }
}Pick the priority by who you want to beat: 1–39 stays under the clock
(you show when the clock plugin is off), 51–54 stands over the clock and
under the player, 56–58 over the player too. Leaving is the binding's
job — raton clears a moment after the mouse goes, active follows,
and the stage returns to the pill; closeOnHoverExit is for summoned
views and a hover view never needs it. ejemplos/hoverpeek/ ships one
working.
All three deregister on their own when your plugin is destroyed — disabled, reloaded, uninstalled — and the manager also sweeps by id: a Settings row that calls a dead plugin cannot exist.
An K4.Ipc target is shared between every plugin
on the bar. Two plugins cannot hold k4.notes: the compositor and
Quickshell hand it to whoever registered first, and the loser stays loaded,
without errors, simply not answering. That failure is invisible — it only
shows up in the log.
So the bar checks it for you. tools/plugins.py reads the targets
out of your QML — not out of the manifest, because what
is registered is what the code says — and cross-checks them against
everything already installed. Whoever asks for a taken command is listed as
not loadable, with the reason:
- notas: command k4.launcher is already registered by «launcher»
Repo plugins come first in the combined catalog, so an outside plugin never
takes a command away from one of the bar's own — the same rule that already
governs ids. The commands each plugin registers travel in the catalog under
comandos, so Settings and the store can show them before you turn
anything on.
One thing this does not do yet, and it is better to know: the scan looks
for target in the first 400 characters of the block, so a target hidden
far below is not seen.
The manifest declares what you use; the bar checks it before listing:
| Permission | What betrays it |
|---|---|
procesos |
K4.Process, K4.Terminal.ejecutar, K4.Terminal.abrir |
red |
XMLHttpRequest, WebSocket |
ficheros |
K4.Fichero |
sound |
K4.Sonido |
audio |
K4.Audio.ponerVolumen, K4.Audio.alternarSilencio |
medios |
K4.Medios.alternarPausa, .siguiente, .anterior, .buscar |
notificaciones |
K4.Notificaciones.limpiar |
portapapeles |
K4.Portapapeles — even just reading it |
paginas |
K4.Pagina — injecting pages into Settings is UI power |
All nine, and note where the line falls: ponerVolumen is watched and
K4.Audio is not, because looking at the volume does nothing to anyone and
changing it does. The clipboard is the only one reversed, because there the
delicate part is reading.
Using something without declaring it makes the plugin not loadable, with the reason in Settings. And the honest part: this is not a sandbox. QML runs inside the bar's process and a loaded plugin can do whatever the bar can do. The permissions are informed consent — the user sees them before turning it on — plus an analysis that catches carelessness and simple deception, not a cage. Installing a plugin is trusting its author.
- Outside plugins arrive disabled: they are turned on in Settings (or
k4 pluginEnable <id>), seeing description and permissions first. - Disabled = not instantiated: your IPC does not even exist.
- If your QML does not compile, the bar starts without you and Settings shows the error with file and line; "retry" reloads from disk after you fix it, without restarting the bar.
python3 tools/plugins.pyvalidates your manifest and permissions without starting anything.
The fastest loop does not involve your bar at all:
tools/plugins.py --test holaThat opens only your plugin, in a separate Quickshell instance: no bar, no services, no notifications, none of your session. If it hangs, it hangs there. Click outside to leave. It works for plugins written against the K4 API — which is every plugin from outside — because the API falls back to sane defaults when there is no bar behind it.
When you do want it in the real bar:
quickshell ipc -p ~/.config/quickshell/k4/shell.qml call k4 pluginReload holaIt destroys your plugin and recreates it from disk: edit, reload, look. It does not restart the bar or touch the others. It reloads the WHOLE directory — entry and views — so it works just as well for a view tweak. If the new version does not compile, your plugin is left in error with its reason and the "retry" in Settings; the rest of the bar does not even notice.
An honest warning: reloading destroys your object. Whatever holds state in
memory and was not saved with K4.Guardado is lost — which for development
is usually exactly what you want.
The pill, volume HUD, sound mixer, clock, player, notifications, control
centre and session are native host features, not plugins: they are always
on, live in services/*Island.qml with views in core/, and are described
by features/catalog.json. Their ids (idle, volume, sound, clock,
player, toast, panel, session, tray) and IPC targets (k4,
k4.panel, k4.sound, k4.session, k4.tray) are reserved — an outside
plugin claiming one fails validation.
Native notifications use the island while it is idle and a separate surface
while another view is active. The separate surface uses the main island's
edge-attached silhouette, curved wings and corner treatment, with the same
content and action buttons. Settings → Island → Notification popups selects the
separate surface's preferred corner (bottom-right by default). It appears on
the open island's monitor, stays within the screen, and uses another corner
when necessary to avoid the island where space allows. This native transient
setting is separate from summoned surfaces' colocable popup settings cards.
Contributing any other module to the bar itself follows the plugin
contract, with three differences: the directory goes in plugins/, it is
registered in plugins/catalog.json, and the directory carries a qmldir
with all its types (Quickshell's URL scheme does not resolve siblings
without it — tools/plugins.py warns if it is missing).
This page is the contract, and it does not get to lie. Two checkers read it against the code on every change:
python3 tools/api.py # every public K4.* type must be mentioned in the quick-ref
python3 tools/docs_check.py # every K4.<Type>.<member> the docs cite must exist;
# every permission and named rule must be in the tableThat is why examples here are real paths (ejemplos/…) and why snippets
name real members: the checkers verify both. The standard for anyone —
human or agent — touching the plugin system is: the change and its
documentation land together, and the checkers stay green. A type that is
not documented does not exist for whoever builds against this guide; a
doc that lies about the code is worse than none.