docs GitHub

Build on Vyre

Modules (API 1)

Everything in Vyre is a module: memory, the vault, GitHub, Lumen's actions, and whatever you or an agent add. This page is the contract a module is written against. The decisions and their reasons are in ADR 0047. If you're an AI agent writing a module, start with the agent brief. A complete working module is at examples/modules/bakery.

In one minute

vyre module new bakery        # a folder that already passes every check
# edit module.json and index.js
vyre module check             # the manifest and the code, against API 1
vyre module test              # the conformance test, then your own tests, in a temp home
vyre module add ./bakery      # one card, one tap, and it's on

A module is a folder with a module.json, an entry file that exports default { start(ctx) }, and anything else it ships. It uses the rest of Vyre only through ctx. It never imports a Vyre file, never opens a network connection or process of its own, and never reads another module's data.

A module you add runs on the server in a sandbox: its own user, no network of its own, and no raw secrets. Every way out goes through ctx, so the floor and the Gate see all of it. Vyre's built in modules run inside vyred and use the same contract.

module.json

{
  "$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" }]
  },
  "watches": { "emits": ["bakery.order-added"] },
  "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"] }
}

The schema is packages/module-sdk/manifest.schema.json. Point $schema at it for editor help. x- keys are free for experiments.

KeyWhat it says
namelowercase letters, digits and dashes. It prefixes every tool, event, setting and table.
versionthe module's own semver
vyrethe contract version it's written for: "1", or "1.2" if it needs something added in 1.2. Required for a module you add.
descriptionone plain sentence, shown on the install card
mainthe entry file, default index.js
roleswhere it runs: box (the server, the default), local (a device's own node), mac or windows (local on that OS only; only the Mac has a local node in 0.2)
requiresmodules that must run first: ["memory"] or { "memory": ">=0.1.0" }
replacesits own name, to stand in for a built in module. A module you add can't replace any in 0.2.
does.toolsits tools, each { name, summary?, reach?, outward?, cost? }
does.commandsCLI verbs: vyre <module> <verb> runs a tool
does.watcherswatcher preset files it ships, offered when someone asks for a watcher
does.hooksharness points (brief, enrich, pretool, stop), each served by one of its tools
watches.emits, watches.onthe events it may emit and the patterns it may listen to
shows.deckslots in the Vyre app on the web, Windows and the phone: now:<tool>, renderer:<tool>, slash:<name>, settings, view:<name>, panel:<name>
shows.capsuleresults and actions in Lumen on a Mac
shows.noticesthe notice kinds it raises
settingsits settings, drawn in Settings and vyre config with no UI work
needs.*what it asks for (next section). This is what the install card shows.
teaches.memory, teaches.prompt, teaches.tipsmemory kinds it writes, prompt layers, tips

Who may call a tool: reach

The person and their agents can do the same things. reach says when an agent needs the person to have asked.

reachYou, on your own screenYour agents (any provider, teammates, the assistant)Other modules
anyoneyesyesyes, if they list it in needs.tools
askedyesonly when your own words asked for itno
moduleshiddenhiddenVyre's own modules only
hookwebhook onlynono

Say reach on every tool. A module you add can only call tools that declared theirs.

person exists for Vyre's own short list (answering a session's question, discarding a held draft). A module you add can't use it: pick asked. An agent that calls an asked tool unasked gets not_asked, tells you what it would do, and your reply is the ask. Nobody is prompted.

A module never asks for Touch ID. Vyre asks for it only when pairing a device, revealing a vault secret, or sending something you didn't ask for.

An asked tool of Vyre's own may carry target, the name of one internal tool of the same module that answers { to: [...] }: what this one call acts on, each entry a key of the tool and the thing (a pull request, a recipient). That answer is what your yes is matched against, so it binds that thing and not the whole tool, so "merge it" about one pull request never lets an agent merge another.

