Modules and add-ons¶
Everything in Starfarer is a module: the core pipeline, future built-ins like the Holodeck, and add-ons other people write. A module declares its parts, and Sensors draws exactly what's declared. Nothing about the graph is hard-coded, so a new module appears on the Bridge by itself, wired where it belongs.
What a module declares¶
from starfarer.core.modules import Module, Node
MODULE = Module(
id="voice", name="Voice", version="0.1",
description="Speaks her replies on room speakers",
nodes=[
Node("tts", "Voice", "voice/tts.py · speak", ins=["words"], outs=["audio"], short="Voice"),
],
wires=[("reply", "to device", "tts", "words", "event")],
)
- Node: one part, with its id, title, the script and function behind it, inputs and outputs.
shortis the name on the small Bridge panel (about 8 letters).lane="support"is for things that feed the path rather than sit on it, like catalogs, model files and servers. - Wires:
(from, port, to, port, kind). Kind isdata(blue),model(purple) orevent(yellow). A bare id means your own node if you have one by that name, otherwise the core's (reply,prompt,generate…).other.idnames another module's node. - Ids are namespaced: your node
ttsbecomesvoice.tts, so an add-on can never pose as a core part. - Placement is automatic: main-lane nodes go in columns by how far along the wires they are, and support
nodes go just left of what they feed.
Node(..., after="reply")places a node that has no wires yet.
Marking steps: one line¶
@MODULE.sensor("tts")
async def speak(text):
...
MODULE.note(f"{secs:.1f}s of audio") # what it did: never anyone's words
The function becomes that node's step in whatever message is running. It's timed, an error turns the node red
with the message, and it lights up on the graph. MODULE.note(summary, detail) says what happened. summary is
seen by the Captain and First Officer, so it must never contain anyone's words. detail can hold the words, and
only the person who triggered the message sees it. The context-manager form is
with MODULE.step("tts") as s: ....
If your code reacts to an event instead of being called during the message (for example after chat.done), join
that message's trace. The event carries its id:
with hub.sensors.attach(event["data"]["trace"]):
await speak(event["data"]["text"])
Live parts¶
A provider adds parts that depend on the live state. The core uses one for a node per connected device, a node per model brain, and a row per persona.
async def live(ctx): # ctx: comps (health), installed, loaded, full (admin viewer?), viewer, router, sensors
return {"rows": {"voice.tts": [{"label": "speaker", "value": "kitchen"}]},
"nodes": [...], "wires": [...]}
MODULE.provider = live
A provider that fails doesn't break the map. Its nodes show red with the reason.
Setup¶
setup(hub) runs once when the hub starts. hub has bus, sensors, router, data, settings, app and
module. An async def setup runs inside the hub's event loop, so start background tasks there. It also has:
hub.caller(): who asked for the tool (or deck button) that's running, and from where:{"who": {"id", "name", "rank", "cadet", "family_safe", "guest"}, "node", "spoken", "page"}, orNoneoutside one."page": the Bridge page (browser tab) they asked from, if they did ("" otherwise). From an away mission (Ops) it's"node": "ops"with"mission"(its id), no page, andwho.adultis always false: put nothing on a screen unless the tool's inputs name one, and a tool that changes something has already had the person's OK and PIN. Who may use a tool is Engineering → Crew → Clearance: the ranks the Captain ticked for it, plus anyone the Captain or First Officer gave it to by name. A Module never decides that itself: a new Module's tools start with the Captain and the First Officer. Itswho="guests"only says a Guest can be given the tool.caller["style"]: how the AI they're talking with talks with them right now ({"persona", "language": family | pg13 | mature | unfiltered, "roleplay": off | light | full}), so what a tool tells the AI can fit it (Video tells it adult-site titles only at unfiltered, and asks for an in-character reaction at light or full).hub.search(query, caller, n, kind="web" | "videos", adult=False): Starbase's own web or video search ([{title, url, body}]; videos:[{title, url, by, duration, published, embed, thumb}],embedbeing the video's own player when the search engine knows it). Strict safe search for Cadets, guests and anyone family-safe; off (adult=True) only for someone allowed adult sites.caller["who"]["adult"]says whether they are (the Captain or First Officer sets it in Engineering → Crew).hub.screens(hub/screens.py):where(caller, name="")gives the screens for whoever asked (a Shuttle in the room they spoke in, one they named, or their own Bridge if they typed there: the page they asked from, so a video asked for on their phone plays on the phone, not on every browser they have open;hub.screens.bridge(who)in a tool or deck button is the page asking too).play(target, card)shows a card or plays a video there:{"kind": "video", "title", "source": your module's id}with one of"youtube": id("signed_out"),"vimeo": id("h"for an unlisted one),"url"(a video file) or"embed"(another site's own player: shown in a sandbox, stopped but not paused). An"embed"with{host}in it gets the screen's own address there (a player that must be told where it's shown, like Twitch'sparent), and plays only on a screen whose address has no port (Twitch's rule); elsewhere the screen says so, with a link to"page"(the video on its site, named by"site") on a Bridge (Satellite 0.3.21 or later on a Shuttle).control(target, action, value)takes pause, play, stop, louder, quieter, volume, forward or back.state(target)andplaying(caller, source=None)say what's on, as the screen itself reported it.private(target, caller): is it only theirs (their Bridge, their own Shuttle)? A video is turned down by itself while a Satellite listens and while the AI talks.hub.setting(key, who=None)andhub.set_setting(key, value, who=None): the module's own settings (below).hub.viewscreen.show(caller, target, view, on_pick)(hub/viewscreen.py): results to pick from, on the screen where they asked:{"layout": "grid", "title", "subtitle", "items": [{"id", "title", "sub", "badge", "thumb"}], "actions": [{"id", "label"}], "options": {"columns": 4, "aspect": "16:9"}, "private": False}. The module chooses the layout and its options: "grid" (tiles with pictures) or "list" (rows of text: title, sub, badge; Satellite 0.3.22 or later on a Shuttle). Thumbnails are https images Starbase fetches. Their pick (a click, or "3", "the second one", title words, "next", "search on X") comes toon_pick(caller, pick), which returns what the AI is told.hub.videos.provide(MODULE, search=..., play=..., sites=...)(hub/video.py): a video module's results go into Starbase's one video search (its Video tools: find, control, what's playing).search(query, caller, site, n)returns[{"id", "title", "by", "length", "thumb", "site", "adult", "data"}];play(caller, target, item)plays the one picked (withhub.screens.play) and returns what the AI is told. Optional:why=fn(caller)(why its last search found nothing that can play),shown=fn(caller, items, target)(a page of its results went up on a Viewscreen: Web Video saves them). An item with"saved": Trueis marked Saved on its tile. A video file a module keeps on Starbase plays on the Bridge from its own address (/api/modules/<id>/...).hub.instant(fn, label=""): quick commands that never wait on a model, for whatever your module has up right now (a game's "A" or "B", a player's "pause").fn(who, text, caller)returns(what happened, what the AI is told)when the whole message is its command and its thing is up, else None (it may be async). Starfarer itself has none: the Viewscreen's grid and the video player register theirs only while something is up.- The decision model (always on; Engineering → Starbase → How the AI decides picks its model) picks a tool in one call that reads
each tool's first sentence and its inputs (
argsdescriptions), and what itshintsays is going on: write them for that reader. It gives the inputs with the decision, sofillonly covers what it left out. hub.person(request)/hub.device(request): who's signed in, or the paired device asking, in a route a module adds tohub.app(None if neither).hub.bussendsme.history_cleared({"uid"}) when someone clears their history: what a module keeps of theirs should go too.
Tools, a deck and settings¶
@MODULE.tool(...)gives the AI a tool (see core/modules.py). Its result text is what the AI is told happened. To say how the AI should talk about it, returnAnswer(text, voice="…")(fromstarfarer.core.modules):voicegoes last, after Starbase's own rules for tool answers, nearest the AI's reply ("They can see them, so don't read the list out. React first, in character…"). The text stays what's real; the voice is how to say it. Optional: a plain str works as before.hint=lambda caller: "a video is playing on the Den screen"(or"") says what's going on right now: then a one-word command ("pause") reaches the tool, and the AI picking tools sees it. Keep it quick (no network).MODULE.deck = async def deck(caller) -> {"sections": [...]}gives the module its own page on the Holodeck (its tile there opens it). See Showing what your app does below for everything a page can have.MODULE.actions = {"name": async def (caller, args) -> str}: what the buttons and forms run, as the person who pressed them. The text it returns is shown to them.MODULE.settings = [{"key", "label", "type": "text" | "number" | "bool" | "choice", "choices", "default", "help", "secret", "per_person"}]: set by the Captain in Engineering → Modules → Settings. A secret is never sent back to the Bridge. A per-person setting has a value for each person aboard.MODULE.frames = ["https://www.youtube.com"]andMODULE.media = ["https://*.example.org"]: web players and video files the module shows on the Bridge, allowed in its pages. The module may change these lists while it runs.@MODULE.tool(..., private=True): what the tool was asked isn't written to the audit log.@MODULE.tool(..., fill=fn, ask={...}):fill(their words, caller) -> {input: value}reads inputs straight from what they said, for any the AI's brain left out (small models sometimes leave everything out); quick, no network.ask={"what": "What should I look for?"}: what to ask them when an input is still missing.@MODULE.tool(..., said=r"..."): a regular expression for what can only be meant for this tool ("did we ever talk about…"): a fixed shortcut, used ahead of the AI's brain and the decision model, with its inputs fromfill. Keep it narrow: anything it matches goes to the tool.@MODULE.tool(..., words_win=("unread",)): inputs where whatfillread from their own words beats the AI's guess (a yes/no the AI tends to fill in on its own, like "unread only": it's only true when they said it).@MODULE.tool(..., words=["message"], words_only=True): matched to what they said bywordsalone, not by its name and description. For a tool reached by itssaidshortcut whose description is mostly examples ("tell Joe dinner's at 6" shouldn't bring it up for "tell me a story").- Settings types: text, lines (several lines), number, bool, choice, range (a slider:
"min","max","step","unit": "GB"; a value outside it is held to it). MODULE.requires = ["ffmpeg"]: other modules it needs. Spacedock installs them first (from a trusted catalog, the one made for this computer), turns them on with it, and won't remove or turn one off while it's needed.MODULE.api = obj: what a module offers others; another gets it withhub.need("<its id>")(None if it isn't on).
Showing what your app does¶
An app shows what it does on its Holodeck page, and puts it on the Viewscreen: the Bridge, and a Shuttle the
same way, and a Starship's corner window too. examples/addons/showcase uses every piece below.
Pictures, video and sound. Share a file you made and use its link:
m = hub.media.share(path, who=caller) # theirs only (the Captain is no exception); everyone=True: the crew
m["url"], m["kind"] # "/api/media/<token>", "image" | "video" | "audio"
Only PNG, JPEG, WebP, GIF, MP4, WebM, MP3, Ogg, WAV and M4A are shared (told by their first bytes; never SVG or HTML). The link is the same each time for the same file and person, so share again whenever the page is drawn. A Shuttle gets a file only when it's sent there (the Viewscreen does that).
An app's page. deck(caller) returns {"sections": [...], "live": seconds}. A section can have:
| Key | What it is |
|---|---|
title, text |
a heading, and plain text |
rows |
[{label, value}]: facts in two columns |
progress |
{value, label}: a bar, 0 to 100, or None while you can't say how far |
media |
[{url, title, sub}]: a gallery of pictures, video and sound (a picture opens big when clicked) |
items |
[{title, sub, media, buttons, toggle, range}]: a list, each with a picture, buttons, a switch {action, on, args} or a slider {action, value, min, max, step, unit, args} |
table |
{columns, rows}: up to 8 columns, 200 rows |
buttons |
[{action, label, args, confirm, primary}] |
form |
{action, submit, fields}; a field is {key, label, placeholder, value, type}, type text (the default), textarea (a big text box, rows), number, toggle, range (min, max, step, unit), image (a picture they send), or choices for a dropdown |
live redraws the page that often (2 to 60 seconds) while something's running; leave it out otherwise. A switch
sends on: "true"/"false", a slider value, a picture field its token: hub.media.path_of(token, caller) is the
file. Everything is text the Bridge escapes; nothing you send runs on the Bridge.
Slow work. An action should start a slow job (asyncio.create_task(...)) and return at once ("Drawing it
now."); the page shows progress with live until it's done.
The Viewscreen. Find where they are, then put it up:
targets = hub.screens.where(caller) # the Shuttle in the room they spoke in, or their own Bridge
await hub.viewscreen.show(caller, targets[0], {"layout": "media", "title": "…",
"items": [{"id": "1", "url": m["url"], "title": "Take 1"}]})
await hub.viewscreen.show(caller, targets[0], {"layout": "blocks", "title": "…", "sections": [...]})
media: one fills the screen, several are numbered (they can say the number, with on_pick). blocks: any of
your page's sections, to look at (its buttons and forms stay on the page). The other layouts (grid, list, mail,
letter) are in hub/viewscreen.py.
Publishing a module (Spacedock)¶
A publisher signs modules into a catalog, and Starbase installs them from it in Spacedock:
python -m starfarer spacedock keygen <key file> # once; keep it private, outside any repo
python -m starfarer spacedock pack <module folder> <catalog folder> --key <key file> --publisher "Your name"
python -m starfarer spacedock verify <catalog folder>
The folder's name is the module's id (it must match MODULE.id). pack zips it the same way every time (no
__pycache__ or .git), signs it with Ed25519, and updates <catalog folder>/catalog.json. Put the catalog folder
anywhere Starbase can read: a web address, or a folder on Starbase's machine.
A module with a program in it, one for each kind of computer, keeps them in platforms/<os>-<arch>/
(windows-x64, linux-x64, linux-arm64, macos-arm64) and is packed once for each with
--platform windows-x64: each zip has only that one's files, and Starbase installs the one for its computer
(Spacedock says when there's none). A package can be up to 160 MB.
How it looks in Spacedock¶
Spacedock shows every module as a tile: its icon, name, publisher, category and description, with Install. Clicking one opens a card with the rest. Give it a look of its own:
icon.pngin its folder (or.webp,.jpg,.gif; orMODULE.icon = "art/logo.png"): square, 256 px or more, up to 2 MB. Not SVG. With none, Spacedock draws one from its name.screenshots/in its folder (orMODULE.screenshots = [...]): up to 6 pictures, 16:10 looks best.MODULE.category("Video", "Home", "Tools"),MODULE.about(a few short paragraphs, plain text),MODULE.changes(what's new in this version),MODULE.homepageandMODULE.support(https only).
pack copies the pictures beside the zip and lists them, with what the module adds (its tools for the AI, a deck,
how many settings). None of that is signed: only the id, version and zip are. So Starbase treats it as something to
show, never to trust. It fetches the pictures itself, checks they're the ones listed and are real pictures, and the
browser never goes to the catalog's site. To change only how a module looks, without a new version or your key:
python -m starfarer spacedock art <module folder> <catalog folder>
In Engineering → Modules → Catalogs the Captain adds the catalog, checks the publisher's key fingerprint with them, and trusts
it. Only then do its modules install, and only if each is exactly what was signed. An install goes to <data>/addons/<id>/
and keeps the copy it replaces in <data>/addons/.spacedock/old/. A removed one is put aside in .../removed/,
not deleted. A module loads once it's turned on and Starbase restarts.
Where add-ons come from¶
- A folder:
<data>/addons/<name>/addon.pythat definesMODULE(Spacedock puts them there, or copy one by hand). - An installed package with an entry point in the group
starfarer.addons.
Found add-ons are listed in Engineering → Modules and stay off until the Captain turns them on (then restart). An add-on is code running inside the hub with the hub's rights, like a browser extension. Turn on only the ones you trust. Sensors keeps its privacy rules for add-on steps automatically, but it can't stop an add-on's own code from reading what the hub can read.
Nothing stays invisible¶
If a step runs for a part no module declared, Sensors still draws it, with a dashed "undeclared" node wired from whatever ran before it, so missing declarations are obvious.
See examples/addons/stopwatch/ for a complete working add-on.