Build on VyreDraft
Build your first module
alex runs Northwind Bakery and wants juno to keep a log of every batch that comes out of the
oven. In this tutorial you build that as a Vyre module called bake. It has two tools,
bake.log and bake.today, one event, bake.logged, and one setting, bake.unit. You load it,
call it from the terminal, test it with node:test, and switch it off.
It takes about 15 minutes. You need Vyre installed and Node 22.5 or later. This page is the
tutorial; Writing a module is the short reference, and the
module contract lists every manifest key and everything on ctx.
1. Work in a throwaway home
Build against a temporary Vyre home, so nothing you try touches your real ~/.vyre. Export
VYRE_HOME and every vyre command in this shell uses it:
$ export VYRE_HOME=$(mktemp -d)
$ mkdir -p "$VYRE_HOME/modules/bake"
$ cd "$VYRE_HOME/modules/bake"
A temporary home never raises a Touch ID or keychain dialog. When the module works, step 10 puts it in your real home.
This tutorial writes every file by hand, so you see what each one does. For your next module,
vyre module new <name> writes a smaller starting point in <home>/modules/<name>/: a
module.json with one tool and one event, an index.js, a test and a README, all of which pass
vyre module check and node --test as they are.
2. Pick a name
The name in module.json is the module's name, and the loader holds everything to it:
- Every tool starts with the name and a dot:
bake.log, neverlogorbakery.log. - Every table starts with the name and an underscore:
bake_batches. A dash in the name becomes an underscore. - Every setting key starts with the name and a dot:
bake.unit.
That is why this module is named bake and not bake-log: a module named bake-log would have
to call its tool bake-log.log. The name is lowercase letters, digits and dashes, 2 to 41
characters, starting with a letter.
3. Write module.json
Save this as module.json in the folder:
{
"name": "bake",
"version": "0.1.0",
"apiVersion": 1,
"description": "A log of every batch Northwind Bakery bakes.",
"roles": ["box", "local"],
"does": { "tools": ["bake.log", "bake.today"] },
"watches": { "emits": ["bake.logged"] },
"settings": [
{
"key": "bake.unit",
"label": "Unit",
"help": "What a batch is counted in.",
"type": "enum",
"enum": ["loaves", "trays", "dozens"],
"default": "loaves",
"levels": ["account"],
"apply": "live"
}
]
}
does.toolslists every tool the module registers. Registering one that is not listed fails the module.watches.emitslists every event it emits. Emitting one that is not listed throws.rolessays where it runs: on the box, on the Mac, or both. Both is the default.settingsdeclaresbake.unitin the settings registry's shape.
Note
Coming in phase 1
Today the loader accepts
apiVersionandsettingsbut does not act on them. From phase 1 of ADR 0033, the loader checksapiVersionand refuses a module that asks for a newer module API, and each setting gets a row in the Vyre app's Settings. Declaring both now means the module needs no change when that lands.
Also save a package.json beside it, so Node reads index.js as an ES module on every version
Vyre supports:
{ "type": "module", "private": true }
Check the manifest against the schema
The schema is packages/module-sdk/manifest.schema.json in the Vyre repository, and
checkManifest() in packages/module-sdk/manifest.js checks a manifest against it with no
dependencies. From a checkout of the repository at <vyre>:
$ node --input-type=module -e '
import fs from "node:fs";
import { checkManifest } from "<vyre>/packages/module-sdk/manifest.js";
const problems = checkManifest(JSON.parse(fs.readFileSync("module.json", "utf8")));
console.log(problems.length ? problems.join("\n") : "module.json is valid");'
You should see
module.json is valid
A problem prints as one line, such as tool "log" must start with "bake.".
vyre module check runs the same check from the CLI, together with the loader's own rules and a
look at the entry file. Step 4 ends with it, once index.js is there.
4. Write index.js
Save this as index.js:
// @ts-check
// bake: a log of every batch Northwind Bakery bakes.
const DAY = 24 * 60 * 60 * 1000;
/** @type {import("@vyre/module-sdk").Module} */
export default {
async start(ctx) {
ctx.store.migrate([
"CREATE TABLE bake_batches (id INTEGER PRIMARY KEY, item TEXT NOT NULL, count INTEGER NOT NULL, unit TEXT NOT NULL, at INTEGER NOT NULL)",
]);
// The unit comes from the bake.unit setting once ctx.settings exists (ADR 0033 phase 1).
const unit = async () => (ctx.api?.has("settings") ? await ctx.settings.get("bake.unit") : "loaves");
ctx.tool("bake.log", {
description: "Log one batch that came out of the oven: what it was and how many.",
input: {
type: "object",
required: ["item", "count"],
properties: { item: { type: "string" }, count: { type: "integer" } },
},
run: async ({ item, count }) => {
if (count < 1) throw Object.assign(new Error("count must be at least 1"), { code: "bad_count" });
const u = await unit();
const r = ctx.store.db
.prepare("INSERT INTO bake_batches (item, count, unit, at) VALUES (?, ?, ?, ?)")
.run(item, count, u, Date.now());
const id = Number(r.lastInsertRowid);
ctx.events.emit("bake.logged", { id, count });
return { id, item, count, unit: u };
},
});
ctx.tool("bake.today", {
description: "Every batch logged in the last 24 hours, newest first, with a total.",
input: { type: "object", properties: {} },
run: async () => {
const batches = ctx.store.db
.prepare("SELECT id, item, count, unit, at FROM bake_batches WHERE at > ? ORDER BY at DESC, id DESC")
.all(Date.now() - DAY);
const total = batches.reduce((n, b) => n + Number(b.count), 0);
return { batches, total };
},
});
ctx.log("ready");
return { async stop() {} };
},
};
What each part does:
ctx.store.migrateruns each step once and records it. Never edit a step after you ship it; add a new one to the end of the list.ctx.store.dbis thenode:sqliteconnection tovyre.db. Write only your own tables.ctx.toolregisters a tool. vyred checks the input againstinputbeforerunsees it, so a missingcountnever reaches your code. The checker does not readminimum, so the tool checkscount < 1itself.- A thrown error with a
codeof lowercase letters and underscores reaches the caller as that code:bad_count. ctx.events.emitwritesbake.loggedto the event log. The payload carries the id and the count, not the item alex typed, because every module and the Vyre app can read the log.ctx.logwrites a line to vyred's log, prefixed[bake].startreturns a handle whosestop()vyred calls on shutdown. This module holds no timers or sockets, so it has nothing to close.
The @type comment gives your editor the types in packages/module-sdk/index.d.ts when it can
find the package. Node ignores it.
Note
Coming in phase 1
ctx.apiandctx.settingsdo not exist yet, sounit()returnsloavestoday. From phase 1,ctx.api.has("settings")is true andctx.settings.get("bake.unit")returns alex's choice. The?.keeps the same file working on both.
Check the module
From the module folder:
$ vyre module check .
You should see
bake /tmp/tmp.alex/modules/bake
ok module.json reads
ok matches the module API schema
ok passes the loader's rules
ok index.js is there
ok index.js parses
next: vyre down && vyre up loads it
It reads module.json against the schema and the loader's rules, checks that the entry file
(main, or index.js) is in the folder, and runs node --check on it, so a syntax error shows
here with its line instead of as a failed module after a restart. It exits 0 when every check
passes and 1 when one fails. --json prints { ok, module, dir, problems } on one line.
5. Start vyred and see the module
Start vyred in the throwaway home. --json keeps vyre up on a Mac from asking where your box
runs:
$ vyre up --json
$ vyre modules
vyre modules lists every module and its state. Find the bake line:
You should see
bake 0.1.0 running
If it says invalid or failed, the reason follows on the same line: a manifest problem, or
the error start threw. Fix it and restart (step 7).
Surfaces read the same list over HTTP, GET /v1/modules on vyred's socket:
$ curl -s --unix-socket "$VYRE_HOME/vyred.sock" http://vyre/v1/modules
The bake entry is {"name":"bake","version":"0.1.0","state":"running"}. When the home's path
is long, the socket lives in a shared folder instead of the home; vyre modules finds it either
way.
vyre tools shows the two tools and their descriptions, which is what Claude reads:
You should see
bake.log Log one batch that came out of the oven: what it was and how many.
bake.today Every batch logged in the last 24 hours, newest first, with a total.
6. Call it
$ vyre call bake.log '{"item":"sourdough","count":24}'
You should see
{
"id": 1,
"item": "sourdough",
"count": 24,
"unit": "loaves"
}
$ vyre call bake.today
You should see
{
"batches": [
{
"id": 1,
"item": "sourdough",
"count": 24,
"unit": "loaves",
"at": 1790517976477
}
],
"total": 24
}
A bad call prints the error code and exits non-zero:
$ vyre call bake.log '{"item":"rye"}'
You should see
bad_input: input.count is required
next: vyre tools shows what bake.log takes
{"item":"rye","count":0} prints bad_count: count must be at least 1.
The event is in the log. Read it over the socket:
$ curl -s --unix-socket "$VYRE_HOME/vyred.sock" 'http://vyre/v1/events?type=bake.logged'
Each event carries type, source (bake), at and your payload, {"id":1,"count":24}.
The same tool is also an MCP tool in Claude Code, mcp__vyre__bake_log, and an HTTP route,
POST /v1/tools/bake.log. You wrote it once. See
one tool, three surfaces.
7. Change it and restart
vyred loads modules when it starts. After any change to module.json or index.js, restart:
$ vyre down && vyre up --json
$ vyre modules
If this happens
vyre modules says vyred is not running
Right after a restart, vyred can still be shutting down when
vyre upruns. Runvyre up --jsonagain.
8. Add a test
A test does not need a running vyred. It can call start with a stand-in ctx that holds only
what the module uses, backed by a real SQLite database in memory. Save this as
test/bake.test.js:
import { test } from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import bake from "../index.js";
// A stand-in for the ctx vyred hands start(): a real SQLite database in memory,
// and tools and events kept in plain collections so the test can look at them.
function fakeCtx() {
const db = new DatabaseSync(":memory:");
const tools = new Map();
const emitted = [];
return {
tools, emitted,
ctx: {
name: "bake",
log: () => {},
store: { db, migrate: steps => steps.forEach(sql => db.exec(sql)) },
tool: (name, def) => tools.set(name, def),
events: { emit: (type, payload) => emitted.push({ type, payload }) },
},
};
}
test("bake.log saves a batch and bake.today adds it up", async () => {
const { ctx, tools, emitted } = fakeCtx();
const handle = await bake.start(ctx);
const logged = await tools.get("bake.log").run({ item: "sourdough", count: 24 });
assert.deepEqual(logged, { id: 1, item: "sourdough", count: 24, unit: "loaves" });
await tools.get("bake.log").run({ item: "rye", count: 12 });
const today = await tools.get("bake.today").run({});
assert.equal(today.total, 36);
assert.deepEqual(today.batches.map(b => b.item), ["rye", "sourdough"]);
// The event carries the id and the count, never what alex typed.
assert.deepEqual(emitted[0], { type: "bake.logged", payload: { id: 1, count: 24 } });
await handle.stop();
});
test("bake.log refuses a batch of zero", async () => {
const { ctx, tools } = fakeCtx();
await bake.start(ctx);
await assert.rejects(tools.get("bake.log").run({ item: "rye", count: 0 }), { code: "bad_count" });
});
Run it from the module folder:
$ node --test
You should see
✔ bake.log saves a batch and bake.today adds it up
✔ bake.log refuses a batch of zero
ℹ tests 2
ℹ pass 2
ℹ fail 0
The stand-in skips what vyred adds around run: the input schema check, the manifest checks and
the table prefix check. Steps 5 and 6 cover those against a real vyred. Inside the Vyre
repository, tests start a real vyred in a temp home instead; see
Writing a module.
Note
Coming in phase 3
@vyre/module-sdk/testinggives a fake registry and a temp home, so a module's own tests run the real checks without a daemon. Not built yet. Until then, keep a stand-inctxlike the one above.
9. Switch it off, and on
Switch a module off with modules.disable in the home's config.json. In the throwaway home
there is no config.json yet, so write one, then restart:
$ vyre down
$ echo '{ "modules": { "disable": ["bake"] } }' > "$VYRE_HOME/config.json"
$ vyre up --json
$ vyre modules
You should see
bake 0.1.0 off
Its tools are gone: vyre call bake.today prints no_such_tool: no tool bake.today. Its table
and events stay in vyre.db, so switching it back on loses nothing. Take bake out of the list
and restart to switch it on.
In a real home, config.json holds other settings: add "disable": ["bake"] inside its
modules object rather than replacing the file. modules.enable does the opposite for roles: it
starts a module on a machine its roles leave out.
Note
Coming in phase 1
vyre module disable <name>andvyre module enable <name>write the same list for you, and a module that requires a switched-off one shows "off: needs bake" instead of failing. Not built yet.
When you are done with the throwaway home:
$ vyre down
$ unset VYRE_HOME
10. Install it for real
With VYRE_HOME unset, add the folder to your own home:
$ vyre module add <path-to>/bake
vyre module add checks the folder the way vyre module check does and refuses it on any
problem. It shows what the module asks for (its tools, its events, the vault items, hosts and
credentials it names) and asks you to confirm. It then copies the folder into
~/.vyre/modules/bake/ (without a .git folder), restarts vyred and says whether bake is
running. Pass --yes where there is no terminal to ask on. It also takes a git URL
(https://, git@ or file://), cloned with --depth 1 into a folder inside your home first.
A module runs inside vyred with Vyre's own access to your machine, so add only code you trust.
vyre module add refuses a name that is already in ~/.vyre/modules/ or is one of Vyre's own
modules. Replacing one of Vyre's own needs "replaces" set to that name in module.json and
--yes on the command line: your explicit consent. In the box's container, vyred restarts from
the host (docker compose restart vyre), and vyre module add says so.
juno can now call bake.log when alex says "log 24 sourdough", and bake.today when alex asks
what came out of the oven today.
Note
Coming in phase 3
vyre module newwrites its files from the CLI today. In phase 3 it scaffolds fromtemplates/module/, which also carries ajsconfig.jsonfor autocomplete, and its test uses@vyre/module-sdk/testing. Not built yet.
What a module will not do
- It never imports another module's files.
ctx.callis the only way one module uses another. - It never writes another module's tables. Reads may join any table.
- It never puts secrets or what a person typed in an event payload.
- A module named like one of Vyre's own is marked
invalidand ignored. Replacing a first-party module comes withreplacesin phase 1 of ADR 0033.
Where to go next
- Writing a module: the short reference and the rules the loader enforces.
- Module contract: every manifest key and everything on ctx.
- Tools and events: calling other modules and listening for events.
- ADR 0033: what the module API adds, phase by phase.