A tool that takes a project names the input field in projectArg (a name, or a list of names). The registry then refuses an agent's call for a project the agent is not granted, with not_found (so a refusal never says whether the project exists), before the tool runs, for every module alike. It asks projects.reach, so the owner's revokes count. cwdArg does the same for a folder: it is mapped to its project, and a folder in no project is refused for an agent with an explicit project list. A named project is rewritten to the canonical slug that was authorized. The tool also gets meta.reach, { all: true } or { all: false, projects: [slug] }, to keep a listing inside the grant when no project is named.

Acting as you outside: outward

Mark a tool "outward": "send" | "post" | "pay" | "delete" when it reaches the world as you: an email, a message, a payment, a remote delete. Vyre routes every call:

  • You asked in your own words (a command you typed, or a chat ask): it runs.
  • You tapped a button your module drew: Vyre shows its own card with the real amount and destination, and your tap on that card runs it. A module's button never pays by itself.
  • An agent, a teammate, a watcher or another module called it: if your own words asked for exactly this ("order the flour when stock is low"), it runs. Otherwise it waits at the Gate as a card, and you approve it with Touch ID.

Your tool's run only ever sees an approved call, and meta.gate says how it was cleared. A module you add has no other way to act as you, because it never holds your credentials: ctx.vault.request attaches them outside the sandbox, and a write through it is held at the Gate the same way.

What a module adds to Flows: flow

A module extends Flows with one declaration, flow, beside does and watches. Nothing else makes a tool a Flow step.

"flow": {
  "steps": [
    { "name": "mail.send", "label": "Send an email", "inputs": { "to": "string", "subject": "string", "body": "string" }, "outputs": { "id": "string" }, "outward": true },
    { "name": "mail.find", "label": "Find an email", "inputs": { "query": "string" }, "outputs": { "count": "number" } }
  ],
  "triggers": [
    { "name": "mail.arrived", "label": "An email arrives", "event": "mail.received", "inputs": { "from": "string", "subject": "string" } }
  ]
}

A step is one of the module's own tools (object form, reach anyone). A Flow's call step runs it. An outward step must name an outward tool ("outward": true on both), so the Flow holds it for the person's yes exactly as it holds any act that leaves Vyre. inputs and outputs are field names with a type (string, number, boolean, object, array) for the Flow editor. A trigger is a named way to start a Flow: an event the module lists in watches.emits, or a watcher it hosts. A Flow stores an event or watcher trigger, the kinds that already exist; the module's name for it only helps the person find it. Added modules may declare both. The install card shows them.

The Flow runs the tool as the person whose Flow it is: the daemon calls it with that person's session, so ctx.kernel.chain(meta) is that person. The runner does not ask the kernel's action table about a Flow step, so the tool must gate itself on the person's chain: read the chain, check the person may read or do this, and refuse otherwise. For an outward tool the Flow's one approval is spent at the call (once, for exactly that input), and that is the only gate beside the tool's own. For a read tool nothing else gates it. Every flow step is listed with the guard it relies on in test/flow-step-guards.json; a tool with no line there, or a line that names no guard, fails the test, so a tool with no gate of its own does not pass review. A connection to an outside service is not declared here: a Flow runs it with the one "Call a service" step.

A module that keeps records of its own lists the Kit files it ships in its manifest: "does": { "kits": ["kit.json"] }. The file is a Kit in the records language's stored form (write kit.ts and compile it with node records/language/cli.js compile kit.ts > kit.json) or in the kernel's form. When someone adds the module (vyre module add), Vyre proposes each Kit file as a card in Now. Nothing is defined until the owner says yes, and the card lists every type and field.

A module's Kit is held to what a module may add. Its id is the module's name. Every record type is named for the module (tasker_item for the module tasker), so it cannot redefine a core type or another Kit's. It adds no roles and no teammates, because those are abilities and the person gives abilities, and it makes no project type. List the types in needs.kernel.records so the module's code may create and list them, as the person who installed it.

To relate an item to a project, give the type a link to project:

project: defineField.link({ to: "project", label: "Project", inverse: { name: "tasker_items", label: "Items" } }),

