Writing a plugin
Everything an agent can actually do is a plugin. This document is the guide to writing one: the ten-minute path from nothing to a tool an agent can call, the contract, the things a plugin can contribute, the rules that bite, a complete worked example you can copy, and a field-by-field reference at the end.
The contract is what @buddi/core/plugin exports: the manifest and tool types
in packages/core/src/tools.ts, and the host a plugin is handed, ctx.buddi, in
packages/core/src/host/types.ts. Both are worth reading before or alongside
this. This document explains them; it does not replace them. §9 is every field
of the manifest and the call in a table, and §9b every area and method of the
host.
Start here: nothing to a running plugin
Section titled “Start here: nothing to a running plugin”Ten minutes, seven commands, no editing of buddi’s source at any point.
What you need
Section titled “What you need”| Node 22 | node --version → v22.x or newer. |
| pnpm | corepack enable is enough; any recent npm works too. |
| A buddi you can restart | buddi doctor answers, and buddi plugins list prints a table. If buddi is not on your PATH you are in a checkout: pnpm build there first, and read docs/install.md. |
| Postgres | Already true if buddi runs: your plugin’s schema goes in the same database. |
@buddi/core |
The contract package, at the version your installation runs. buddi plugins init reads that version and writes it into your package.json for you. Your code imports @buddi/core/plugin and nothing else of it (§1.2). |
You do not need a fork of buddi, a branch, or a line added to any file in it. A plugin is an ordinary npm-style package that lives wherever you keep your own code.
1. Scaffold it
Section titled “1. Scaffold it”buddi plugins init weather # writes ./weatherbuddi plugins init weather --dir ~/src/weatherIt refuses a directory that already exists, so it can never write over work. What it writes:
weather/ package.json # "buddi": { "manifest", "core", "uses", "hostApi" }, # the keyword, @buddi/core as a PEER, and a dev # link to the core your installation is running tsconfig.json src/index.ts # the manifest: one `auto` tool, one `gated` tool with # describe(), and a commented-out source stub src/index.test.ts # loads the manifest through core's own validation migrations/001_weather.sql # `create table note (...)`, in your own schema buddi.md # what the owner reads before anything is imported README.md .gitignoreThe name you give is the plugin family (weather.forecast), the directory under
<data>/plugins, and — with - folded to _ — the Postgres schema it owns. It
must be lowercase, start with a letter, and not be the name of a plugin this
build already ships (init refuses those).
2. Build it and run its test
Section titled “2. Build it and run its test”cd weatherpnpm installpnpm build # tsc → dist/pnpm test # registers the manifest in a real ToolRegistryThe test is the cheapest proof that the plugin will load: ToolRegistry.register
is exactly what runs at startup — it derives a JSON Schema from every zod input
and refuses one no provider would accept, refuses a colliding tool name, and
parses every view descriptor. It needs no database.
If @buddi/core will not resolve: core is an optional peer, and the scaffold’s
pnpm-workspace.yaml points it at the core your installation is running with a
pnpm override ('@buddi/core': "link:<that core>"). That checkout has to be
built (pnpm -r build there): its types and entry point are in its dist.
The file is never packed, so nothing you publish names a link.
3. Install it from the directory
Section titled “3. Install it from the directory”buddi plugins install ./weather # stages it and prints what it claimsbuddi plugins install ./weather --yes # approves it: imports it, migrates itThe first command installs nothing. It stages the package, reads its
package.json and its buddi.md, hashes what is on disk, and prints the card —
including the sentence that a plugin runs inside buddi’s process with everything
buddi can do. Your own directory is the one source that keeps the plain --yes;
a package from a registry or a .tgz has to have its integrity hash typed back
(§8).
Approving is the first time your code is imported. Then the contribution summary
is printed from your manifest — the auto tools first, in capitals — your
buddi.md is compared with it, and your migrations are applied into your schema.
4. Restart, and see it
Section titled “4. Restart, and see it”buddi service restart # a packaged install# or: stop `buddi serve` and start it again, in a checkoutbuddi plugins listPlugins are registered at start. There is no reload; §8, “Plugins load at start”, says why, and
buddi plugins dev is the loop that makes living with it bearable.
buddi plugins list should now show ok weather 0.1.0 installed. On the
dashboard, Settings → Plugins lists it as a row; the row opens a sheet
with what it brought and the agents it proposes. Nothing can
call your tools yet: being installed grants nothing.
5. Grant it to an agent
Section titled “5. Grant it to an agent”A tool reaches a model only when an agent’s own tools: line names it. Either
edit the agent file in your agents directory:
tools: - weather.*…or ask buddi to do it, which goes through the gated platform.update_agent
and shows you the whole grant before it is written:
"grant @buddi the weather tools"A grant ending in ? holds only if provided: weather.*? gives the agent
the weather tools when a plugin provides them and is skipped silently when none
does. Without the ?, a family nothing here provides holds the whole agent
back until it is installed (the right answer for an agent that is nothing
without it); with it, the agent loads with the rest of its grant. Only the last
? is the marker (any other ? is the glob’s one character), and a tool that
does not exist in a family that is loaded is still an error. The starter
Planner grants weather.*? and calendar.*? this way.
Then ask the agent something your tool answers. An auto tool runs inline; your
gated one comes back as an approval card with the preview your describe
rendered.
6. The dev loop
Section titled “6. The dev loop”buddi plugins dev ./weatherIt watches weather/dist. On a rebuild it restarts the service when there is a
service to restart, and otherwise prints the one line telling you to restart
buddi yourself. Run pnpm build --watch beside it and the loop is: save, build,
restart, ask.
7. Ship it
Section titled “7. Ship it”npm pack # → buddi-plugin-weather-0.1.0.tgzbuddi plugins install ./buddi-plugin-weather-0.1.0.tgzA tarball is the private path: it never goes near a registry, and it installs
exactly like a published package — which means it is approved like one, with
--integrity <hash>:
buddi plugins install ./buddi-plugin-weather-0.1.0.tgz# … prints the card, including `integrity sha512-…`buddi plugins approve <id> --integrity sha512-…npm pack runs the scaffold’s prepack, which builds, and ships dist without
source maps or build info, migrations, buddi.md and LICENSE. Installing
that tarball yourself is the check that what you would publish installs.
Then publish, when you want other people to have it, from CI or your machine with provenance, so the registry records where it was built:
npm publish --provenance --access publicbuddi plugins install @you/buddi-plugin-weather # on somebody else's machinebuddi plugins approve <id> --integrity sha512-…Publish under a scope you own (@you/…) or an unscoped name you hold. Never
@buddi: buddi does not own that scope on npm, and nothing of yours belongs
in it. The package name is yours to choose because buddi.name says what the
plugin is called; the scaffold writes it. A package that declares none installs
under its manifest’s name, which the card says it will read at approval.
Three things make a published plugin installable by a stranger, and the scaffold
writes all three: the keywords: ["buddi-plugin"] they search for, the
"buddi": { "manifest": "manifest" } field that lets the package be called
buddi-plugin-weather while the manifest is weather, and @buddi/core as a
peer dependency and never a normal one. §8 is the whole of distribution:
what is staged, what is hashed, what the two approvals are, and what uninstall
does.
Getting listed on withbuddi.com. The market at withbuddi.com/plugins, and
the Browse tab in every buddi, are built from the public repository
withbuddi/buddi-market. To be listed, open a pull request there adding
plugins/<name>/entry.json with your npm name, the version, a category, and
optionally an icon and screenshots. A check job downloads that version, checks
its integrity and provenance, and writes what it brings into the entry from the
code itself, so a listing cannot claim less than the plugin does. Merging is the
review; a new version is a pull request bumping the version.
What the check job writes is what this command prints, and you can run it yourself first:
buddi plugins describe @you/buddi-plugin-weather@0.1.0 # the card and what it bringsbuddi plugins describe @you/buddi-plugin-weather@0.1.0 --json # the entry's claimsdescribe stages the package exactly as install does (an npm package, a
.tgz or a directory), imports its manifest to read it, prints, and deletes the
stage. It writes no record, touches no schema and registers no tool, but
reading the manifest does run the plugin’s top-level code in that process, as
approving would.
Where to go next
Section titled “Where to go next”| If you want to… | Read |
|---|---|
| reach the database, the clock, files or the web | §1.1, §9b |
| know what a plugin may contribute | §2 |
decide auto or gated |
§2.1 |
| poll the world with no agent in the loop | §2.2 |
| watch for a fact and say so | §2.3 |
| own tables | §3 |
| copy something complete | §4 |
| test it | §5 |
| ship it to other people | §8 |
| look up one field | §9 |
1. What a plugin is, and is not
Section titled “1. What a plugin is, and is not”A plugin is a package that exports a PluginManifest. It reaches buddi through
two things and nothing else:
ctx.buddi, the host: one object core builds for your plugin atregister()and puts on every context it hands you — a tool’s, a page query’s, a metric’s, a source’s, a sentinel’s. The database, the owner, the clock, your directory, the Files library, web requests, the owner’s secrets: everything you reach beyond your own arguments is an area of it (§1.1).@buddi/core/plugin, the types of the contract and a handful of pure helpers —localDateString,sha256Of,pageFile,QueryRefusal,checkUrl— with no state and no I/O (§1.2).
Core never imports a plugin, and a plugin imports nothing of core but that entry point. Neither direction is a convention:
scripts/check-boundaries.mjsscanspackages/coreand fails the build if any source file orpackage.jsondependency there names@buddi/runtime,@buddi/gatewayor@buddi/tool-*. It runs first inpnpm test. The same script also fails the build on outbound HTTP that does not go through the shared transport — see “Outbound HTTP” in §4.packages/gateway/src/plugin-imports.test.tswalks every plugin’s source and fails, naming the file, on any reach past the two (§1.5).packages/gateway/src/generic-install.test.tsboots the system with no plugins at all. Core with zero plugins installed is a valid, running state — the registry is empty, the sentinel tick returns[], the source tick returns[], andbuddi missions add-defaultsregisters only the gateway’s ownsentinel-wake.
“Droppable” here means exactly that: buddi plugins uninstall <name> removes
the record, the code stops loading, and the system still boots. Its Postgres
schema is kept unless you also --purge it (§8).
There is no sandbox, and that is the one thing to be clear-eyed about. A plugin
is dynamically loaded — packages/gateway/src/plugins/load.ts imports the
entry point named in plugins.json, once, at start — but loading is not
isolation: the module runs inside buddi’s process with everything buddi can do.
ctx.buddi is the supported path, not a wall: a plugin that wants to can import
a file by absolute path, read process.env or open a socket to Postgres. The
security properties come from the registry and the approval machinery, from the
two approvals that stand between a package and its first import, from the
recorded hash that says later when the files changed, and from the owner
reading the install card. What the host adds is that an honest plugin has no
reason to reach further, so any reach is visible in review. The plugins this
repository compiles in (email, memory, artifacts, web, the browser and
host families) are registered at the composition root instead, in
createToolRegistry; that line is for plugins shipped inside buddi and you
never touch it to distribute one. They are held to the same host and the same
import test as yours.
What a plugin is not:
- It is not trusted to decide what reaches the owner. A sentinel returns findings; core decides whether any of them wake anybody.
- It is not trusted to execute its own effects. A
gatedtool is executed by one function,executeApproved, and only against an approval row. - It does not schedule anything by being installed. Missions are suggestions.
A plugin that reaches outside the database is where these rules earn their keep.
@buddi/tool-browser is the one to read first: one pair of tools over three
backends (the owner’s apps, a browser of its own, and “Your browser”, the Chrome
extension), each behind the same BrowserDriver seam, and none of them able to
import the gateway — the extension’s WebSocket endpoint is injected into the
plugin as a bridge interface the plugin declares. docs/browser.md describes the
three modes and what each refuses.
1.1 The host: ctx.buddi
Section titled “1.1 The host: ctx.buddi”interface BuddiHost { readonly version: string; // '1.18' — see §1.7 readonly plugin: string; // your manifest's name log(line: string): void; // an operational line, prefixed with your name scrub(text: string): string; // stored values -> ‹secret:NAME› (owner-secrets §5) owner: OwnerArea; // id, timezone, agentForRole, hasAgent, protectedPaths, language, formats; notify with `owner:notify`, places with `owner:places` clock: ClockArea; // now(), today() db: DbArea; // query(), transaction() — never a raw pool dir: DirArea; // <data>/plugins-data/<plugin> approvals: ApprovalsArea; // the owner's decisions about your own tools pages: PagesArea; // previewPort(), previewUrl() http?: HttpArea; // declared as `http` accounts?: AccountsArea; // declared as `accounts` files?: FilesArea; // declared as `files` or `files:library` memory?: MemoryArea; // declared as `memory` — a type only, for now proposals?: ProposalsArea; // declared as `proposals` schedule?: ScheduleArea; // declared as `schedule` secrets?: SecretsArea; // declared as `secrets` channels?: ChannelsArea; // declared as `owner:channel` plugins?: PluginsArea; // when your manifest `requires` another plugin (§2.10)}A context is two things. The call is the facts about this one invocation:
who called (agentId), in which conversation, under which approval
(actionId, choices), with what cancellation (signal). The host is
ctx.buddi: everything the plugin reaches that is not an argument. The
template’s read tool is the whole idea in one line:
const { rows } = await ctx.buddi!.db.query( `select id::text as id, body, created_at from template.note order by created_at desc limit $1`, [input.limit ?? 20], );buddi is optional in the type only so that core’s own contexts compile; core
sets it on every context it hands a plugin, which is why the template writes
ctx.buddi!. Seven areas are always there, because they reach nothing beyond
your plugin: your schema, your directory, your own tools and their approvals. The other
seven exist only when you declare them (§1.3); one you did not declare is
undefined, not a refusal.
Every area is scoped to your plugin. approvals answers only about your own
tools, files shows the files you saved and the files handed into the
conversation your tool runs in, proposals counts only yours, secrets lists
only secrets bound to your destinations, and accounts resolves only an account
the owner bound to you. §9b is every area and method, with the version each
arrived in.
Two things about db, because they are what a first plugin trips on.
db.query runs one statement on the shared pool and does not set a
search_path, so name your tables with your schema (template.note), as the
template does; db.transaction(fn) takes one connection, begins, puts your
schema first on search_path, and commits, or rolls back when fn throws.
Both answer { rows, rowCount }. In a page query or a metric the same db is
read-only (§2.5b). Scope in db is a rule for now, not a grant: the import test
refuses a core. table in your SQL (§1.5), and a Postgres role per plugin is
deferred until every install path can create one.
1.2 What you import
Section titled “1.2 What you import”@buddi/core/plugin, and nothing else of core:
import type { EffectDescription, PluginManifest, ToolDefinition } from '@buddi/core/plugin';It holds the contract’s types (the manifest, tools, sources, sentinels, views,
pages, metrics, the host and its areas) and the pure helpers a plugin needs
beside the host: localDateString and sha256Of; pageFile and
QueryRefusal for a page query’s answer; checkUrl, BlockedError and the
address rules, for a plugin that checks a URL before it hands it to anything;
parseViewDescriptors; the uses names and HOST_API_VERSION.
packages/core/src/plugin/entry.test.ts walks what the entry point imports
and fails if anything with state or I/O reaches it.
A test may also import @buddi/core/testing: all of core, plus the throwaway
database helper (§5). Nothing a plugin ships may.
1.3 uses: what the owner reads at install
Section titled “1.3 uses: what the owner reads at install” // The areas of `ctx.buddi` you reach beyond your own schema, directory and // approvals: `http`, `files`, `accounts`, ... Repeated as `buddi.uses` in // package.json, because the install card is drawn before this file is // imported; the two must match. This plugin reaches nothing else. uses: [],The manifest’s uses is what your ctx.buddi is built from. The same list
goes in package.json as buddi.uses, because the install card is drawn
from static metadata before anything of yours is imported. When the plugin
loads the two are compared, and a plugin whose lists differ does not register;
the load report says so in one sentence. An area name buddi does not know is
refused at staging.
On the card each declared area is one plain line, beside the tools, the schema, the timers and the hosts:
uses |
The owner reads |
|---|---|
http |
sends web requests |
accounts |
uses a model account you pick |
files |
keeps files in your Files library |
files:library |
reads every file in your Files library |
memory |
reads and writes memory as the agent that calls it |
proposals |
proposes rules |
schedule |
starts agent runs by itself |
secrets |
fills secrets you bind to it |
owner:notify |
can send you messages when you are away |
owner:channel |
adds a way for buddi to reach you |
owner:places |
reads your places (Home, Work…) and their addresses |
assets |
keeps small images it fetched, like logos |
files sees what you saved and what was handed into your conversation;
files:library is the whole library, and the card says so in those words —
finance, artifacts, host, image and speech declare it. An upgrade that adds an area marks it
as added on its card, and says what it drops. uses: [] is a real answer: the
template, memory and browser reach nothing beyond themselves.
1.4 The register hook
Section titled “1.4 The register hook” register?(host: RegisterHost): void; // RegisterHost = { version, plugin, dir, channels? }Called once by register(), after every check on the manifest has passed, with
the parts of the host that need no call: the version, your name and your
directory, and channels when you declare owner:channel, so a channel is
registered once, before any context exists. It is for a plugin that has to fix something before any context
exists — the browser plugin learns where the owner’s profile lives here, and
resolves its controller. Nothing is awaited, and there is no database or clock
in it: anything that needs those waits for a call.
1.5 What the import test forbids
Section titled “1.5 What the import test forbids”packages/gateway/src/plugin-imports.test.ts walks the src of every plugin
under packages/tools/* and examples/plugins/*; buddi-plugins runs the same
check from its root pnpm test (scripts/check-plugin-imports.mjs). It fails,
naming the file, on:
- an import of
@buddi/coreother than@buddi/core/plugin, or@buddi/core/testingin a test; - an import of
@buddi/runtime,@buddi/gatewayor any@buddi/tool-*(four tests that drive web and browser over the real transport or the real loop are named in the test as the exceptions); - a relative import that leaves the plugin’s own package;
- a
core.table named in a SQL string, outside tests.
Comments are blanked before the check, so prose may mention core.events; code
may not. Run the same walk over your own plugin before you ship it.
1.6 The installed @buddi/core, and plugins dev
Section titled “1.6 The installed @buddi/core, and plugins dev”@buddi/core is a peer dependency so your plugin shares the running process’s
core rather than a copy of its own (§8). What an installed plugin resolves by
that name is not the whole package: staging writes
node_modules/@buddi/core as a small package whose only exports are
./plugin and ./package.json, re-exporting the running core’s
dist/plugin by absolute path. Every class and constant is core’s own object,
and an import of @buddi/core, /testing or an internal fails to resolve, by
name, the first time your plugin is imported. It is buddi’s, not yours, so it is
left out of the staged hash.
A directory install and buddi plugins dev <dir> are your own checkout, and
keep the core it resolves (the scaffold’s pnpm override), whole, so your tests
can import @buddi/core/testing. There nothing stops an import of an internal at
resolution; the import test is what does. plugins dev itself only watches
<dir>/dist and, on a rebuild, restarts the service when there is one, or
prints the line telling you to restart buddi yourself.
1.7 Versioning
Section titled “1.7 Versioning”ctx.buddi.version is major.minor, 1.18 today. Say what you were built
against as buddi.hostApi in package.json — the scaffold writes "^1.0". A
plugin asking for more than this buddi has is refused at staging, before
anything is imported, with both numbers: ^1.2 on a 1.0 host is “built for
host API ^1.2, and this buddi has 1.0”.
A minor adds a method, an optional argument or an optional field on a return, and never changes what an existing call does. A major removes or changes something, and ships only after one release in which both shapes exist and the old one logs its caller. §9b gives every member the minor it arrived in.
2. The contributions
Section titled “2. The contributions”export interface PluginManifest { name: string; // 'finance' version: string; // part of the approved args hash — see §2.1 schema: string; // the Postgres schema this plugin owns migrationsDir: string; // absolute path to a directory of *.sql, or '' tools: ToolDefinition<any, any>[]; sentinels?: Sentinel[]; sources?: Source[]; missions?: SuggestedMission[]; views?: ViewDescriptor[]; // how the dashboard should draw your results home?: HomeContribution[]; // read-only blocks on the dashboard's Home page widgets?: WidgetDefinition[]; // small live panels the owner places on Home — see §2.5d metrics?: MetricDefinition[]; // numbers a GOAL can watch — see §2.3a pages?: PageDescriptor[]; // screens of your own: a rail place, a settings tab queries?: PageQuery[]; // the read-only functions those screens draw from files?: WorkspaceFiles; // queries that read a per-agent directory: a Files tab agents?: SuggestedAgent[]; // agents you PROPOSE; the owner approves each one skills?: SuggestedSkill[]; // shared procedures you propose previews?: PreviewProvider; // a loopback process of yours the owner can open policies?: PolicyHandler; // how a rule the owner kept becomes yours carryOver?: CarryOverContributor; // lines in the note a rolled-over chat opens with — §2.7 description?: string; // one line, shown before anyone installs you network?: NetworkUse[]; // the hosts you intend to reach, and why uses?: PluginUse[]; // the areas of ctx.buddi you declare — §1.3 destinations?: SecretDestination[]; // where the owner's secrets go — §6 register?(host: RegisterHost): void; // once, at register() — §1.4}name, version, schema, migrationsDir and tools are required —
migrationsDir may be '' for a plugin that owns no tables, and tools may be
empty. Everything after them is optional. agents, skills, description,
network and uses exist for one reason: somebody who is not you has to
decide whether to run your code. See §1.3, §2.6 and §8. Every field, with its
type and one line, is §9.
2.1 Tools
Section titled “2.1 Tools”export interface ToolDefinition<I = unknown, O = unknown> { name: string; // namespaced: 'finance.project_cashflow' description: string; // shown to the model tier: Tier; // 'auto' | 'draft' | 'gated' | 'session' tierFor?(input: I, ctx: ToolContext): Promise<{ tier: Tier; reason?: string }>; input: ZodType<I>; execute(input: I, ctx: ToolContext): Promise<O>; describe?(input: I, ctx: ToolContext): EffectDescription | Promise<EffectDescription>; claim?(input: I, ctx: ToolContext): Promise<void>; // hold it, just before dispatch timeoutMs?: number; reusableApproval?: boolean; // the owner may remember their yes producesArtifacts?: boolean; // its output names files it saved untrusted?: UntrustedKind; // its output is a page, a mail, a file, a chat, a connected service's answer sequential?: boolean; // dependent calls are skipped after it fails waitsForOwner?: boolean; // a success ends the run: the owner decides next image?(output: O, ctx: ToolContext): Promise<{ mime: string; data: string } | undefined>;}Choosing a tier. packages/core/src/registry.ts runs exactly one inline:
export const EXECUTABLE_TIERS: readonly Tier[] = ['auto'];export const GATED_TIERS: readonly Tier[] = ['gated'];auto— a read, or a write to your own schema. It runs inline inside the model’s turn. Everything inmemory,artifactsandfinanceisauto.gated— it changes something outside this machine.invokenever runs it: the call becomes an immutable action plus a pending approval, and the model is toldapproval-requiredwith the action id.email.sendand finance’s two destructive tools are the shipped examples.session— it runs only inside a live owner request.invokeexecutes it when, and only when, there is an unexpiredctx.ownerRequest, this tool is named inctx.sessionTools, there is an agent and a conversation, anddelegationDepthis 0. Anything less issession-not-authorized.browser.actis the one shipped at this tier; do not reach for it unless the whole point of your tool is “only while the owner is asking, right now”.draft— refused withtier-not-executable. The machinery it needs does not exist. Declaring it means your tool never runs.
The rule of thumb: if you would want to read the arguments before it happened,
it is gated.
When the tier is a property of the arguments: tierFor. rm -rf . and
ls are both “run a command”, and only the first is the reason approvals
exist. A tool whose cost cannot be read off its name declares the strictest
tier it will ever need and narrows each call:
// The developer plugin. The mode is the owner's, set on the agent's page and// never by the agent; the always-gated rules (docs/developer.md §5) are// a parser over the command's own words.tier: 'session',async tierFor(input, ctx) { const workspace = await workspaceFor(ctx); // the plugin's own record if (!workspace) return { tier: 'gated', reason: 'This agent has no workspace yet.' }; const rule = alwaysGated(input.command); if (rule) return { tier: 'gated', reason: rule }; // "npm install reaches the network." return workspace.mode === 'run' ? { tier: 'auto' } : { tier: 'gated', reason: 'This agent runs commands in edit mode.' };},registry.invoke calls it once, after zod has validated the arguments and
before the tier is acted on.
The declared tier is the floor, not a default. tierFor reads arguments a
model chose — the example above is a parser over attacker-chosen text — so
it cannot be the boundary. It chooses only within the envelope the
declaration already bought:
| Declared | tierFor may return |
What it means |
|---|---|---|
auto |
auto, gated |
Runs, or asks about this one call. session is refused tool-error: a session grant is resolved from the declared tier, so one that was never declared can never be authorized. |
gated |
gated |
A gated tool is gated on every call. auto or session is refused tool-error — a rule written over model-chosen arguments does not get to overrule “the owner sees this first”. |
session |
auto, gated, session |
Every session precondition is checked first, whatever the answer: a live ctx.ownerRequest, membership in ctx.sessionTools, an agentId and a conversationId, and delegationDepth === 0. Only then does tierFor pick between “run it now, under the grant” (auto) and “ask the owner about this one” (gated). |
draft |
— | Refused at register(): a draft tool never executes, so there is nothing to decide per call. |
So a delegate of a developer agent gets session-not-authorized from
developer.run even in run mode, and a scheduled job with no owner request
gets the same. That is the property the declaration is for.
The rest:
- It may return
auto,gatedorsession, and nothing else. A fourth value is a defect in your plugin and the call is refusedtool-error. - A throw is a refusal (
tool-error). A rule that could not be evaluated is not a rule that passed. reasonis one plain sentence naming the rule that decided. When the call is gated it is appended to the preview, so the card the owner reads says why it is being asked.- A tool with a
tierFornever uses a standing permission, even withreusableApprovalset. “Always allow this tool” was said about a call that wasls, and there is nothing in the permission row that could tell it from the call that isnpm install.
Nothing calls tierFor again later: an approved action executes what it
recorded, under the tier it was created with — which the action now carries in
core.actions.tier, inside the hash, and which the Executor asserts is
gated.
invoke fails closed. The refusals, in the order they are decided:
| Situation | reason |
|---|---|
| Name not registered | unknown-tool |
| Zod rejects the arguments | invalid-args |
tierFor threw, asked for a tier that is not one of the three, or asked for one outside the declared tier’s envelope |
tool-error |
Declared session, on any per-call result, without a live owner request / grant / conversation, or inside a delegation |
session-not-authorized |
Tier gated |
not a refusal — approval-required plus an actionId |
Tier gated with reusableApproval, called by a delegate |
tool-error — a delegate never inherits a standing approval |
Tier session with no live owner request, no session grant, or inside a delegation |
session-not-authorized |
Tier draft |
tier-not-executable |
execute threw |
tool-error |
A defect never surfaces as success, and the model never sees a raw exception.
describe is how a gated tool earns its approval. It returns:
export interface EffectDescription { envelope: unknown; // everything that decides what the world will see preview: string; // plain text, short, owner-facing choices?: OwnerChoice[]; // what the owner may set when they approve it}Five rules, all of them load-bearing:
- The envelope is complete. Every recipient including BCC, the resolved
account, the body and its hash, attachment hashes.
core.effect_attemptsstoresenvelope_hashbefore dispatch; if a fact is not in the envelope, the owner did not approve it.email.send’sSendEnvelopeis the model to copy. - The preview is rendered from the envelope and from nothing else. Never from text the model wrote. Plain text, no markdown.
describeis pure and read-only. It runs before any approval exists. Adescribethat sent an email is the exact bug this boundary exists to stop.- A choice is picked, never typed.
choicesis how a tool lets the owner settle something at the moment they approve — which of their addresses a send leaves from, say. The options come from the tool, before anyone is asked; core validates the answer against them and fills an unanswered key in from its default;executereadsctx.choices. “What is approved is what is shown” is untouched: the owner picks among what the envelope already listed. - If you omit it, the registry falls back to the canonical arguments as the
envelope and their JSON as the preview. Honest, complete, and plainly a
fallback.
@buddi/tool-emaildefines aGatedToolDefinitionthat makesdescriberequired; copy that narrowing (packages/tools/email/src/types.ts).
execute on a gated tool is only ever called by the Executor. The single
caller is executeApproved in packages/core/src/actions/execute.ts, which:
- claims the approval atomically (
approved→executing) — two racing workers produce one winner and onealready-claimed; - recomputes
hashArgs(tool, toolVersion, canonicalArgs)and refuses withargs-hash-mismatchif it no longer matches what the owner approved. This is whymanifest.versionmatters: bumping it voids standing approvals; - writes the
effect_attemptsrow before dispatch; - dispatches under a deadline.
ctx.actionId is your idempotency key. It is set only by executeApproved,
from the action being executed. A tool that must not act twice must:
const actionId = ctx.actionId?.trim();if (!actionId) { throw new Error('weather.x: no approved action id in the tool context; refusing');}and then claim on it — atomically, in SQL, not in TypeScript, and in the claim
hook so that a lost claim leaves no effect attempt behind. email.send’s claim
is one statement:
update email.drafts set sent_action_id = $2, updated_at = $3 where id = $1 and sent_action_id is null and status in ('draft', 'edited') -- still live and artifact_id = $4 -- the version the envelope namedreturning ...Every clause is a race somebody can win against you. sent_action_id is null
is the second executor; status is the owner discarding it while the card was
open; artifact_id is the owner saving new text between the executor’s
re-description and this statement — the window describe cannot close, because
describe reads and this writes.
Zero rows back means someone else owns it: if the owner is the same action and
the row is already sent, return the recorded receipt (replayed: true);
otherwise throw, and the Executor settles the approval refused with no attempt
recorded. Re-dispatching is never the answer.
The other half of that guard belongs to everyone who writes the row: every
editor of a claimable row carries and sent_action_id is null in its own
predicate, so nothing can be edited out from under a dispatch that is already
in flight — and every writer that read a version before writing carries that
version too, so two writers who read the same row cannot silently replace each
other.
The Executor reaches claim through ToolRegistry.lookup, which builds the
executable view of a tool field by field. A field it does not copy is a hook
that silently never runs, and for this one the symptom is invisible in the happy
path: the effect still refuses, one step later, as failed with an attempt row
claiming it may have happened. actions.db.test.ts asserts lookup(...).claim
is a function for exactly that reason.
timeoutMs and what unknown means. The Executor waits timeoutMs
(DEFAULT_EFFECT_TIMEOUT_MS when you declare none) and then records the attempt
as unknown — never as failed, and never auto-retried. unknown is the
honest state for “the effect may or may not have happened”: SMTP does not offer
exactly-once, and pretending otherwise is how mail gets sent twice. Only the
owner decides what an unknown means. Set timeoutMs to the longest your
transport can legitimately take (email.send uses 60s), and when your dispatch
throws after the wire was touched, leave the claim in place and record the error
rather than releasing it.
ToolContext, the part every tool uses:
interface ToolContext { buddi?: BuddiHost; // the host: db, owner, clock, files, … (§1.1) conversationId?: string; // provenance agentId?: string; // provenance delegationDepth?: number; // 0 or absent = the owner started this run jobId?: string; actionId?: string; // gated execute only — your idempotency key choices?: Readonly<Record<string, string>>; // what the owner picked on the card signal?: AbortSignal; // check it before each external operation}That is eight of eighteen fields; the rest are for the runtime’s own tools
(group, surface, ownerRequest, sessionTools, nativeSearch,
approvedEffect, suspend, systemContext, toolUseId, provenance) and §9
lists every one of them. Everything else a tool needs is on the host: the
database is ctx.buddi.db, the owner ctx.buddi.owner.id, the time
ctx.buddi.clock.now() — never the wall clock — and a day
ctx.buddi.clock.today(), in the owner’s zone (ctx.buddi.owner.timezone),
never UTC.
The optional fields are optional so every caller keeps compiling. A tool that
needs one must fail closed when it is absent rather than guess — see
resolveScope in packages/tools/memory/src/tools/shared.ts, which refuses to
widen a private note to shared just because no agent id arrived.
2.2 Sources
Section titled “2.2 Sources”export interface Source { id: string; // 'email.inbox-poll' — namespaced, stable; keys the ledger description: string; every: number; // poll period in SECONDS poll(ctx: SourceContext): Promise<void>; watch?(ctx: SourceContext): SourceWatch; // optional push channel, since 1.15}
export interface SourceContext { buddi?: BuddiHost; // the host, bound to the plugin this source belongs to}A source is the half of the contract with no agent in the loop: mail arrives and
a triage run begins, because mail arrived. Core decides only when a source is
due — the ledger is core.source_runs, one row per source id — and hands it a
context. What it polls and how it advances is entirely yours.
A source’s context is the host and nothing else, because there is no call: it
reaches its schema through ctx.buddi.db, the time through
ctx.buddi.clock, logs through ctx.buddi.log — operational lines, never the
owner’s channel; a source notifies nobody — and starts a run through
ctx.buddi.schedule.enqueueRun({ agentId, prompt, dedupKey, conversationHint? }), which needs schedule in uses.
A source whose world can push (IMAP IDLE, a websocket) may also have a
watch: core calls it once the plugin is loaded and calls the stop() it
returns when the plugin is taken out or buddi stops. The watcher keeps its
own connection and calls the source’s own poll code when something changed;
serialising that against the scheduled poll is the source’s job. The poll
stays the safety net, and an older buddi never calls watch. The email
inbox’s IDLE watcher (packages/tools/email/src/sources/idle.ts) is the
worked example.
The rules, all of them learned from packages/tools/email/src/sources/inbox-poll.ts:
- Identity is not a number you invented. An IMAP UID means nothing without
its UIDVALIDITY, so the messages table is unique on
(account, mailbox, uidvalidity, uid). When the server’s UIDVALIDITY changes, every stored uid for that mailbox belongs to a generation that no longer exists: the change is logged, the cursor is re-planted, and the old rows are kept because the unique constraint keeps the generations apart. Find the equivalent fact in your world and store it. - A new cursor starts at now, not at record one. “No cursor” must never
mean “read 140,000 messages from UID 1”. The inbox plants the cursor at
UIDNEXT - 1, andEMAIL_BACKFILL=Nplants it N lower on purpose. The same policy runs on a UIDVALIDITY reset: re-reading a decade of mail is not a re-sync, it is an outage. - Cursor advancement is transactional. Rows and the cursor commit in one transaction, so a crash can re-fetch but can never skip.
schedule.enqueueRunis idempotent ondedupKey. It cannot join yourdb.transaction— the queue is the gateway’s. So commit your rows with the enqueue stamp null, enqueue after the commit, and write the stamp last. A crash in between leaves the row unstamped, the next poll re-enqueues the same key, and the dedup makes that a no-op. Choose a key that is stable for the life of the row:triage:<message row id>, never a timestamp.- Every outbound call has a deadline. A socket can accept your connection and
then say nothing. Time out, close, throw.
runSourceswrites the message tocore.source_runs.last_errorand emitssource.polledwith it, so a source that stopped answering is visible rather than silent. A source that throws never stops the others. - State the offline/retention contract. The inbox’s is: IMAP keeps the
messages, the cursor is where we left off, and a machine that slept a week
walks forward
MAX_PER_POLL(50) messages per poll until it catches up. Nothing is lost the way a webhook is. If your world does not keep the backlog for you, say so at the top of the file. - A missing configuration is a valid state, not a failure. No account configured: log once and return.
A source may also be pure housekeeping — email’s retention source originates no
run and wakes nobody; it rides the contract only for the period ledger.
2.3 Sentinels
Section titled “2.3 Sentinels”export interface Sentinel { id: string; // 'finance.floor-breach' — namespaced, stable; keys the ledger description: string; every: number; // period in SECONDS coalesce?: { windowSeconds: number; maxWaitSeconds: number }; // one wake run per agent per window (host API 1.20) run(ctx: SentinelContext): Promise<Finding[]>;}
export interface Finding { key: string; // stable dedup key severity: 'urgent' | 'info'; title: string; // a short label; stands in when there is no ownerLine detail: string; // the AGENT BRIEF: what to check and do; never shown to the owner ownerLine?: string; // the owner's line: plain, short, what happened and why it matters (host API 1.23) kind?: string; // what sort of fact; one watcher's findings of a kind are one row (1.23) subject?: { id: string; label: string }; // what it is about: an account, a sender (1.23) group?: { title: string; ownerLine?: string }; // how a group reads, `{count}` replaced (1.23) actions?: FindingAction[]; // what the owner can do, the first primary (1.23) agentId?: string; // who should speak about it; resolve it by role wake?: boolean; // an INFO finding that wakes once instead of waiting notify?: { urgency?: 'today'; dedupeKey?: string }; // the agent's line waits for the end of the day cooldownMs?: number; // how long the same fact stays quiet after it spoke data?: unknown; // structured evidence, handed over verbatim}
export interface SentinelContext { buddi?: BuddiHost; // db, owner (with agentForRole), clock, …}Two texts, two readers (host API 1.23). ownerLine is what the owner
reads — the only words the Alerts page, Home, Telegram and the recap show —
so write it for him: “Checking hasn’t been updated in 17 days (last 2340.00
USD), so the cash forecast starts from a guess.” detail is the brief for the
agent that picks the finding up, a wake or the owner pressing Ask: “Read the
draft with email.read_draft and tell the owner what it says.” It is never
drawn. A finding without an ownerLine shows its title, never the brief.
Only decisions are listed. An urgent finding is a row on the Alerts page
and a line in Home’s Needs you; an info one is never listed and waits for the
recap, counted in one line (“12 notes saved for Friday’s recap · Preview”).
Findings of the same watcher and kind are one row — “9 balances not updated
in 2+ weeks”, the subjects inside — titled by group.title.
Every row has a primary action and ways out. actions is the first:
type FindingAction = | { kind: 'open'; label: string; page?: string; item?: string; place?: 'files' } // this plugin's page, or Files | { kind: 'run'; label: string; tool: string; args?: object; confirm?: string; tone?: 'danger' } | { kind: 'fill'; label: string; groupLabel?: string; title?: string; tool: string; args?: object; field: { name: string; label: string; type: 'number' | 'text' | 'date'; value?: string | number; hint?: string } } | { kind: 'ask'; label?: string } // asks the finding's agent, with the brief | { kind: 'dismiss'; label: string }; // "Not needed": quiet until the fact changesA run or fill runs one of your own tools as the owner — the page names
the finding and the action’s index, never a tool, and a gated tool raises its
approval exactly as from your page. A group’s fills that name the same tool
become one quick form, a field per subject (“Update them”); a run is about
one finding, so in a group each finding keeps its own on its row when the
group is expanded (two drafts: a Send and a Discard each), with its own open
when the items differ. Core adds the
ways out itself: Not now (a week), Stop telling me this (silences the
subject, or the whole kind for a group or a finding without one; taken back
in Settings → Watchers) and Clear all. An older buddi ignores all five
fields.
A sentinel is deterministic: SQL and TypeScript, no model, no prompt, no judgement call that needs a sentence to explain. It answers one question — is something true right now that the owner would want to know? — and returns findings. It never speaks to anybody.
The key is the identity of a fact, not a description of it. The same
condition must produce the same key on every run, and a different fact a
different key. floor-breach:2026-10-02 is a key; floor-breach with a moving
date is not — it would report one breach as four across a day of six-hourly runs.
Anchor the key to the date or the row the condition rests on.
What core does with a finding (packages/core/src/sentinels/run.ts):
- A finding already in the table has its
last_seen_attouched and nothing else happens. The same fact is not news twice. - A finding fires when it is new, when it had resolved and came back, or when its
cooldown ran out.
urgentenqueues an occurrence of the gateway’ssentinel-wakemission, then stays quiet forURGENT_COOLDOWN_MS(24 h).infolands in the weekly digest, then stays quiet forINFO_COOLDOWN_MS(7 days). Silence is the default, not an optimization. - A burst can be one run. A watcher that may raise several urgent facts in
one tick (five cards all due this week) sets
coalesce: { windowSeconds: 120, maxWaitSeconds: 600 }: its first wake waits two minutes for company, each one that follows for the same agent pushes the run back by the window again, and none waits longer than ten minutes. The agent then reads every finding in one run (findingsbeside the firstfindingin the occurrence’s payload), and one notification reaches the owner instead of five. Unset, each wake runs at once, as before. - A finding you stop returning is resolved.
resolveMissingmarks every open finding you did not return this run as resolved and clears its cooldown, so if it comes back the owner hears about it. Returning a shorter list is how you say “that stopped being true”. - A sentinel that throws is recorded and never aborts the tick. The message
lands in
core.sentinel_runs.last_error, the tick continues, and the findings of the failed run are ignored entirely — a half-list is not evidence that anything resolved.
Address a finding by role, never by id. ctx.buddi.owner.agentForRole('credit') returns
the id of the agent that answers for a role — the first runnable agent holding
it in roster order — or undefined when nobody does. Held-back and unbound
agents are skipped: an agent this installation cannot run would take the finding
and say nothing.
const { owner } = ctx.buddi!;const agentId = owner.agentForRole('credit') ?? owner.agentForRole('overview');return [{ key, severity: 'urgent', title, detail, ...(agentId ? { agentId } : {}) }];A role is the owner’s word — roles: [credit] in an agent’s frontmatter — and it
survives that agent being renamed, replaced or deleted; agentId: 'credit-coach'
written into your plugin does not, and core will faithfully address a ghost. When
nobody holds the role, leave agentId unset: the finding falls to the wake
mission’s agent, which by construction exists. An unaddressed finding reaches the
owner; a misaddressed one does not.
Two more things that hold in practice:
- Severity is a rule, not a mood. Finance encodes it as constants
(
URGENT_BREACH_DAYS = 7,URGENT_DUE_DAYS = 3) in one file of pure functions and calls into it from every sentinel. Copy that split: a query that shapes rows, then a pure function that decides. It is the only way the judgement is testable without a database. - Reuse the tool rather than re-deriving.
finance.floor-breachcallsprojectCashflow.executeinstead of writing its own projection, so the sentinel and the agent can never quote the owner two different numbers for the same week.
2.3a A number a goal can watch
Section titled “2.3a A number a goal can watch”A goal is core’s object: a target, a deadline, an agent that holds it, and
a rhythm of checks that runs whether or not anybody is talking to buddi
(docs/goals.md). What can be measured is not core’s: it comes from
here.
export interface MetricDefinition { id: string; // 'finance.total_debt' — namespaced under your plugin description: string; // a sentence an agent reads before naming it unit: 'number' | 'currency' | 'percent' | 'count' | 'minutes'; direction: 'down' | 'up'; // which way is better params?: z.ZodObject<any>; // optional narrowing, made strict for you measure(params: unknown, ctx: ToolContext): Promise<{ value: number; currency?: string; asOf: Date; note?: string; } | null>;}Three things to know before you write one:
- It only reads.
measureis always handed a context whosectx.buddi.dbis read-only — the same Postgres read-only transaction a page query runs in (§2.5b) — so it cannot write even through a volatile function of your own. Nothing you do gets you a writable database here, and nothing needs to: a metric answers a number. nullis an answer, not a failure. “No data yet”, “the account has not synced”, “the thing this counts does not exist here”: returnnulland the check records not measurable — with core’s own generic reason, because a barenullcarries no words of its own — and the goal shows “not measured since …” rather than a made-up figure. Ameasurethat throws is treated the same way except that its message is kept as the check’s note, which is the way to say why you cannot answer. Either way one plugin’s bad afternoon does not stop the other eleven goals being checked.- Parameters are strict, including when you declare none. A metric with no
paramsaccepts{}and nothing else, so ameasurethat defensively reads a field it never declared can never be handed one a model chose. What reachesmeasureis what your schema made of the input — defaults applied, values coerced — and that is what the goal stores. - The holder is the agent in
ctx.ctx.agentIdis whichever agent holds the goal being checked, not the owner, so a metric that scopes to an agent scopes to the right one.ctx.buddi.owneris the installation’s, as everywhere else.
Ids are validated at register(): <your plugin>.<name>, lowercase, unique
across the installation, unit and direction in the enums, and params an
object schema, which core makes strict for you. A bad one is a startup error
naming your plugin, not a goal that quietly stops being checked six weeks
later.
Agents see your metrics through goal.metrics and nowhere else, and an
installation without your plugin has no idea the number could exist.
2.4 Suggested missions
Section titled “2.4 Suggested missions”export interface SuggestedMission { id: string; // 'friday-recap' name: string; agentRole?: string; // resolved through the agent catalog agentId?: string; // or pinned by name cron: string; // five fields timezone?: string; // the owner's zone when omitted (Settings → Profile, else BUDDI_TZ) misfirePolicy?: MisfirePolicy; // 'coalesce' | 'latest-only' | 'skip-after-deadline' prompt: string; alwaysDeliver?: boolean; // default false enabledByDefault?: boolean; // default true}A default mission is domain knowledge — “every Friday, recap the week” only means
something because the finance tools exist — so it travels with the plugin.
Nothing is scheduled by installing a plugin: buddi missions add-defaults is the
owner accepting the suggestion.
- Name a role, not an agent.
agentRole: 'recap'lands on whichever agent this installation gave that role.agentIdpins one agent by name and is right only when the mission is meaningless anywhere else. A suggestion whose role nobody claims is skipped with a printed reason — never silently registered on the default agent — and so is one that names neither (packages/gateway/src/missions/defaults.ts). - Misfire policy is what a closed laptop owes the owner.
coalesce(the default) runs the missed occurrences as one;latest-onlyruns just the most recent — a laptop shut for three days owes one morning message, not three;skip-after-deadlinedrops anything past its deadline. alwaysDeliver: truemeans the owner asked for this message whatever it says. The Friday recap is the one mission that has it. Everything else is false.enabledByDefault: falseregisters the mission switched off — a placeholder for work whose tools do not exist yet.- An unattended run must end in
mission.reportormission.silent. WithalwaysDeliver: false, the executor delivers only when the run calledmission.report; a run that ends without calling either is logged as a warning and treated as silent. So write the decision into the prompt: say what counts as worth speaking, and say “otherwise call mission.silent with the one-line reason”. A prompt that does not say this produces a mission that never speaks.
2.5 View descriptors
Section titled “2.5 View descriptors”export interface ViewDescriptor { tool: string; // 'weather.forecast' renderer: 'timeseries' | 'table' | 'bars' | 'keyvalue' | 'tiles' | 'document' | 'diff' | 'terminal' | 'image' | 'preview' | 'envelope' | 'structured'; title?: string; // the canvas panel's heading map: ViewMap; // declarative: paths, columns, formats. Never a function.}The dashboard has a canvas beside the conversation, and it draws tool results on
it. The renderers are shapes, not domains — a line, a table, a bar, a list of
figures — and nothing in packages/web is allowed to know the word “cashflow”.
A view descriptor is the join between the two: your plugin knows what your output
means, the page knows how to draw a line, and this says which is which.
- The mapping is data. It is serialised to JSON and served to the browser by
GET /api/chat/views. No plugin code runs in the page, no build step changes when a plugin is installed, and an installation without your plugin ships none of your mapping. That is whymapholds field paths and column definitions rather than a function: a function cannot cross that boundary. - A path is a path, not an expression.
days,summary.dateRange.from,cards[0].name. If a descriptor needs arithmetic, the tool should be returning the number — the owner cannot audit a calculation that happens in a chart. calendaris dated events.{ kind: 'calendar', query, events, map, views?, default?, hours? }: a week of hours, a month of days or a list of days behind a switch, drawn from the design kit.mapnames each event’sid,title,start,end(ISO instants, or dates for an all-day event,endthe day after) and optionallyallDay,calendar,tone(0–3) andlocation. The page addsfromandto(dates,toexclusive) to the query as the owner moves, so the query must declare both. Needs host API^1.11; docs/plugin-pages.md §4.tabs,heroandtilesare a forecast.{ kind: 'tabs', tabs, default?, pick? }draws views behind a switch, only the chosen one asked for, withpick(options, oroptionsFrom) a second switch written into a page parameter the queries below read.herois one big value with its glyph, word and labelled facts;tilesthe canvas card on a page, laid out as agrid, arowor a sidewaysstrip, withselectwriting the picked item’s key into a parameter. Achartwithseriesdraws a line over bars, each on its own scale. Needs host API^1.12; docs/plugin-pages.md §4.- A day is one panel.
{ kind: 'series-panel', query, points, x, series, tiles, labelEvery? }draws the points’ series behind tabs — an area or bars with the values written on it, on a scale the day fills — over the strip of tiles drawn from the same points; hovering or picking an hour marks it in both. Needs host API^1.13; docs/plugin-pages.md §4. - A form is a conversation.
Field.whenandField.disabledWhenare asked of the form’s own values first — a path that names a field on the form reads what the owner has just typed — and of the loaded data otherwise, so ticking a box reveals or greys a field with no round trip. A field the form is not asking for — hidden bywhen, greyed bydisabledWhen— is not required and is not submitted: two branches may each have their own required field, and the owner only ever fills the one they are on. A select whose options are a read rather than a list takesoptionsFrom: { query, rows, value, label, dependsOn? }: it loads when the form opens, and again whenever a field independsOnchanges, with those fields’ values as the query’s parameters (mailbox, then what is in it). A single select may carryaction: { tool, label, icon?: 'play', args? }: a small icon button after it that runs the tool with the form’s current, unsaved values (argsas a submit’s, or all active values without them) and refreshes nothing, so the choices stay. Speech’s play button beside the Voice is one. - A sound, played and forgotten. A tool a page calls may answer
{ play: { mime: 'audio/…', data: '<base64, ≤ 512 KB>' }, message? }: the dashboard plays it through one shared player from a blob URL and stores nothing, and showsmessageif there is one. A field’sactionshows a spinner while the tool runs and a stop button while the sound plays. - Say what happened.
ToolRef.doneis the sentence shown after a write worked — a string, or aValueRefread out of the tool’s own result. A gated tool says it once the approval has executed, because that is when it is true, and theValueRefthen reads what that execution returned. A write that failed leaves its sentence on the page and re-reads the component’s query underneath it, so a save refused because the draft moved on shows the refusal and the draft as it now stands. - A query may refuse in words. Throw
QueryRefusalfromproduceand the owner gets that sentence with a 400 — “No conversation here has that id.” Anything else you throw is a defect: one generic sentence, and the detail in the log. - Rows the owner acts on. A
listwithselectdraws a select-all box over the rows they may act on, and aBulkActionwithall: trueis about every one of them when nothing is ticked.{count}in a label or a confirmation is that number,{one|many}is the word that goes with it, and aRowAction’s — or abutton’s — label, confirmation anddonemay carry{field}placeholders read from the row it stands in (“Remove {address}?”, “Fetch what {from} sent”). ARowAction.whenoffers the action only on the rows where it holds — Fetch, until there is a file. - State, in the tone the data names.
ListItem.pilltakes atonethat may itself be a path,ListItem.pillsdraws several, and a table column withpill: { tone }draws its cell as one — and when that cell’s value is an array, each{ value, tone }item is its own pill, so “off” and “from .env” are two facts in one column rather than a sentence. Asectionmay carryactions— a link or a button — beside its heading. - A link to a settings page is a Settings entry. A
RouteRefnaming a page whoseplaceissettingsresolves to#/settings/p.<plugin>[.<page>], never to a place of its own: the owner lands on Settings with that entry open. - One link leaves the plugin: an agent’s chat.
{ chat: ValueRef }in place of{ page }names an agent id read out of your data, and the dashboard opens that agent’s conversation. It is the exception to “never another plugin, never a core page”, and it is narrow on purpose: it opens a chat the owner already has, and a descriptor cannot write a URL. Use it for something that belongs to an agent — a goal’s holder — not as a way out. - And one to your proposed rules.
{ proposals: true }opens Settings → Proposals filtered to your plugin’s rules. It names nothing; the dashboard fills in the plugin drawing the page. For a plugin withpolicies, it is where the rules it learned wait, instead of a second copy on your own page. - A tone is one of five.
good,warning,critical,neutral— andaccent, for the thing this screen is about: a draft waiting on the owner is not good or bad. A figure never takes the accent; a pill and a notice may. - The rest of the set, in one line each.
list-detailhasselection: 'route' | 'local'—localfor a second level inside a detail the URL already owns.searchtakesrows, and optionallycount,note(both paths),auto: truefor a picker with no button andreset: truefor a Clear beside it.editortakesfootnote,readOnlyWhenand aversionpath;FieldtakesdisabledWhen. AToolReftakesbusy(its label while it runs),pending(a gated action’s sentence, drawn above its approval card while it waits: “Nothing has been sent…”) andplacement: 'leading'(left of the toolbar, with a spacer after it). Alistmust say what keys a row —key, or aselect.key; a row without one, or with one another row already used, is not drawn. - Write a shared piece once. Two rows may carry the same action object and
two sections the same
ListItem: reuse is reuse, not a cycle, and only a descriptor that contains itself is refused. Nesting is counted in components — eachbodylevel — up to twelve; the arrays and the small objects between them are not levels. - A running process is a renderer too.
previewtakes{ src, title?, output? }:srcis a path into your result, and what it finds must be the string/preview/<plugin>/<name>/— which names a process rather than being a URL the page may load. The panel reads the plugin and the name out of it, asksGET /api/preview/<plugin>/<name>/link(§2.5c), and frames the ticketed URL that comes back, with “Open in a tab” beside the heading — which asks for a ticket of its own — and the text atoutputnext to the frame. The frame is cross-origin, so nothing sizes it to its content, and it is sandboxed as well (allow-scripts allow-forms allow-same-origin allow-modals allow-downloads): the origin is what keeps the app out of the dashboard, the sandbox is what keeps it from navigating the top window. Anything else — another path, another site, a path that resolves out of the prefix — names no process and is drawn as nothing: a descriptor is data from a plugin, and “put this URL in an iframe on the dashboard” is not a sentence a plugin gets to say. - Small cards are a renderer too.
tilestakes{ items, icon, value, label, lines?, tone?, empty?, notice? }:itemsis the path to an array, and every other path is read within one item — a bigvalue(“64° / 55°”), alabel(“Tuesday”), at most two smalllinesand atonepath.iconis{ path }or{ const }naming one of a pinned set — the page icons (mail,money,calendar,people,file,chart,bell,plug,key,globe) and the sky (sun,partly-cloudy,cloud,rain,drizzle,snow,storm,fog,wind,moon-clear, and since 1.22moon-cloud, a partly cloudy night); a constant outside it is refused at load, and a path that finds anything else draws a neutral dot. The values are already formatted: the tool writes “64°”, in the owner’s units, and the page draws it.notice: { text, icon?, link?: { page } }is the one card drawn instead whentextfinds a sentence — a tool that is not set up yet answers{ message: "No calendar is linked yet…" }rather than failing, and the card links to your page where it can be (a page you do not contribute is a startup error). Needs host API^1.10. - It is validated at load.
ToolRegistry.registerparses every descriptor with zod (packages/core/src/views.ts), checks the renderer against its own map shape, and refuses a descriptor naming a tool your manifest does not contribute. A typo is a startup error naming the plugin and the tool, not an empty panel nobody can explain. - Not every tool deserves one. A result whose shape carries meaning the
digits do not — a balance over time, utilization against its limit, spending
by category — earns a descriptor. Everything else falls back to
structured, a readable view of the JSON, which is often the honest answer.
The shipped example is examples/plugins/weather/src/views.ts: eight lines that
turn a forecast into a line chart with freezing drawn on it. The real thing is
the finance plugin’s src/views.ts (in the buddi-plugins repository), which
has thirteen.
An agent can also draw deliberately, with the platform’s own canvas.show — for
something it worked out that no single tool result covers. Precedence in the
page is: an explicit canvas.show in the run, else a declared descriptor for the
tool, else structured.
2.5a Where a plugin appears in the dashboard
Section titled “2.5a Where a plugin appears in the dashboard”Every place a plugin can put something in front of the owner, and the field
that puts it there. There is no other way in: nothing in packages/web knows a
plugin by name, and a plugin ships no page code.
| Place | What appears | Comes from |
|---|---|---|
| Home, “Needs you” | An urgent finding, until it resolves or is snoozed | a sentinel’s urgent finding (§2.3) |
| Home, blocks | A read-only card in your own units (a balance, a count, a date) | home (HomeContribution[]) |
| Home, glances | One line beside the date under the greeting (“☁ 18°C Lyon”), at most three, which the owner can hide. A glance that also sends a card (a figure, a line, a sparkline, a foot) is offered as a small widget; while a widget of the glance’s id is on Home, the line steps aside |
home with placement: 'glance' |
| Home, widgets | A small live panel the owner places, orders and sizes in Home’s Widgets section: a figure, a few rows, a strip of tiles, a bar or a sentence, refreshed on your schedule | widgets (§2.5d) |
| Home, “On offer” | A suggested mission the owner can run in one tap | missions (§2.4) |
| Chat canvas | A drawing of a tool result (table, series, figures, envelope) | views (§2.5), or an explicit canvas.show in the run |
| The rail | A place of your own, with its own URL and a pinned icon | pages with place: 'rail' (§2.5b) |
| Settings | An entry of your own in the Settings list, after All plugins, alphabetical by title, with your pinned icon |
pages with place: 'settings' (§2.5b) |
| Approval card, everywhere | The envelope your gated tool described, and any choices it declared |
a gated tool’s describe (§2.1) |
| Watchers | One row per sentinel: description, cadence, last run, on/off switch | sentinels (§2.3); the switch is core’s |
| Agents, “Proposed” | An agent or skill you suggest, awaiting the owner’s approval | agents, skills (§2.6) |
| Plugins | Your name, version, source, provenance, the hosts you reach, what you proposed | the manifest itself, network |
| Weekly recap | A finding that did not wake anyone, read out once | any info finding (§2.3) |
Not available to a plugin, on purpose:
- Code in the page. No plugin JavaScript ever runs in the dashboard. A view descriptor and a page descriptor are data the page interprets, and that is the whole boundary: a page is a tree of the components below, and the set does not grow to fit one plugin’s wish.
- Free layout, custom styling, your own components. The dashboard draws
your page with its own primitives, in the owner’s theme. A plugin that needs
its own interface serves its own app through the developer proxy
(
docs/developer.md): buddi proxies it behind the dashboard’s session and the rail links to it. - A place inside a core page. You add beside Home, Settings and the rail, never inside them. Home blocks and view descriptors remain the way into Home and the canvas.
2.5d Widgets: a live panel on Home
Section titled “2.5d Widgets: a live panel on Home”A widget is a small panel the owner can put on Home or on the lock screen, move, resize, set up and take off. You declare it in the manifest and answer a body when asked; the dashboard draws the frame (title, menu, stale and error states) and the body, from a fixed vocabulary. No HTML, no styling, no plugin code in the page.
Each time the owner puts your widget somewhere is a placement: the widget, a size and its own settings. The same widget can sit twice on Home (the weather at Home and at Work) and on the lock screen with different settings; every placement keeps its own, and nothing is shared between two of them.
import type { WidgetDefinition } from '@buddi/core/plugin';
export const todayWidget: WidgetDefinition = { id: 'calendar.today', // <plugin>.<name>: what the owner's layout remembers title: 'Today', // the frame's title and the gallery's name, ≤ 40 characters sizes: ['medium', 'small'], // what it can be drawn at; the first is where it starts refreshSeconds: 300, // 60–86400; ten minutes when left out link: { page: 'agenda' }, // a page of yours the body opens (optional) // sensitive: true, // hidden on screen until Show, never on a lock screen settings: [ // what each placement can set (host API 1.19), at most eight { key: 'calendars', kind: 'multiselect', label: 'Calendars', hint: 'None ticked: all of them.', options: async (ctx) => (await listCalendars(ctx.buddi!.db)).map((c) => ({ value: c.id, label: c.name })) }, { key: 'days', kind: 'select', label: 'How far ahead', default: '2', options: [{ value: '1', label: 'Today' }, { value: '2', label: 'Two days' }, { value: '7', label: 'A week' }] }, { key: 'time', kind: 'timeFormat', label: 'Times' }, ], async produce(ctx, { size, settings }) { // Read-only: ctx.buddi.db is the read-only pool a page query runs in. // settings: this placement's, resolved — { calendars: string[], days: '2', time: '12h' | '24h' | null }. return { kind: 'list', rows: [{ title: 'Dinner with Ana', sub: 'Le Kitchen', side: '20:00' }], more: '2 more by tomorrow night' }; },};
export const manifest: PluginManifest = { /* … */ widgets: [todayWidget] };The six bodies, every value already formatted in your units:
kind |
Fields | Drawn as |
|---|---|---|
stat |
value, icon?, caption?, trend?: { label?, points }, foot? |
a figure with a glyph, a quiet line, a sparkline (2–48 numbers, no axis), a foot — the weather card |
list |
rows: { title, sub?, side?, tone?, image?: { asset } }[], more?, max?: 3 | 5, wrap? |
up to three rows (a small widget leaves out sub), the right-hand figure, a foot; since 1.27 a row’s image.asset, a key of your assets, leads it as a small logo at both sizes and on the lock screen (anything else is left off), max: 5 asks for five denser rows at medium (small and the lock screen draw three), wrap lets a title take two lines, and a title may be 120 characters |
strip |
items: { label, icon?, value }[], icon?, value?, caption? |
a headline and a row of tiles: four on small, six on medium (eight kept) |
progress |
value, ratio (0–1), caption?, foot?, tone? |
a figure and a bar |
text |
text (≤ 160), icon?, sub? |
a glyph and a sentence: for “add a place first” as much as for news |
clocks |
home (the owner’s IANA zone), clocks: { label, zone, latitude?, longitude? }[] (1–4, labels ≤ 24; coordinates since 1.24, both or neither), time?: '12h' | '24h' |
analog faces side by side, four on medium and two on small, that the page ticks itself from each zone — no new answer every minute; a light face from sunrise to sunset at the face’s coordinates (6:00–18:00 in its zone for a face without them) and a dark one at night; under each the label, Today / Tomorrow / Yesterday against home and the offset (“+6 h”, “−1 h 30”); a first face in home reads “Here”. Zones must be IANA names. Host API 1.21 |
Glyphs are the tile icons (views.ts); one outside the set is left off.
Values are cut at 12 characters and lines at 40, with an ellipsis; a body
with no figure, no rows or an unknown kind is refused and the frame says
the widget could not load. null means nothing to show right now. Answer a
text instead when there is something to say (“Link a calendar on Settings →
Calendar to see your day here”).
Settings (host API 1.19). A widget may declare up to eight fields; the owner sets them per placement in a sheet that shows the placement live as they change. The page draws them from a fixed vocabulary and knows nothing of your plugin:
kind |
Extra fields | Drawn as | settings[key] in produce |
|---|---|---|---|
select |
options, default?, inTitle? |
three short choices as a segment, more as radio rows | the chosen value, or the default (the first option when none) |
multiselect |
options, default?, inTitle? (1.27) |
tick rows | the chosen values; empty means “all” — say so in hint; with inTitle the placement is named by every option ticked (“Top stories · AI, US politics”) |
toggle |
default? |
a checkbox | true or false |
text |
placeholder?, max? (≤ 120, 60 by default), default? |
a line | the line |
place |
multiple? (up to three), inTitle? |
the owner’s places (Settings → Profile) as rows, or any town found by name | { id, label, name, latitude, longitude, timezone } (id null for a town), or a list; one left unset is the owner’s Home, or null with no place |
timeFormat |
— | Profile (12-hour or 24-hour, what it reads as now), 12-hour, 24-hour | '12h', '24h', or null for Auto, with the owner’s Profile applied |
Every field has a key (a lowercase letter, then letters, digits or _), a
label (≤ 40) and an optional hint (≤ 160). options is a list of
{ value, label } (1–24), or a function options(ctx) read when the sheet
opens — your calendars, mailboxes, places — on the same read-only pool as
produce, kept for a minute. inTitle: true names the placement by that
field’s choice when it is not the default: “Weather · Work”. What is kept is
only what differs from the defaults, a place by id so a renamed or moved
place follows; a choice you no longer offer is refused when the owner saves
and falls back to the default when read. A buddi from before 1.19 calls
produce(ctx, { size }): read settings with ?? {}.
What buddi does with it: produces each placement at most once per
refreshSeconds per size and resolved settings — two placements set alike
share one — (a failure is tried again after a minute), gives it five seconds,
and keeps the last good body, so a widget that fails a refresh is drawn with
it and marked stale while the rest of Home is untouched. size is the size
the owner chose, so a medium can say more. The placements live in the
installation (core.web_settings under widgets, Home’s and the lock
screen’s apart); until they arrange anything, Home shows every widget that
is not sensitive, and the lock screen the first four of Home’s. The lock
screen holds at most four, compact, never a sensitive one, and leaves out
one with nothing to show; a text body there always takes one column, so a
sentence like “Free for the rest of today.” reads as one rather than a hollow
card.
buddi itself provides one widget, the World clock (buddi.clock): the
time at the owner’s places in other zones, or at towns they pick. Its Style
setting draws it Digital (a figure or rows) or Analog (a clocks body: the
owner’s own zone first, then the places).
A widget with the same id as one of your glances replaces that glance’s card:
while it is on Home, the glance’s line leaves the date line. That is how the
Weather plugin moved its card: weather.now is both its glance and its
widget, and the glance still sends card for a buddi from before widgets. A
glance that sends a card and has no widget of its id is offered as a small
stat widget, so an older plugin’s card still reaches Home.
Host API 1.17; settings 1.19, and the tile icons check and clock; the
clocks body 1.21. An older buddi ignores widgets and settings, so
declaring them need not mean asking for ^1.19; one before 1.21 refuses a
clocks body as a kind it cannot draw, so a widget that answers one asks for
^1.21. See packages/core/src/widgets.ts and
packages/core/src/widget-settings.ts.
2.5b Pages: a screen of your own
Section titled “2.5b Pages: a screen of your own”export interface PageDescriptor { id: string; // 'mail', 'settings' — unique in your plugin title: string; place: 'rail' | 'settings'; icon?: 'mail' | 'money' | 'calendar' | 'people' | 'file' | 'chart' | 'bell' | 'plug' | 'key' | 'globe'; order?: number; data?: QueryRef; // one read for the page itself, resolved once body: Component[]; // the tree, from the fixed component set}
export interface PageQuery { name: string; // 'threads', 'accounts' params: z.ZodTypeAny; // validated; unknown keys refused produce(params: unknown, ctx: ToolContext): Promise<unknown>; result?: z.ZodTypeAny; // validated before it leaves, when given sensitive?: boolean; // masked on the page, left out over MCP unless asked}A page is pages plus queries, and it follows the same rule views do: a
screen is data the page interprets; no plugin code runs in the browser. The
full contract is docs/plugin-pages.md; the shape of it is:
- Reads are queries, writes are tools. A component that shows something
names one of your
queries; a button that changes something names one of yourtoolsand is invoked as the owner. Anautotool executes; agatedtool yields an approval and the page draws the card in place, choices and all. There is no third path, so everything the owner can do from your screen is something an agent could be granted, audited the same way. - A query cannot write, and Postgres is what says so.
produceis handed aToolContextwhosectx.buddi.dbruns every statement inside a read-only transaction with a five-secondstatement_timeoutand rolls it back.insert,update,delete,create,select … into,nextvaland a large-object write are all refused including inside a volatile function you wrote yourself — a textual check could never have decided that. A cheap pre-filter in front of it refuses what is plainly not a read (the first keyword, a second statement, a locking clause); it is not the boundary. - Every parameter arrives as a string, because a query string is strings.
Write
z.coerce.number()for a number andz.enum(['true','false'])for a flag; a parameter your schema does not declare is refused, not ignored. - The components are a fixed set, each a shape:
section,notice,link,progress,chart,stats,list,table,detail,form,search,list-detail,repeat,calendar,tiles,series-panel,hero,tabs,expand,button,approval,artifact,editor. Every one may carrytitle,note,emptyandwhen. Paths are view paths, exactly as in §2.5. repeatis how a row becomes a sub-tree.{ kind: 'repeat', query, rows, key, body }drawsbodyonce per row with that row as its data, so a message’sexpandfetches its own body, a row’sbuttonacts on its own id, andartifact,approvalandwhenall work per row.whenis the only logic, and it is a condition, not an expression:{ path, equals }, or{ path, in: [...] }for “one of these”, withnot: trueto invert. The same shape isField.disabledWhen,editor.readOnlyWhenandSelection.disabledWhen.- A page may have its own read.
PageDescriptor.datais resolved once and is the data the top ofbodyis drawn against — without it awhenat the top level has nothing to be about. - Text is a constant or a value.
notice.text,expand.label,progress.labelandprogress.doneaccept aValueRefas well as a string, andToolRef.confirmmay contain{count}, replaced by the size of the selection. progressis a bar.{ kind: 'progress', value, total?, label?, done? }:valueis a fraction 0–1, or a count of bytes againsttotal, drawn as the bar and “7 of 252 MB · 2%”;donereplaces the bar once it is full.chartis a small trend.{ kind: 'chart', query, rows?, x, y, type?, label?, target? }:xandyare paths within a row (yup to four for several series),rowsthe array in the answer or left out when the answer is the array,typeline(default) orbar, andtargeta value in the answer drawn as a dashed line. A row whoseyis not a number is a gap.- A form is a conversation.
Field.whenandField.disabledWhenare asked of the form’s own values first — a path that names a field on the form reads what the owner has just typed — and of the loaded data otherwise, so ticking a box reveals or greys a field with no round trip. A select whose options are a read rather than a list takesoptionsFrom: { query, rows, value, label, dependsOn? }: it loads when the form opens, and again whenever a field independsOnchanges, with those fields’ values as the query’s parameters (mailbox, then what is in it). - Say what happened.
ToolRef.doneis the sentence shown after a write worked — a string, or aValueRefread out of the tool’s own result. A gated tool says it once the approval has executed, because that is when it is true. A write that failed leaves its sentence on the page and re-reads the component’s query underneath it, so a save refused because the draft moved on shows the refusal and the draft as it now stands. - A query may refuse in words. Throw
QueryRefusalfromproduceand the owner gets that sentence with a 400 — “No conversation here has that id.” Anything else you throw is a defect: one generic sentence, and the detail in the log. - Rows the owner acts on. A
listwithselectdraws a select-all box over the rows they may act on, and aBulkActionwithall: trueis about every one of them when nothing is ticked.{count}in a label or a confirmation is that number,{one|many}is the word that goes with it, and aRowAction’s label and confirmation may carry{field}placeholders read from the row (“Remove {address}?”). ARowAction.whenoffers the action only on the rows where it holds — Fetch, until there is a file. - State, in the tone the data names.
ListItem.pilltakes atonethat may itself be a path,ListItem.pillsdraws several, and a table column withpill: { tone }draws its cell as one. Asectionmay carryactions— a link or a button — beside its heading. - A link to a settings page is a Settings entry. A
RouteRefnaming a page whoseplaceissettingsresolves to#/settings/p.<plugin>[.<page>], never to a place of its own: the owner lands on Settings with that entry open. - One link leaves the plugin: an agent’s chat.
{ chat: ValueRef }in place of{ page }names an agent id read out of your data, and the dashboard opens that agent’s conversation. It is the exception to “never another plugin, never a core page”, and it is narrow on purpose: it opens a chat the owner already has, and a descriptor cannot write a URL. Use it for something that belongs to an agent — a goal’s holder — not as a way out. - And one to your proposed rules.
{ proposals: true }opens Settings → Proposals filtered to your plugin’s rules. It names nothing; the dashboard fills in the plugin drawing the page. For a plugin withpolicies, it is where the rules it learned wait, instead of a second copy on your own page. - The rest of the set, in one line each.
list-detailhasselection: 'route' | 'local'—localfor a second level inside a detail the URL already owns.searchtakesrows, and optionallycount,note(both paths) andauto: truefor a picker with no button.editortakesfootnote,readOnlyWhenand aversionpath;FieldtakesdisabledWhen. AToolReftakesbusy(its label while it runs),pending(a gated action’s sentence, drawn above its approval card while it waits: “Nothing has been sent…”) andplacement: 'leading'(left of the toolbar, with a spacer after it). Alistmust say what keys a row —key, or aselect.key; a row without one, or with one another row already used, is not drawn. - Write a shared piece once. Two rows may carry the same action object and
two sections the same
ListItem: reuse is reuse, not a cycle, and only a descriptor that contains itself is refused. Nesting is counted in components — eachbodylevel — up to twelve; the arrays and the small objects between them are not levels. - It is validated at load.
ToolRegistry.registerparses every descriptor and checks every reference: a query name you do not contribute, a tool that is not yours, a link to a page that does not exist. A typo is a startup error naming the plugin, the page and the field path. So is a descriptor that is not a screen: components nested deeper than 12, more than 400 nodes, more than 64 KB of JSON, or an object that contains itself. - A page writes only through the tools its own descriptors name.
POST /api/pages/<plugin>/actrefuses every other name, including your own plugin’s other tools: those are an agent’s business. It is rate-limited to 60 writes a minute per session, and it is CSRF- and Origin-checked like every other write. - Your routes are yours. A rail page is
#/p/<plugin>/<page>, an item inside alist-detailis#/p/<plugin>/<page>/<itemId>, and a settings page is the tab#/settings/p.<plugin>(or#/settings/p.<plugin>.<page>when you ship several). Thep.is always there, so a plugin calledmemoryorbackupcannot land on a core section’s hash.
Two limits on an answer, so a screen stays a screen: a query’s statement runs
in a read-only transaction with a five-second statement_timeout, and an
answer of more than 2,000 rows in any array or 1 MB of JSON is refused with a
502 naming the query. What the browser is told about any failure is one
sentence — “The <plugin> plugin could not answer <query>.” — with a
reference; the detail goes to the installation’s log.
owner and room are ids nothing may be called: they are what the ledger
writes for the owner themselves and for a group’s own voice. An installation
that already has an agent by one of those names still starts — the file is
read, said out loud in the log, and listed on the Agents page as held back
with nothing granted — but it is in no roster, answers to no handle, is
nobody’s delegate and can never be the default.
A tool the owner may run from a page but no model should ever see carries
ownerOnly: true (§2.1): the registry leaves it out of the list every provider
and every agent grant is built from, and invoke refuses it for anyone but the
owner’s own path. A tool that stores a secret is the case it exists for.
The worked example is the mail plugin. Its two screens — the Mail place and
the Email settings page — are descriptors like the ones above and nothing else:
packages/tools/email/src/pages/descriptors.ts is the whole of what the owner
sees, queries.ts is every read behind it, and accounts.ts,
policies-tools.ts and drafts-tools.ts are the ownerOnly tools it writes
through. It was compiled into packages/web until this existed, which is why
it is worth reading: a list-detail with a URL per conversation, a repeat of
editors, a gated Send whose approval card is drawn in place, a drawer that
takes a password, and two lists with bulk actions over a selection.
2.5c A process the owner can see
Section titled “2.5c A process the owner can see”export interface PluginManifest { previews?: PreviewProvider;}
export interface PreviewProvider { /** The port behind `/preview/<plugin>/<name>/`, or null when there is none. */ resolve(name: string, ctx: ToolContext): Promise<{ port: number; host?: '127.0.0.1' } | null>;}A plugin that starts long-lived processes — the developer plugin’s
developer.start, a dev server, a docs site — has something running on a
loopback port that only that machine can reach. previews is the one door
through it.
The trust boundary, plainly. The app behind that port is untrusted code:
it is what an agent wrote a minute ago, most likely from input somebody else
chose. So it never shares the dashboard’s origin, the dashboard’s cookies or
the dashboard’s API. It is served from a second loopback listener on a second
port (the dashboard’s plus one, tried a few doors along when that is taken,
or BUDDI_PREVIEW_PORT), which serves
/preview/<plugin>/<name>/… and answers 404 to everything else — no /api,
no assets, no session. An iframe sandbox attribute would not have done this
job: with allow-same-origin the frame keeps the origin, without it most dev
servers stop working, and “Open in a tab” bypasses the frame either way.
Getting in is an exchange, not a cookie you already have. The dashboard’s session cookie is host-only, and browsers ignore ports when they decide what to send, so it does arrive at the preview port — and is never looked at there. Instead:
GET /api/preview/<plugin>/<name>/linkon the dashboard, behind the dashboard’s own gate, answers{ url }: the preview origin, that preview’s path, and a single-use ticket good for five minutes.- Opening it spends the ticket and sets
buddi_preview— HttpOnly,SameSite=Lax,Path=/preview/<plugin>/<name>, 24 hours — then redirects to the clean path. A ticket names the preview it was minted for: it is not a key to another one, and neither is the cookie. - Every later request and every websocket upgrade needs that cookie. Anything
else is
401with no body and noLocation.
resolve is asked on every request, and may only answer with a port it is
actually running. It is a lookup, not a launcher: it starts nothing, and a
null — or a throw, or a plugin with no previews — is 404. The gateway
forces the host to 127.0.0.1 whatever you return, and refuses a port outside
1024–65535, Postgres’s 5432, and either of its own two listeners (a preview
pointed at the preview listener is an infinite proxy loop). Everything else is
yours to get right: return the port of the process you started under that name
and nothing else, because a port you hand over is a port the owner’s browser
will be pointed at.
What crosses. Up: the method, the path below the prefix, the query, the
body, and every header except the hop-by-hop ones (including the fields the
request’s own Connection nominates), authorization, the dashboard’s CSRF
header, and buddi’s own three cookies — the app’s other cookies go through,
because it has an origin now and they are its own. Plus
x-forwarded-prefix: /preview/<plugin>/<name>, which a framework that can be
told its base path should be told from. Down: the status, the headers, and the
body byte for byte, with each Set-Cookie’s Path scoped into the prefix, any
Set-Cookie named after one of buddi’s dropped, Cache-Control: no-store,
X-Content-Type-Options: nosniff and — unless the app sent its own —
Content-Security-Policy: frame-ancestors <the dashboard>. Both directions
stream; nothing is buffered. Websockets are proxied on the same listener, so
hot reload works.
Nothing in the body is rewritten. An app that assumes it owns the root of a
host — <script src="/main.js"> — breaks under a path prefix, and an HTML
rewriter that is wrong is worse than an app that is plainly broken. So the
proxy scans the first 64 KB of the first HTML response for src="/…" and
href="/…" outside the prefix, and GET /api/preview/<plugin>/<name>/check
answers { ok, absoluteAssets }. ok is false when nothing serves that
preview at all. Your tool reads it and warns the owner, with “Open in a tab” as
the answer.
Knowing the port. Do not compute it. “The dashboard plus one” is wrong the moment that port was taken, and a route built on a guess points at nothing. The gateway publishes the port it actually bound in two places, both of which say the same number:
ctx.buddi.pages.previewPort()— anumber, orundefinedwhen previews are not being served;ctx.buddi.pages.previewUrl(name)is the wholehttp://127.0.0.1:<port>/preview/<plugin>/<name>/;BUDDI_PREVIEW_PORTin the process environment, set after the listener binds, for a plugin that reads configuration rather than context.
Neither is how a preview is reached — that is the link route, which mints a credential. They are the number, for building a URL that is not the gateway’s to build.
On the tailnet. The dashboard’s own tailscale serve does not publish this
port. A plugin that wants a preview reachable from the tailnet asks for it
itself — tailscale serve --https=<port> http://127.0.0.1:${ctx.buddi.pages.previewPort()} —
and owns turning it off again; buddi does not do it for you, and the sentence
on the page that offers it should say what it exposes.
The preview renderer (§2.5) frames the link route’s answer beside the
process’s output, which is how it reaches the canvas.
2.6 Proposed agents and skills
Section titled “2.6 Proposed agents and skills”export interface SuggestedAgent { id: string; // 'meteo' — the directory name too handle: string; // what the owner types: @meteo name: string; description: string; // one line; other agents read it to hand it work persona: string; // the body of the file, in markdown tools: string[]; // THE GRANT. Names or family globs. roles?: string[]; model?: string; provider?: 'anthropic' | 'openai'; maxTurns?: number; language?: 'mirror' | 'en' | 'fr'; idleRollover?: '3h' | '1d' | '1w' | 'never'; // idle time before a fresh chat (1.16) skills?: SuggestedSkill[]; // written into this agent's own skills/ missions?: SuggestedAgentMission[]; // created with it, run as it offer?: { text: string; query?: string }; avatar?: BundledMascot;}Tools without an agent are a box of parts. You know what a useful agent made of
your tools sounds like, and that knowledge should travel with the plugin exactly
as a suggested mission does — so a manifest can carry agents and, for
procedures every agent should read, skills.
They are proposals, and a plugin can never create one. Creating an agent is
creating a principal, and the tools: line in its file is the only thing that
decides what that principal can reach. So there is no code path anywhere that
writes an agent file because a plugin was installed. What exists instead:
platform.plugin_agents |
tier auto. Every agent and shared skill the installed plugins propose, with the grant each asks for and whether the owner has accepted it. |
platform.accept_plugin_agent |
tier gated. Builds exactly the envelope platform.create_agent builds, from your proposal’s fields. Your proposal names no provider account — it cannot know what this installation has — so unless the owner names one it is given the account the accepting agent speaks through, and the preview says so. |
platform.accept_plugin_skill |
tier gated. The same, for a shared skill. |
Accepting reuses create_agent wholesale, which is the point — your proposal
gets no shorter path to a principal than the owner’s own agent does:
- the same validation, before the action exists. A duplicate id, a taken
handle, a tool that is not installed here, a model that does not belong to the
provider, a file the loader would refuse: every one of them is a refusal in
describe, so the owner is never asked to approve something that cannot happen; - the same preview. It names your plugin and its version, then the whole grant in the registered tools’ own words — what it reaches, tool by tool, and what it does not reach, named;
- the same refusal to hand over the platform’s write tools. A proposal
naming
platform.create_agent— orplatform.*, which resolves to it — is refused outright. One approval must never buy a second agent that can write the installation for ever after.
Whose file is it?
Section titled “Whose file is it?”The owner’s, from the moment they accept. It is written into their private agents directory, and your plugin cannot rewrite it, at any version, ever.
That is a decision with a cost — you cannot ship a fix to a persona — and it is the right one, because the alternative is worse in a way that matters: a file your upgrade could rewrite is a tool grant your upgrade could widen, with no approval anywhere. It would also mean an owner who improved your persona loses the improvement next Tuesday.
So an upgrade tells the truth and changes nothing. A small plugin.json beside
agent.md records which plugin proposed it, at which version, with a hash of
the proposal and a hash of the file as written. buddi plugins install <dir>
on a plugin that is already installed prints, per proposed agent, one of:
meteo — accepted from weather@0.1.0, unchanged on both sides meteo — the plugin proposes a different meteo now (you accepted 0.1.0, this is 0.2.0). Your copy is untouched — accepting again is an approval you make, grant and all. meteo — you have edited your copy; it is yours and nothing will change it meteo — you have not accepted this oneAnd then it stops. If you have changed the proposal and the owner wants it, they accept it again, see the new grant, and approve it — or they do not.
Writing a good one
Section titled “Writing a good one”- Propose the smallest grant that does the job.
weather.*and nothing else. An agent that also asks formemory.*“for context” is an agent the owner has to think about instead of accept. - Ship its skill with it. A persona says who it is; a skill says how it works. Skills listed on the agent are written into its own directory by the same approval, and they grant nothing.
- Write the persona for someone else’s installation. You do not know what else is installed. Say what the agent cannot see, and what it should do when the answer depends on something it cannot see.
examples/plugins/weather/src/agents.ts is the worked example.
An agent whose work outlives an afternoon may say how long its chats sit idle
before a fresh one starts: idleRollover: '1d' (or '1w', 'never'; absent is
three hours). It is written into the accepted file, the approval card says it in
a sentence, and the owner changes it on the agent’s Brain tab. A transcript that
grows too long still rolls over whatever it says. The developer plugin’s agent
asks for a day. Host API 1.16; an older buddi ignores the field and the agent
gets three hours, so setting it need not mean asking for ^1.16.
2.7 Lines in the carry-over note
Section titled “2.7 Lines in the carry-over note”When a conversation rolls over — idle past the agent’s setting, or grown too long — the fresh one opens with a short note written from the old one by the agent’s own model: what we were doing, decisions, open threads, what is next (docs/conversations.md). A plugin can add a few lines of state the transcript cannot be trusted to hold:
carryOver: { async lines({ agentId, conversationId, reason }, ctx) { // ctx.agentId is the agent whose chat rolled over; ctx.buddi is yours. return ['Branch: buddi/dev/dark-mode', 'Last commit: abc1234 Add the toggle']; },},Core prints them under From <plugin>:, keeps at most eight, clips each to 200
characters, takes anything that looks like a credential out, and gives you five
seconds. A throw, a timeout or an empty list is simply no lines; the note and
the rollover go ahead. Facts only — names, not contents: the note is replayed
into every turn of the new conversation. The developer plugin adds the
workspace, branch, last commit and uncommitted files. Host API 1.16; an older
buddi never asks, so a plugin with carryOver need not ask for ^1.16.
2.8 The owner’s places
Section titled “2.8 The owner’s places”Home, Work and any other place the owner names live in core, on Settings →
Profile, beside the timezone: a label, the address as typed, the town it was
matched to, coordinates and the place’s own zone. Declare owner:places and
read them:
uses: ['owner:places'],// ...const places = await ctx.buddi!.owner.places!();// [{ id: 'home', label: 'Home', address: '12 Elm St, Portland, Maine',// name: 'Portland, Maine, United States', latitude: 43.66, longitude: -70.26,// timezone: 'America/New_York' }, ...]Read-only, in the owner’s order (Home, then Work, then the rest), on every context including a page query’s and a widget’s. The card says “reads your places (Home, Work…) and their addresses”. A plugin may still keep places of its own — the weather plugin keeps the extra cities you ask it about — but the owner’s are the first it offers. Host API 1.18.
2.9 Setup: whether you can do anything yet
Section titled “2.9 Setup: whether you can do anything yet”A plugin that is installed but cannot do anything until the owner does one thing — pick a place, link a calendar, pick a model account — says so:
setup: { async produce(ctx) { const places = await ctx.buddi!.owner.places!(); return places.length > 0 ? { ready: true } : { ready: false, note: 'Pick a place for the forecast.', page: 'settings' }; },},produce runs on the read-only pool, like a page query, with five seconds;
note is one short sentence (120 characters at most) and page one of your
own pages’ ids. Not ready, the Plugins row says needs setup with the note
in place of what you contribute and a Set it up button that opens the page;
the notice after the restart that loads you says what to do first; and Home’s
tips may name you a day later. A setup that throws or times out is logged and
counts as no answer: you are shown as loaded and never held back by your own
check. The answer is kept half a minute. A plugin without setup is simply
loaded. Host API 1.18.
2.10 Requiring another plugin, and its exports
Section titled “2.10 Requiring another plugin, and its exports”A plugin built on another says so, with a semver range, in the manifest and in
package.json (the install card is drawn from the second; the two must match):
requires: { weather: '^0.2.0' },"buddi": { "name": "commute", "requires": { "weather": "^0.2.0" } }The card lists each requirement with where it stands (“0.2.0 is here”, “not installed”) and offers Install weather first for a missing one, through the same read-then-approve as any install. Installing is never blocked. Until every requirement is installed, enabled, in range and set up (§2.9), your tools, pages, widgets and watchers do not load; the row says needs weather with what is missing (“Needs setup in weather: Pick a place for the forecast.”) and the one fix (Install, Enable, Update or Set up weather). Your data stays. buddi checks again every minute and whenever the owner changes something on the Plugins page, so you come in the moment the requirement is ready, with no restart. A plugin this build ships satisfies any range.
Plugins never reach into each other. The one road is a named, read-only export:
// weatherexports: { forecast: { description: 'The forecast for one of the owner places, or coordinates.', params: z.object({ place: z.string().optional(), days: z.number().int().min(1).max(10).optional() }).strict(), async produce(params, ctx) { /* reads, with weather's own ctx.buddi */ }, },},
// commute, which requires weatherconst forecast = await ctx.buddi!.plugins!.call('weather', 'forecast', { place: 'work', days: 1 });Core refuses, with a PluginCallRefusal naming why: a plugin not in your
requires, one not loaded, one outside the range, a name it does not export,
arguments its params refuse, and anything taking more than five seconds. The
export runs as the plugin that owns it — its schema, its host — on the
read-only pool, so a write inside it fails as a page query’s does. Never a
tool, never another schema. ctx.buddi.plugins exists only when you declare
requires (or optional, below). Host API 1.18.
A plugin that only works better with another names it as optional instead (host API 1.27), the same way twice:
optional: { speech: '^0.1.3' },"buddi": { "name": "news", "optional": { "speech": "^0.1.3" } }Nothing is held back without it. ctx.buddi.plugins.has('speech') says
whether it is loaded now and in range, and plugins.call reaches its exports
while it is, under the same refusals:
const voice = ctx.buddi!.plugins!.has?.('speech') ?? false;return { readAloud: voice ? settings.voiceEditions : [], readAloudNote: voice ? '' : 'Needs the Speech plugin and a voice.' };3. Schema and migrations
Section titled “3. Schema and migrations”A plugin owns one Postgres schema, named after the plugin: finance, email,
memory, weather. Core owns core and references none of your tables, and
you reference none of its: what you need of core’s rows is an area of
ctx.buddi (the Files library is files, a reminder is
schedule.remindersFor, an approval is approvals), and the import test
refuses a core. table in your SQL (§1.5).
migrationsDir is an absolute path to a directory of *.sql files applied
in filename order, resolved from the built file so it works from dist:
export const MIGRATIONS_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations',);and migrations goes in the package’s files array. Use fileURLToPath, never
new URL('./migrations', import.meta.url).pathname: that form percent-encodes a
space, so under a data directory called owner data — or
~/Library/Application Support/... — the path does not exist on disk. A
migrationsDir that is not there is now an error naming the directory
(migrate used to treat it as “nothing to do”), reported on install as
migrationProblem.
migrate(pool, { schema, dir }) (packages/core/src/db.ts) is per-plugin and
additive:
core.migrationsand the target schema are created if missing;- each file runs in its own transaction with
set local search_path to <schema>, public— so writecreate table if not exists location (...), unqualified, and the file cannot touch another plugin’s tables by accident. At runtime it is the other way round:ctx.buddi.db.querysets nosearch_path, so a statement namesweather.location(§1.1); - applied files are tracked by
(schema, filename)and skipped next time. Files are never re-run and never rolled back: add a new file, never edit an applied one. Number them001_,002_. - the schema name is checked against
/^[a-z_][a-z0-9_]*$/before it is quoted.
runMigrations(pool, manifests, { optional, onProblem }) runs core’s first,
then each manifest’s. optional names the installed third-party plugins: one
of those failing is that plugin failing to load (docs/install.md §7), so it
is reported through onProblem, left out of installedManifests for the rest
of the run and listed by pluginLoadReport — the Plugins page and doctor say
why. Core’s own migrations and a compiled-in plugin’s still throw. Every start
path goes through the gateway’s migrateAtStart(pool, env) — the “newer than
this code” refusal, then migrateInstalled(pool, env), which fills those two
options in — and so does buddi migrate. A manifest with an empty migrationsDir is skipped — that is @buddi/tool-artifacts
saying it owns no schema at all, not a missing path. Its tools read the Files
library through ctx.buddi.files, declared files:library, because a dropped
plugin must not take the owner’s files with it.
How your schema gets applied. A start does it for you, and there are two entry points besides, both over the manifests the gateway has installed:
buddi migrate→runMigrations(pool, installedManifests()), for migrating without starting;pnpm db:migrate→scripts/migrate.mjs, which importspackages/tools/<name>/dist/index.jsfor each name in its list and skips any plugin that is not built (if (!existsSync(entry)) continue). Core with zero plugins is a valid state, so a missingdistis not an error.
Uninstalling. buddi plugins uninstall weather removes the record and the
package directory and keeps your schema, printing its tables and their row
counts so the owner knows what is being kept; --purge --confirm weather is the
separate verb that drops it. Core keeps no reference to your tables either way —
the rows in core.migrations for that schema are the only trace, and they are
harmless. The exception is data core owns on your behalf (artifacts): that
stays. §8 has the whole plan, including what happens to agents that were granted
your tools.
4. A worked example: weather
Section titled “4. A worked example: weather”A complete plugin, in this repository at
examples/plugins/weather. One read tool, one
sentinel, one suggested mission, a view, a proposed agent, one schema holding
the owner’s location, and one declared area, http. Each file is quoted below
as it is, less its opening comment; views.ts and agents.ts are in the
directory. It
builds and its test passes in this workspace; it is deliberately not
registered in the gateway.
package.json
Section titled “package.json”{ "name": "@buddi/tool-weather", "version": "0.1.0", "private": true, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.test.json", "test": "vitest run" }, "dependencies": { "@buddi/core": "workspace:*", "pg": "^8.13.1", "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^22.10.2", "@types/pg": "^8.11.10", "typescript": "^5.6.3", "vitest": "^2.1.8" }, "files": [ "dist", "migrations" ], "buddi": { "uses": [ "http" ], "hostApi": "^1.0" }}tsconfig.json
Section titled “tsconfig.json”{ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", "tsBuildInfoFile": "./dist/.tsbuildinfo" }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.test.ts"], "references": [{ "path": "../../../packages/core" }]}A plugin under packages/tools/ is already in the workspace; this one lives in
examples/, so pnpm-workspace.yaml names examples/plugins/* too.
migrations/001_weather.sql
Section titled “migrations/001_weather.sql”-- weather plugin schema (applied with search_path = weather, public).---- One row: where the owner is. The singleton is enforced in the table rather-- than in TypeScript, so two processes racing cannot leave two locations and a-- forecast for whichever one the query happened to sort first.create table if not exists location ( id integer primary key default 1 check (id = 1), label text not null, latitude double precision not null check (latitude between -90 and 90), longitude double precision not null check (longitude between -180 and 180), updated_at timestamptz not null default now());src/ports.ts — the seam to the network
Section titled “src/ports.ts — the seam to the network”import type { HttpArea } from '@buddi/core/plugin';
/** One day of forecast, as this plugin understands a day. */export interface DailyForecast { /** `YYYY-MM-DD` in the owner's timezone. */ date: string; lowC: number; highC: number; summary: string;}
export interface ForecastQuery { latitude: number; longitude: number; timezone: string; days: number;}
/** * `http` is the plugin's `ctx.buddi.http`: the real adapter goes through it, a * stub ignores it. */export type FetchForecast = (query: ForecastQuery, http: HttpArea | undefined) => Promise<DailyForecast[]>;The forecast service is a parameter, not an import, so the tool and the sentinel
are both testable against a stub that opens no socket — the same thing
@buddi/tool-email does with IMAP and SMTP. The real one is handed
ctx.buddi.http; a stub ignores it.
src/location.ts
Section titled “src/location.ts”import type { DbArea } from '@buddi/core/plugin';
export interface Location { label: string; latitude: number; longitude: number;}
/** The owner's location, or null when nobody has set one. */export async function loadLocation(db: Pick<DbArea, 'query'>): Promise<Location | null> { const { rows } = await db.query<Location>( `select label, latitude, longitude from weather.location where id = 1`, ); const row = rows[0]; return row ? { label: row.label, latitude: Number(row.latitude), longitude: Number(row.longitude) } : null;}
/** * What a caller is told when there is no location. A plugin that needs * configuration says which line fixes it; it never guesses a city. */export const NO_LOCATION = 'weather: no location is set. Insert one: ' + "insert into weather.location (id, label, latitude, longitude) values (1, 'Paris', 48.86, 2.35);";A real plugin would add a small weather.set_location tool at tier auto; it is
left out here to keep the example one tool long.
src/frost.ts — the rule, as a pure function
Section titled “src/frost.ts — the rule, as a pure function”import type { Finding } from '@buddi/core/plugin';import type { DailyForecast } from './ports.js';
/** Below this, tomorrow's low is worth saying out loud tonight. */export const FREEZING_C = 0;
/** * One finding, or none. * * The key is anchored to the date the forecast is *about*, never to "tomorrow": * a watch that runs every six hours must produce the same key all four times, * and a different day must produce a different one. */export function frostFinding(day: DailyForecast, place: string): Finding | null { if (day.lowC >= FREEZING_C) return null; return { key: `weather.frost:${day.date}`, severity: 'urgent', title: `Frost in ${place} on ${day.date}`, detail: `The forecast low for ${day.date} is ${day.lowC.toFixed(1)}°C in ${place} ` + `(high ${day.highC.toFixed(1)}°C, ${day.summary}). Anything outside that ` + 'minds the cold needs covering tonight.', data: { date: day.date, lowC: day.lowC, highC: day.highC, place }, };}src/open-meteo.ts — the adapter
Section titled “src/open-meteo.ts — the adapter”import type { DailyForecast, FetchForecast } from './ports.js';
export const OPEN_METEO_URL = 'https://api.open-meteo.com/v1/forecast';
/** How long one forecast call may take before it is a failure, not a wait. */export const FETCH_TIMEOUT_MS = 10_000;
interface OpenMeteoDaily { daily?: { time?: string[]; temperature_2m_min?: number[]; temperature_2m_max?: number[]; weather_code?: number[]; };}
/** WMO codes, collapsed to the handful of words a person actually wants. */export function describeCode(code: number | undefined): string { if (code === undefined) return 'unknown'; if (code === 0) return 'clear'; if (code <= 3) return 'cloudy'; if (code <= 48) return 'fog'; if (code <= 67) return 'rain'; if (code <= 77) return 'snow'; if (code <= 82) return 'showers'; return 'storms';}
/** Shape the payload into days. Pure, so the parsing is testable on its own. */export function toDays(payload: unknown): DailyForecast[] { const daily = (payload as OpenMeteoDaily).daily; const times = daily?.time ?? []; return times.map((date, i) => ({ date, lowC: daily?.temperature_2m_min?.[i] ?? Number.NaN, highC: daily?.temperature_2m_max?.[i] ?? Number.NaN, summary: describeCode(daily?.weather_code?.[i]), }));}
export const openMeteo: FetchForecast = async (query, http) => { if (http === undefined) throw new Error('weather: no http area, so no forecast (declare uses: http)'); const url = new URL(OPEN_METEO_URL); url.searchParams.set('latitude', String(query.latitude)); url.searchParams.set('longitude', String(query.longitude)); url.searchParams.set('timezone', query.timezone); url.searchParams.set('forecast_days', String(query.days)); url.searchParams.set('daily', 'temperature_2m_min,temperature_2m_max,weather_code');
// Through `ctx.buddi.http`, never the global `fetch`: it is the one // transport every long-lived caller shares, one connection per request, // with the address guard in front (docs/plugin-host-api.md §4). const response = await http.request({ url: url.toString(), method: 'GET', headers: { accept: 'application/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), idleTimeoutMs: FETCH_TIMEOUT_MS, }); if (!response.ok) { throw new Error(`weather: forecast service answered ${response.status}`); } return toDays(await response.json());};Outbound HTTP
Section titled “Outbound HTTP”A plugin that talks to the network declares http in uses and sends through
ctx.buddi.http.request({ url, method, headers, body, signal, idleTimeoutMs, maxBytes }) — never the global fetch, never undici, never its own
node:http(s) client with an agent. The weather plugin hands the area to its
adapter as the second argument of FetchForecast, so a stub in a test takes
none.
The reason is not style. fetch is undici, and undici keeps a connection pool
per origin. When a pooled connection dies — an idle keep-alive socket the far
end closed, an HTTP/2 session that was destroyed — undici hands the dead one
back for ever: every later request in the process fails in about a millisecond
with a bare fetch failed, and the process never recovers by itself. In a
one-shot script that is invisible. Inside buddi serve, which runs for weeks
and whose sentinels and sources fire on a timer, it took the whole assistant
down for hours and killed twelve unattended jobs in one evening.
The area is the shared transport: HTTP/1.1 with keepAlive: false, one
connection per request, nothing held between them, so a failed request cannot
poison the next one. It is also behind the address guard that used to be the
web plugin’s alone: a URL naming this machine, its network, or a port other
than 80 and 443 is refused with a BlockedError before anything is sent, and
names resolve inside the socket, so a public name that answers with a private
address is refused where it is dialled. Redirects are not followed. A host your
network does not declare is logged with your plugin’s name, and will be
refused once every plugin declares its hosts. scripts/check-boundaries.mjs
fails the build on any other outbound client (tests and the browser bundle
excepted), and names the file. Keep the network behind a port like
FetchForecast anyway, so the plugin’s own tests open no socket.
src/tools/forecast.ts — the read tool
Section titled “src/tools/forecast.ts — the read tool”import type { ToolDefinition } from '@buddi/core/plugin';import { z } from 'zod';import { loadLocation, NO_LOCATION } from '../location.js';import type { DailyForecast, FetchForecast } from '../ports.js';
const forecastInput = z.object({ days: z .number() .int() .min(1) .max(7) .optional() .describe('How many days ahead, starting today. Defaults to 3, at most 7.'),});
export type ForecastInput = z.infer<typeof forecastInput>;
export interface ForecastOutput { place: string; days: DailyForecast[];}
export const DEFAULT_DAYS = 3;
export function createForecastTool( fetchForecast: FetchForecast,): ToolDefinition<ForecastInput, ForecastOutput> { return { name: 'weather.forecast', description: "The forecast for where the owner lives, day by day: low, high and a one-word sky. Use it when the owner asks about the weather, and before suggesting anything that happens outdoors.", tier: 'auto', input: forecastInput, async execute(input, ctx) { const location = await loadLocation(ctx.buddi!.db); // Fail closed and say the line that fixes it; never guess a city. if (!location) throw new Error(NO_LOCATION); const days = await fetchForecast({ latitude: location.latitude, longitude: location.longitude, // The owner's zone, from the context — a forecast rendered in UTC is a // forecast for the wrong day after 8 PM in New York. timezone: ctx.buddi!.owner.timezone, days: input.days ?? DEFAULT_DAYS, }, ctx.buddi!.http); return { place: location.label, days }; }, };}Note the two things every tool description should do: say when to use it, and
say it in the second person to the model. The zod .describe() on each field is
what the model sees as the parameter’s documentation — zodToJsonSchema turns
the whole input into the JSON Schema in the tool spec.
src/sentinels/frost.ts — the watcher
Section titled “src/sentinels/frost.ts — the watcher”import { localDateString, type Finding, type Sentinel, type SentinelContext } from '@buddi/core/plugin';import { frostFinding } from '../frost.js';import { loadLocation } from '../location.js';import type { FetchForecast } from '../ports.js';
/** Four looks a day: enough for a forecast that changes, cheap enough to run. */export const EVERY_6H = 6 * 60 * 60;
export function createFrostSentinel(fetchForecast: FetchForecast): Sentinel { return { id: 'weather.frost', description: "Warns when tomorrow's forecast low is below freezing.", every: EVERY_6H, async run(ctx: SentinelContext): Promise<Finding[]> { const buddi = ctx.buddi!; const location = await loadLocation(buddi.db); // No location configured is a valid, quiet state — not a failure. if (!location) return [];
const days = await fetchForecast({ latitude: location.latitude, longitude: location.longitude, timezone: buddi.owner.timezone, days: 2, }, buddi.http); const tomorrow = localDateString( new Date(buddi.clock.now().getTime() + 86_400_000), buddi.owner.timezone, ); const day = days.find((d) => d.date === tomorrow); if (!day) return [];
const finding = frostFinding(day, location.label); return finding === null ? [] : [finding]; }, };}Two things to notice. The sentinel returns a list of length zero or one and never
tracks what it said last time: when the forecast changes its mind, the key stops
appearing and core resolves it. And it reaches the network, which most sentinels
do not — “deterministic” means no model in the loop, not no I/O — so the same
injected FetchForecast keeps it testable, and a throw here is recorded in
core.sentinel_runs.last_error rather than breaking the tick.
src/missions.ts — the suggestion
Section titled “src/missions.ts — the suggestion”import type { SuggestedMission } from '@buddi/core/plugin';
export const MORNING_WEATHER_ID = 'morning-weather';export const MORNING_WEATHER_CRON = '0 7 * * *';
export const weatherMissions: SuggestedMission[] = [ { id: MORNING_WEATHER_ID, name: 'Morning weather', agentRole: 'overview', cron: MORNING_WEATHER_CRON, // A missed morning owes the owner one message this morning, not four. misfirePolicy: 'latest-only', prompt: `Call weather.forecast for the next 2 days.
Stay silent unless the owner would change their day because of it. That means one of:- tomorrow's low is below freezing;- rain, snow or storms on a day that is currently forecast clear or cloudy in the message you would otherwise send.
If none of that holds, call mission.silent with the one-line reason. Otherwise call mission.report with at most 300 characters, plain text, no markdown: the day, the numbers, and the one thing to do about it.`, // It speaks only when it calls mission.report. alwaysDeliver: false, },];src/index.ts — the manifest
Section titled “src/index.ts — the manifest”import path from 'node:path';import { fileURLToPath } from 'node:url';import type { PluginManifest } from '@buddi/core/plugin';import { weatherAgents } from './agents.js';import { weatherMissions } from './missions.js';import { openMeteo } from './open-meteo.js';import type { FetchForecast } from './ports.js';import { createFrostSentinel } from './sentinels/frost.js';import { createForecastTool } from './tools/forecast.js';import { weatherViews } from './views.js';
/** Absolute path to this plugin's migrations, resolved from the *built* file. */export const MIGRATIONS_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations',);
/** The manifest, with the forecast service as a parameter. */export function createWeatherManifest( fetchForecast: FetchForecast = openMeteo,): PluginManifest { return { name: 'weather', version: '0.1.0', description: 'The local forecast, a frost watcher, and an agent that reads them.', schema: 'weather', migrationsDir: MIGRATIONS_DIR, uses: ['http'], tools: [createForecastTool(fetchForecast)], sentinels: [createFrostSentinel(fetchForecast)], missions: weatherMissions, views: weatherViews, agents: weatherAgents, // Declared so the owner reads one line per destination before installing. // Nothing enforces it at runtime, and saying so is the point: an undeclared // host means the author did not write one down. network: [ { host: 'api.open-meteo.com', why: 'the forecast itself. It sends a latitude and a longitude and no key; nothing else leaves.', }, ], };}
/** The installed manifest: the real forecast service. */export const manifest: PluginManifest = createWeatherManifest();
export default manifest;Installing it
Section titled “Installing it”pnpm build # in your plugin's directorybuddi plugins install /path/to/weather # stages it and prints what it claimsbuddi plugins install /path/to/weather --yes # approves it, imports it, migrates itbuddi service restart # plugins are registered at startThat is the whole of it, and none of it touches buddi’s source. The record is
plugins.json in the owner’s private directory; the next process to start reads
it, imports your entry point, and registers your manifest alongside the built-in
ones. Your tools appear in platform.installed_tools, the serve loop ticks
your sentinels and sources over wiring.registry.manifests(), buddi migrate
applies your schema, buddi missions add-defaults reads your suggestions, and
platform.plugin_agents offers your agents. See §8.
The plugins this repository ships are different: email, memory,
artifacts, web, browser and host are compiled into the build, registered in
createToolRegistry in packages/gateway/src/agents/catalog.ts, and they cannot
be uninstalled because they are part of it. (installedManifests() derives the
migratable subset from the registry rather than repeating the list — a
hand-written one was the defect that let web ship without being migrated.) If
you are adding a plugin to buddi itself, createToolRegistry is the one line
to add. If you are distributing one, you never touch that file.
Neither is done for the example. The weather plugin is in the workspace so
it compiles and is tested against the real types — and it is what the end-to-end
install is exercised against.
5. Testing your plugin
Section titled “5. Testing your plugin”Pure helpers first. Put every judgement in a function that takes plain values
and returns a Finding, a number or a string, and test it with no database and
no clock. The finance plugin’s src/sentinels/helpers.ts (in buddi-plugins)
is the pattern: the sentinels are a query plus a call into it, and
helpers.test.ts covers the rules. The weather example does the same with frostFinding.
The throwaway database. Anything that needs SQL gets a real Postgres, never a
mock, and never the developer’s own database. The suite creates a database,
migrates into it, and drops it at the end; without a test database it skips.
A test imports core from @buddi/core/testing — all of core, plus
testDatabaseUrl — and never from @buddi/core itself. From
packages/tools/memory/src/tools/tools.db.test.ts:
import { ToolRegistry, createPool, migrate } from '@buddi/core/testing';import type { CoreToolContext } from '@buddi/core/testing';import { testDatabaseUrl } from '@buddi/core/testing';
const databaseUrl = await testDatabaseUrl();const suite = databaseUrl ? describe : describe.skip;
const TEST_DB = `buddi_memory_test_${process.pid}`;
suite('memory tools (postgres)', () => { let admin: Pool; let pool: Pool; const registry = new ToolRegistry();
let clock = new Date('2026-09-13T12:00:00Z'); const now = (): Date => clock;
/** A context as the runtime builds one: owner, conversation, calling agent. */ const contextFor = (agentId?: string): CoreToolContext => ({ db: pool, ownerId: 'test', now, timezone: 'UTC', conversationId: CONVERSATION, ...(agentId ? { agentId } : {}), });
beforeAll(async () => { admin = createPool(databaseUrl as string); await admin.query(`drop database if exists ${TEST_DB}`); await admin.query(`create database ${TEST_DB}`);
const testUrl = new URL(databaseUrl as string); testUrl.pathname = `/${TEST_DB}`; pool = createPool(testUrl.toString()); await migrate(pool, { schema: manifest.schema, dir: manifest.migrationsDir });
registry.register(manifest); }, 60_000);
afterAll(async () => { await pool?.end(); if (admin) { await admin.query(`drop database if exists ${TEST_DB}`); await admin.end(); } });});${process.pid} in the name is what lets two suites run at once. Use
runMigrations(pool, [manifest]) instead of migrate when your plugin also
reads a core table — that applies core’s migrations first (the finance plugin’s
src/sentinels/sentinels.db.test.ts does exactly that).
A test hands the registry a CoreToolContext: the call, plus the facts core
builds a host from — the pool, the owner, the clock and the zone. The registry
puts your plugin’s ctx.buddi on it exactly as it does at runtime, bound to your
manifest’s uses, so the tool under test sees the host it will see installed.
Drive tools through the registry, not by calling execute directly. That is
what proves the tier, the zod schema, the refusal paths and the host, which is
most of what can go wrong:
const result = await registry.invoke(name, args, contextFor(agentId));if (!result.ok) throw new Error(`${name} refused (${result.reason}): ${result.message}`);Testing a gated tool without sending anything. Three layers, and only the third needs a fake transport:
describealone. It is pure and read-only: call it and assert on the envelope — that the BCC nobody mentioned is in it, that the preview contains the hash. No approval exists at this point, and nothing is dispatched.registry.invoke. Assert that it returns{ ok: false, reason: 'approval-required', actionId }, thatgetAction(pool, actionId)ispendingwith your envelope on it, and that your fake transport recorded nothing. This is the whole point of the gate and it is one assertion.decideApprovalthenexecuteApproved, with a fake transport injected the waycreateSendTool({ send })takes one. Then assert the things that only this path can prove: that a secondexecuteApproveddoes not send twice, that editingcanonical_argsunder a standing approval refuses withargs-hash-mismatch, that a hanging transport lands asunknownand not asfailed.packages/core/src/actions/actions.db.test.tsdoes all of these against a toy manifest and is worth reading before you write your own.
6. Trust and classification
Section titled “6. Trust and classification”- The tier your tool declares is what the registry enforces. There is no
re-tiering at install: the owner reads your whole contribution and accepts or
refuses it as a whole, and from then on your
tieris the policy. The registry executesautoonly and turnsgatedinto an action and an approval — so declaringautoon something that leaves the machine is the failure mode to watch for in review, not a clever shortcut. (sessionis a real tier and a narrow one — a live owner request, an explicit per-session grant and no delegation — anddraftis a label the registry refuses withtier-not-executablebecause the machinery it needs does not exist yet. Do not ship a tool atdraft, and reach forsessiononly when “while the owner is asking” is the whole point.) - Unknown tool and invalid arguments fail closed, before any code of yours
runs. A model that hallucinates a tool name gets
unknown-tool; arguments zod rejects getinvalid-argswith the field path. Make the schema tight: bounds on numbers,.uuid()on ids, enums instead of free strings. Every constraint you put in the schema is a check that happens before your code. - A tool must never widen its own reach at runtime. No reading a credential
the context did not give it, no falling back to
process.envfor something the owner configures, no “the agent id is missing so this must be shared”. When a fact you need is absent, throw —resolveScopein the memory plugin refuses rather than publish a private note to every agent. - Content is data, never instruction. A mail body, a PDF, a web page and a memory note are all things somebody else wrote. Never let one decide what your tool does, and store provenance for anything you persist so it can be traced and deleted.
- A plugin uses the owner’s secrets and never reads one. There is no
read path: the
secretsarea has noget. A plugin that needs a password or a token declaressecretsinusesand registers a destination — in the manifest’sdestinations, or throughctx.buddi.secrets.registerDestination— as{ kind, checkTarget, describe, deliver, maxRule }, its kind in its own namespace (email.account). A secret is bound to a kind and a target when the owner stores it — email’sownerOnlyadd_accountdoes that throughsecrets.put. A call asksctx.buddi.secrets.use(name, kind, target); core finds the binding, has the destination check the target, applies the stricter of the binding’s rule and the kind’smaxRule(every time, first time, pre-approved) — raising an ordinary approval card when the owner has to be asked — reads the vault, and callsdeliver.deliveris the only code that ever receives a value, and the call is answered{ done, use },{ pending }or{ refused }, never the value.listgives names, bindings and the last use;put,rename,rebindanddeleteare the owner’s own call, from anownerOnlytool, and touch only your kinds. The one stated exception is an account credential: a kind named<plugin>.accountis delivered into a connection the plugin holds for as long as it lives — email’s mailbox password, handed to one IMAP or SMTP session — and each such use is recorded asheld, not delivered, so the owner can see which secrets a plugin’s process keeps. The product isdocs/owner-secrets.md. - Never
process.envfor a secret. Not to read one the owner configured, and not to write one for later: the environment is readable by every plugin in the process.
Before shipping a plugin that touches money, mail or the filesystem:
- Is every world-touching tool
gated? Is everyautotool genuinely a read or a write to your own schema? - Does
describeput everything in the envelope — every recipient, the resolved account, hashes of the bytes? Would the owner be surprised by anything the preview does not mention? - Is
describeread-only, with no write and no network call that changes anything? - Does
executerefuse whenctx.actionIdis missing, and claim on it with one atomic statement before it acts? - Does a replay of the same action return the recorded receipt instead of acting again? Does a different action against an already-acted-on row refuse?
- Is
timeoutMsset to something the transport can actually meet, and does a post-dispatch failure leave the claim in place so the attempt staysunknownrather than looking retryable? - Does bumping
versionvoid standing approvals in a way you are happy with? (It does void them — that is the point.) - Can an attacker who controls the content your plugin reads change what your plugin does? If yes, you have a prompt-injection path, not a tool.
7. Pre-flight checklist
Section titled “7. Pre-flight checklist”[ ] Manifest: name, version, schema, migrationsDir (absolute, resolved from the built file), tools — plus sentinels / sources / missions if you have them.[ ] Tool names namespaced to the plugin; no collision (the registry throws).[ ] Every tool's tier is 'auto' or 'gated'. Nothing declares 'draft'; 'session' only where a live owner request is the whole point.[ ] Every gated tool has describe(); the envelope is complete and the preview is rendered from it.[ ] Every gated execute() refuses without ctx.actionId and claims atomically on it.[ ] timeoutMs set on anything slower than a local query.[ ] No wall clock (ctx.buddi.clock.now()), no UTC days (ctx.buddi.clock.today(), or localDateString(date, ctx.buddi.owner.timezone)).[ ] Optional context fields (agentId, conversationId) fail closed, never guessed.[ ] Everything beyond the arguments through ctx.buddi; imports of core only from @buddi/core/plugin (and @buddi/core/testing in tests); no core. table in SQL; no process.env for anything the owner configures.[ ] uses in the manifest and buddi.uses in package.json name the same areas, and buddi.hostApi says what you were built against ("^1.0").[ ] Sources: stable dedupKey, transactional cursor advancement, a deadline on every outbound call, a first-contact cursor that starts at now, and a documented offline/retention contract.[ ] Sentinels: stable key anchored to the fact; severity by rule; a shorter list means resolved; no model, no delivery.[ ] Missions: agentRole not agentId; a prompt that ends in mission.report or mission.silent unless alwaysDeliver is true.[ ] Migrations: numbered, additive, never edited after they are applied, unqualified table names, `migrations` in package.json "files".[ ] Tests: pure helpers with no database; a throwaway database for the SQL; tools driven through registry.invoke; a gated tool proven not to send.[ ] `pnpm -r build && pnpm typecheck && pnpm test` green — check-boundaries included.[ ] package.json: "buddi": { "manifest", "uses", "hostApi" }, keywords ["buddi-plugin"], @buddi/core in peerDependencies and NOT in dependencies, and "files" naming dist, migrations and buddi.md.[ ] buddi.md shipped, with a Schema: line that is the schema the manifest owns and a Hosts: line that matches `network` — anything else costs the owner a second approval.[ ] Proposed agents (if any): the smallest grant that does the job; no platform.* write tool and no platform.* glob; a skill shipped with each one; a persona that says what the agent cannot see.[ ] `description` and `network` filled in: they are what a stranger reads before they run your code.[ ] `buddi plugins install <dir>` (no --yes) read end to end, as the owner will: is the auto-tier list what you meant it to be?[ ] `buddi plugins uninstall <name>` read end to end: does anything dangle?[ ] Distributed: nothing to register by hand. A register() line in createToolRegistry is only for plugins shipped *inside* buddi.8. Distributing it: install, upgrade, uninstall
Section titled “8. Distributing it: install, upgrade, uninstall”What a plugin is, on disk
Section titled “What a plugin is, on disk”A built npm-style package directory:
weather/ package.json # "main": "./dist/index.js" (or exports["."]) dist/index.js # exports `manifest` (or a default export) migrations/*.sql # if you own tablesbuddi plugins install <spec> resolves main (or the . export), imports it,
and expects manifest or default to be a PluginManifest.
The three sources
Section titled “The three sources”parsePluginSpec decides, in this order:
| What you type | What it is |
|---|---|
| a path that exists and is a directory | the developer path: your own build, in place |
a path ending .tgz |
a tarball on disk, for a plugin that should never be public |
| anything else | an npm package: weather, @you/buddi-plugin-weather@1.2.3 |
A directory wins over a registry name on purpose. A developer with a finance
directory beside them means that directory; resolving the registry instead
would install a stranger’s package with the same name and say nothing.
Publishing one
Section titled “Publishing one”buddi plugins init writes all of this for you; what follows is what it wrote
and why, so you can check it or do it by hand.
{ "name": "buddi-plugin-weather", "version": "1.0.0", "main": "./dist/index.js", "keywords": ["buddi-plugin"], "license": "Apache-2.0", "buddi": { "name": "weather", "manifest": "manifest", "core": ">=0.1.0", "uses": [], "hostApi": "^1.0" }, "scripts": { "prepack": "tsc -p tsconfig.json" }, "peerDependencies": { "@buddi/core": ">=0.1.0" }, "peerDependenciesMeta": { "@buddi/core": { "optional": true } }, "files": ["dist", "!dist/**/*.map", "!dist/**/*.tsbuildinfo", "migrations", "buddi.md"]}Core is nowhere in devDependencies: the development link lives in
pnpm-workspace.yaml as a pnpm override, which npm pack never ships. The
peer is optional so no package manager goes looking for core on a registry.
buddi plugins init --license <spdx> picks the license; without it the
scaffold writes Apache-2.0 and a LICENSE placeholder that says so.
buddi.uses is the list the install card reads, and must equal the manifest’s
uses (§1.3); buddi.hostApi is the host version you were built against
(§1.7).
@buddi/core is a peer dependency and never a normal one. A plugin holding
its own copy of core would get a second registry, a second pool and a second set
of module-level singletons: its tools would register into an object nobody reads
and its approvals would be written by a machine nobody asks. Staging enforces
this — the staged tree’s node_modules/@buddi/core is replaced by the plugin
API of the core the gateway is running, and nothing else of it (§1.6) — but
declaring it correctly is what makes npm install of your package outside
buddi behave the same way.
npm pack builds the same tarball npm would publish, and
buddi plugins install ./buddi-plugin-weather-1.0.0.tgz installs it: the same
two approvals, the same integrity hash, and nothing on a registry. It is the
right shape for a plugin that is nobody else’s business, and it is also how you
check what npm publish would actually ship before you ship it — anything
missing from files is missing from that tarball, migrations and buddi.md
included.
Ship a buddi.md beside it. It is prose, it is what the owner reads before
anything of yours is imported, and two labelled lines are parsed out of it:
# weather
Looks up the forecast for a place you name and remembers nothing else.
Schema: weatherHosts: api.open-meteo.comAnything else in the file is shown verbatim. There is no schema to learn: what matters is that an owner can read it and that it agrees with your manifest, because those two are compared and any difference costs them a second approval.
npm is never asked for core
Section titled “npm is never asked for core”A stage runs npm install on a package.json with the development half taken
out: no devDependencies (a link: there is your checkout, not a specifier
npm can follow), no @buddi/core in dependencies, peerDependencies or
optionalDependencies, and an override that answers any request for core —
yours or a dependency’s — from a local placeholder. The author’s package.json
is put back, byte for byte, the moment npm is done; the integrity you approve
is the tarball’s and never changes. Buddi then links its own core.
The @buddi scope on npm is not buddi’s. If somebody published
@buddi/core there, an install that let npm resolve it would run their code
in every stage; this one never asks. A package that lists core as an ordinary
dependency still stages, and its card says “it asks npm for buddi’s core;
buddi provides its own”.
Install is two halves, and nothing runs in the first
Section titled “Install is two halves, and nothing runs in the first”Importing a module executes it, so an install splits at exactly that line.
Stage. npm view for the exact version, the integrity hash and the
publisher; npm pack to fetch it; extract; npm install --ignore-scripts --omit=dev --omit=peer for its dependencies, never asking for core (above);
write the narrowed @buddi/core. Then read
static metadata only — package.json (its buddi.uses and buddi.hostApi
among it), the integrity hash and publisher, your buddi.md, and which
packages in the installed tree declare preinstall/install/postinstall or
ship a binding.gyp (node-gyp is an install script nobody wrote down).
Nothing is imported, nothing is registered, no schema exists. The staged package
sits in <data>/plugins/staging/<id>/, and a stage nobody decides on is deleted:
a day after it was read when the owner opened it (its full card was shown on the
Plugins page, or they pressed Review on its row), two hours after when nobody
ever did — one a script or the CLI made and nobody looked at. buddi plugins staged prints each stage’s deletion time. On the Plugins page only the stage
just read opens as the full card; every other one waits as a row under
Waiting for you, above Installed, with Review and Not this one.
Four things are checked before any of that is shown, and each one refuses rather than warns:
- the tarball is the one the registry named. The sha512 of what
npm packwrote is compared with thedist.integrityof the packument, and the extractedpackage.jsonhas to carry the same name and version npm resolved. - it is files and directories. The extracted tree is walked with
lstat, and a symlink, a device, a fifo or anything whose real path is outside the staging directory is refused and the whole stage deleted. This is not hypothetical tidiness: a member callednode_modules/@buddipointing somewhere else turns the@buddi/corepeer link that comes next into a write outside the stage.taris also run with--no-same-owner --no-same-permissions, because an archive’s mode bits are its author’s opinion. - the tree is hashed.
stagedHashis sha256 over the extracted package and itsnode_modules— every path, every byte, and the target of every link npm wrote among the dependencies — withnode_modules/@buddi/coreexcluded, since that is buddi’s view of its own core and not part of your package. It is shown beside the integrity hash. - the entry point is inside the package.
main(or the.export) is resolved with its links followed and has to land under the package’s own real path. - it asks for nothing this buddi lacks. A
buddi.usesnaming an area buddi does not know, or abuddi.hostApiasking for more than this host has, is refused with both numbers (§1.7).
Approve 1. The owner is shown all of it and this sentence:
A plugin runs inside buddi’s process with everything buddi can do; it is not sandboxed, and a plugin that wants to can bypass tool approvals and the network allowlist. Install only what you would run as yourself.
They approve by passing back the integrity hash they were shown; a mismatch
refuses and still imports nothing. --yes does not stand in for this: for a
registry or a tarball source, buddi plugins install <spec> --yes without
--integrity <hash> prints the card and the approve command and installs
nothing, because a yes typed before the fetch cannot be a yes to a particular
package. It exits 3 when it does that: “staged, not installed”, distinct
from 1 (something failed) and from 0, so a set -e install step in somebody’s
setup notes cannot read a refusal as success. A directory source has no hash
and keeps the plain --yes.
Immediately before the first import the staged tree is hashed again and has to
equal stagedHash; a stage that changed while it waited is refused. Only then
is the entry point imported for the first time, the plan below runs, and your
buddi.md is compared with your manifest.
Approve 2, and only when that comparison found a difference. Then the
record is written first, marked placing, and only then does the package move
to <data>/plugins/<name>/ — so a machine that dies in between leaves a record
that says what was happening rather than a directory nothing accounts for, and
the sweep at the next start can tell an orphan from an install. After the move
the tree is hashed once more and must still equal the pre-import hash: a plugin
that rewrote its own files while it was being imported is refused and removed.
Then its migrations are applied into its own schema, and the record is
finalised with its provenance: the source, the integrity hash, the publisher,
the hash of the files as installed, and when it was approved.
Approvals are serialised per plugin name, so two of them for the same name cannot both rename into the same directory.
The tools are registered by the next process start. The CLI and the API both
say so; the API returns restartNeeded: true.
Plugins load at start
Section titled “Plugins load at start”There is no “reload installed plugins”. An installed plugin’s tools appear when a buddi process starts and not before, and that is a property of the design rather than a missing feature:
- the import happens once.
loadPluginsOnceimports every entry point inplugins.jsonbefore any registry exists, and publishes the result to the process. Node’s module cache is keyed by URL, so re-importing a rebuiltdist/index.jshands back the module that is already loaded; picking up new code would mean cache-busting the specifier and leaking the old module, its timers and its pools for the life of the process. - the registry is built once and held by reference.
createWiringbuilds oneToolRegistry, and the agent catalog is loaded against it while the delegation, platform and canvas tools close over it. There is nounregister:ToolRegistry.registerthrows on a name it already has. A reload would have to swap an object that a dozen live closures are holding, mid-run, and re-run the collision checks against a half-replaced set.
A start migrates before it loads. There is nothing to run by hand first.
Every start path — the packaged supervisor, and a checkout’s buddi serve —
goes through the gateway’s migrateAtStart(pool, env) before any wiring
exists: a schema newer than the running code refuses the start before anything
runs; core’s migrations and the compiled-in plugins’ are this build describing
itself, so one that will not apply stops the start with the error; an installed
third-party plugin’s failure demotes that plugin to the load report
(docs/install.md §7) and the start carries on without it. Each applied
migration is one line on the log, followed by one summary. A migration
transaction takes an advisory lock, so two processes started at once queue
instead of racing. buddi migrate is still there for the owner who wants to
migrate without starting.
So the loop is: build, restart, ask. buddi plugins dev <dir> is what makes
that bearable — it watches <dir>/dist and, on a rebuild, restarts the service
when this installation has one and otherwise prints the one line reminding you
to restart buddi yourself:
buddi plugins dev ./weatherThe one thing that is reloaded without a restart is the agent catalog: a new agent, or a changed tool grant, is reachable from every surface on its next turn. Agents are files the gateway reads; plugins are modules it imported.
What is recorded, and what doctor does with it
Section titled “What is recorded, and what doctor does with it”plugins.json is version 2. A v1 file still reads — every entry in one is a
directory source with no provenance, which is exactly what it was. A registry
entry carries installedHash: sha256 over the package’s files and its
node_modules, path and content both, sorted, with node_modules/@buddi/core
(buddi’s view of the running core) and .git excluded. It is the hash that
was taken before the plugin was ever imported. buddi doctor recomputes it —
for every plugin in the record, including the ones that did not load — and
warns, naming the plugin, when it no longer matches.
Including node_modules is deliberate and it has a consequence worth stating:
the hash covers the bytes that actually run, dependencies and all, so a
dependency rewritten in place is now visible. It is not a reproducibility claim
about npm install. Installing the same version twice on two machines can
produce two different trees and therefore two different hashes; what is
recorded is what was staged here, and an update is the only thing that is
supposed to change those files afterwards. A symlink among the package’s own
files is refused rather than skipped, because a hash that ignores what it
cannot read proves nothing; links inside node_modules are hashed by their
target.
Be clear about what that buys. It detects a plugin whose files changed after the owner approved them. It is not a signature, and it protects nobody from what the plugin does while running. A plugin is trusted code; the honest controls are the sentence above, the recorded hash, and doctor saying when what is on disk is no longer what was agreed to.
What the owner sees before they say yes
Section titled “What the owner sees before they say yes”install with no --yes stages and prints, and installs nothing. After
approval the same contribution summary is printed, and it lists:
- every tool, and — first, and in capitals — the ones at tier
auto, which run the moment a model decides to call them, with nobody asked. A plugin quietly shipping one is exactly what this exists to surface; - the Postgres schema it will own;
- everything that runs on a timer, with its period: “every 6 hours, by itself”;
- the hosts it declares in
network, and — when it declares none — one line saying that nothing enforces this, so an undeclared host means the author did not write one down, not that the plugin cannot reach the network; - the areas of
ctx.buddiit declares inuses, one plain line each (§1.3) — these are on the staged card too, read frombuddi.usesbefore anything is imported; - the agents it proposes, each with the grant it asks for, and the sentence that nothing is created by installing.
That summary is produced by importing your entry point — there is no way to describe a module without loading it — which is why it comes after the first approval and never before it. What the staged screen shows beforehand is your own claim, labelled as one.
The name, and where the files go
Section titled “The name, and where the files go”A published package is usually called buddi-plugin-weather while its manifest
is weather, so the rule is written down rather than guessed:
When the package declares
buddi.name, the manifest’snamemust equal it. When it declares none, the manifest’snameis the plugin’s — the card says it is read at approval — unless a different package is already installed under that name.
An install refuses otherwise. Without that a package called
buddi-plugin-weather could carry a manifest called finance, take the
installed finance’s place in the record, take its schema, and inherit its
grants. Re-installing or upgrading the same package is not a different one. A directory source is exempt: the owner typed a path to
a build on their own disk, and the identity of that build is the path.
The name also decides a directory: <data>/plugins/<name>, with a scoped name
folded to one segment (@you/weather → @you+weather). It is validated against
npm’s charset first, staging is reserved, and the resolved directory is
checked to be inside the plugins root before anything is renamed or removed. A
schema name is validated too, against ^[a-z_][a-z0-9_]*$, before it is ever
recorded — it reaches create schema, set search_path and, on a purge, drop schema, where it is also quoted.
What a plugin may not be called
Section titled “What a plugin may not be called”Install refuses a manifest that takes something this build already ships: the
name of a built-in family (finance, web, platform, and the per-run
families mission and conversation), the name of a tool one of them already
answers to, or a Postgres schema one of them owns (core and public
included). Two plugins with one name collide on every tool; two plugins with one
schema share tables, because db:migrate applies each plugin’s migrations into
the schema it declares.
None of those sets is written down anywhere. They are derived from the registry
this build actually creates (builtInManifests in
packages/gateway/src/agents/catalog.ts) and from the manifests the runs
actually register per run, so a plugin added to buddi tomorrow is reserved the
moment it is registered. The lists that used to be there had already fallen
behind by one plugin.
Upgrades
Section titled “Upgrades”buddi plugins update weather # stages the newer versionbuddi plugins update weather --version 2.1.0buddi plugins update weather --yes --integrity <hash> # ...and approves itAn update is a stage plus the same two approvals, because a new version is somebody else’s code exactly as the first one was. It is newer only: migrations in this system run forward and nothing knows how to undo one, so a downgrade is refused with both versions named rather than silently left half-migrated. The comparison is semver’s own, prerelease rules included, and a version it cannot parse is refused rather than guessed at — “not newer” is the answer that stops an update, so guessing is how a downgrade gets through.
An upgrade’s card marks each area of uses the new version adds and says
which it drops, so a plugin whose next version starts reading the whole Files
library says so before it runs.
Installing the same plugin again does the same thing. The record is replaced; the migrations run forward; no agent file is written. Per proposed agent you get one line saying where the owner’s copy stands — untouched, edited by them, or superseded by a proposal you have changed. See §2.6.
A version that claims a different schema is refused: that is a data move, not an upgrade, and it should be decided deliberately.
Disabling a plugin
Section titled “Disabling a plugin”buddi plugins disable weather # off now: the record says enabled: falsebuddi plugins enable weather # back nowDisabling is the owner’s “not now” (also Disable… in the plugin’s ⋯ menu
and its detail sheet in Settings → Plugins, which asks once in a small dialog). Nothing is removed: the package, the schema and its
data, the agents accepted from it and their grants all stay. It takes effect in
the running gateway at once, with no restart: the plugin is unregistered, so
its tools, pages, views, glances, sources, sentinels and channels all go — the
rail and Settings drop its pages, the loops stop polling its sources and
sentinels, and every agent’s grants are resolved again for its next turn. (The
CLI asks the running gateway to do it; with nothing running, it writes the
record, which the next start reads.) At a start a disabled plugin is not
imported and its migrations are not run. Enabling loads it the way a start
does — import, migrate its schema, register, adopt its proposed rules — and
says “restart buddi to finish” only for a part it could not do live.
Missions registered from its suggestions are paused at once with the reason
paused: weather is disabled, which the Missions page and buddi missions
show; enabling resumes exactly those. An agent granted weather.* is not held
back: the grant is skipped like a weather.*?, and the agent’s page and prompt
say “weather is disabled, so its tools are out.” Its proposed agents are not
offered while it is disabled. An update keeps it disabled.
Uninstall
Section titled “Uninstall”buddi plugins uninstall weather # shows what it would dobuddi plugins uninstall weather --yes # removes the code, KEEPS the databuddi plugins uninstall weather --yes --purge --confirm weather # ...and drops the schemaThe default keeps the data, and the command prints the schema, its tables and
its row counts so the owner knows exactly what is being kept and where.
--purge also requires the plugin’s own name typed back (--confirm <name>, or
the same field on the dashboard); the engine requires it too, so a caller that
forgets is refused rather than obeyed.
Reinstalling finds it again. For the finance plugin the alternative would be
every account, transaction and card ledger the owner has, destroyed by a verb
that sounds like “remove the code” — so destroying is a separate flag that
prints what it is about to destroy first, and is recoverable only from a backup.
It also stands down everything that would otherwise be left pointing at tools that no longer exist, because a half-removed plugin is worse than one that stays:
| What | What happens |
|---|---|
| An agent whose grant names your tools | Refused, naming the agents. A tools: entry that resolves to nothing is a catalog load error — the installation would not start. --detach-agents removes those entries first (it only ever removes names). |
| Missions registered from your suggestions | Disabled, not deleted. A disabled mission is a row the owner can see and re-enable; a deleted one is a mystery next month. |
| Jobs queued for those missions | Cancelled. |
| Approvals pending on your tools | Rejected: they could never execute. |
| Agents the owner accepted from your proposals | Left alone — except for the grant rewrite above. They are the owner’s files. |
| Your schema | Kept, unless --purge. |
The package directory under <data>/plugins |
Removed — buddi put it there. A directory source is left exactly where it is, because buddi did not. |
| Rows in core’s tables (the action ledger, the event log) | Kept. They are the record of what happened, and history does not become false because a plugin left. |
Everything above is planned before anything happens, printed, and only then applied — the same shape as an approval.
9. Reference: every field of the contract
Section titled “9. Reference: every field of the contract”Generated by hand from packages/core/src/tools.ts, sentinels/types.ts,
views.ts, pages.ts, metrics.ts and host/types.ts, and kept honest by
packages/core/src/plugin-reference.test.ts: it
reads these tables and the interface declarations and fails, naming the field,
when one has something the other does not. A field added to the contract without
a line here is a failing test, on purpose — the type says what it is, and only
this says what it is for. The same test checks §1.3’s uses table against
the names the manifest’s type allows, and that no sentence here still reaches
for a field the context no longer has.
“Required” is the type’s own answer: a ? in the declaration is no.
A context is split in two, and so is the reference: the call — the fields
left on ToolContext, facts about this one invocation — is below, under
“The call”; the host — every area and method of ctx.buddi, with the
version each arrived in — is §9b.
The manifest
Section titled “The manifest”PluginManifest
Section titled “PluginManifest”| Field | Type | Required | What it is |
|---|---|---|---|
name |
string |
yes | The plugin family: finance, weather. It names the tools, the record entry and the directory under <data>/plugins. It must equal the package’s buddi.name when it declares one; without one it is the plugin’s name unless another package is installed under it. |
version |
string |
yes | Part of the approved args hash. Bumping it voids every standing approval for this plugin’s tools. |
schema |
string |
yes | The one Postgres schema this plugin owns. Checked against ^[a-z_][a-z0-9_]*$; core and another plugin’s schema are refused. |
migrationsDir |
string |
yes | Absolute path to a directory of *.sql, resolved from the built file. '' means this plugin owns no tables. |
tools |
ToolDefinition<any, any>[] |
yes | Everything an agent can call. May be empty. |
sentinels |
Sentinel[] |
no | Deterministic watchers that run on a period and return findings. Most plugins have none. |
sources |
Source[] |
no | Pollers that originate runs with no agent in the loop. Most plugins have none. |
missions |
SuggestedMission[] |
no | Scheduled missions you suggest. Installing schedules nothing; buddi missions add-defaults is the owner accepting. |
views |
ViewDescriptor[] |
no | How the dashboard canvas should draw your tool results. Parsed at register(); a bad one is a startup error naming the plugin. |
home |
HomeContribution[] |
no | Read-only blocks for the dashboard’s Home page, already formatted in your own units. With placement: 'glance' a contribution is instead one line beside the date: produce answers { icon, text, link?: { route: { page } } } | null — a tile icon, at most 60 characters (longer is cut), and a page of yours it opens. Home draws the first three the owner has not hidden, in plugin order; one that throws is left out. It may also answer card: { value, caption?, trend?: { label, points }, foot? } (value at most 12 characters, caption and foot 40, two to 48 finite numbers drawn as a sparkline): since host API 1.17 a glance with a card is offered as a small stat widget under the glance’s id, unless you declare a widget with that id (§2.5d); while that widget is on Home the line leaves the date line. A buddi from before 1.17 draws the card on the right of the greeting. Produced on each Home read, like a block, so cache anything slow. Needs host API ^1.10. See packages/core/src/home.ts. |
widgets |
WidgetDefinition[] |
no | Small live panels for Home and the lock screen that the owner places, orders, sizes and sets up: { id: '<plugin>.<name>', title, sizes: ('small' | 'medium')[], refreshSeconds?, link?: { page }, sensitive?, settings?, produce(ctx, { size, settings }) }; settings (1.19) is up to eight fields from a fixed vocabulary — select, multiselect, toggle, text, place, timeFormat — each placement keeping its own, handed to produce resolved. produce answers a body from a fixed vocabulary — stat, list, strip, progress, text, clocks (1.21) — or null, under a read-only pool; buddi caches it for refreshSeconds (60–86400, ten minutes by default) and gives it five seconds. Checked at register. Host API 1.17; an older buddi ignores the field. See §2.5d and packages/core/src/widgets.ts. |
metrics |
MetricDefinition[] |
no | Numbers you can answer, that a goal can watch. Same shape as a Home block — a named read-only function — and core never learns your domain, only that finance.total_debt is a currency that should go down. See §2.3a. |
pages |
PageDescriptor[] |
no | Screens of your own: a rail place, a settings tab. Data, like views; parsed at register(), and a bad one is a startup error naming the page and the field. See §2.5b. |
queries |
PageQuery[] |
no | The reads those pages are drawn from. Read-only by enforcement: each statement runs in a Postgres read-only transaction, so even a volatile function of your own cannot write through one. |
files |
WorkspaceFiles |
no | For a plugin that keeps a directory per agent: the five queries (workspace, list, stat, read, archive) the chat canvas’s Files tab reads it with, for any conversation whose agent has one. read and archive answer with pageFile(...) — bytes the gateway streams on the query route, deciding itself what may be shown inline. Each name must be one of your queries, checked at register(). |
agents |
SuggestedAgent[] |
no | Agents you propose. A plugin can never write an agent file; the owner accepts one through gated platform.accept_plugin_agent. |
skills |
SuggestedSkill[] |
no | Shared procedures you propose, accepted through gated platform.accept_plugin_skill. A skill grants nothing. |
carryOver |
CarryOverContributor |
no | Lines you add to the note a rolled-over conversation opens with: lines({ agentId, conversationId, reason }, ctx) => Promise<string[]>, asked with the agent’s context. At most eight, 200 characters each, five seconds, secrets taken out; a throw is no lines. Host API 1.16. See §2.7. |
previews |
PreviewProvider |
no | A loopback process of yours, served on the gateway’s preview origin — a second loopback listener with a credential of its own, never the dashboard’s. Almost no plugin has one. See §2.5c. |
description |
string |
no | One line, shown before anybody installs you. A plugin meant to be distributed should write one. |
author |
PluginAuthor |
no | Who made you: { name, url? }, the name at most 80 characters, the URL https:. The install card and the Plugins page show “by register(). Without it the card reads author from your package.json (a string or { name, url }); with both, the names must match or approval is refused. |
network |
NetworkUse[] |
no | The hosts you intend to reach. Documentation, not a sandbox — and compared with your buddi.md. |
uses |
PluginUse[] |
no | The areas of ctx.buddi you reach beyond yourself: http, accounts, files, files:library, memory, proposals, schedule, secrets, owner:notify, owner:channel, owner:places, assets (1.27). Repeated as buddi.uses in package.json, because the install card is drawn before anything is imported; the two must match or the plugin does not register. See §1.3. |
destinations |
SecretDestination[] |
no | Where the owner’s secrets can be delivered into your plugin ({ kind, checkTarget, describe, deliver, maxRule }), each kind <plugin>.<what>; registered at register() and only with secrets in uses. deliver is the only code that ever receives a value. See docs/owner-secrets.md §3. |
setup |
PluginSetup |
no | Whether you can do anything yet: { produce(ctx) } answering { ready, note?, page? }, read-only, five seconds. Not ready, the Plugins row says “needs setup” and opens page. Host API 1.18. See §2.9. |
requires |
Record<string, string> |
no | Plugins you need, by name, with a semver range. Repeated as buddi.requires in package.json; the two must match. Until each is installed, enabled, in range and set up, nothing of yours loads; declaring any gives ctx.buddi.plugins. Host API 1.18. See §2.10. |
optional |
Record<string, string> |
no | Plugins you use when they are there, by name, with a semver range. Repeated as buddi.optional in package.json; the two must match. Nothing of yours is held back without one: ctx.buddi.plugins.has(name) says whether it is loaded and in range, and plugins.call reaches its exports while it is (“Needs Speech” beside a setting). A name may not be both required and optional. Host API 1.27. See §2.10. |
exports |
Record<string, PluginExport> |
no | Named read-only queries ({ description?, params, produce(params, ctx) }) a plugin that requires you may call through ctx.buddi.plugins.call. Nothing else of yours is reachable from another plugin. Host API 1.18. See §2.10. |
register |
(host: RegisterHost) => void |
no | Called once by register(), after every check has passed, with { version, plugin, dir } — the host’s parts that need no call. The place to learn your directory before any context exists (the browser’s profile is fixed here). Nothing is awaited. See §1.4. |
policies |
PolicyHandler |
no | How you apply a rule the owner kept on Settings → Proposals: apply(proposal, { db, now }) writes the rule your gate reads, revoke drops it, adopt moves proposals you held in your own tables before (idempotent, run on start), and applied(ctx, since) counts how many times your gate acted on a kept learned rule since then, which the weekly digest reports as what buddi stopped doing (leave it out and the digest says “not measured yet”). You propose from inside a tool call with ctx.buddi.proposals.proposePolicy(ctx, { matcher, action, params, verdicts, why, sources }), your name and the clock filled in (declare proposals); adopt is handed your host as buddi. Keeping one for a plugin with no apply is refused and the card stays open. |
PreviewProvider
Section titled “PreviewProvider”| Field | Type | Required | What it is |
|---|---|---|---|
resolve |
(name, ctx) => Promise<{port, host?}|null> |
yes | The loopback port behind /preview/<plugin>/<name>/, asked on every proxied request. A lookup, never a launcher: null — or a throw — is a 404. It must be a port your plugin is actually running that process on; the host is 127.0.0.1 whatever you return, and the gateway refuses a privileged port, 5432 and its own two listeners. See §2.5c. |
ToolDefinition
Section titled “ToolDefinition”| Field | Type | Required | What it is |
|---|---|---|---|
name |
string |
yes | Namespaced to the plugin: finance.project_cashflow. A collision with an already-registered name throws at register(). |
description |
string |
yes | What the model reads. Say when to use it, in the second person. |
tier |
Tier |
yes | 'auto' | 'draft' | 'gated' | 'session' — see the table below. |
tierFor |
(input, ctx) => Promise<{tier, reason?}> |
no | Decide this one call’s tier from its arguments, after zod and before the tier is acted on — within the declared tier’s envelope, which is the floor: a gated tool stays gated, a session tool keeps every session precondition on every call, and a per-call session on anything else is refused. A throw, or a tier outside that envelope, refuses the call tool-error. reason is one sentence, appended to the preview when the call is gated. See §2.1. |
reusableApproval |
boolean |
no | Opt-in: the owner may remember their approval for this tool/agent/version. A delegate can never use one — a gated call with this set is refused at delegationDepth > 0 — and neither can a tool with a tierFor: a permission is keyed on the tool, and the whole point of tierFor is that one tool has many costs. |
ownerOnly |
boolean |
no | The owner may call this from one of your pages; no model ever sees it. Left out of registry.list() — the one list every provider and every agent grant is built from — and refused by invoke for anyone but the owner’s own path. For a write that stores a secret. |
producesArtifacts |
boolean |
no | This tool saves files and names them in its output as artifacts: [{ id }]. Only a tool that says so has its outputs recorded as produced. |
untrusted |
'web' | 'mail' | 'file' | 'chat' | 'finding' | 'mcp' | 'other' |
no | This tool’s output is text someone other than the owner wrote ('mcp': a message from a connected service, what every Connections tool declares). A run that called it is known to have had untrusted text in view, whatever the result looked like; a learning proposal made in that run is marked and lists the call as a source (docs/learning.md §3). Declare it on every tool that returns a page, a mail, a file or a chat. |
input |
ZodType<I> |
no | The arguments. zodToJsonSchema turns it into the spec the provider sees, so .describe() every field. Give exactly one of input and inputSchema; a hand-written tool gives this one. |
inputSchema |
JSONSchema7 |
no | The arguments as a JSON Schema, kept as written (host API 1.6): for tools that arrive described that way, a remote MCP server’s. Validated with Ajv before anything runs, handed to the model as is, and canonicalised into the approval envelope exactly as zod’s arguments are. The top level must be type: "object" with no anyOf, oneOf or allOf; drafts 07, 2019-09 and 2020-12 (the default); no remote $ref, no $data, at most 64 KiB. |
execute |
(input, ctx) => Promise<O> |
yes | The work. On a gated tool the only caller is executeApproved. |
describe |
(input, ctx) => EffectDescription |
no | The effect envelope and the owner-facing preview. Optional in the type, required in spirit for every gated tool; pure and read-only. |
claim |
(input, ctx) => Promise<void> |
no | For a gated tool whose subject someone else can edit while the owner decides: take exclusive hold of it in one conditional statement, immediately before the ledger row. A throw settles the approval refused and records no effect attempt. |
timeoutMs |
number |
no | How long the Executor waits before recording the attempt as unknown. DEFAULT_EFFECT_TIMEOUT_MS when absent. |
sequential |
boolean |
no | Dependent calls in the same model turn are skipped after this one fails. |
waitsForOwner |
boolean |
no | A successful call leaves a decision with the owner: no more tools this run. |
image |
(output, ctx) => Promise<{mime,data}|undefined> |
no | An ephemeral image for the next model call. Never stored as base64. |
The tiers, as packages/core/src/registry.ts enforces them:
tier |
What invoke does |
|---|---|
auto |
Executes inline, now. A read, or a write to your own schema. |
gated |
Records an immutable action plus a pending approval and answers approval-required with the action id. execute runs later, once, from executeApproved. |
session |
Executes only with a live ctx.ownerRequest, this tool named in ctx.sessionTools, an agent and a conversation, and delegationDepth === 0. Anything less is session-not-authorized. |
draft |
tier-not-executable. The machinery does not exist; a tool declaring it never runs. |
EffectDescription
Section titled “EffectDescription”| Field | Type | Required | What it is |
|---|---|---|---|
envelope |
unknown |
yes | Everything that decides what the world will see — every recipient, the resolved account, the hashes. It is what core.effect_attempts hashes before dispatch. |
preview |
string |
yes | The short plain sentence the owner approves. Rendered from the envelope, never from model prose; no markdown. |
choices |
OwnerChoice[] |
no | Settings on this effect the owner decides when they approve it: { key, label, options, default } each. Core refuses anything not on the declared list, and execute reads the result from ctx.choices. Declare one only when there is something to choose. |
The call
Section titled “The call”ToolContext
Section titled “ToolContext”What a tool is handed: the call, and the host as buddi. Optional fields are
optional so every caller keeps compiling: a tool that needs one must fail
closed when it is absent rather than guess.
| Field | Type | Required | What it is |
|---|---|---|---|
buddi |
BuddiHost |
no | The host, bound to your plugin: everything you reach beyond your arguments — the database, the owner, the clock and zone, the preview port, the protected paths, the model accounts. Set by core on every context it hands you; optional only so core’s own contexts compile. See §1.1 and §9b. |
systemContext |
(run?) => Promise<SystemContext> |
no | Fresh owner timezone and host facts, from the composition root. The loop passes the run’s agent and grant, so a paragraph meant only for agents holding certain tools (the learning one) is decided by the grant. |
ownerRequest |
{ id; text; expiresAt } |
no | Issued by an authenticated interactive surface, never by a model. What a session tool is checked against. |
sessionTools |
readonly string[] |
no | Session grants resolved for this run. A delegate never inherits them. |
signal |
AbortSignal |
no | Cooperative cancellation. Check it before each external operation. |
approvedEffect |
{ envelope: unknown } |
no | Set only by the executor: the exact effect the owner approved. |
conversationId |
string |
no | Provenance. The loop fills it in for every call it makes. |
agentId |
string |
no | Provenance: who called. |
toolUseId |
string |
no | The tool_use block this call answers. Provenance, never authorization. |
group |
GroupContext |
no | The room this run speaks in, when it is a group run. Trusted context from the group row. |
suspend |
(actionId: string) => void |
no | Say the run is now waiting on an owner decision elsewhere; the loop stops dispatching and ends the run as awaiting it. |
delegationDepth |
number |
no | 0 or absent for an owner-started run, 1 inside a delegation. The delegation tool refuses at >= 1, so no cycle can exist. |
surface |
SurfaceProfile |
no | The surface this run is answering on. Delegation’s one reader. |
nativeSearch |
{ provider; maxUses } |
no | Set when the provider is searching the web itself, server-side, instead of a tool being dispatched. |
jobId |
string |
no | The durable job this run belongs to, when a job started it. |
actionId |
string |
no | Set only by executeApproved. Your idempotency key on a gated tool: absent, refuse. |
choices |
Readonly<Record<string, string>> |
no | Set only by executeApproved: what the owner picked among the choices your describe declared, already validated against them, with every unanswered key filled in from its default. Cope with it being absent. |
provenance |
() => RunProvenance |
no | Set by the runtime loop on every call: the run’s id, the owner turn it answers, the model step, and the untrusted inputs in its context, derived from the messages the model was shown. The learning tools record it; a tool that needs it fails closed when it is absent. |
GroupContext
Section titled “GroupContext”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | The group’s id. |
name |
string |
yes | What the room is called. |
coordinator |
string |
yes | The agent running the room. |
members |
readonly string[] |
yes | Member agent ids, in roster order. A member request is checked against this. |
requestId |
string |
yes | The owner request this run spends against. |
Sources
Section titled “Sources”Source
Section titled “Source”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | Namespaced and stable, e.g. email.inbox-poll. It keys core.source_runs, so changing it restarts the ledger. |
description |
string |
yes | One line, for the install summary and the logs. |
every |
number |
yes | Poll period in seconds. |
poll |
(ctx) => Promise<void> |
yes | One pass. It owns its cursor and its transaction; core decides only when it is due. |
watch |
(ctx) => SourceWatch |
no | A long-lived watcher beside the poll (since host API 1.15), for a push channel such as IMAP IDLE that lets the source poll right away. Core starts it once the plugin is loaded, with a live clock, and calls the returned stop() when the plugin is taken out or buddi stops; stop must release every socket and timer. Keep the poll as the safety net. |
SourceContext
Section titled “SourceContext”| Field | Type | Required | What it is |
|---|---|---|---|
buddi |
BuddiHost |
no | The host, bound to the plugin that ships this source. See §9b: the pool, the clock and zone, the log and schedule.enqueueRun are reached there. Commit your rows and your cursor in one db.transaction; enqueue after the commit. |
Sentinels
Section titled “Sentinels”Sentinel
Section titled “Sentinel”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | Namespaced and stable, e.g. finance.floor-breach. It keys core.sentinel_runs. |
description |
string |
yes | One line: what it watches. |
every |
number |
yes | Period in seconds. |
coalesce |
{ windowSeconds, maxWaitSeconds } |
no | Gather this watcher’s wakes for the same agent into one run (since host API 1.20): the first waits windowSeconds for company, each that follows pushes it back by the window, none waits past maxWaitSeconds. Window 1–3600, max wait no shorter than it and at most 86400. Unset: one run per wake, at once. |
run |
(ctx) => Promise<Finding[]> |
yes | Deterministic: SQL and TypeScript, no model. Returning a shorter list is how you say a fact stopped being true. |
SentinelContext
Section titled “SentinelContext”| Field | Type | Required | What it is |
|---|---|---|---|
buddi |
BuddiHost |
no | The host, bound to the plugin that ships this sentinel. See §9b: the pool, the owner, the clock and zone, and owner.agentForRole — the only supported way to address a finding — are reached there. |
MetricDefinition
Section titled “MetricDefinition”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | Namespaced under your plugin and stable: finance.total_debt. Lowercase, <plugin>.<name>, unique across the installation; checked at register(). |
description |
string |
yes | A sentence: “Everything you owe across cards and loans, in your currency.” It is what an agent reads before naming it in a goal. |
unit |
MetricUnit |
yes | 'number' | 'currency' | 'percent' | 'count' | 'minutes'. It decides how the goal’s numbers are rendered on the card and in the wake. |
direction |
MetricDirection |
yes | 'down' or 'up': which way is better. A goal’s target, its milestones and “on track” are all checked against it. |
params |
z.ZodObject<any> |
no | Optional narrowing — { account?: string }. Core makes it strict, so unknown keys are refused by the framework and not by you remembering .strict(). |
measure |
(params, ctx) => Promise<{value, currency?, asOf, note?} | null> |
yes | The value now, under the read-only pool, as the goal’s holder. null means “cannot measure right now”; a throw is treated the same way, with the message kept as the check’s note. |
Finding
Section titled “Finding”| Field | Type | Required | What it is |
|---|---|---|---|
key |
string |
yes | The identity of a fact, stable across runs. Anchor it to the date or the row the condition rests on. |
severity |
Severity |
yes | 'urgent' wakes the owner through the sentinel-wake mission, then stays quiet 24 h, and is a row on the Alerts page. 'info' goes to the weekly digest, then stays quiet 7 days, and is never listed — only counted in the recap line. |
title |
string |
yes | A short label; what surfaces show when there is no ownerLine. |
detail |
string |
yes | The agent brief: what the agent that picks it up (a wake, or the owner’s Ask) should check and do. Never shown to the owner. |
ownerLine |
string |
no | The owner’s line (host API 1.23): plain, short, what happened and why it matters. The only text the dashboard, Telegram, the recap and Home show. |
kind |
string |
no | What sort of fact this is within its watcher (1.23). Open findings of one watcher and kind are one row. |
subject |
{ id: string; label: string } |
no | What it is about — an account, a sender (1.23). Names the row inside a group; what “Stop telling me this” silences. |
group |
{ title: string; ownerLine?: string } |
no | How a group of this kind reads, {count} replaced (1.23): '{count} balances not updated in 2+ weeks'. |
actions |
FindingAction[] |
no | What the owner can do, the first primary (1.23): open, run, fill, ask, dismiss (§2.3). |
agentId |
string |
no | Who should speak about it: resolved by the plugin — normally ctx.buddi.owner.agentForRole(role) — or the wake mission’s agent by default. Never an id hard-coded in the plugin. |
wake |
boolean |
no | An info finding that wakes its agent once, when it is first raised, instead of taking a digest line. For good news with a short shelf life — a milestone crossed, a target reached. Every later fire of that key behaves like any other info. Ignored on urgent, which already wakes. |
notify |
{ urgency?: 'today'; dedupeKey?: string } |
no | How the agent’s line reaches the owner when the wake is not news for this minute. urgency: 'today' holds it for the end of the owner’s day instead of sending it now; dedupeKey is the notification’s “this thing, again” (default finding:<key>). It only ever lowers: there is no now here. Core’s goal watcher uses it for “you have not told me your weight this week”. |
cooldownMs |
number |
no | How long the same key stays quiet after it spoke, in place of the 24 h (urgent) or 7 days (info) default. The first raise still speaks at once. Core’s goal watcher sets one cadence on “off track”, so a weekly goal is news once a week. |
data |
unknown |
no | Structured evidence, handed to that agent verbatim. |
Suggestions
Section titled “Suggestions”SuggestedMission
Section titled “SuggestedMission”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | Stable mission id, e.g. friday-recap. |
name |
string |
yes | What the owner sees in the mission list. |
cron |
string |
yes | Five fields. A mission with no schedule is infrastructure, not this. |
prompt |
string |
yes | The run’s instructions. Unless alwaysDeliver, say what counts as worth speaking and to call mission.silent otherwise. |
agentRole |
string |
no | Which agent runs it, by capability. Resolved through the agent catalog; a role nobody claims is skipped out loud. |
agentId |
string |
no | Or pinned by name, when the mission is meaningless on any other agent. |
timezone |
string |
no | IANA zone; the owner’s (Settings → Profile, else BUDDI_TZ) when omitted. |
misfirePolicy |
MisfirePolicy |
no | What a closed laptop owes the owner: coalesce (default), latest-only, skip-after-deadline. |
alwaysDeliver |
boolean |
no | The owner asked for this message whatever it says. Default false. |
enabledByDefault |
boolean |
no | Default true; false registers a placeholder switched off. |
context |
MissionContext |
no | { plugin, export, args? }: an export core calls before each run, its JSON answer placed in the run’s first message, so a run that writes from material needs one model call, not two. The plugin is yours or one you require (checked at register()); a call that fails is said in the message instead, and the run goes on. Host API 1.27. |
reportMax |
number |
no | The longest report its mission.report takes, 200 to 6,000 characters; 1,500 when absent. Telegram splits a long one at its paragraphs. Host API 1.27. |
SuggestedAgent
Section titled “SuggestedAgent”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | Agent id and directory name, kebab-case. |
handle |
string |
yes | What the owner types, without the @. |
name |
string |
yes | Its display name. |
description |
string |
yes | One line: what it is for. Other agents read this to hand it work. |
persona |
string |
yes | The body of the file, in markdown. |
tools |
string[] |
yes | The privilege boundary. Names or family globs, shown to the owner tool by tool. A platform.* write tool is refused outright. |
roles |
string[] |
no | Capabilities it answers for, e.g. ['overview']. |
model |
string |
no | The model it runs on. Checked against the provider. |
provider |
'anthropic' | 'openai' |
no | Which provider. |
maxTurns |
number |
no | Turn budget per run. Omitted, the default of 40 applies; a run that reaches the budget stops and says so. |
language |
'mirror' | 'en' | 'fr' |
no | What it answers in. |
idleRollover |
'3h' | '1d' | '1w' | 'never' |
no | How long its chats may sit idle before the next message starts a fresh conversation. Omitted, three hours. Written into the accepted file; the owner changes it on the Brain tab. Size still rolls a chat over. Host API 1.16. See §2.6. |
skills |
SuggestedSkill[] |
no | Skills written into this agent’s own skills/ when it is accepted. |
missions |
SuggestedAgentMission[] |
no | Missions it arrives with: { id, name, cron, prompt, misfirePolicy?, alwaysDeliver? }, a SuggestedMission without the addressing. The same approval creates each as agent:<agent id>:<id>, run as the new agent in the owner’s timezone, and the preview names every one. skip-after-deadline is refused. The finance plugin’s Ledger arrives with its daily check and Friday recap. |
offer |
{ text, query? } |
no | Offer it on Home while no agent has its id: text is the card’s one line; query names one of your page queries whose answer carries wanted: true while the offer is worth making. Accepting is the same gated platform.accept_plugin_agent; the owner may dismiss it. A page can offer it in place with the agent-offer component. |
avatar |
BundledMascot |
no | The face it arrives with: one of the bundled Buddi Blob mascots (core, coding, finance, garage, mail, maker, playground, research), stored as the agent’s picture when the owner accepts. Without one it shows its initials until the owner picks a face. |
SuggestedSkill
Section titled “SuggestedSkill”| Field | Type | Required | What it is |
|---|---|---|---|
name |
string |
yes | Kebab-case, and also the file name: staging-an-import. |
description |
string |
yes | One line on when this procedure applies. |
body |
string |
yes | The procedure itself, in markdown. A skill grants no tool and lowers no tier. |
Views and the network
Section titled “Views and the network”ViewDescriptor
Section titled “ViewDescriptor”| Field | Type | Required | What it is |
|---|---|---|---|
tool |
string |
yes | The tool whose result this draws. Naming a tool your manifest does not contribute is a startup error. |
renderer |
RendererName |
yes | timeseries | table | bars | keyvalue | tiles | document | diff | terminal | image | preview | envelope | structured. Shapes, never domains. |
map |
ViewMap |
yes | Declarative paths, columns and formats. Data, never a function: it is serialised to the browser. |
title |
string |
no | The canvas tab and panel heading. Defaults to the tool name. |
TilesMap
Section titled “TilesMap”| Field | Type | Required | What it is |
|---|---|---|---|
items |
string |
yes | Path to the array of items, one tile each. |
icon |
ValueRef |
yes | A path within the item, or a constant, naming a TileIcon; anything else is a neutral dot. |
value |
string |
yes | Path within the item to the big figure, already formatted. |
label |
string |
yes | Path within the item to the words under it. |
lines |
string[] |
no | At most two paths within the item, drawn small. |
tone |
string |
no | Path within the item to good | warning | critical | neutral | accent. |
empty |
string |
no | What to say when there are no items. |
notice |
{ text, icon?, link?: { page } } |
no | The one card drawn instead when text (a path) finds a sentence: a tool not set up yet. link is a page of yours. |
PageDescriptor
Section titled “PageDescriptor”| Field | Type | Required | What it is |
|---|---|---|---|
id |
string |
yes | mail, settings. Lower-kebab-case, unique in your plugin, and the <page> of its route. |
title |
string |
yes | The rail entry’s word, the settings tab’s word, the page’s heading. |
place |
'rail' | 'settings' |
yes | A place of its own on the rail, or a tab after the core settings sections. |
icon |
PageIcon |
no | One of a pinned set the dashboard draws — mail, money, calendar, people, file, chart, bell, plug, key, globe. Never an image you supply. Defaults to the plug. |
order |
number |
no | Where you sit among the plugin entries. The core places are fixed. |
data |
QueryRef |
no | One read for the page itself, resolved once. Its answer is what the top of body — and any when there — is drawn against. |
actions |
SectionAction[] |
no | A rail page’s head, on the right: up to three links or buttons drawn against data (the News page’s Sources and Latest edition); tone: 'accent' on a link makes it the primary. Not on a settings tab. Host API 1.27. |
body |
Component[] |
yes | The tree: the fixed component set of §2.5b, each bound to a query for its data and a tool for its writes. |
PageQuery
Section titled “PageQuery”| Field | Type | Required | What it is |
|---|---|---|---|
name |
string |
yes | threads, accounts. Lower_snake_case, unique in your plugin; it is the <query> of GET /api/pages/<plugin>/<query>. |
params |
z.ZodTypeAny |
yes | The parameters, checked before produce sees them. They arrive as strings: use z.coerce.number(). An undeclared key is refused. |
produce |
(params, ctx) => Promise<unknown> |
yes | The read. ctx.buddi.db runs every statement in a read-only transaction with a five-second timeout, so a query that tries to write fails loudly — in Postgres’s own words — rather than writing something nobody approved. Throw QueryRefusal for what the owner can act on (“No conversation here has that id.”): its message is their 400. |
result |
z.ZodTypeAny |
no | The result shape. When given, the answer is validated before it leaves the process — the page draws what it is handed and cannot check it. |
sensitive |
boolean |
no | Balances, pay, anything not to be read over a shoulder — as a Home block’s sensitive. The page masks every section that reads it behind Show/Hide and masks it again when the window loses focus; buddi.page_query over MCP returns only its name unless called with includeSensitive: true. |
OptionsFrom
Section titled “OptionsFrom”| Field | Type | Required | What it is |
|---|---|---|---|
query |
QueryRef |
yes | The read behind a select’s options, checked at load like any other reference. |
rows |
string |
yes | Path to the array of rows in the answer. |
value |
string |
yes | Path within a row to what a choice submits. |
label |
string |
yes | Path within a row to the words the owner reads. |
dependsOn |
string[] |
no | Field names whose current value is sent as a parameter of the same name, and whose change re-reads the options. An empty one is left out. |
NetworkUse
Section titled “NetworkUse”| Field | Type | Required | What it is |
|---|---|---|---|
host |
string |
yes | api.open-meteo.com, or *.example.com when it really is several. |
why |
string |
yes | One line the owner can weigh: what you send there and what you fetch. |
Nothing enforces network at runtime. Saying so plainly is the point: an
undeclared host is a plugin author who did not write it down, not a plugin that
cannot reach the network. It is compared with the Hosts: line of your
buddi.md, and a difference costs the owner a second approval.
Network at runtime. network is read once, at register(). A plugin that
only learns where it talks while buddi runs — Connections, whose hosts are the
services the owner connected — declares them with ctx.buddi.network.declare
(host API 1.7, §9b) and takes them back with undeclare. A runtime host is
listed on the Plugins page beside the manifest’s, and http treats it the same.
Sentinel constants worth knowing
Section titled “Sentinel constants worth knowing”URGENT_COOLDOWN_MS |
24 h. How long the same urgent key stays quiet after it fires. |
INFO_COOLDOWN_MS |
7 days. The same, for the digest. |
SENTINEL_WAKE_MISSION_ID |
sentinel-wake — the mission an urgent finding enqueues. |
EXECUTABLE_TIERS |
['auto'] — what invoke runs inline. |
GATED_TIERS |
['gated'] — what it turns into an action. |
DEFAULT_EFFECT_TIMEOUT_MS |
The wait before an effect attempt is recorded unknown. |
9b. Reference: the host, ctx.buddi
Section titled “9b. Reference: the host, ctx.buddi”Everything a plugin reaches beyond its own arguments, in one object bound to
the plugin (docs/plugin-host-api.md). Core builds it at register() from
your manifest’s name, schema, tools, network and uses, and puts it on every
context it hands you: a tool’s, a page query’s, a metric’s, a home block’s, a
preview’s, a source’s, a sentinel’s. §1.1 is the idea; this is every member.
Eight areas are always there. The rest exist only when your manifest’s uses
declares them, and package.json’s buddi.uses says the same (§1.3). An area
you did not declare is absent: ctx.buddi.http is undefined, not a refusal.
The types are exported from @buddi/core/plugin.
“Since” is the host version that introduced each member (§1.7). The host is
1.27; most members are from 1.0, and the later minors’ additions are the rows
that say otherwise.
BuddiHost
Section titled “BuddiHost”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
version |
string |
yes | 1.0 | major.minor, HOST_API_VERSION. |
plugin |
string |
yes | 1.0 | Your manifest’s name. |
log |
(line) => void |
yes | 1.0 | An operational line, prefixed with your name. Never the owner’s channel. |
scrub |
(text) => string |
yes | 1.0 | Every stored value a text contains becomes ‹secret:NAME› — buddi’s own keys under their own names (§6). Never the reverse, and never a read. |
owner |
OwnerArea |
yes | 1.0 | Who this installation belongs to. |
clock |
ClockArea |
yes | 1.0 | The clock. |
db |
DbArea |
yes | 1.0 | The database, never a raw pool. |
dir |
DirArea |
yes | 1.0 | A directory of your own. |
approvals |
ApprovalsArea |
yes | 1.0 | The owner’s decisions about your own tools. |
pages |
PagesArea |
yes | 1.0 | What a query or a tool asks about pages and previews. |
tools |
ToolsArea |
yes | 1.6 | Your own tools, added and removed while buddi runs. Also on the register hook’s host. |
network |
NetworkArea |
yes | 1.7 | Hosts you talk to that you learn while buddi runs, beside your manifest’s network. Also on the register hook’s host. |
http |
HttpArea |
no | 1.0 | Declared as http. |
accounts |
AccountsArea |
no | 1.0 | Declared as accounts. |
files |
FilesArea |
no | 1.0 | Declared as files or files:library. |
memory |
MemoryArea |
no | 1.0 | Declared as memory. A type only in 1.0: never present yet, until a plugin needs it. |
proposals |
ProposalsArea |
no | 1.0 | Declared as proposals. |
schedule |
ScheduleArea |
no | 1.0 | Declared as schedule. |
secrets |
SecretsArea |
no | 1.0 | Declared as secrets. The owner’s secrets, used and never read (§6). |
channels |
ChannelsArea |
no | 1.3 | Declared as owner:channel. A way to reach the owner that you carry, listed in Settings → Notifications. Also on the register hook’s host. |
plugins |
PluginsArea |
no | 1.18 | Present when your manifest declares requires, or optional (1.27): the named read-only exports of those plugins (§2.10). |
assets |
AssetsArea |
no | 1.27 | Declared as assets. Small images you fetched, kept and served by buddi. |
OwnerArea
Section titled “OwnerArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
id |
string |
yes | 1.0 | The owner’s id. |
timezone |
string |
yes | 1.0 | The owner’s IANA zone. |
agentForRole |
(role) => string | undefined |
yes | 1.0 | The first runnable agent holding a role, or undefined. Never an agent’s file, grant or provider. |
hasAgent |
(id) => boolean |
yes | 1.1 | Whether an agent with this id is installed — for a plugin that proposes one and must not start runs for it before the owner accepts it. true where there is no roster to ask. |
protectedPaths |
readonly string[] |
yes | 1.0 | Directories no plugin may write into, whatever it was granted. |
language |
() => Promise<string | undefined> |
yes | 1.5 | The language the owner asked to be answered in (their profile’s “Answer me in”), as a tag: fr, pt-BR. A language name, in English or its own words, becomes its ISO 639-1 code; undefined when blank or not a language. |
formats |
() => Promise<{ time, date }> |
no | 1.19 | How the owner reads times and dates (Settings → Profile): time is '12h', '24h' or null (Auto), date is 'short' (Thu, Oct 1), 'long' (Thursday, 1 October), 'iso' or null. Always present from 1.19; absent before. |
places |
() => Promise<OwnerPlace[]> |
no | 1.18 | The owner’s places from Settings → Profile, in their order: { id, label, address, name, latitude, longitude, timezone }. Read-only. Declared as owner:places (§2.8). |
notify |
(message: PluginOwnerMessage) => Promise<{ id }> |
no | 1.2 | Tell the owner something: urgency (now, today, digest), a one-line title, optional text, link: { route }, dedupeKey, agentId and, since 1.24, action (at most 80 characters: what you ask the owner to do; with it the message waits in Needs you, without it it is information). Declared as owner:notify. The kind is always plugin, your name is on the message, and the owner’s settings pick the channel, never you. See notifications.md. |
ClockArea
Section titled “ClockArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
now |
() => Date |
yes | 1.0 | Now. Never read the wall clock directly. |
today |
() => string |
yes | 1.0 | The owner’s local date, YYYY-MM-DD. |
DbArea
Section titled “DbArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
query |
(sql, params?) => Promise<{rows, rowCount}> |
yes | 1.0 | One statement on the shared pool; rowCount is how many rows it touched. No search_path is set, so name your tables with your schema (template.note). In a page query or a metric it is read-only. |
transaction |
(fn) => Promise<T> |
yes | 1.0 | One connection: begin, your schema first on search_path, fn(tx), then commit — or rollback when fn throws. |
DirArea
Section titled “DirArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
path |
string |
yes | 1.0 | <data>/plugins-data/<plugin>, absolute, created on first read. Never the data directory itself. |
legacyPath |
string | undefined |
yes | 1.0 | <data>/<plugin>, where the built-in browser and host plugins kept the owner’s profile and workspaces before plugins-data; undefined for every other plugin. Not created. |
ApprovalsArea
Section titled “ApprovalsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
assert |
(ctx, envelope) => void |
yes | 1.0 | Throw unless envelope is the effect the owner approved on this call. |
standing |
(tool) => Promise<ToolPermission | null> |
yes | 1.0 | The standing permission that answers for one of your tools on this call. Refused for another plugin’s tool. |
approvedInConversation |
(tool, conversationId) => Promise<boolean> |
yes | 1.0 | Whether the owner approved your tool in this conversation or one delegated from it. Refused for another plugin’s tool. |
decisionsInConversation |
(tool, conversationId) => Promise<ConversationDecision[]> |
yes | 1.4 | Your tool’s cards in this conversation or one delegated from it, oldest first: { envelope, state, choices? }. For a tool that asks once per subject and must not ask again after a yes or a no. Refused for another plugin’s tool. |
PagesArea
Section titled “PagesArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
previewPort |
() => number | undefined |
yes | 1.0 | The port previews are served on, when they are. |
previewUrl |
(name) => string | undefined |
yes | 1.0 | http://127.0.0.1:<port>/preview/<plugin>/<name>/, when there is a port. |
ToolsArea
Section titled “ToolsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
register |
(definitions: ToolDefinition[]) => void |
yes | 1.6 | Add tools while buddi runs, for tools you cannot know at boot (a connection the owner made this afternoon). Each name must be <plugin>.<what> (letters, digits, _, -); every check a manifest’s tools get applies — no name twice, tier and tierFor coherent, untrusted a known kind, an input a provider can take. All or nothing: one bad definition and none is added. Agents’ tools: grants are resolved again for their next turn, so mcp.github.* covers a tool added after the agent loaded. |
unregister |
(names: string[]) => void |
yes | 1.6 | Remove tools you added with register. A manifest tool, or another plugin’s, is refused; so is the whole call if one name is. An approval still pending on a removed tool is refused when it would execute. |
registered |
() => string[] |
yes | 1.6 | The names you have added at runtime and not removed, in order. |
NetworkArea
Section titled “NetworkArea”Your manifest’s network is fixed when buddi starts. A plugin that learns
where it talks while buddi runs (Connections: the owner connects Notion at
3pm) declares those hosts here, and from that moment they are part of what the
Plugins page lists under what leaves the machine, and part of the list http
checks a request against, exactly like a manifest host.
| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
declare |
(uses: { host, why }[]) => void |
yes | 1.7 | Add hosts: a name (mcp.notion.com) or *.name, never a URL, a port or an address, each with one line the owner can weigh. Declaring a host again replaces its why; a host your manifest already names changes nothing. A malformed host throws and nothing is added. |
undeclare |
(hosts: string[]) => void |
yes | 1.7 | Remove hosts you declared with declare. Your manifest’s stay. |
declared |
() => { host, why, runtime }[] |
yes | 1.7 | Every host you declared: the manifest’s first (runtime: false), then the runtime ones in the order they came. |
HttpArea
Section titled “HttpArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
request |
(req) => Promise<HttpResponse> |
yes | 1.0 | { url, method?, headers?, body?, signal?, idleTimeoutMs?, maxBytes? } on the shared transport, behind the address guard: a URL naming this machine, its network or a port other than 80 and 443 is refused with a BlockedError before anything is sent, and names resolve through guardedLookup inside the socket, so a public name that answers with a private address is refused where it is dialled. Redirects are not followed. A host your network does not declare is logged with your name in 1.0, and will be refused once every plugin declares its hosts. With auth: { secret, header? } one of the owner’s secrets goes into one header of this request (docs/owner-secrets.md §3, http.header): core reads the secret by name, finds the binding that names this request’s host and header, applies the rule, and inserts the header itself after the address checks — the value never passes through your hands. HTTPS only; header is Authorization when absent; a use waiting on the owner throws SecretPendingError with the action id, a refusal throws the refusal. Since 1.9, auth: { secret, as: 'url' } makes the secret the whole address (http.url), for a private link whose credential is in its path, such as a calendar’s ICS address: url names only the host you expect (https://calendar.google.com/), and core fetches the stored address instead once it is HTTPS on that same host and passes the address rules. GET only, with no body and no header; an error that would quote the address says ‹secret:NAME›. The binding’s target is { plugin, host } with your name in it, filled in by the area, so no other plugin can fetch it. Since 1.26, auth: { secret, as: 'basic', username } makes the secret a sign-in’s password (http.basic): core sends Authorization: Basic base64(username:password), HTTPS, for GET, HEAD, OPTIONS, PROPFIND, REPORT, PUT or DELETE only, with a body of at most 256 KiB, an answer capped at 10 MB and 120 requests a minute per secret; the binding’s target is { plugin, host }, the host exact or *. a domain. |
AccountsArea
Section titled “AccountsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
list |
() => ProviderAccountListing[] |
yes | 1.0 | Every model account, never a key: { id, label, kind, enabled, configured, defaultModel, baseUrl? }, where baseUrl (1.8) is an HTTP account’s address. |
resolve |
(id, model, signal?) => Promise<ResolvedProvider> |
yes | 1.0 | An HTTP account’s endpoint and key. Refused for an account the owner has not bound to you. |
withCodexProfile |
(id, use, signal?) => Promise<T> |
yes | 1.0 | Deprecated since 1.8: kept for compatibility; a picture needs generateCodexImage. Run use with a Codex account staged (a private CODEX_HOME for a codex child). Refused for an account the owner has not bound to you. |
generateCodexImage |
(id, { prompt, references, size?, model?, signal? }) => Promise<CodexImage> |
yes | 1.8 | One picture from a Codex account’s ChatGPT subscription: the host sends a Responses request whose one tool is the hosted image_generation and hands back { bytes, mime, revisedPrompt? }. references are { bytes, mime }; size is 1024x1024, 1024x1536 or 1536x1024; model is the Responses model that calls the tool, the account’s default when absent. The token never reaches you; the account’s lock and refresh apply as for a chat. Refused for an account the owner has not bound to you. |
bind |
(id) => Promise<void> |
yes | 1.0 | Bind an account to you: the owner’s pick on your settings page, from an ownerOnly tool. Refused from any other call. |
FilesArea
Section titled “FilesArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
save |
({bytes, mime, filename?, caption?, source?}) => Promise<FileRow> |
yes | 1.0 | Keep a file in the Files library. Who made it, where it lives and the conversation it belongs to (the call’s, when that conversation exists) are filled in by core. A FileRow is the library’s row without its path on disk, with conversationId. |
get |
(id) => Promise<FileRow | null> |
yes | 1.0 | One file in scope, or null. |
read |
(id) => Promise<Buffer> |
yes | 1.0 | Its bytes. |
list |
({since?, before?, kind?, sha256?, surface?, limit?}) => Promise<FileRow[]> |
yes | 1.0 | Newest first, at most 100. In scope: the files you saved and the files handed into the conversation your tool runs in — every file with files:library. |
MemoryArea
Section titled “MemoryArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
recall |
(query, opts?) => Promise<MemoryNote[]> |
yes | 1.0 | As the calling agent, under memory.recall’s scope rules. A type only in 1.0. |
note |
(text, opts?) => Promise<{id}> |
yes | 1.0 | As the calling agent, under memory.note’s scope rules. A type only in 1.0. |
ProposalsArea
Section titled “ProposalsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
proposePolicy |
(ctx, input, within?, opts?) => Promise<CreateProposalResult> |
yes | 1.0 | Core’s proposePolicy, with your name and the clock filled in. ctx is the call that noticed (its agent, conversation and run are the provenance), or null; within is a db.transaction handle, for a proposal that must commit with your own rows. input.kind and input.kindLabel name what kind of rule it is (the track record and the inbox’s groups are per kind). Since 1.14, opts.announce: false for a card you keep yourself in the same transaction. |
countOpen |
() => Promise<number> |
yes | 1.0 | How many of your policy proposals are open. |
listOpen |
() => Promise<Proposal[]> |
yes | 1.14 | Your open policy proposals, oldest first. |
keepItself |
(id, within?) => Promise<Proposal | null> |
yes | 1.14 | Keep one of your open policy cards yourself, recorded as decided by auto, after writing the rule in within. Only for what your own rule allows without asking (learning.md §4). null when it is not open or not yours. |
trackRecord |
(kind) => Promise<TrackRecord> |
yes | 1.14 | The owner’s record with your rules of one kind: kept (owner keeps since their last discard), discarded, and trusted once kept reaches five. Auto keeps never count. |
takeBack |
(id, opts?) => Promise<Proposal | null> |
yes | 1.14 | The owner took back a kept rule of yours (an Undo on your page): it becomes the owner’s discard as of now. opts.reason, opts.within. |
ScheduleArea
Section titled “ScheduleArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
enqueueRun |
({agentId, prompt, dedupKey, conversationHint?}) => Promise<void> |
yes | 1.0 | Start an agent run. Idempotent on dedupKey. |
remindersFor |
({contextKey, values}, days) => Promise<{value, day}[]> |
yes | 1.0 | Which of these values already have a pending reminder due on one of these days, read by one key of the reminder’s context. Never a reminder’s text. |
SecretsArea
Section titled “SecretsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
registerDestination |
(destination) => void |
yes | 1.0 | Register one of your destinations ({ kind, checkTarget, describe, deliver, maxRule }, kind <plugin>.<what>). The manifest’s destinations does the same at register(). |
use |
(name, kind, target) => Promise<SecretUseResult> |
yes | 1.0 | Deliver a bound secret into target of your destination kind: { done, use }, { pending: actionId } when the owner is asked, or { refused }. Never the value. |
list |
() => Promise<SecretListing[]> |
yes | 1.0 | Secrets bound to your kinds: names, bindings, last use. No values. |
put |
(name, value, bindings) => Promise<void> |
yes | 1.0 | Owner only (an ownerOnly tool): store a secret the owner typed, bound to your kinds. Since 1.9 a plugin that also declares http may bind it to core’s http.url with { plugin: <your name>, host }, and then fetch it with http.request’s auth: { secret, as: 'url' }; since 1.26 likewise to http.basic with { plugin: <your name>, host }, a password sent with auth: { secret, as: 'basic', username }; rename, rebind and delete treat those bindings as yours. |
rename |
(name, to) => Promise<boolean> |
yes | 1.0 | Owner only. A secret bound only to your kinds. |
rebind |
(name, bindings) => Promise<boolean> |
yes | 1.0 | Owner only. Replace its bindings, your kinds only. |
delete |
(name) => Promise<boolean> |
yes | 1.0 | Owner only. The rows and the value. |
ChannelsArea
Section titled “ChannelsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
register |
(channel: PluginChannel) => () => void |
yes | 1.3 | Add a channel of yours, kind <plugin>.<what>: { kind, describe(buddi), can, deliver(message, buddi) }. describe answers { label, where? }, or null when there is nothing to carry a message now, and the channel is then not listed. deliver gets the stored message (title, text, link: { route, url? } with url on the public origin, offers as labels only, never an approval’s id) and answers { id } or { refused: sentence }. Both are handed your host, built over core’s pool, since core calls them outside any context. Never the default over Telegram or the system notification. Registering the kind again replaces it; the function removes it. See notifications.md. |
PluginsArea
Section titled “PluginsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
call |
(plugin, name, args?) => Promise<T> |
yes | 1.18 | Call a named read-only export of a plugin you require (or name as optional, 1.27), with arguments its params check. It runs as that plugin, on the read-only pool, within five seconds. Refused — a PluginCallRefusal — for a plugin not in your requires or optional, one not loaded or out of range, or a name it does not export. See §2.10. |
has |
(plugin) => boolean |
no | 1.27 | Whether a plugin you name in requires or optional is loaded now, at a version in the range. False for any other name. For a plugin that adapts: “Read editions aloud” says “Needs Speech” while it is false. |
AssetsArea
Section titled “AssetsArea”| Field | Type | Required | Since | What it is |
|---|---|---|---|---|
put |
(key, bytes, mime) => Promise<PluginAsset> |
yes | 1.27 | Keep an image under key (lower case, digits, ., _, -, at most 96), replacing what was there. PNG, JPEG, GIF (its first frame) or ICO, at most 256 KB; SVG is refused (it can carry script), and WebP too for now. Core decodes it and keeps PNGs it drew itself, 64 and 128 px square, the picture fitted and centred; served session-gated at /api/plugin-assets/<plugin>/<key> (?size=64). 20 MB per plugin. Answers { key, bytes, updatedAt }; a refusal is thrown with the sentence why. |
delete |
(key) => Promise<boolean> |
yes | 1.27 | Delete one. False when there was none. |
list |
() => Promise<PluginAsset[]> |
yes | 1.27 | Every asset you keep. An export’s read-only host keeps only this. |
