Skip to content
mercury framework
GitHub

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.

Terminal window
bun add @mercury-fw/kit ai zod
  • @mercury-fw/kit is the plugin contract: the Plugin type, and the version of the contract your plugin is written against.
  • ai is the AI SDK, which Mercury uses to talk to the model; its tool() builds a tool.
  • zod describes the tool’s input, so the model knows what to send.

Create src/plugins/guest-wifi.ts. It has to be under src/, the folder the app’s Docker image copies:

src/plugins/guest-wifi.ts
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:

  • apiVersion is 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.
  • name is what the logs call the plugin.
  • description says, 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.
  • actsAs says 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.
  • systemPromptFragment goes 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.
  • build runs once at startup, with the environment in ctx.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.
  • sessionTools returns the tools by name. description is what the model reads to decide whether to call it, inputSchema the input it must send (none here), and execute what 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.

A plugin does nothing until the app declares it. In mercury.config.ts, import it and put it in plugins:

mercury.config.ts
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 } },
});

The password is a secret, so it goes in .env and nowhere else:

.env
GUEST_WIFI_NAME=Office-Guest
GUEST_WIFI_PASSWORD=correct-horse-battery

Then bunx mfw restart, and ask the agent about the Wi-Fi: step 6 of Your first agent shows what comes back.

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:

plugins/guest-wifi/package.json
{
"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:

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:

mercury.config.ts
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:

plugins/guest-wifi/index.test.ts
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 test
bun test v1.4.2 (744846f84)
2 pass
0 fail
3 expect() calls
Ran 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.