The item then shows on the project (records.linked on the project lists it), and a Flow can start from it like any record.

An upgrade keeps everything. A new version of the module and a new version of its Kit replace the old ones. The module's own tables migrate forward with ctx.store.migrate, the Space's records stay as they were and keep their links, and the update card says which types changed and what was removed. Keep the tool names, view names and field names a person has pinned or used in a Flow: removing one hides what depends on it. test/module-kit-upgrade-daemon.test.js installs a module, makes records linked to a project, upgrades the module and its Kit, and checks all of it is still there.

What a module gets: ctx

Every member that reaches outside the module returns a promise. Types are in packages/module-sdk/index.d.ts.

MemberUse it toDeclare it inAvailable
ctx.tool(name, { description, input, examples, run })register a tool. run(input, meta) returns JSON or throws { code, message }.does.toolsnow
ctx.call(tool, input)use another module's tool. It answers { data } or { error }.needs.toolsnow
ctx.events.emit / on / since / latestIdemit and follow eventswatches.emits, watches.onnow
ctx.store.db, ctx.store.migrate(steps)your own SQLite tables, with migrations that only go forwardnow
ctx.paths.datayour own folder, the one place you write filesnow
ctx.settings.get / set / onyour own settingssettingsnow
ctx.vault.request(id, { method, url, ... })call a vendor API with a credential you never seeneeds.credentialswhen vault.request lands
ctx.connections.call(provider, tool, input)use a connected vendor MCP server (Google, Notion, Slack...)needs.connectionsnow
ctx.fetch(url, init)GET or HEAD from a public host, with no credentials and no body. To send data, use ctx.vault.request or an outward tool.needs.networkwhen the module host lands
ctx.gate.request({ kind, via, to, content, why })propose a send through another module's senderneeds.tools: ["gate.request"]now
ctx.memory.write({ kind, project, text, subject?, source_ref? })write memory, shown with your module as its source and quoted as datateaches.memorywhen memory.write lands
ctx.ask(prompt, { purpose, maxUsd })a one-shot model read with no tools, counted against your daily capneeds.spendwhen spend.check lands
ctx.spend.record / checkcount your own paid API useneeds.spendwhen spend.record lands
ctx.push.offer({ title, body, kind })ask to notify the person. It answers sent or deferred, under one shared daily budget.shows.noticeswhen push.offer lands
ctx.undo.record({ tool, input, inverse })give an action you took an Undowhen undo.record lands
ctx.log.info / warn / error / debuglogs, read with vyre logs <module>now
ctx.api.version, ctx.api.has(feature)find newer features without breaking on older Vyrenow

A call without its declaration throws undeclared.

Relaying a call: who you act for

