OpenOv AI

Tools of your own

The sixteen tools the server serves are the ones every workspace wants. The ones only yours wants — the tracker your team files work in, the machine your builds run on — are written where the machine is known, beside the instance. A file under plugins/ is a tool: three exports, a restart, and every session can call it.

The file is the tool

~/workspace/bin/ovai plugin ci-status

writes plugins/ci-status.mjs from a scaffold, and that is the whole of adding one. Its name is the tool's name, and the directory is the list: nothing is registered, no list is kept in step, and renaming a tool is renaming its file. A name is a letter, then letters, digits and hyphens, up to 32 of them — no underscore, because a permission rule for one tool of a server is spelled mcp__<server>__<tool>, and a name holding two of them would make a rule that named a different tool than the one you meant.

ovai plugin refuses a name the server already serves (room is a name a tool can have; what is in the way is that this server already has one), and a name whose file is already there: what is in that file is somebody's work, and a command that quietly replaced it would be worse than the surprise it saved. plugins/ sits at the instance directory, beside desks/, outside everything an update replaces.

The contract

A tool is three exports, and nothing else is read from the file:

export const description = "What it does, and what it refuses. A session reads this before calling.";
export const inputSchema = { type: "object", properties: {}, additionalProperties: false };
export function run(args, { seat, role, root, config }) {
  return { text: `${seat} called, and this workspace lives at ${root}` };
}
description
What a session reads before it decides whether to call this at all. Say what it does and what it refuses: a session that cannot tell will either not call it or call it wrongly.
inputSchema
What the call may carry, as a JSON schema. Every property wants a description of its own — it is the only place a session is told what to put there — and required says which of them it cannot leave out.
run(args, context)
The work. args is what the call carried. The context is what the server knows about the caller: seat is who is calling, resolved from the process's secret and never from anything the session says; role is Leader or Worker; root is where the instance is, as an absolute path — the one thing the file cannot work out for itself, since an instance records no path anywhere; config is openovai.json, the names of the User and the Leader included. It may be async.

Answer { text } with what to tell the caller, or { refused } with why not. A refusal is an answer: it arrives as words the calling session reads and can act on, rather than as a call that failed. There is no third shape — a handler that forgets to return is answered for by the server, with a refusal naming the file and what it answered instead, so silence never reaches the model. Whatever run throws is caught and reaches the caller the same way, naming the file; the server and everybody working in it survive a bad afternoon in there. A tool of yours is offered to everybody and refuses in its own words if it is the Leader's.

One from scratch: what is on CI for this branch

A team that keeps asking "did the build of my branch pass?" gives the question to the sessions. The whole tool is ci-status.mjs, thirty-five lines with its comments:

const CI = "https://ci.example.com";

export const description =
  "The last build of a branch on CI: its state and where its log is. Read-only: it starts " +
  "nothing and cancels nothing. Refuses a branch name that is not a plain ref.";

export const inputSchema = {
  type: "object",
  properties: {
    branch: { type: "string", description: "The branch, as git names it: main, feature/login." },
  },
  required: ["branch"],
  additionalProperties: false,
};

export async function run({ branch }, { seat }) {
  if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch)) {
    return { refused: `${branch} is not a branch name this tool asks CI about` };
  }
  const response = await fetch(`${CI}/api/builds?branch=${encodeURIComponent(branch)}&limit=1`);
  if (!response.ok) {
    return { refused: `CI answered ${response.status} for ${branch}` };
  }
  const [build] = await response.json();
  if (build === undefined) {
    return { text: `no build of ${branch} on CI yet` };
  }
  return { text: `${branch}: ${build.state}, build #${build.number}, `
               + `log at ${build.url} (asked by ${seat})` };
}

