Repository navigation
plugins
An add-on connects her to something the core has no business knowing
about: a stream chat, a game, a piece of hardware. Drop a .py file in
plugins/ and it's found; delete it and it's gone. Nothing in the core
names a plugin, which is what makes them droppable.
/plugins what's installed, and whether it's running
/<name> on start one (saved, so it comes back next launch)
/<name> off stop it
/<name> its own status block
Every loaded plugin answers to its own name automatically: /youtube on
works the day you write plugins/youtube.py, with no change to the core.
> /plugins
Plugins:
[on ] youtube answers live chat out loud
[off] example a template - connects to nothing, answers nothing
[--] twitch no API key - see plugins/twitch.py
[--] discord no bot token - put it in ~/.config/ai-voice/discord.json
[!!] mygame didn't load: ModuleNotFoundError: No module named 'pygame'
[--] is loaded but can't run and says why; [!!] didn't import at
all. Neither stops the app starting.
Plugins are tracked in git, all of them, including any you add
later, so keep anything private out of the file itself. Credentials go
in ~/.config/ai-voice/, and channel or account names in
config.json.
A module with a NAME and whichever of these it needs:
NAME |
what /<name> on|off calls it: the only required one
|
SUMMARY |
one line for /plugins
|
available() / why_unavailable()
|
can it run, and if not why |
start(model) / stop()
|
(ok, message) |
running() / status()
|
state, and the block /<name> prints |
command(text) |
anything else after /<name>: /minecraft goal build a hut calls command("goal build a hut")
|
A plugin can also bring an MCP server with it and drop it again:
mcpclient.connect_server(name, spec) starts the server and registers
its tools while the plugin is on, and mcpclient.disconnect_server(name)
takes them back out of her prompt. That's how the Minecraft tools only
exist while /minecraft is on.
Everything missing gets a sensible default, so a plugin that only needs
start() is four lines. Settings live under plugins in config.json
keyed by NAME, read with config.plugin_settings(NAME): the core
never learns what those keys mean.
A plugin that fails to import, or explodes on start, is reported and skipped. It's an add-on; a broken one must not be why the app won't launch.
> /plugins
[!!] youtube didn't load: ModuleNotFoundError: No module named 'googleapiclient'
config.json is in the repo, so a key pasted into it is a key on
GitHub. Read yours from the environment or from a file outside the repo
entirely:
CREDENTIALS_FILE = os.path.expanduser("~/.config/ai-voice/yourplugin.json")
key = os.environ.get("YOURPLUGIN_KEY", "") or _stored().get("apikey", "")Two things worth copying rather than rediscovering:
- Tell a broken credentials file apart from a missing one. Swallowing a JSON syntax error into an empty dict makes a misplaced comma look exactly like "no API key", and sends you hunting for a key that was there all along.
-
Reject placeholders.
"PUT_YOUR_KEY_HERE"is a non-empty string, so it passes every check, starts cleanly, and then fails at the far end where the only symptom is silence.
An account name or id belongs in the same file as the key, not in
config.json. They're one credential (the key belongs to that account),
and splitting them across two files buys you a mismatch that looks
exactly like a dead connection. Which channel to watch is not a
credential; that stays in config.json with the rest of the behaviour.
| Page | What's on it |
|---|---|
| Stream chat | pomf.tv, Twitch, YouTube and IRC: answering your stream's chat, flood defences, what viewers can reach |
| Games | Minecraft, Zork, Vibe City, REALITI: playing on her own, her notes, watching her play |
| Home and devices | Home Assistant, Lovense, Buttplug.io toys |
| Cron and Discord | scheduled jobs, and the same Luna from your phone (below) |
Every other plugin connects her to a conversation. These two give her a sysadmin side: she looks after the machine on a schedule, and she can be reached from anywhere, the way a homelab bot would be. For putting them to work, with ready-made jobs, see Sysadmin. They're adapted from CR0N, a friend's Discord sysadmin bot. Its scheduler and remote control came over; its regex-gated shell didn't (see What came across).
| Layer | What it is | Out of the box |
|---|---|---|
Scheduler: plugins/cron.py
|
Jobs she runs on her own on a timer, reporting out loud and in the TUI | off |
Discord: plugins/discord.py
|
Remote access: the owner talks to her from a private channel or DM | off, and its writes ask |
Both are off until you switch them on, and neither does anything
on a fresh install. /cron on and /discord on save "enabled": true
into config.json so they come back next launch. If you share your
folder with someone, check those two keys (and
files.auto_approve_sources) first, or they get your switches too.
/cron on /cron off /cron (jobs, last runs)
"cron": {
"enabled": false,
"job_tools": ["get_datetime", "time_until", "system_status", "web_search",
"read_page", "list_files", "read_file", "write_file",
"edit_file", "recall_facts"]
}Ask for one in plain words: "every weekday at 8, check disk and VRAM
and note anything low in ~/luna-jobs/disk.md". She calls
schedule_job, and you approve it in the popup before it's saved.
This is the step where you hand her permission to act later, so it's
the step that asks. list_jobs and cancel_job cover the rest. All
three sit in the Jobs group of the tools pane, and they're only
offered while /cron is on.
Jobs live in agent/cron.json (gitignored, since it's yours) and can
be edited by hand:
[{"name": "disk-check", "schedule": "0 8 * * 1-5",
"task": "Check free disk and VRAM; note anything under 10% in ~/luna-jobs/disk.md",
"speak": false}]| Schedule | Means |
|---|---|
0 8 * * 1-5 |
cron line: weekdays at 08:00 |
*/15 * * * * |
every quarter hour |
every 30m / every 2h / every 1d
|
an interval, 5 minutes minimum, counted on your local clock (every 1d = local midnight, every 2h = 00:00, 02:00, ...) |
2026-10-07 08:00 |
once, then removed |
-
speakdefaults totrue. A job reads its report aloud, so set"speak": falseon anything that fires at night. - Day-of-month and weekday are ANDed, not cron's OR.
-
Missed runs are skipped, not caught up. A 08:00 disk check is no
use at 14:00. A one-off whose time passed while the app was closed
shows in
/cronas skipped and stays in the file until you delete it. -
A broken
agent/cron.jsonstops/cron onwith the parse error rather than starting with no jobs, so a stray comma doesn't look like everything got cancelled. A single bad entry is skipped, shown in/cron, and kept in the file when she saves.
A job firing is a turn of its own, not one of yours:
- Fresh context, nothing stored. It doesn't see your history, and its tool output (web pages, file contents) never lands in history, where it would be replayed into your private turns.
-
Its own tool list:
job_tools, enforced the same way as chat's. A job can never callschedule_job, so jobs don't breed jobs. - It waits its turn. If you're mid-conversation it queues behind you, and a slow job isn't fired again on top of itself.
-
It's echoed as
Job: disk-checkin the conversation and in the logbook./cronshows the last result of each.
A job that can't write is half a job, and a popup at 3am is a no. So
there's one carve-out, and it lives in files, not in the plugin:
"files": { "enabled": true, "approval_timeout": 120,
"job_write_dirs": ["~/luna-jobs"] }Inside those folders, a job's write_file / edit_file goes through
without the popup and is logged as job_write_dirs, not asked.
Everything else is unchanged:
- Anywhere else asks. Away from the keyboard that times out as a no, and her report says what she would have written.
-
The deny list still wins.
.ssh,.env, keys and the rest are refused before this check is ever reached. -
~itself is ignored, since that would be every file you own, and so is anything overlapping this app's folder. A job that can quietly rewrite her own code can switch this check off. (That's the exact hole CR0N had.) -
Only job turns get it. The flag is thread-local and set only by
assistant.respond_to_job, under the turn lock. A reminder firing mid-job, or anything you type, still asks. -
Symlinks don't escape. Paths are resolved first, so
~/luna-jobs/up -> ~writes to~and asks.
read_page is in the default job_tools, so a page a job reads can
try to steer what it writes. The worst it can do unasked is write
inside ~/luna-jobs. Drop read_page from the list if that's still
too much.
A plugin is a file in a folder, and a permission a plugin can grant itself is not a permission. The plugin only labels its turns as jobs. Which folders that label unlocks is decided by you, in core config.
/discord on /discord off /discord (status)
The second layer is CR0N's best idea: a private Discord channel (or a
DM) as a remote control. From your phone you get the same Luna as at
the desk. It's a normal private turn with your history and the full
tool list, and it waits its turn instead of cutting off whatever is
happening at the keyboard. Your message shows in the TUI as yours,
tagged [discord].
pip install discord.pyCredentials go outside the repo:
In the Developer Portal:
New Application → Bot → Reset Token (that's token), and switch on
Message Content Intent on the same page. Invite it under OAuth2 →
URL Generator with the bot scope and Send Messages / Read Message
History. For owner_ids: Discord Settings → Advanced → Developer
Mode, then right-click your name → Copy User ID. DISCORD_TOKEN in
the environment works instead of the file. With no token, or no owner
ids, /plugins shows it as [--] and says which is missing.
"discord": {
"enabled": false,
"channel": "",
"speak": false,
"remember": true,
"tools": null
}| Key | |
|---|---|
channel |
name or id of the one server channel she listens in. "" = DMs only |
speak |
also read replies aloud at the desk. Off: whoever's in the room didn't ask |
remember |
true = part of your normal history, like a typed turn |
tools |
null = every tool. A list narrows it. Don't delete the key: missing, it falls back to the stream-chat list |
Every tool includes clipboard and look_at_screen, which means
"what's on my clipboard?" from your phone sends it to Discord. That's
the point of remote access, but if a password manager copies through
your clipboard, give tools a list without those two.
In Discord, !stop cuts off the reply in progress (CR0N's !kill)
and !status shows what /discord does. Long replies are split
under Discord's 2000-character limit. /discord also tells you why
it's quiet: a rejected token, Message Content Intent switched off, or
a non-owner it's been ignoring.
When a Discord turn wants to write a file or run a command, the
plugin's approval setting decides who says yes:
| Mode | What happens |
|---|---|
discord (default) |
She asks in the same chat: the command or the file change, then y / n (or tap ✅ / ❌). Only owner replies count. No answer in approval_timeout seconds (300) is a no |
auto |
Nobody is asked: it goes straight through. For automation |
desk |
The popup on the desk screen, as if you'd asked at the keyboard. From your phone that times out as a no |
/discord approval auto|discord|desk switch it (saved)
/discord shows the current mode
"discord": { ..., "approval": "discord", "approval_timeout": 300 }Every command a Discord turn runs is also shown at the desk as
discord ran: ..., and every approval, denial and timeout goes in the
log.
On auto, security is the owner's to assume: whoever holds the bot
token, or an owner's Discord account, has a shell on this machine, and
so does a web page she reads during a Discord turn if it talks her into
running something. These guards stay on in every mode:
| Guard | Why |
|---|---|
Only owner_ids are obeyed; everyone else is ignored and logged |
Without it, anyone who can type in the channel has remote access |
| The Permissions switches in the tools pane | Execute off takes commands away from Discord too; read and write off the same |
The file deny list (.ssh, .gnupg, .env, ~/.config/ai-voice, keys, shell startup files) |
read_file and write_file refuse them, including the bot token. A command isn't bound by it |
File writes to Luna's own folder, ~/.config, ~/.local always ask, even on auto
|
A write there is code that runs later |
The approver is attached to the Discord turn's own thread by
assistant.respond_remote, so a reminder or a job firing at the same
time still follows its own rules. files.auto_approve_sources and
shell.auto_approve_sources still exist for other remote sources, but
Discord doesn't need them any more.
| From CR0N | Here |
|---|---|
| Scheduled autonomous tasks, 👍 before scheduling |
schedule_job + popup approval |
| Reminders vs tasks ("who holds the verb?") |
set_reminder pings you; schedule_job is work she does |
| Every action logged | logbook, plus the Job: line on screen |
| Turn budget and stuck-loop detection | not yet. Jobs run under the normal tool-round limit |
!kill mid-run |
!stop in Discord |
| Helper delegation to other PCs | not planned |
| Remote access over Discord, no approval |
plugins/discord.py: approval auto, or ask in the chat, or ask at the desk |
bash with regex auto-approve |
run_command: asks at the desk or in Discord, or runs straight through on approval: auto (no regex) |
pass password store |
no |
| Write-guard on its own code by path string | no. Paths are resolved, and the app folder is excluded outright |
Both plugins are tracked in git like every other plugin. Neither holds
anything personal: the token lives in ~/.config/ai-voice/, and your
jobs in the gitignored agent/cron.json.