Build on Vyre
Tools and events
Modules talk to the rest of Vyre in two ways. A tool is something a module can be asked to do: Claude, the Vyre app, Lumen, the CLI and other modules all call it the same way. An event is a fact about something that already happened, written to vyred's event log for anyone to hear. Tools are requests; events are news. This page covers both, from Sections 5 and 6 of the spec, and ends with a module you can install and run.
The full contract, including every check the loader makes, is on the module contract.
Offer a tool: ctx.tool
ctx.tool("digest.files", {
description: "Files one session changed, newest first.",
input: { type: "object", required: ["session"], properties: { session: { type: "string" }, limit: { type: "integer" } } },
run: async ({ session, limit = 50 }, { caller, thread }) => { /* return any JSON value */ },
});
- The name must be listed under
does.toolsinmodule.jsonand start with the module name. descriptionis what Claude reads to decide when to call it. Say what it returns.inputis checked beforerunsees it. A bad call getsbad_inputwith the reason (input.session is required).runreturns the result, which the caller gets as{ data }. To fail, throw. An error with a short lowercasecodereaches the caller as that code.- The second argument holds what vyred verified about the caller:
caller, andthread,agent,peerandpresencewhen they were proved.
Optional fields limit who may call it: callers (an allowlist of caller kinds such as cli, deck, mcp, module), internal (modules only), and presence (a person must prove they are there; see presence).
One definition is reachable three ways:
vyre call digest.files '{"session":"<session id>"}' # the terminal
POST /v1/tools/digest.files # a surface, over vyred's socket or the box's network listener
mcp__vyre__digest_files # Claude, through the Harness MCP server
Use another module: ctx.call
const r = await ctx.call("recall.search", { q: "Northwind Bakery invoice", limit: 3 });
if (r.error) { /* r.error.code, r.error.message */ }
else { /* r.data */ }
ctx.call never throws for a failed call. It resolves to { data } or { error: { code, message } }, exactly what an HTTP caller would get. The call is made as module:<your name>, goes through the same checks as any other caller, and is exempt only from presence (modules are code the user installed).
It is the only way to use another module. Never import another module's files.
On a Mac, ctx.remote(tool, input) does the same against the paired box, and resolves to { error: { code: "box_unreachable" } } at once while the box is away, so you can fall back to local results.
On a box, six read tools can take in the paired Mac's rows: projects.catalog, projects.list, recall.search, recall.sessions, recall.thread and threads.list. A module gets them only when it passes machines: "all"; each row then carries source ("box" or "mac") and machine. A Mac that is away answers mac_offline at once and the box's rows come back alone; projects.catalog and projects.list say which machines answered in sources. Without machines: "all" a module gets the box's rows, unlabelled. See The box and the Mac.
Every built-in tool, with its input, is on tools.
Emit an event: ctx.events.emit
ctx.events.emit("digest.searched", { hits: 3 }, { thread });
typemust be listed underwatches.emitsand readnoun.past-verb:digest.searched,invoice.filed,watcher.fired. Name what happened, not a command.payloadis any JSON object. It must never carry a secret: the log refuses anything that looks like one (known API key prefixes, private keys,"password": "..."and similar) by throwing, because every module and the Vyre app can read the log. Leave out what the user typed, too.- The third argument, optional, files the event under a
projector athread(a Claude Code session id), which surfaces use to filter the log.
Each stored event looks like this:
{ "id": 42, "at": 1790457328512, "type": "digest.searched", "source": "digest",
"project": null, "thread": "<session id>", "payload": { "hits": 3 } }
source is set by the loader to the emitting module. You cannot emit as someone else.
Hear events: ctx.events.on and since
const off = ctx.events.on("file.touched", e => { /* e.payload.path */ });
// "file.touched" one type · "file.*" every type whose first word is "file" · "*" everything
- A listener hears events from every module, as they are emitted, inside vyred.
- A listener that throws is ignored; it never stops the emitter or other listeners.
- A listener that also catches up with
since(below) can see the same event twice. Write listeners so that the same event twice changes nothing. onreturns a function that stops listening. Call it in yourstop().
on hears only what happens after it is called. To catch up on what happened while vyred was down, read the log from a cursor you keep:
const missed = ctx.events.since(lastId, { type: "file.touched", limit: 200 }); // oldest first
Surfaces read the same log over HTTP: GET /v1/events?since=<id>&type=<type> for a page, or GET /v1/events/stream for server-sent events (type filters the same way; since=latest skips the backlog).
Every built-in event, with its payload fields, is on events.
A worked example
The digest module keeps a table of the files each Claude Code session changed, by listening to the Harness's file.touched event, and offers a quick look back through Recall. It uses all four pieces: ctx.tool, ctx.call, emit and on.
~/.vyre/modules/digest/module.json
{
"name": "digest",
"version": "0.1.0",
"description": "What each session changed, and a quick look back.",
"requires": ["recall"],
"does": { "tools": ["digest.files", "digest.recall"] },
"watches": { "emits": ["digest.searched"] }
}
~/.vyre/modules/digest/index.js
// @ts-check
export default {
async start(ctx) {
ctx.store.migrate([
"CREATE TABLE digest_files (session TEXT NOT NULL, path TEXT NOT NULL, tool TEXT, at INTEGER NOT NULL, PRIMARY KEY (session, path))",
]);
const put = ctx.store.db.prepare(
"INSERT INTO digest_files (session, path, tool, at) VALUES (?, ?, ?, ?) " +
"ON CONFLICT (session, path) DO UPDATE SET tool = excluded.tool, at = excluded.at");
// Every file change the Harness records. The same event twice writes the same row.
const off = ctx.events.on("file.touched", e => {
if (e.payload.session) put.run(e.payload.session, e.payload.path, e.payload.tool, e.at);
});
ctx.tool("digest.files", {
description: "Files one session changed, newest first.",
input: { type: "object", required: ["session"], properties: { session: { type: "string" }, limit: { type: "integer" } } },
run: async ({ session, limit = 50 }) =>
ctx.store.db.prepare("SELECT path, tool, at FROM digest_files WHERE session = ? ORDER BY at DESC LIMIT ?").all(session, limit),
});
ctx.tool("digest.recall", {
description: "The three past turns most about a topic, from every Claude Code session on this machine.",
input: { type: "object", required: ["q"], properties: { q: { type: "string" } } },
run: async ({ q }, { thread }) => {
const r = await ctx.call("recall.search", { q, limit: 3 });
if (r.error) throw Object.assign(new Error(r.error.message), { code: r.error.code });
ctx.events.emit("digest.searched", { hits: r.data.length }, thread ? { thread } : {});
return r.data;
},
});
return { async stop() { off(); } };
},
};
What each part does:
- The table is named
digest_files.ctx.store.migraterefuses a table that does not start withdigest_. requires: ["recall"]makes vyred start Recall first, and faildigestwith the reason if Recall is not running.- The listener uses
ON CONFLICT ... DO UPDATE, so a repeatedfile.toucheddoes no harm. digest.recallturns a failedctx.callinto a thrown error with the same code, so its caller seesbad_inputorno_such_tool, not a generic failure.- The event carries the number of hits, not the query: what someone searched for is theirs, and the log is read by every module.
Run it:
vyre down && vyre up
vyre modules
Among the other modules:
You should see
digest 0.1.0 running
Then call it:
vyre call digest.recall '{"q":"Northwind Bakery invoice"}'
vyre call digest.files '{"session":"<session id>"}'
vyre call digest.files '{}'
The last call has no session, so it is refused before run sees it:
You should see
bad_input: input.session is required
In a Claude Code session with the Vyre plugin, Claude now sees digest_files and digest_recall among the vyre MCP tools.
Next
- The module contract: every rule the loader enforces.
- Writing a module: tests with a temporary
VYRE_HOME. - The MCP hub: how tools reach Claude.