Read it top to bottom, it is the contract in order:

  1. The description says what it refuses

    "Read-only: it starts nothing and cancels nothing" is there for the session, not for you: a Leader that has been told this does not reach for it to rerun a build, and does not ask you whether it may.

  2. The schema names the one thing a call carries

    One property, described in git's own words, required, and nothing else allowed. A session gets this text and nothing more when it decides what to put there.

  3. The first thing run does is refuse

    A branch name that is not a plain ref is answered with { refused } before anything leaves the machine. A refusal is words the session reads; it is not a failure, and the turn goes on.

  4. Then it asks, and answers in one line

    The state, the build number, where the log is — and who asked, from seat, which the session cannot forge. An answer a session can paste into its reply as it is. A CI that is down is a refusal with the status code, never a throw.

What this example does not do

It has not been run against a CI server: ci.example.com and the JSON shape — a list with state, number, url — are stand-ins for whatever yours answers, and the first thing to change. It carries no credential; a CI that needs one gets it from a file beside the plugin or from the environment the server was started in, never from the session calling. And it never starts, restarts or cancels anything — a tool that could would have to say so in its description.

Scaffold, edit, restart

  1. ovai plugin ci-status

    writes plugins/ci-status.mjs from the scaffold: the three exports with comments saying what each is for, and a run that refuses everybody but the Leader, as a start. Replace it with the file above, or with yours.

  2. ovai restart

    Plugins are read once, when the server starts — a directory read on every call would show a new file and go on serving the stale code of a changed one, and nobody could tell which they had. So restart after writing or changing one. The log then says, on a plugins row, This instance serves a tool of its own: ci-status — or names the file and why it is not served.

  3. Ask on a panel

    Every seat sees it as mcp__openovai__ci-status, under the one permission rule the instance already holds for the server's tools. "What did CI make of feature/login?" on the Leader's panel is one tool line and one sentence back. No permission popup appears, for this tool or any other in plugins/.

ovai plugin writing the tool, a restart, and the plugins line in runtime.log

What is not served, and why

A file that cannot be served is not an error and not nothing. Not an error: the server starts anyway and goes on serving the rest, since a workspace where nobody can talk to anybody is a worse answer to a typo in one file than a workspace missing one tool. Not nothing: a tool silently absent is the one failure this arrangement is most likely to produce, because the directory is the list and a file in it looks served. So the reason is written to runtime.log, one row per file, in these words:

the filethe row says
is not named the way a tool isplugins/my_tool.mjs is not served: "my_tool" is not a name a tool can have — a letter, then letters, digits and hyphens, up to 32 of them
takes a built-in nameplugins/room.mjs is not served: room is already the name of a tool the chat serves everywhere, and a file cannot take it
does not load — a syntax error, an import of something not installed, work at the top level that throwsplugins/ci-status.mjs is not served: it could not be read: <the error>
loads and is missing an exportplugins/ci-status.mjs is not served: a tool is description, inputSchema, run, and this one exports no inputSchema

The first two are answered without reading the file; the two names are checked before the module is imported, so a file called room.mjs is told it cannot be called that instead of being appended to a list where the first room wins. A file that loads and is missing a part is refused whole, while the answer can still be a sentence: offered, it would be a tool with no description, or one a session calls and is answered by nothing.

Shared code goes in a directory

The loader serves the .mjs files of plugins/ and never looks inside a directory: a directory in there is not a tool with parts, anything that is not a module is not a module, and notes somebody left beside their plugin are notes. So a module two tools share lives in a subdirectory — plugins/ci/client.mjs, say — and each tool imports it; a words file, a schedule, a token file sit beside the plugin the same way, read by the tool and served by nobody. Files under plugins/ are sorted by name when the server lists what it serves, so the log reads the same on two machines.

Writing the file is the deciding

A tool of yours runs inside the server process, so the permission system never sees it: there is no permission popup for a plugin call, and no rule to grant, because mcp__openovai is granted already. That is the point — a session calls your tracker the way it calls index — and it is also the whole of the trust model: whatever the file can reach, a session that calls it reaches. Write the refusals in; give the tool the least it needs; keep a credential out of the session's hands by keeping it out of the answer. And note what a plugin is not: a tool a session chooses to call, never a way to the person. The page notifies; a plugin cannot.

Next

Customization: the personas you extend, every key of openovai.json, and no knob that does not exist.