Build on Vyre
Writing a Vyre module: the brief
You are writing a module for Vyre. Read this whole page before you write a line. Everything you may
use is here, in packages/module-sdk/index.d.ts, and in the complete example at
examples/modules/bakery in the Vyre repository.
What a module is
A module is a folder: module.json, an entry file (index.js) whose default export has
start(ctx), and anything else it ships. It talks to the rest of Vyre only through ctx.
The golden rules
- Only
ctx. Every way out of the module is actxmember: another module's tool (ctx.call), the network (ctx.fetch), a vendor API with a key (ctx.vault.request), memory, push, spend, the Gate. An added module runs in a sandboxed host where nothing else works. - Never import a Vyre file. No
../core/..., no path outside your folder. Import only your own files and safe Node built ins (node:crypto,node:path,node:url,node:fsinsidectx.paths.data). - No process, socket or thread of your own. Never import
node:child_process,node:net,node:http,node:https,node:dgram,node:worker_threadsornode:cluster, with or without thenode:prefix. - Every tool says who may call it. Each entry under
does.toolsis an object withname,summaryandreach. A tool that acts as the person outside (sends, posts, pays, deletes somewhere else) also saysoutward. - Never ask for presence. No
presenceon a tool, no Touch ID, no prompt of your own. Usereach: "asked"for anything that changes settings, deletes local data or reshapes an agent. Useoutwardfor anything that acts as the person outside; the Gate handles it. - Nothing polls faster than 60 seconds. Listen to events first. A timer you start, you clear
in
stop(). - Migrations are forward only.
ctx.store.migrate([...])takes an ordered list. Add a step to change a table; never edit or remove a step that has run. Tables start with your module's name and an underscore. - Each ctx door has one declaration.
ctx.callneeds the tool inneeds.tools,ctx.gate.requestneedsgate.requestthere,ctx.vault.requestaneeds.credentialsid,ctx.connections.callaneeds.connectionsprovider,ctx.fetchaneeds.networkhost,ctx.memory.writeits kind inteaches.memory,ctx.askandctx.spendaneeds.spendcap, andctx.push.offerits kind inshows.notices.ctx.undo.recordneeds nothing. A call without its declaration throws an error with codeundeclared. The install card is built from these declarations, so declare only what you use. - Plain words and the sample world. Summaries are one lowercase line. No em dash, no section
sign. Example people and businesses are only alex, Juniper Studio, Northwind Bakery, juno and
kit, and example hosts end in
.example.
The steps
- Make the folder.
vyre module new <name> --dir <parent>writesmodule.json,index.js,<name>.test.js,README.md,AGENTS.md(this brief) andjsconfig.json. It already passes every check. The name is lowercase letters, digits and dashes, 2 to 41 long. - Edit
module.json. Declare every tool (withreach, andoutwardwhere it applies), every event you emit, every event you listen to, every setting, and everything you need. - Write the tools in
index.js. Each one getsdescription, a JSON Schemainput, at least one entry inexamples, andrun(input, meta). - Write the tests in
<name>.test.jswith the SDK's harness (testModule), one per tool, plus the refusals: an outward tool held for an agent, an asked tool refused without an ask. - Check until clean. Run
vyre module check ., thenvyre module test .(conformance, then your tests). Fix every line it prints, and run both again. Do not stop at one failure. - Add it.
vyre module add <folder>shows the person an install card built from your manifest. Their tap turns it on.
Versions: what stays working
"vyre": "1"names the module contract you write for. Write"1.2"only when you use something added in contract 1.2; a Vyre that has only 1.0 then says so plainly and never runs your code.- Inside contract 1, Vyre only adds. Nothing you use is removed, renamed or narrowed, and a default never changes what your module does. A module written for 1.0 keeps working on every 1.x Vyre.
- A key Vyre doesn't know is ignored when it loads, and
vyre module checkshows it as a warning: a typo, or a key from a newer contract. Warnings never fail a check or a test. - Check for a newer ctx member with
ctx.api.has("<feature>")and work without it when you can.ctx.api.versionis the contract this Vyre speaks, like"1.0". - Something deprecated keeps working, with a warning, for at least two Vyre releases or six
months.
vyre module upgrade .rewrites what it safely can (for exampleapiVersionto"vyre", string tool entries to objects,ctx.memory.teachtoctx.memory.write), lists what it left for you, and runs the checks.--dry-runshows the changes and writes nothing. - When contract 2 arrives, a
"vyre": "1"module keeps running beside it for at least 12 months.
module.json cheat sheet
{
"$schema": "https://vyre.run/schema/module-1.json",
"name": "bakery",
"version": "0.1.0",
"vyre": "1",
"description": "Northwind Bakery's orders, the daily target and the flour order.",
"roles": ["box"],
"does": {
"tools": [
{ "name": "bakery.orders", "summary": "list today's orders" },
{ "name": "bakery.target", "summary": "change the daily target", "reach": "asked" },
{ "name": "bakery.flour", "summary": "order flour from the supplier", "outward": "pay" }
],
"commands": [{ "verb": "orders", "tool": "bakery.orders", "summary": "today's orders" }],
"watchers": ["watchers/big-order.json"]
},
"watches": { "emits": ["bakery.order-added"], "on": ["memory.written"] },
"shows": { "deck": ["now:bakery.today"], "notices": ["target-reached"] },
"settings": [{ "key": "bakery.target", "label": "Daily target", "type": "int", "default": 40,
"levels": ["account"], "apply": "live" }],
"needs": {
"credentials": [{ "id": "supplier", "kind": "api-credential", "provider": "flourco", "purpose": "place flour orders" }],
"network": ["api.flourco.example"],
"spend": { "dailyUsd": 0.5 }
},
"teaches": { "memory": ["note"] }
}
| Key | Rule |
|---|---|
name | ^[a-z][a-z0-9-]{1,40}$. It prefixes every tool, event, setting and table. |
vyre, description | Required. The contract you write for ("1"), and one plain sentence for the install card. |
roles | box (the server), local (a device), mac. Default ["box"]. windows alone loads nowhere yet. |
does.tools[] | { name, summary, reach?, outward?, cost? }. name is <module>.<verb>. |
reach | anyone (default): the person, agents, modules listing it. asked: an agent only when the person's own words asked. modules: Vyre's own modules only. hook: the webhook route only. Never person. |
outward | send, post, pay or delete, with reach anyone or asked. Runs for the person's tap; every other call is held at the Gate. |
cost | "paid" when the tool spends money through your own model or API use. |
does.commands[] | { verb, tool, summary, args? } gives vyre <module> <verb>. |
does.watchers[] | Relative .json preset files. A preset only describes; it runs no code. |
watches.emits | Every event type you emit, <module>.<past-verb>. |
watches.on | Every pattern you subscribe to: a type, noun.* or *. |
shows.deck | Slots: now:<tool>, renderer:<tool>, slash:<name>, settings, view:<name>, panel:<name>. |
settings[] | { key: "<module>.<key>", label, type, default, levels, apply }. |
needs.tools | Every tool you call with ctx.call, named one by one (no module.*), and gate.request if you use ctx.gate.request. You reach only tools whose own manifest declares reach anyone or asked; any other answers not_declared. |
needs.credentials | Vault items by id, kind, provider, purpose. Used only through ctx.vault.request. |
needs.network | Public hosts ctx.fetch may read from without a key. |
needs.connections | { provider, purpose } for vendor MCP connections. |
needs.spend | { dailyUsd }, your daily cap. Needed for ctx.ask and ctx.spend. |
teaches.memory | The kinds ctx.memory.write writes: fact, note or both. |
shows.notices | The notice kinds ctx.push.offer raises. |
| Built in only | does.providers, shows.streams, needs.vault, replaces. An added module can't use them in 0.2. |
ctx cheat sheet
| Member | Use |
|---|---|
ctx.name, ctx.version | Your module's name and version. |
ctx.api.version, ctx.api.has(feature) | 1, and whether an addition inside API 1 is there. |
ctx.log.info/warn/error/debug(msg, extra?) | Logs prefixed with your name. |
ctx.tool(name, { description, input, output?, examples, run }) | Register a declared tool. run(input, meta) returns JSON or throws an Error with a short lowercase code. |
meta | { caller, who, agent?, thread?, project?, asked?, gate? }, set by Vyre, never from input. gate is person, asked or approved on an outward tool. |
ctx.call(tool, input) | Another module's tool. Resolves { data } or { error: { code, message } }. Listed in needs.tools. |
ctx.events.emit(type, payload) | A declared event. Every module can read it: carry ids, not what a person typed. |
ctx.events.on(pattern, fn) | Subscribe; returns the unsubscribe you call in stop(). |
ctx.events.since(id, opts), .latestId() | Read the log from a cursor. |
ctx.store.db, ctx.store.migrate(steps) | Your own SQLite (node:sqlite), forward-only migrations. |
ctx.paths.data | Your own folder, the only place you may write files. |
ctx.settings.get(key), .set(key, value), .on(key, fn) | Your declared settings. |
ctx.vault.request(id, { method, url, headers?, query?, body? }) | A vendor API call with a key you never see. Writes hold at the Gate unless the person asked. |
ctx.connections.call(provider, tool, input) | A tool on a vendor MCP connection you declared. |
ctx.fetch(url, init?) | A GET or HEAD, with no body, to a needs.network host. Anything else throws method_not_allowed. Private addresses are refused. |
ctx.gate.request({ kind, via, to, content, why }) | Propose an outward act through an existing sender. Answers { held } or { sent }. |
ctx.memory.write({ kind, project?, text, subject?, source_ref? }) | A memory row. Set source_ref so a retry is one row. |
ctx.ask(prompt, { purpose, maxUsd? }) | A one-shot model read. { text, usd } or { error: { code: "capped" } }. |
ctx.spend.record({ usd, purpose }), .check(purpose) | The cost of your own paid API use. |
ctx.push.offer({ title, body, url?, kind }) | Ask to notify the person. sent or deferred. |
ctx.undo.record({ tool, input, inverse }) | The inverse of what you just did, so it can be undone. |
ctx.modules.status() | Read-only rows of every module. |
Anything in index.d.ts tagged @internal is not for you: ctx.config, ctx.route,
ctx.upgrade, ctx.provider, ctx.vault.fetch and the rest.
Worked example: Northwind Bakery
A tool anyone may call, with examples and a coded refusal:
ctx.tool("bakery.add", {
description: "Record an order: who it is for and how many items",
input: { type: "object", required: ["customer", "items"], additionalProperties: false,
properties: { customer: { type: "string", minLength: 1 }, items: { type: "integer", minimum: 1 } } },
examples: [{ input: { customer: "Juniper Studio", items: 24 } }],
run: async ({ customer, items }) => {
const id = Number(insert.run(today(), customer, items, Date.now()).lastInsertRowid);
ctx.events.emit("bakery.order-added", { id, items, big: items >= 20 });
if (items >= 20) await ctx.memory.write({ kind: "note", text: `${customer} ordered ${items} items.`, source_ref: `bakery:order:${id}` });
return { id };
},
});
An outward tool. Vyre only runs it after the person's own words asked for it or they approved it. A tap on a button your module drew never runs it: Vyre shows its own Gate card instead. The one supplier write inside the cleared run goes through without a second card:
ctx.tool("bakery.flour", {
description: "Order flour from the supplier, in kilograms",
input: { type: "object", required: ["kg"], properties: { kg: { type: "integer", minimum: 1, maximum: 500 } } },
examples: [{ input: { kg: 25 } }],
run: async ({ kg }, meta) => {
const r = await ctx.vault.request("supplier", { method: "POST", url: "https://api.flourco.example/orders", body: { kg } });
if ("held" in r) return { held: r.held };
if (r.status >= 400) throw Object.assign(new Error(`the supplier said ${r.status}`), { code: "supplier_refused" });
return { ordered: true, kg, cleared: meta.gate };
},
});
The migration, and a stop that leaves nothing running:
ctx.store.migrate([
"CREATE TABLE bakery_orders (id INTEGER PRIMARY KEY AUTOINCREMENT, day TEXT NOT NULL, customer TEXT NOT NULL, items INTEGER NOT NULL, at INTEGER NOT NULL)",
"CREATE INDEX bakery_orders_day ON bakery_orders (day)",
]);
const off = ctx.events.on("memory.written", e => ctx.log.debug("memory kept a note", { id: e.id }));
return { async stop() { off(); } };
The test, on the SDK's harness:
import { testModule } from "@vyre/module-sdk/testing";
test("bakery.flour is held for an agent and runs for the person", async t => {
const h = await testModule(DIR, { vault: { supplier: { status: 201, headers: {}, body: {} } } });
t.after(() => h.stop());
assert.deepEqual(await h.call("bakery.flour", { kg: 25 }, { who: "agent" }), { held: "hold-1" });
assert.equal((await h.call("bakery.flour", { kg: 10 })).data.cleared, "person");
assert.equal((await h.call("bakery.target", { target: 60 }, { who: "agent" })).error.code, "not_asked");
});
h.call(tool, input, { who, asked }) routes a call as Vyre does: who is person (default),
agent, module or hook. h.holds, h.calls, h.events and h.memory record what happened;
h.approve(id, content) approves a hold as the person would.
Common refusals and their fixes
| The check says | Change |
|---|---|
"vyre" is required outside Vyre's own modules | Add "vyre": "1". |
needs a newer Vyre (module contract 1.2); this Vyre has 1.0 | Update Vyre, or write for an older contract: use only what "vyre": "1" has. |
apiVersion is deprecated and other warnings | Nothing fails. Run vyre module upgrade . to move to the current form. |
description is required outside Vyre's own modules | Add one plain sentence under description. |
tool "x.y" must be an object like { "name": ... } | Write the entry as { "name": "x.y", "summary": "...", "reach": "anyone" }. |
tool "x" must start with "<name>." | Prefix every tool with your module's name and a dot. |
reach "person" is kept for Vyre's own tools; use "asked" | Use "reach": "asked". |
an outward tool must have reach "anyone" or "asked" | Drop modules or hook from an outward tool, or drop outward. |
does.providers / shows.streams / needs.vault is built in only in 0.2 | Remove it. Use needs.credentials with ctx.vault.request instead of needs.vault. |
roles ["windows"] loads nowhere in 0.2 | Add "box" or "mac". |
names X, which is not under does.tools | Declare the tool a command, hook or setting points at. |
imports node:child_process; a module has no process, socket or thread of its own | Use ctx.fetch, ctx.vault.request or ctx.call. |
imports ...; a module never imports Vyre's files | Remove the import and use ctx. |
imports ..., which is outside the module folder | Copy what you need into your folder. |
ctx.fetch sends GET or HEAD with no body (method_not_allowed) | To send data, webhook URLs included, use an outward tool or ctx.vault.request. |
needs.tools "x.*": an added module names each tool it calls | List each tool by name. |
replaces: an added module can't replace one of Vyre's modules | Remove replaces and pick a name of your own. |
not_declared from ctx.call | The tool you called has no declared reach, or is for Vyre's own modules. Use another tool. |
X is declared under does.tools, but start did not register it | Call ctx.tool("X", ...) in start, or remove the entry. |
registered tool X, which its manifest does not declare | Add X to does.tools. |
X has no examples | Give ctx.tool examples: [{ input: {...} }]. |
X examples[0] failed: ... | Make the example valid for input, and make run throw only Errors with a code. |
emitted X, which its manifest does not declare under watches.emits | Add X to watches.emits. |
ctx.call X, which needs.tools does not list | Add X to needs.tools. |
... (undeclared), or an error with code undeclared | Declare the door: see golden rule 8's list of which key each ctx member needs. |
subscribed to X, which its manifest does not declare under watches.on | Add X to watches.on. |
fetched X, which needs.network does not list | Add the host to needs.network. |
asked the vault for X, which needs.credentials does not declare | Add X to needs.credentials. |
start did not return within 2 s | Do slow work after start returns. |
start left a N ms interval running | Listen to an event, or poll no faster than every 60 s. |
... was still running after stop; clear it in stop() | Clear every timer and unsubscribe every listener in stop. |
a second start changed the schema | Never change a migration step that ran; add a new step. |
has an em dash / has a section sign | Rewrite with a colon, a comma or two sentences. |
names a real person or business | Use the sample world: alex, Juniper Studio, Northwind Bakery, juno, kit. |
What a module must never do
- Send data with
ctx.fetch. It reads only (GET or HEAD, no body). A URL can carry a secret, such as a webhook URL, so posting to one goes through anoutwardtool orctx.vault.request, where the Gate and the vault see it. - Import a Vyre file, or anything outside its folder.
- Open a process, socket, server or thread of its own.
- Read or write files outside
ctx.paths.data, or another module's data. - Hold, read or print a raw secret. Keys stay in the vault;
ctx.vault.requestattaches them. - Act as the person outside without
outwardon the tool orctx.gate.request. - Declare presence, approve at the Gate, call as another caller, or add a prompt of its own.
- Emit a reserved event (
sync.*,gate.*,presence.*,vault.*,turn.said). - Poll faster than every 60 seconds, or keep running work nobody needs.
- Put a customer's or a person's words into an event.
- Use real people's names, emails or hosts in examples, tests or tips.