Your first plugin
A plugin is how an agent gets to do something. It’s a plain object that tells Mercury its name, a few lines for the model about what it offers, and the tools it contributes: functions the model can decide to call, with a description of what they do and of the input they take.
This page writes one inside your app, the guest Wi-Fi plugin from Your first agent. A plugin living in your app is for your app only; to share one with every Mercury user, see Proposing a plugin.
The packages
Section titled “The packages”bun add @mercury-fw/kit ai zod@mercury-fw/kitis the plugin contract: thePlugintype, and the version of the contract your plugin is written against.aiis the AI SDK, which Mercury uses to talk to the model; itstool()builds a tool.zoddescribes the tool’s input, so the model knows what to send.
The plugin
Section titled “The plugin”Create src/plugins/guest-wifi.ts. It has to be under src/, the folder the app’s Docker image copies:
import { PLUGIN_API_VERSION, type Plugin } from "@mercury-fw/kit";import { tool } from "ai";import { z } from "zod";
export const guestWifiPlugin: Plugin = { apiVersion: PLUGIN_API_VERSION, name: "guest-wifi", description: "Tells people the name and password of the office guest Wi-Fi.", actsAs: "anyone", systemPromptFragment: "## Guest Wi-Fi\n{{assistant}} can look up the office guest Wi-Fi with `guest_wifi`. Use it when someone asks how to get online.", build: (ctx) => { const network = ctx.env.GUEST_WIFI_NAME; const password = ctx.env.GUEST_WIFI_PASSWORD; if (!network || !password) { ctx.log("guest-wifi: GUEST_WIFI_NAME or GUEST_WIFI_PASSWORD is not set: no tools"); return {}; } return { sessionTools: () => ({ guest_wifi: tool({ description: "Returns the name and password of the office guest Wi-Fi.", inputSchema: z.object({}), execute: async () => ({ ok: true, network, password }), }), }), }; },};Field by field:
apiVersionis the version of the contract the plugin was written for. Mercury checks it when it loads the plugin and skips one it can’t run, saying why in the log, instead of failing later in a stranger way.nameis what the logs call the plugin.descriptionsays, in one sentence, what the plugin lets the agent do. The model reads it before every conversation, next to the other plugins’ ones, and answers from that list when someone asks what it can do. It’s required: Mercury skips a plugin without one, saying why in the log.actsAssays whose identity the tools use at the service they reach."anyone"means none: there’s no login, and everyone may use it. People and roles explains the other two.systemPromptFragmentgoes into the instructions the model reads before every conversation, so it knows the tool exists and when to use it.{{assistant}}becomes the assistant’s name.buildruns once at startup, with the environment inctx.env. It reads the two settings and, when one is missing, logs it and contributes nothing: the agent starts anyway, without the tool. That’s how every plugin behaves, so one plugin set up wrong never stops the rest.sessionToolsreturns the tools by name.descriptionis what the model reads to decide whether to call it,inputSchemathe input it must send (none here), andexecutewhat runs.
What execute returns goes back to the model, which writes the answer from it. A plugin that also shows the person
words of its own, such as a status line while its tool runs, writes them in its messages: see
Texts the person reads.
Declare it
Section titled “Declare it”A plugin does nothing until the app declares it. In mercury.config.ts, import it and put it in plugins:
import { defineMercuryConfig } from "@mercury-fw/core";import en from "./locales/en.json";import { guestWifiPlugin } from "./src/plugins/guest-wifi.ts";
const name = "Mercury";
export default defineMercuryConfig({ persona: { name, identity: `You are ${name}, an internal assistant.`, tone: [ // … ].join("\n"), }, plugins: [guestWifiPlugin], channels: [], i18n: { locales: { en } },});Its settings
Section titled “Its settings”The password is a secret, so it goes in .env and nowhere else:
GUEST_WIFI_NAME=Office-GuestGUEST_WIFI_PASSWORD=correct-horse-batteryThen bunx mfw restart, and ask the agent about the Wi-Fi: step 6 of Your first agent
shows what comes back.
A package of its own
Section titled “A package of its own”One file is the smallest plugin. Once it grows (more tools, its own tests, a dependency the rest of the app
doesn’t need), it can become a package inside your app, under plugins/: every app is set up for it, with
plugins/* declared as workspaces in its package.json and a Dockerfile that copies plugins/ before installing.
It’s also the shape a plugin needs to be published one day.
Move the file to plugins/guest-wifi/index.ts, unchanged, and give the folder a package.json of its own, with
the packages the plugin is written with:
{ "name": "@my-agent/plugin-guest-wifi", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./index.ts" }, "dependencies": { "@mercury-fw/kit": "^0.59.1", "ai": "^7.0.152", "zod": "^4.6.7" }}The name is yours to choose; a scope with the app’s name keeps it apart from packages on npm. private keeps it
from being published by mistake.
The app then depends on it like on any package, but from its workspace. In the app’s package.json:
"dependencies": { "@mercury-fw/core": "^0.59.1", "@mercury-fw/kit": "^0.59.1", "ai": "^7.0.152", "@my-agent/plugin-guest-wifi": "workspace:*", "zod": "^4.6.7" },and mercury.config.ts imports it by that name:
import { guestWifiPlugin } from "@my-agent/plugin-guest-wifi";Run bun install to link it. Its tests go next to it, and the app’s bun run test (which runs bun test, the
first line it prints) runs them with the rest:
import { describe, expect, test } from "bun:test";import type { PluginRuntimeContext, SessionToolContext } from "@mercury-fw/kit";import { guestWifiPlugin } from "./index.ts";
/** The context Mercury builds a plugin with: this plugin reads only `env` and `log`. */function ctx(env: Record<string, string>, logs: string[] = []): PluginRuntimeContext { return { model: {} as never, env, log: (line) => logs.push(line) };}
describe("guest-wifi", () => { test("without its settings it contributes no tool, and logs why", () => { const logs: string[] = []; expect(guestWifiPlugin.build!(ctx({}, logs))).toEqual({}); expect(logs).toEqual(["guest-wifi: GUEST_WIFI_NAME or GUEST_WIFI_PASSWORD is not set: no tools"]); });
test("guest_wifi returns the network and the password from its settings", async () => { const built = guestWifiPlugin.build!(ctx({ GUEST_WIFI_NAME: "Office-Guest", GUEST_WIFI_PASSWORD: "secret" })); const tools = built.sessionTools!({} as SessionToolContext); expect(await tools.guest_wifi!.execute!({}, {} as never)).toEqual({ ok: true, network: "Office-Guest", password: "secret" }); });});$ bun testbun test v1.4.2 (744846f84)
2 pass 0 fail 3 expect() callsRan 2 tests across 1 file. [63.00ms]bunx mfw restart builds the image with the package in it, and the agent answers about the Wi-Fi as before.
Where to go next
Section titled “Where to go next”- Plugins and skills explains what else a plugin can contribute.
- The contract reference lists every field, generated from the source.