docs GitHub

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.tools in module.json and start with the module name.
  • description is what Claude reads to decide when to call it. Say what it returns.
  • input is checked before run sees it. A bad call gets bad_input with the reason (input.session is required).
  • run returns the result, which the caller gets as { data }. To fail, throw. An error with a short lowercase code reaches the caller as that code.
  • The second argument holds what vyred verified about the caller: caller, and thread, agent, peer and presence when 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 });
  • type must be listed under watches.emits and read noun.past-verb: digest.searched, invoice.filed, watcher.fired. Name what happened, not a command.
  • payload is 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 project or a thread (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.
  • on returns a function that stops listening. Call it in your stop().

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.migrate refuses a table that does not start with digest_.
  • requires: ["recall"] makes vyred start Recall first, and fail digest with the reason if Recall is not running.
  • The listener uses ON CONFLICT ... DO UPDATE, so a repeated file.touched does no harm.
  • digest.recall turns a failed ctx.call into a thrown error with the same code, so its caller sees bad_input or no_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