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.
| Key | What it says |
|---|---|
name | lowercase letters, digits and dashes. It prefixes every tool, event, setting and table. |
version | the module's own semver |
vyre | the contract version it's written for: "1", or "1.2" if it needs something added in 1.2. Required for a module you add. |
description | one plain sentence, shown on the install card |
main | the entry file, default index.js |
roles | where 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) |
requires | modules that must run first: ["memory"] or { "memory": ">=0.1.0" } |
replaces | its own name, to stand in for a built in module. A module you add can't replace any in 0.2. |
does.tools | its tools, each { name, summary?, reach?, outward?, cost? } |
does.commands | CLI verbs: vyre <module> <verb> runs a tool |
does.watchers | watcher preset files it ships, offered when someone asks for a watcher |
does.hooks | harness points (brief, enrich, pretool, stop), each served by one of its tools |
watches.emits, watches.on | the events it may emit and the patterns it may listen to |
shows.deck | slots in the Vyre app on the web, Windows and the phone: now:<tool>, renderer:<tool>, slash:<name>, settings, view:<name>, panel:<name> |
shows.capsule | results and actions in Lumen on a Mac |
shows.notices | the notice kinds it raises |
settings | its 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.tips | memory 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.
reach | You, on your own screen | Your agents (any provider, teammates, the assistant) | Other modules |
|---|---|---|---|
anyone | yes | yes | yes, if they list it in needs.tools |
asked | yes | only when your own words asked for it | no |
modules | hidden | hidden | Vyre's own modules only |
hook | webhook only | no | no |
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 ships a Kit: records, fields and a link to projects
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.
| Member | Use it to | Declare it in | Available |
|---|---|---|---|
ctx.tool(name, { description, input, examples, run }) | register a tool. run(input, meta) returns JSON or throws { code, message }. | does.tools | now |
ctx.call(tool, input) | use another module's tool. It answers { data } or { error }. | needs.tools | now |
ctx.events.emit / on / since / latestId | emit and follow events | watches.emits, watches.on | now |
ctx.store.db, ctx.store.migrate(steps) | your own SQLite tables, with migrations that only go forward | now | |
ctx.paths.data | your own folder, the one place you write files | now | |
ctx.settings.get / set / on | your own settings | settings | now |
ctx.vault.request(id, { method, url, ... }) | call a vendor API with a credential you never see | needs.credentials | when vault.request lands |
ctx.connections.call(provider, tool, input) | use a connected vendor MCP server (Google, Notion, Slack...) | needs.connections | now |
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.network | when the module host lands |
ctx.gate.request({ kind, via, to, content, why }) | propose a send through another module's sender | needs.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 data | teaches.memory | when memory.write lands |
ctx.ask(prompt, { purpose, maxUsd }) | a one-shot model read with no tools, counted against your daily cap | needs.spend | when spend.check lands |
ctx.spend.record / check | count your own paid API use | needs.spend | when 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.notices | when push.offer lands |
ctx.undo.record({ tool, input, inverse }) | give an action you took an Undo | when undo.record lands | |
ctx.log.info / warn / error / debug | logs, read with vyre logs <module> | now | |
ctx.api.version, ctx.api.has(feature) | find newer features without breaking on older Vyre | now |
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
startreturns within 2 seconds and returns{ stop() }.stopfinishes within 5 seconds.- Nothing polls faster than once a minute. Listen to events instead.
- Every tool has an input schema and at least one
examplesentry. - 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 checkshows them as warnings. Usectx.api.has("<feature>")for anything newer than yourvyreversion. - 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 doctorand 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 upgraderewrites 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.