docs GitHub

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

  1. Only ctx. Every way out of the module is a ctx member: 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.
  2. 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:fs inside ctx.paths.data).
  3. No process, socket or thread of your own. Never import node:child_process, node:net, node:http, node:https, node:dgram, node:worker_threads or node:cluster, with or without the node: prefix.
  4. Every tool says who may call it. Each entry under does.tools is an object with name, summary and reach. A tool that acts as the person outside (sends, posts, pays, deletes somewhere else) also says outward.
  5. Never ask for presence. No presence on a tool, no Touch ID, no prompt of your own. Use reach: "asked" for anything that changes settings, deletes local data or reshapes an agent. Use outward for anything that acts as the person outside; the Gate handles it.
  6. Nothing polls faster than 60 seconds. Listen to events first. A timer you start, you clear in stop().
  7. 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.
  8. Each ctx door has one declaration. ctx.call needs the tool in needs.tools, ctx.gate.request needs gate.request there, ctx.vault.request a needs.credentials id, ctx.connections.call a needs.connections provider, ctx.fetch a needs.network host, ctx.memory.write its kind in teaches.memory, ctx.ask and ctx.spend a needs.spend cap, and ctx.push.offer its kind in shows.notices. ctx.undo.record needs nothing. A call without its declaration throws an error with code undeclared. The install card is built from these declarations, so declare only what you use.
  9. 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

  1. Make the folder. vyre module new <name> --dir <parent> writes module.json, index.js, <name>.test.js, README.md, AGENTS.md (this brief) and jsconfig.json. It already passes every check. The name is lowercase letters, digits and dashes, 2 to 41 long.
  2. Edit module.json. Declare every tool (with reach, and outward where it applies), every event you emit, every event you listen to, every setting, and everything you need.
  3. Write the tools in index.js. Each one gets description, a JSON Schema input, at least one entry in examples, and run(input, meta).
  4. Write the tests in <name>.test.js with 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.
  5. Check until clean. Run vyre module check ., then vyre module test . (conformance, then your tests). Fix every line it prints, and run both again. Do not stop at one failure.
  6. 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 check shows 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.version is 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 example apiVersion to "vyre", string tool entries to objects, ctx.memory.teach to ctx.memory.write), lists what it left for you, and runs the checks. --dry-run shows 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"] }
}
KeyRule
name^[a-z][a-z0-9-]{1,40}$. It prefixes every tool, event, setting and table.
vyre, descriptionRequired. The contract you write for ("1"), and one plain sentence for the install card.
rolesbox (the server), local (a device), mac. Default ["box"]. windows alone loads nowhere yet.
does.tools[]{ name, summary, reach?, outward?, cost? }. name is <module>.<verb>.
reachanyone (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.
outwardsend, 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.emitsEvery event type you emit, <module>.<past-verb>.
watches.onEvery pattern you subscribe to: a type, noun.* or *.
shows.deckSlots: now:<tool>, renderer:<tool>, slash:<name>, settings, view:<name>, panel:<name>.
settings[]{ key: "<module>.<key>", label, type, default, levels, apply }.
needs.toolsEvery 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.credentialsVault items by id, kind, provider, purpose. Used only through ctx.vault.request.
needs.networkPublic 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.memoryThe kinds ctx.memory.write writes: fact, note or both.
shows.noticesThe notice kinds ctx.push.offer raises.
Built in onlydoes.providers, shows.streams, needs.vault, replaces. An added module can't use them in 0.2.

ctx cheat sheet

MemberUse
ctx.name, ctx.versionYour 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.dataYour 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 saysChange
"vyre" is required outside Vyre's own modulesAdd "vyre": "1".
needs a newer Vyre (module contract 1.2); this Vyre has 1.0Update Vyre, or write for an older contract: use only what "vyre": "1" has.
apiVersion is deprecated and other warningsNothing fails. Run vyre module upgrade . to move to the current form.
description is required outside Vyre's own modulesAdd 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.2Remove it. Use needs.credentials with ctx.vault.request instead of needs.vault.
roles ["windows"] loads nowhere in 0.2Add "box" or "mac".
names X, which is not under does.toolsDeclare the tool a command, hook or setting points at.
imports node:child_process; a module has no process, socket or thread of its ownUse ctx.fetch, ctx.vault.request or ctx.call.
imports ...; a module never imports Vyre's filesRemove the import and use ctx.
imports ..., which is outside the module folderCopy 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 callsList each tool by name.
replaces: an added module can't replace one of Vyre's modulesRemove replaces and pick a name of your own.
not_declared from ctx.callThe 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 itCall ctx.tool("X", ...) in start, or remove the entry.
registered tool X, which its manifest does not declareAdd X to does.tools.
X has no examplesGive 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.emitsAdd X to watches.emits.
ctx.call X, which needs.tools does not listAdd X to needs.tools.
... (undeclared), or an error with code undeclaredDeclare 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.onAdd X to watches.on.
fetched X, which needs.network does not listAdd the host to needs.network.
asked the vault for X, which needs.credentials does not declareAdd X to needs.credentials.
start did not return within 2 sDo slow work after start returns.
start left a N ms interval runningListen 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 schemaNever change a migration step that ran; add a new step.
has an em dash / has a section signRewrite with a colon, a comma or two sentences.
names a real person or businessUse 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 an outward tool or ctx.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.request attaches them.
  • Act as the person outside without outward on the tool or ctx.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.