Build plugins for Iris
A plugin teaches Iris something new: a command she can run, a slash command in the chat, a window or a whole screen. Moneybird invoices, the weather, a WhatsApp digest, your own tools. Once it is in the marketplace, every Iris can install it by being asked.
There are two ways to build one:
- Ask your Iris. Tell her what you want ("build a plugin that lists my open Moneybird invoices") and she writes it, tests it and can publish it for you. She follows this same guide.
- Write it yourself. A plugin is a plain folder with a manifest and a few scripts. No SDK, no build step.
If you are a language model: this page is also at /docs.md as plain Markdown, and /llms.txt points to everything you need.
What a plugin is
One flat folder in ~/plugins/<name>/ inside an Iris home: a plugin.json plus the files it names. No subfolders. Your code runs in that Iris's own private workspace (Linux, with Python 3, Node and the usual command line tools), never on anyone else's.
~/plugins/moneybird/
plugin.json
moneybird.py the command `moneybird`
moneybird.jsx a window with the open invoices
icon.svg its icon in the marketplace
README.md its page in the marketplace
plugin.json
{
"name": "moneybird",
"version": "1.0.0",
"author": "Woodst",
"description": "Open invoices, payments and contacts from Moneybird.",
"permissions": ["internet", "secrets"],
"commands": { "moneybird": "moneybird.py" },
"slash": { "moneybird": "open invoices" },
"window": "moneybird.jsx",
"category": "finance",
"icon": "icon.svg"
}
| Field | Required | Meaning |
|---|---|---|
name | yes | The name, equal to the folder name: lowercase letters, digits and -, at most 40 characters. |
version | yes | Three numbers, like 1.0.0. Every published version is final; change anything and raise it. |
author | yes | Who made it, shown in the marketplace. |
description | yes | One sentence: what it does for the person, not how it is built. |
permissions | yes | The permissions it needs, see below. An empty list is fine and trusted most. |
commands | no | Command name to file in the folder. The file starts with a #! line. |
slash | no | Commands that also appear in the / menu of the chat, with a short explanation. |
window | no | A .jsx window shown over the conversation. |
screen | no | A .jsx screen that rearranges the whole page. |
routes | no | Pages of your own on the house's address, see below: { "support": { "page": "desk.jsx", "command": "desk", "public": true } }. |
kit | with a page | The UI kit (IrisUI) major your pages are built on, at least 34: "kit": 34. Required once the plugin has a window, screen or a route page. The house gets that kit before it turns your plugin on. |
database | no | true: the plugin keeps a database of its own in its own folder, see below. |
category | no | One of communication, finance, productivity, home, media, knowledge, developer, other. |
icon | no | A square .svg or .png in the folder. Without one the marketplace draws a letter. |
screenshots | no | Up to 6 .png, .jpg or .webp files in the folder, shown on its marketplace page in this order. |
usage | no | What costs money once it runs, like [{ "what": "Calls", "price": "$0.05 per started minute" }]. Installing is always free. |
provides | no | Capabilities it delivers, so a template can ask for the job instead of the brand: ["mail-calendar"]. |
hooks | no | Events of other plugins it acts on, with one of its own commands: { "proposal-signed": "moneybird proposal-signed" }, see Hooks. |
notifications | no | The kinds of notification it sends, each with a short English title: { "booking": "New bookings" }. The owner can switch each off, see Notifications the owner can switch off. |
setup | no | How Iris connects it when she hatches: oauth, dial-code, pair-code, open-link or choose. |
oauth | no | The owner's account at Google, Microsoft or bunq, with one tap, see Accounts (OAuth). |
initiative | no | Its topics with a starting stop, 0 (only when asked) to 3 (does it): { "missed-calls": 3 }. Each topic gets a slider. |
actions | no | What a button of Iris may run in this plugin: { "today": "calendars" }, see Button actions. |
kind | no | "template" for a template, see below. Leave it out for a normal plugin. |
Screenshots show the plugin at work: its window, its screen, or a conversation where Iris uses it. Use made-up demo content, never someone's real messages or data. A phone shot (1170 x 2532) or a window shot (1280 x 800) both work; keep the whole folder under 6 MB.
A README.md in the folder becomes the plugin's page in the marketplace: what it does, what the person needs (an account, an API key), and a few example sentences to say to Iris.
Does part of it run on the person's own computer, in their coding agent (Claude Code, Codex, Gemini CLI)? Add an AGENT.md: a prompt written to that agent. Its page then gets a button that copies it; the person pastes it in their agent, which sets itself up. coding-agents does this.
Button actions
Iris puts buttons under her answers. Most answer her ("Yes", "Not now"), but a button can also do something:
| She writes | A tap |
|---|---|
Not now | answers her with that word |
Empty :: I learn only what you ask me | the same, drawn as a card with one line under it |
Open Google Calendar -> https://calendar.google.com/calendar/r/settings | opens that page in a new tab |
Copy the code -> copy:**004*+31850835196# | puts the text on the clipboard |
Show today -> calendars:today | runs the plugin's action today and hands her what it printed |
Paste the link -> input:calendars:add | opens a field; what the person pastes goes to the action as its argument, never into the conversation |
A plugin offers its actions in plugin.json:
"actions": { "today": "calendars", "feeds": "calendars feeds" }
The first word is one of the plugin's own commands, the rest are fixed arguments; a button may add one more (-> calendars:search dentist), never one that starts with -. It runs in the person's own home, never through a shell, and only while the plugin is switched on. Iris gets the output back as a message, so a proof ("it reads your calendar: dentist at 10:00") or the next step follows by itself. Put the button in your SKILL.md, so she knows when to offer it. A tap is the owner's own: the house runs it from outside Iris's sandbox, so there the command has no parent process (os.getppid() == 0), while one she runs herself always has one. That is how a plugin keeps something for the owner's tap only, like sending a mail she wrote (imap-caldav does this).
A watch
An action named watch makes the plugin a watcher: "actions": { "watch": "google mail recent" }. The house runs it every 15 minutes, without Iris. It prints its recent items, one per line as <id><TAB><text>, or nothing; the house remembers which ids it has seen, so the plugin keeps no place of its own. The first run only remembers. New lines go to a small model without tools first, which keeps only what needs the owner (a person waiting, a client, a deadline); only then does Iris get one line about it, never the raw text. Failing for an hour is told to the owner. The owner switches a watch off per plugin on the Plugins screen; it is on by default. A watch is never the owner's tap: it has a parent process, so it can read, never approve. It gets 100 seconds.
A line may also be a JSON object, so the owner can mute what it is about (see below): {"id": "m-81", "text": "Anna asks about the quote", "kind": "client-mail", "source": {"id": "anna@example.com", "name": "Anna"}}. kind and source are optional.
Notifications the owner can switch off
Declare what your plugin tells the owner about under "notifications" in plugin.json: a kind id (lowercase letters, digits and -, at most 40) with a short English title, a plural noun phrase of at most 60 characters, at most 10 kinds. Translate the titles in lang-<code>.json under the same key.
"notifications": { "booking": "New bookings", "cancellation": "Cancellations" }
Each kind gets its own switch on the owner's Notifications screen. Say the kind, and who it is about, with every notification: in a route's answer as "ring": {"text": "...", "kind": "booking", "source": {"id": "anna@example.com", "name": "Anna"}} (a plain "ring": "..." still works), in a watch as the JSON line above. source.id is a stable id of 1 to 120 letters, digits and _ . @ + - (an email lowercased, a phone number without spaces); name is what the owner reads. A kind switched off, or a source the owner muted, costs no turn: nothing is said. The notification itself carries a button: "Nothing more from Anna" with a source, "No more new bookings" with only a kind. A kind your plugin.json does not declare is ignored.
Templates
A template sets an Iris up for a kind of work or life in one go: a hair salon, a handyman, a busy parent. It is a plugin with "kind": "template" and a few extra fields. When someone picks it as Iris hatches, it installs the plugins it needs, gives her a character, and brings its own intake questions and suggested loops.
{
"name": "assistant",
"kind": "template",
"version": "1.0.0",
"author": "Iris",
"description": "The standard assistant. Calm, short, runs your day.",
"permissions": [],
"requires": ["calendars", "voices", "telegram", "mail-calendar"],
"core": ["mail-calendar"],
"persona": "PERSONA.md",
"intake": "INTAKE.md",
"loops": [{ "name": "morning", "at": "07:30", "do": "Tell today's agenda and weather." }],
"initiative": { "agenda": 2 }
}
| Field | Meaning |
|---|---|
requires | Plugins by name, or capabilities another plugin provides. One plugin fits: installed. Several: Iris asks which. None yet: she says so. |
core | At most two of requires that must work on day one. The rest goes on a list, one a day. |
persona | A .md with tone and habits, written into her memory. Never a name: she picks her own. Only the first template gives a character; later ones add plugins and loops. |
intake | A .md with the questions she asks to get to know the person. |
loops | Suggestions, up to 10: at for a routine ("07:30"), until for a goal that ends ("a new appointment is booked"). She makes her own version with the person. |
initiative | Starting stops per topic, set before the plugins' own. What the owner chose is never overwritten. |
The plugins a template installs stay when the template goes: they are the person's now.
Permissions
Say honestly what the plugin needs. People see this list before they install, and a new version that asks for more is never installed without their yes.
| Permission | Means |
|---|---|
internet | Fetches something from the internet. |
files | Reads or writes files outside its own folder. |
secrets | Uses a key from the owner's vault (an API token, a login). |
phone | Sends something to the owner's phone. |
voice | Speaks or listens. |
messages | Reads and sends messages in the owner's name (WhatsApp, mail, Telegram). |
Commands
A command is any executable file. What it prints is what Iris reads back, so keep output short and readable: it is an answer, not a log.
#!/usr/bin/env python3
import sys
print("3 open invoices, 1.240 euro in total. The oldest is 34 days overdue.")
Commands under slash show up in the chat's / menu once the plugin is on. Choosing /moneybird overdue runs moneybird overdue and shows what it prints. Words after the command arrive as arguments, never through a shell.
A command never replaces an existing one: if web or klus already exists, pick another name.
Keys and logins
A plugin never sees a secret. The owner keeps tokens in the vault; your command asks the vault to make the call and gets back only the response. {g} is replaced by the secret inside the vault.
vault call moneybird GET https://moneybird.com/api/v2/administrations.json --header "Authorization: Bearer {g}"
For HTTP Basic auth use {g:base64}. Missing key? Tell the owner in one sentence which key to add and where to get it: vault ask moneybird --domain moneybird.com "your Moneybird API token" opens a safe window for them to paste it. Such a plugin needs the secrets permission.
Accounts (OAuth)
For a big provider with a sign-in (Google, Microsoft) there is no key to paste: the owner taps a button, signs in there and allows what you ask. The house holds the token; your command never sees it. Say which provider and which scopes, grouped in parts the owner allows one at a time (the first part is asked first):
"oauth": { "provider": "google", "scopes": {
"calendar": ["https://www.googleapis.com/auth/calendar.events"],
"mail": ["https://www.googleapis.com/auth/gmail.readonly"] } }
provider is google, microsoft (Microsoft Graph scopes like Calendars.ReadWrite) or bunq (no scopes: the owner allows what the bunq API offers, so give one part like { "bank": ["bunq"] }). Your command talks to the house at http://host.docker.internal:8790:
| Request | What it does |
|---|---|
POST /oauth/<plugin>/start {"part": "mail"} | { url }: a one-time link (30 minutes) for a button Connect -> <url>. What was allowed before stays. |
POST /oauth/<plugin>/fetch {"method", "url", "body", "summary", "kind"} | The call, with the token added: { ok, status, data }. Only https to the provider's own hosts. |
GET /oauth | The connected accounts (plugin, account, scope) and what waits for the owner. |
Reading (GET) is free, and so are the writes that stay with the owner (a draft, an appointment without guests, a contact). Anything that sends, invites, moves or deletes comes back as { proposal } instead: it is on the owner's screen and their phone, with your summary (plain words: to whom, what) and the exact payload (and the draft or event it changes, read by the house), and only their Face ID tap on that payload runs it, once. kind (mail.send, event.create, event.update, event.delete) picks the question above it in their language. These rules are the house's, per provider; your plugin cannot loosen them. After connecting, Iris hears [OAuth] The owner connected <plugin> as <account>: let your SKILL.md tell her how to prove it (a button action like today). google-workspace, microsoft-365 and bunq are built this way. At bunq a draft payment and a bunq.me link are free (the owner still approves a draft in the bunq app); a payment or a request to someone waits for Face ID.
Settings
A settings.json next to plugin.json gives the owner a form under Integrations, and lists the settings on the plugin's marketplace page. Your plugin keeps the values, wherever it likes.
{
"command": "moneybird",
"fields": [
{ "key": "administration", "label": "Administration", "type": "text", "help": "Its id, from the Moneybird address bar." },
{ "key": "remind", "label": "Remind me of overdue invoices", "type": "toggle" },
{ "key": "currency", "label": "Currency", "type": "choice", "options": ["EUR", "USD"] }
]
}
Types: text, number, toggle (on or off), choice (one of options) and list (one or more texts, shown as pills; the command gets and gives them as one text with commas: +31612345678, +31687654321). At most 20 fields, keys in lowercase letters, digits and _. The command answers two calls:
moneybird settings # print the values as JSON: {"administration": "123", "remind": "on"}
moneybird settings set remind on # change one; what it prints is shown to the owner under the field
Windows and screens
A window is a picture over the conversation; a screen rearranges the whole page. Both are one .jsx file with a default export, built from the same kit Iris uses for her own screens. plugin open <name> shows it.
export default () => (
<Card label="Moneybird" title="3 open invoices" icon="euro">
<Row left="Bakkerij Jansen" right="480 euro" />
<Row left="Studio Noord" right="760 euro" />
<Buttons>
<Button primary say="Remind Studio Noord about their invoice">Send a reminder</Button>
</Buttons>
</Card>
);
| Component | Props |
|---|---|
Screen | title, subtitle, icon: the frame of a whole screen |
Card | label, title, icon |
Row | left, right, icon |
Stats, Stat | value, label, icon: numbers side by side |
Buttons, Button | primary, onClick, or say: the sentence Iris receives when it is tapped |
Header | title, right: the head of a phone page |
Tiles, Tile | label, icon, value, subtitle, wide, tone, color, on, key: widgets in a grid |
Item | title, subtitle, right, unread, on, key: a row in a wide tile |
Text | dim |
List | items |
Icon | name, size |
say("...") sends a sentence to Iris from your own code, as if the owner said it.
A tap is not a message. Tile, Item and Row answer a tap by themselves: without say Iris hears quietly what was tapped, with no turn and nothing in the chat. Give say only when the tap is a real question or command. Anything with a state gets on: <Item on={false}> ticks and strikes through, <Tile on={false}> is a switch. The house keeps the state, so it survives a redraw and shows the same on every device; for your own parts use const [value, save] = useStand("key", start) instead of useState. A window older than five minutes draws itself anew when the owner comes back to it, so fetch live data when it shows and never add a refresh button.
Routes
A window lives over the conversation; a route is a page of your plugin on the house's own address, with its own link someone can open, save or send to another person.
"routes": {
"support": { "page": "desk.jsx", "command": "desk", "public": true }
}
That one entry answers three things on https://<house>.okayiris.com/support:
| Request | What happens |
|---|---|
GET /support | Your page (.jsx, drawn with the same kit) as a whole page. |
GET /support/page.js | That page's code, which the page loads (you never serve files yourself). |
POST /support/api | Your command runs in the owner's own sandbox, with the request as JSON on its stdin; what it prints is the answer. GET /support/api?x=1 does the same, read-only, with the query in the payload. |
The name is one word (lowercase letters, digits, -): the first word of the path and nothing else. Your page and your command come from your own folder, and the command has to be one of your own commands. Deeper paths are yours to make up (/support/api is the only one the house answers itself).
public: truelets a visitor in who is not signed in, once the owner has published it (the Public switch on the plugin's page in Plugins; every page starts hidden). Without it, or until then, the page sits behind the house's own sign-in, like every other page. Use it for the page your customers or your colleagues open, never for one that shows the owner's data.- The request arrives as JSON on stdin so your command does not have to speak HTTP to its own house:
``json { "plugin": "support-desk", "route": "support", "method": "POST", "path": "api", "query": { "id": "T-3" }, "body": { "text": "hello" }, "house": "anna", "lang": "nl", "public": true, "owner": "Anna", "at": 1790000000000, "ip": "203.0.113.7" } ``
- In your page there is a
pageApi(payload, method): it POSTs to your own/apiand gives you back the JSON your command printed ({ "error": "..." }when something was wrong). Your page also hastext(key, "fallback"), exactly like a window, and the whole kit (Card,Row,Button,Screen,Stats,List,Icon) withh,render,useState,useEffect. Export the page asexport default () => .... - The page looks like the rest of the house, without you doing anything. It is drawn in the house's own column (the same width and gutter as the talk page), with the house's colours, letters and buttons, and with the icon set loaded:
<Icon name="clock" />draws, exactly as it does in a window. Your part is only the content. A board fills that column next to each other; something you read stays narrow by capping itself (style="max-width:32rem"on a form), never the other way around. plugin routesin the house lists what is open, andplugin disable <name>takes a page off the air the same way it takes away the commands.- A public route is open to the world: keep it small, never put anyone's data behind it without the key of the thing they are looking at (a ticket number plus its own token, for instance), and expect to be rate-limited (60 requests a minute per address).
"ring"in your command's answer tells the owner:"ring": "New booking B-3: Anna, Thu 10:00.". The bridge takes it out before the page sees the answer. As an object,{"text", "kind", "source"}, it says which of your notification kinds it is and who it is about, so the owner can switch it off (see Notifications the owner can switch off).
Hooks
Plugins work together through events: one plugin says something happened, every plugin that listens acts on it. That is how an adapter works: Moneybird making a quote when a proposal is signed, a CRM taking in a new lead, without either plugin knowing the other.
"provides": ["sales-accounting"],
"hooks": { "proposal-signed": "moneybird proposal-signed" }
- Listening: under
hooks, an event name (lowercase letters, digits,-) and the line to run: one of your own commands plus fixed arguments. It gets{"event", "id", "at", "data"}as JSON on stdin and prints what it did, best as JSON with anotefor the owner:{"note": "Draft quote 2026-014 waiting", "url": "..."}. - Telling:
plugin emit <event>with the data as JSON on stdin runs every listener side by side (only switched-on plugins, never through a shell, a minute each) and prints their answers as a JSON list. One failing listener never stops the others. Emit from the background when a page is waiting: a route answers within 30 seconds. Anevent_idin the data becomes the event'sid, so a retry carries the same one. - Do it once, and check first. Every plugin in a house runs as the same owner, so any plugin could emit any event. A listener that does something with money or people (a quote, a mail) asks the plugin the event is about before it acts (
proposal status P-0001answers as JSON), and keeps the event's natural key (P-0001/v2) so a second delivery does nothing.plugin listshows what each plugin listens to, next to its permissions. - Finding:
plugin hookslists who listens to what.providesnames the job (sales-accounting,sales-leads), so a template can ask for the job instead of the brand. - Events are named
<plugin thing>-<what happened>:proposal-signed,lead-new. The plugin that emits one documents itsdatain its SKILL.md (seeproposals).
Database
A plugin that has to remember things does not need a server of your own: say so in the manifest and the house gives you a database inside your own folder.
"database": true
plugin enable <name>(and every install) makes~/plugins/<name>/data.db, a SQLite database, and runs yourschema.sqlonce, the first time. Your data survives new versions: a database that is already there is never touched again.- Your command opens it itself (
SQLITE/sqlite3in Python,node:sqlitein Node) and only ever writes in its own folder. No connection string, no password, no server, and nothing of it leaves the house. plugin db <name>shows the tables and what is in them;plugin db <name> "select ..."runs one read query. Asking is reading: what changes the data is your own command.- Keep personal data out of what you publish. The database is the owner's, in the owner's house.
plugin enable issue-tracker
plugin db issue-tracker
plugin db issue-tracker "select id, lane from issues where lane != 'done'"
Design rules (required)
Every plugin looks and behaves like the rest of Iris. The style gate checks the first four before a window or screen is shown; a plugin that breaks one is not shown and not accepted in the marketplace.
1. Only the kit. Everything you can press is a Button: it lights up, gives way when pressed, shows a focus ring and a spinner while it works. No bare <button>, no clickable <div>. 2. Only the house colours, through var(--fg), var(--dim), var(--faint), var(--accent), var(--good), var(--warn), var(--bad), var(--glass), var(--edge). No own font, no emoji, no em or en dashes, no outline: none or transition: none. 3. English code and at least lang-en.json with every text; the window reads them with text("key", "English fallback"). 4. Fits any size. The owner moves your window by its grip, resizes it with magnets (it snaps to the grid and shows where it lands) and makes its content smaller or bigger. So: no fixed pixel widths or heights above 64px and no position: fixed; let the content fill the tile, centred, and never overflow it. 5. One frame. A Card inside your window has no frame of its own; the window is the frame. 6. Images and charts: keep an image's ratio (object-fit: cover, never stretch); draw a chart at its real size and show the value on hover. 7. A tap acts, it does not talk. It does its thing on the screen and tells Iris quietly; only say puts a sentence in the chat. No success toasts.
The parts themselves, with live examples and every value: https://ui.okayiris.com/ (the map of the system; every page has a plain-Markdown twin and there is /llms.txt, /api/components.json and /api/tokens.json for a machine). The full house style: DESIGN.md in the Iris repository.
Languages
plugin.json is English and every plugin ships lang-en.json with all its texts (required). For other languages put a lang-<code>.json next to it, one per language, with the code of the language (lang-nl.json, lang-de.json, lang-pt-BR.json):
{
"description": "Het weer waar je bent, als antwoord of als venster met de komende uren.",
"slash": { "weather": "het weer vandaag" },
"texts": { "refresh": "Vernieuw", "feels": "Voelt als {n}°" }
}
descriptionandslashare shown in the owner's own Iris in their language: the Marketplace screen and the/menu. What is missing stays English.textsare for your window or screen:text("refresh", "Refresh")gives the text in the house's language, else the fallback.textis reserved; don't declare your own.notificationstranslates the titles of your notification kinds:{ "booking": "Nieuwe boekingen" }.- What your commands print needs no translation: Iris reads it and speaks with the owner in their language.
Try it out
Inside an Iris home:
plugin check moneybird # is plugin.json right? (changes nothing)
plugin enable moneybird # put its commands on the path
moneybird # run it
plugin open moneybird # show its window or screen
plugin disable moneybird # and off again
Publish
plugin publish moneybird
This sends the version to the marketplace. It waits for a review first; once approved it is listed here and any Iris can install it. The first home that publishes a name owns it: later versions of moneybird can only come from that same home.
Before you publish:
- No keys, passwords, names or personal data of your owner in the files. Other people will run this.
- The permissions match what the code does.
- One flat folder, at most 6 MB in total.
- The version is higher than the one already listed.
A review looks at exactly these points and at what the code does. A rejected version can be fixed and submitted again with a higher version number.
The marketplace page in the admin panel keeps that same review within reach after the fact: every plugin that is already listed can be opened (its manifest and every file) and taken out again. Taking one out stops the store from handing it out; a home that already installed it keeps the copy in its own sandbox.
Updates
Every Iris checks the marketplace every ten minutes. A new version that asks for no more permissions than the one installed goes in by itself, and the owner gets a message. The owner can switch that off in the Marketplace screen; then they get a message that an update is waiting and update it themselves. One that asks for more waits: Iris asks her owner, and only on a yes does she run plugin update <name>. Folders you wrote yourself are never replaced; only plugins installed from the marketplace are.
plugin update # everything that can update without asking
plugin update moneybird # this one, also when it asks for more (after a yes)
Install
In any Iris, say "install moneybird", or run:
plugin browse # what is in the marketplace
plugin install moneybird # the newest version, enabled
plugin install moneybird@1.0.0
Price
Installing is free. When something inside costs money (a call, a text), say so in usage: the marketplace shows it under Price on its page. Paid plugins, with the money going to their maker, come later.
API
Read-only and public. Every published version is immutable and carries one SHA-256 hash over its files.
| Request | Returns |
|---|---|
GET /api/plugins | All plugins, newest version first: the manifest plus versions, hash, published. |
GET /api/plugins/<name> | The newest package: manifest, files (file name to base64), hash, published. |
GET /api/plugins/<name>/<version> | That exact version. |
Iris Apps