When your tool calls another tool with ctx.call, Vyre records who the call you are handling came from and passes it on as the call's origin. You never set it and you cannot forget it: a client cannot send one, and a module cannot overwrite it. So a module that relays a model's call is judged as acting for that model on every tool that cares (originClass(meta), wantsMacs, a person-only tool a module reaches only on a person's behalf). The one thing to know is that the origin lives only while your call is running. Work you start for later (a timer, an event you store, a job queue) has no running call and is your own, as if you had made the call yourself. If it should act for the caller, keep ctx.origin() next to the stored work and replay it with ctx.withOrigin(origin, fn). test/module-origin.test.js shows both.

House rules

  • start returns within 2 seconds and returns { stop() }. stop finishes within 5 seconds.
  • Nothing polls faster than once a minute. Listen to events instead.
  • Every tool has an input schema and at least one examples entry.
  • Errors carry a short lowercase code. Words people see are plain, with no em dash.
  • Nothing is shown that doesn't work: a slot whose tool fails is hidden.

Adding, updating and removing

vyre module add <folder | git URL> (or asking an agent to add it) checks the module and shows one card, built by Vyre from the manifest rather than the module's own words:

Northwind bakery  0.1.0   from github.com/alex/bakery
Does:   list today's orders · change the daily target (when you ask)
Acts as you:  order flour from the supplier (pay), held unless you asked
Talks to:     api.flourco.example (reads only)
Uses:  your flourco key · memory (writes notes) · up to $0.50 a day
Shows: a Now card · `vyre bakery orders`
Runs: on your server, sandboxed
[Turn on]   [Not now]

One tap turns it on. If an agent thought of it on its own, the card waits in your list instead. Updates run when you ask (or type vyre module update). Say "keep bakery updated" once and it updates itself for as long as it asks for nothing new. An update that asks for nothing new installs with no card, and its row says when the code changed. One that asks for more shows the card again with only what changed. A module may carry an optional signature: the card names who signed it, and a later update from a different signer shows the card again.

vyre module list, update, disable, enable and remove do what they say. remove keeps the module's data unless you pass --data.

Every agent knows it at once

When a module is added, turned on, updated or removed, Vyre rebuilds the capability manifest (modules.capabilities) from the manifests. Every agent, whatever its provider, sees the new tools on its next turn: the Vyre MCP server announces the change, and the assistant's list of what it can do updates with it. An agent is only shown the tools it can actually use.

Versions

Your module keeps working when Vyre updates. The rules:

  • You name the contract. "vyre": "1" in module.json.
  • Inside a major, nothing breaks. New keys and ctx members are optional. Nothing is removed, renamed or retyped. Unknown keys are ignored when a module loads, and vyre module check shows them as warnings. Use ctx.api.has("<feature>") for anything newer than your vyre version.
  • Deprecation warns, never fails. A deprecated key or member keeps working for at least two releases or six months, whichever is longer. Until then, vyre module test, vyre doctor and the log warn about it, and nothing fails.
  • A new major keeps yours running. Vyre 2 will run "vyre": "1" modules unchanged through an adapter for at least 12 months. vyre module upgrade rewrites a module for the new major and tests it. It already exists and moves older manifests to today's shape.
  • Every release tests old modules. Pinned modules for every contract version and every example run against every supported version in CI, and a release that breaks one doesn't ship.
  • Too new for this Vyre? You get a plain message, "bakery needs a newer Vyre (module contract 1.2); this Vyre has 1.0", and nothing crashes.

What's built in only, for now

Session providers (does.providers), streams (shows.streams), raw HTTP routes, raw vault values stay with Vyre's own modules in 0.2, because each needs a process, a socket or a secret that the sandbox withholds. vyre module check says so if you use them.

The # tag: mentions

Typing # in a chat opens one picker over everything the person may mention. A module offers a kind of thing with one entry in mentions, and mentions.search asks every module that does at once.

"mentions": [{ "kind": "vault", "label": "Vault", "icon": "key", "search": "vault.mentions.search", "resolve": "vault.mentions.resolve" }]

search and resolve are tools of the same module. Search takes { q, limit } and answers { items: [{ id, name, hint?, icon? }] }: names only, never a value, and it runs as the person who is typing. Resolve takes { id, thread, said }, runs as sessions or the assistant (never a model), and answers what the tag means for a thread: a grant (a use, a read) and a context (a title, a summary), decided from the person's own turn and never from a model. A kind has one provider; a second module that claims it fails to load.

A module you add can offer a kind too, under three limits. The kind is the module's own name (or starts with it and a dash), so nothing poses as vault or drive. Search and resolve are its own tools and must be reads. And what resolve gives back is cut: no grant, no hosts, and its words always count as outside text (a note joins the context), because a tag may only give the module's own reads, never a grant beyond them.

Views: your screens, drawn by Vyre

A module describes its screens under views and Vyre draws them, in the app (web, Mac, Windows, phone) and in Lumen, in its own components. Nothing from a module runs in Vyre's window. This is the Lumen's view language below, promoted to the whole app and extended with a board and a summary; shows.capsule's view:<id> entries are the older name for the same declaration, and views wins when both name an id.

"views": {
  "board": { "title": "Board", "icon": "tray",
    "board": { "tool": "cards.list", "columns": [{ "id": "todo", "title": "To do" }, "doing", "done"],
      "map": { "rows": "cards", "id": "id", "title": "name", "subtitle": "who", "column": "status" },
      "actions": [{ "id": "move", "title": "Move", "tool": "cards.move", "input": { "id": "{id}", "status": "{column}" } }] } },
  "counts": { "title": "Counts", "summary": { "tool": "cards.count",
    "map": { "cards": [{ "label": "Open", "path": "open" }], "chart": { "kind": "bar", "rows": "byDay", "label": "day", "value": "n" } } } }
}

A view is a list (with a detail and actions), a board, a summary or a form. A board groups the rows of its tool into the declared columns by the field map.column names; a row whose value is no declared column goes to a last column, Other. Dropping a card on a column calls the action with the id move, with {column} filled in (only a declared column; any other leaves the card where it is). A summary reads counts and one small bar or line chart by dotted path. map names fields by plain dotted path, templates fill a fixed set of names, an outward action shows its exact words first and sends only with the preview's own token, and the module's tools run as the module on behalf of the person who opened the view, never as the person. The app asks views.list, views.get and views.act; Lumen asks capsule.commands, capsule.view and capsule.act, and shows only the views it can draw (list, detail, form).

A view is also a sidebar screen: the entry is { "kind": "module", "module": "cards", "screen": "board" } with the view's id as screen, and sidebar.get marks it view: true. A view id a new version drops hides the pin; it never deletes it.

Lumen: view: entries

A module adds commands to Lumen by declaring them under shows.capsule. Lumen draws them natively; nothing from a module runs inside it.

"shows": { "capsule": {
  "view:orders": {
    "title": "Orders", "keywords": ["bakery"], "icon": "tray", "root": true,
    "arg": { "name": "q", "placeholder": "customer" },
    "list": {
      "tool": "bakery.orders", "input": { "q": "{q}", "limit": 20 },
      "map": { "rows": "orders", "id": "ref", "title": "name", "subtitle": "note", "url": "link" },
      "actions": [
        { "id": "open", "title": "Open", "do": { "open": "{url}" } },
        { "id": "reply", "title": "Reply", "form": "reply" }
      ]
    },
    "forms": { "reply": { "title": "Reply to {title}", "fields": [{ "name": "body", "label": "Your reply", "type": "multiline", "required": true }],
      "submit": { "title": "Send", "tool": "bakery.reply", "input": { "ref": "{id}", "body": "{body}" }, "outward": true } } }
  }
} }

A view is a list (with an optional detail and its actions) or a form. map names fields in the tool's JSON by plain dotted path: no expressions, no code. Templates fill {q}, {id}, {title}, {subtitle}, {accessory}, {url}, a form's field names, and {front.app} and {front.selection} only when the module declares needs.slots: ["front"]; any other name is empty. An action ends in do (open, copy, say, ask or push), a tool of the module's own, or a form. An added module opens only https and mailto links (a vyre: link can act, so it is Vyre's own), pushes only to its own commands, and may name only its own tools and the ones in needs.tools. Its tools run as the module, never as you, and its rows say "from" the module. An outward action shows the exact words first, and a second Enter sends; the preview carries a token good for two minutes that the second call must return. Icons are system symbol names from a fixed list, or app:<bundle id>.

A wrapped app's screens need no tool of their own. Where a view's list, detail, action or form submit names a tool, it may name { "connection": "docuseal", "operation": "list", "input": { "query": { "status": "pending" } } } instead: the operation of a Connection (its input is { params, query, headers, body }, with the same templates). A module reaches only the Connection of its own app and only the operations that Connection declares; a send, change or delete is held for your yes like any outward call.

Lumen reads capsule.commands for the list of commands, capsule.view for a frame and capsule.act for an action. It sends ids, never tool names. The older results:<tool> and action:<tool> keys keep working and appear as one command.