Vai al contenuto

Sviluppatori

Company Brain plugins: developer guide

EnglishLa documentazione per gli sviluppatori è in inglese. Formato del manifest: versione 1.

A plugin adds a capability to Company Brain, the AI workspace a company runs on its own server. A plugin can bring:

  • actions: functions that Brain (the assistant) and the agents can call, shaped like MCP tools;
  • instructions that tell Brain when and how to use those actions;
  • agents: specialists that people call with @Name in a chat;
  • permissions that guard all of the above;
  • pages, sidebar entries and home widgets, described as data and drawn by the platform.

You describe all of it in one JSON object, the manifest. The platform reads the manifest and builds everything else from it. Your actions run on your own server, which the platform calls over MCP. Company Brain's own modules use the same manifest format.

This guide is for third-party plugin authors. It starts with a working plugin (§1), then explains each part (§2) and gives the full reference (§3 to §6). Two appendices hold what you can't use yet: features that are specification only, and what is reserved for built-in plugins.

What you can build today (manifest format version 1)

  • Runtime: your own server, remote over mcp (MCP Streamable HTTP), with a bearer token or no auth (5.1). A company's administrators install it from «Moduli» by its manifest.
  • Actions for Brain and the agents, with permissions, confirmations and prices (2.6); instructions (2.7); agents with their own model (2.8).
  • Views: table (with search and rowLink), cards, detail, form, chart and number pages; sidebar badges; home widgets with a number, cards or chart view (2.9).

Everything else the validator accepts is specification only (Appendix A). Status of features has the full list.

Contents#

  1. Your first plugin
  2. Concepts
  3. Field reference
  4. Validation
  5. Runtime and transport
  6. Package reference
  7. Status of features

1. Your first plugin#

1.1 Set up the project#

The manifest's types, the validator, the MCP mapping and a mock host are in the package @brain/plugin-spec. It is not published yet: its package.json says "private": true. It depends only on zod and imports nothing else from the Company Brain repository. Once it is published, you will install it with npm install @brain/plugin-spec.

Until then, copy the package's folder into your project and install it as a local (file:) dependency. From an empty folder, with the Company Brain repository checked out beside it:

npm init -y && npm pkg set type=module
cp -R ../company-brain/packages/plugin-spec ./plugin-spec
npm install ./plugin-spec zod@4
npm install --save-exact @modelcontextprotocol/sdk@1.32.2
npm install --save-dev tsx
npm pkg set scripts.start="tsx server.ts" scripts.validate="tsx validate.ts" scripts.inspect="tsx inspect.ts"

Copy the folder without a node_modules of its own (or delete it after copying). "private": true only stops the package from being published; a local install works. If we gave you a tarball instead of the repository, npm install ./brain-plugin-spec-0.1.0.tgz works the same way.

Your package.json then has these fields, besides npm's defaults (the versions may be newer):

{
  "name": "plugin-spedizioni",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "tsx server.ts",
    "validate": "tsx validate.ts",
    "inspect": "tsx inspect.ts"
  },
  "dependencies": {
    "@brain/plugin-spec": "file:plugin-spec",
    "@modelcontextprotocol/sdk": "1.32.2",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "tsx": "^4.23.15"
  }
}

Two things matter here:

  • "type": "module". The files of this guide are ES modules, and inspect.ts uses top-level await, which works only in an ES module.
  • tsx. The package's entry points are TypeScript files (src/index.ts), not compiled JavaScript, so plain node can't load them. Run your files with tsx, or build them with a bundler that compiles TypeScript.

1.2 The manifest#

The example below is a complete, valid plugin for a courier, «Spedizioni» (Italian for "shipments"). It uses only what the platform runs today: a table of shipments whose rows open a detail page, cards of the couriers, a form that books a pickup, a chart of the last months, sidebar entries with a badge, two home widgets and an agent. Every field that a page, the badge or a widget reads is declared in the outputSchema of the action it reads. Every input they pass is declared in its inputSchema. The validator doesn't compare the two, so keeping them in step is your job (Views).

This is the repository's examples/plugin-spedizioni/src/manifest.ts, word for word. Save it as manifest.ts. It only exports the manifest, so other files can import it without running anything:

import { definePlugin } from "@brain/plugin-spec"

/**
 * The «Spedizioni» plugin's manifest. This file is docs/PLUGINS.md §1.2, word
 * for word: change both together. Only what the platform runs today: five
 * pages (a table whose rows open a detail, cards of the couriers, a form that
 * books a pickup, a chart), sidebar entries with a badge, two home widgets,
 * an agent.
 */
export const manifest = definePlugin({
  spec: 1,
  id: "spedizioni",
  name: "Spedizioni",
  description: "Le spedizioni del corriere, lette in diretta.",
  version: "0.1.0",
  publisher: { id: "acme", name: "Acme Srl", url: "https://acme.example" },
  icon: "truck",
  color: "orange",
  category: "integration",
  settings: [{ id: "key", label: "Chiave del corriere", type: "secret", required: true }],
  permissions: [
    {
      id: "spedizioni_shipments",
      label: "Spedizioni",
      icon: "package",
      actions: [
        { id: "view", label: "Vedere le spedizioni", byDefault: true },
        { id: "book", label: "Prenotare un ritiro" },
        { id: "costs", label: "Vedere i costi delle spedizioni" },
      ],
    },
  ],
  actions: [
    {
      name: "spedizioni_search",
      title: "Ricerca spedizioni",
      description:
        "Shipments by tracking number or customer, or of one courier. With late: true, only the late ones. Returns the rows, how many of them are late and how many there are in all.",
      kind: "read",
      loading: "always",
      permissions: ["spedizioni_shipments.view"],
      inputSchema: {
        type: "object",
        properties: {
          query: { type: "string", description: "A tracking number or a customer's name" },
          late: { type: "boolean", description: "Only the late shipments" },
          courier: { type: "string", description: "Only this courier's shipments, by its code (BRT, GLS, SDA)" },
        },
      },
      outputSchema: {
        type: "object",
        properties: {
          rows: {
            type: "array",
            items: {
              type: "object",
              properties: {
                tracking: { type: "string" },
                customer: { type: "string" },
                courier: { type: "string" },
                status: { type: "string", enum: ["in_viaggio", "consegnata"] },
              },
              required: ["tracking", "status"],
            },
          },
          late: { type: "number", description: "How many of the shipments found are late" },
          total: { type: "number", description: "How many shipments were found" },
        },
        required: ["rows", "late", "total"],
      },
    },
    {
      name: "spedizioni_get",
      title: "Dettaglio spedizione",
      description: "One shipment by its tracking number: customer, courier, status, destination, expected day, whether it is late.",
      kind: "read",
      permissions: ["spedizioni_shipments.view"],
      inputSchema: { type: "object", properties: { tracking: { type: "string", description: "The tracking number" } }, required: ["tracking"] },
      outputSchema: {
        type: "object",
        properties: {
          tracking: { type: "string" },
          customer: { type: "string" },
          courier: { type: "string" },
          status: { type: "string" },
          destination: { type: "string" },
          eta: { type: "string", description: "Expected day, YYYY-MM-DD" },
          late: { type: "boolean" },
        },
        required: ["tracking", "status"],
      },
    },
    {
      name: "spedizioni_book_pickup",
      title: "Prenotazione ritiro",
      description: "Books a courier pickup on the given day. Waits for the person's Conferma. Returns the pickup's number.",
      kind: "write",
      confirmation: "always",
      permissions: ["spedizioni_shipments.book"],
      inputSchema: {
        type: "object",
        properties: {
          date: { type: "string", format: "date", description: "The day of the pickup, YYYY-MM-DD" },
          courier: { type: "string", enum: ["BRT", "GLS", "SDA"], description: "The courier's code" },
          parcels: { type: "integer", minimum: 1, maximum: 20, description: "How many parcels" },
          notes: { type: "string", maxLength: 300, description: "Notes for the driver" },
        },
        required: ["date"],
      },
      outputSchema: { type: "object", properties: { pickup: { type: "string" }, date: { type: "string" } }, required: ["pickup"] },
    },
    {
      name: "spedizioni_couriers",
      title: "Corrieri",
      description: "The couriers the company ships with: code, name, service, how many of the shipments are theirs and how many of those are late.",
      kind: "read",
      permissions: ["spedizioni_shipments.view"],
      inputSchema: { type: "object", properties: {} },
      outputSchema: {
        type: "object",
        properties: {
          rows: {
            type: "array",
            items: {
              type: "object",
              properties: {
                courier: { type: "string", description: "Its code, e.g. BRT" },
                name: { type: "string" },
                service: { type: "string" },
                shipments: { type: "number" },
                late: { type: "number" },
              },
              required: ["courier", "name"],
            },
          },
        },
        required: ["rows"],
      },
    },
    {
      name: "spedizioni_trend",
      title: "Andamento spedizioni",
      description: "Shipments and their cost per month, the last six months, oldest first. The cost is in euro.",
      kind: "read",
      permissions: ["spedizioni_shipments.view"],
      prices: "spedizioni_shipments.costs",
      inputSchema: { type: "object", properties: {} },
      outputSchema: {
        type: "object",
        properties: {
          rows: {
            type: "array",
            items: {
              type: "object",
              properties: { month: { type: "string" }, shipments: { type: "number" }, cost: { type: "number", format: "money" } },
              required: ["month", "shipments"],
            },
          },
        },
        required: ["rows"],
      },
    },
  ],
  instructions: [
    {
      id: "spedizioni",
      summary: "spedizioni e ritiri del corriere",
      text:
        "Per lo stato di una spedizione usa spedizioni_search (o spedizioni_get con il numero di tracking). " +
        "Per i corrieri usa spedizioni_couriers; per l'andamento dei mesi spedizioni_trend (mostralo con show_chart se serve un grafico). " +
        "Per prenotare un ritiro usa spedizioni_book_pickup: chiedi sempre il giorno; il ritiro parte solo con la Conferma della persona.",
      actions: ["spedizioni_get", "spedizioni_couriers", "spedizioni_trend", "spedizioni_book_pickup"],
      when: { words: { pattern: "\\b(spedizion|tracking|corriere|ritiro)", flags: "i" }, pages: ["/spedizioni"] },
    },
  ],
  agents: [
    {
      id: "logistica",
      name: "Logistica",
      description: "Spedizioni e ritiri del corriere.",
      icon: "truck",
      color: "amber",
      prompt:
        "Sei la Logistica dell'azienda: segui le spedizioni e prenoti i ritiri con gli strumenti spedizioni_*. Di ordini o fatture non ti occupi: di' che può chiederlo a @Brain.",
      starters: ["Dov'è la spedizione SP-1001?", "Quali spedizioni sono in ritardo?"],
      actions: ["spedizioni_search", "spedizioni_get", "spedizioni_couriers", "spedizioni_trend", "spedizioni_book_pickup"],
      platformActions: ["show_chart"],
      instructions: ["spedizioni"],
    },
  ],
  ui: {
    pages: [
      {
        id: "shipments",
        path: "/spedizioni",
        title: "Spedizioni",
        permission: "spedizioni_shipments.view",
        view: {
          type: "table",
          source: { action: "spedizioni_search", rows: "rows" },
          columns: [
            { field: "tracking", label: "Tracking" },
            { field: "customer", label: "Cliente" },
            { field: "status", label: "Stato", format: "badge", labels: "status" },
          ],
          rowLink: "/spedizioni/dettaglio/{tracking}",
          search: true,
        },
      },
      {
        id: "detail",
        path: "/spedizioni/dettaglio",
        title: "Spedizione",
        permission: "spedizioni_shipments.view",
        view: {
          type: "detail",
          source: { action: "spedizioni_get" },
          sections: [
            {
              title: "Spedizione",
              fields: [
                { field: "tracking", label: "Tracking" },
                { field: "customer", label: "Cliente" },
                { field: "status", label: "Stato", format: "badge", labels: "status" },
              ],
            },
            {
              title: "Consegna",
              fields: [
                { field: "courier", label: "Corriere" },
                { field: "destination", label: "Destinazione" },
                { field: "eta", label: "Consegna prevista", format: "date" },
              ],
            },
          ],
        },
      },
      {
        id: "couriers",
        path: "/spedizioni/corrieri",
        title: "Corrieri",
        permission: "spedizioni_shipments.view",
        view: {
          type: "cards",
          source: { action: "spedizioni_couriers", rows: "rows" },
          title: "name",
          subtitle: "service",
          fields: [
            { field: "shipments", label: "Spedizioni", format: "number" },
            { field: "late", label: "In ritardo", format: "number" },
          ],
          // A card opens the shipments' table with courier=<its code>: the search gets it as its courier.
          link: "/spedizioni/{courier}",
        },
      },
      {
        id: "pickup",
        path: "/spedizioni/ritiro",
        title: "Prenota ritiro",
        permission: "spedizioni_shipments.book",
        view: {
          type: "form",
          fields: [
            { id: "date", label: "Giorno del ritiro", type: "date", required: true },
            { id: "courier", label: "Corriere", type: "choice", options: ["BRT", "GLS", "SDA"] },
            { id: "parcels", label: "Colli", type: "number" },
            { id: "notes", label: "Note per l'autista", type: "long-text" },
          ],
          // Its confirmation is "always": the form prepares the pickup and the person confirms it on the card.
          submit: "spedizioni_book_pickup",
          submitLabel: "Prenota",
        },
      },
      {
        id: "trend",
        path: "/spedizioni/andamento",
        title: "Andamento",
        permission: "spedizioni_shipments.view",
        view: {
          type: "chart",
          source: { action: "spedizioni_trend", rows: "rows" },
          chart: "bar",
          category: "month",
          series: [
            { field: "shipments", label: "Spedizioni" },
            // Money: without spedizioni_shipments.costs the chart has no cost series.
            { field: "cost", label: "Costi", format: "money" },
          ],
        },
      },
    ],
    sidebar: [
      { page: "shipments", section: "work", badge: { action: "spedizioni_search", field: "late" } },
      { page: "couriers", section: "work", icon: "warehouse" },
      { page: "pickup", section: "work", icon: "calendar" },
      { page: "trend", section: "work", icon: "chart-bar" },
    ],
    widgets: [
      {
        id: "spedizioni.late",
        label: "Spedizioni in ritardo",
        sizes: ["small"],
        defaultSize: "small",
        permission: "spedizioni_shipments.view",
        // Called with late: true, the search finds only the late shipments: its total is how many are late.
        view: { type: "number", source: { action: "spedizioni_search", input: { late: true } }, field: "total", label: "In ritardo" },
      },
      {
        id: "spedizioni.trend",
        label: "Andamento spedizioni",
        sizes: ["medium", "large"],
        defaultSize: "medium",
        permission: "spedizioni_shipments.view",
        view: {
          type: "chart",
          source: { action: "spedizioni_trend", rows: "rows" },
          chart: "line",
          category: "month",
          series: [{ field: "shipments", label: "Spedizioni" }],
        },
      },
    ],
  },
  labels: { status: { in_viaggio: "In viaggio", consegnata: "Consegnata" } },
  runtime: { type: "remote", transport: "mcp", url: "https://acme.example/mcp", auth: { type: "bearer", setting: "key" } },
})

/** The same manifest with your server's own MCP address (what GET /manifest.json serves). */
export const manifestFor = (url: string) => ({ ...manifest, runtime: { ...manifest.runtime, url } })

Then save the check as validate.ts, next to it. It is the example's src/validate.ts:

import { validateManifest } from "@brain/plugin-spec"

import { manifestFor } from "./manifest"

/** Checks the manifest as Company Brain will (a local url needs its host among the development hosts). */
const url = process.argv[2] ?? "http://localhost:4319/mcp"
const result = validateManifest(manifestFor(url), {
  platformActions: ["show_chart", "search_chats"],
  knownModels: ["gpt-6-luna", "gpt-5.4-mini", "gpt-6.1-sol"],
  devHosts: [new URL(url).host],
})
console.log(result.ok ? "ok" : result.problems)
if (result.warnings.length) console.warn(result.warnings)

It validates the manifest with the address you give it as runtime.url (by default your local server's, http://localhost:4319/mcp). A plain http address is valid only on a development host, so the check passes that address's host as one (devHosts, 4.2). The two lists are the platform's actions and models (6). Run it:

npm run validate

It prints:

ok

Before you publish, check the manifest with your public address too: npm run validate -- https://acme.example/mcp.

1.3 Your server#

The plugin's actions run on your server: an MCP server over Streamable HTTP (5.1). Save this as server.ts, next to manifest.ts. It:

  • is stateless: a new MCP server for each request, answering in JSON;
  • refuses a request without the right bearer token;
  • lists exactly your manifest's actions;
  • reads who is asking from every call, and keeps each company's data apart;
  • serves its manifest at /manifest.json, with its own address in it.

Its data are placeholders: put yours behind shipmentsOf, couriers and trendOf.

import { createServer, type IncomingMessage, type ServerResponse } from "node:http"

import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { readEnvelope, toMcpTool } from "@brain/plugin-spec"

import { manifestFor } from "./manifest"

const port = Number(process.env.PORT ?? 4319)
const host = process.env.HOST ?? "127.0.0.1" // 0.0.0.0 to be reached from a container
const token = process.env.PLUGIN_TOKEN // the value the company types as «Chiave del corriere»
// The base address Company Brain reaches this server at; the MCP endpoint is <base>/mcp.
const base = (process.env.PUBLIC_URL ?? `http://localhost:${port}`).replace(/\/$/, "")
const manifest = manifestFor(`${base}/mcp`)

type Shipment = { tracking: string; customer: string; courier: string; status: "in_viaggio" | "consegnata"; late: boolean }
// Placeholder data: put yours behind these functions. Always look data up by the company's id (your tenant key).
const shipmentsOf = (companyId: string): Shipment[] =>
  companyId ? [{ tracking: "SP-1001", customer: "Cliente di prova", courier: "BRT", status: "in_viaggio", late: true }] : []
const couriers = [{ courier: "BRT", name: "BRT", service: "Espresso nazionale" }]
const trendOf = (companyId: string) => (companyId ? [{ month: "ott 2026", shipments: 1, cost: 12.35 }] : [])

// structuredContent for pages, badges and widgets; the same as text for the model.
const answer = (data: Record<string, unknown>, text: string): CallToolResult => ({ structuredContent: data, content: [{ type: "text", text }] })
const refuse = (text: string): CallToolResult => ({ content: [{ type: "text", text }], isError: true })

function mcpServer() {
  const server = new Server({ name: "Spedizioni", version: manifest.version }, { capabilities: { tools: {} } })
  // toMcpTool gives plain JSON Schemas; the SDK's Tool type wants its own shape of them, hence the cast.
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: manifest.actions.map(toMcpTool) as never }))
  server.setRequestHandler(CallToolRequestSchema, async ({ params }): Promise<CallToolResult> => {
    const who = readEnvelope(params._meta) // which company, who, their permissions in your plugin
    if (!who) return refuse("Chiamata senza l'envelope di Company Brain.")
    // The platform doesn't check the arguments against your inputSchema: check them here, ignore unknown keys.
    const args: Record<string, unknown> = params.arguments ?? {}
    const text = (key: string) => {
      const value = args[key]
      return typeof value === "string" ? value.trim() : ""
    }
    const shipments = shipmentsOf(who.company.id)
    switch (params.name) {
      case "spedizioni_search": {
        const query = text("query").toLowerCase()
        const courier = text("courier").toUpperCase()
        // From a widget late is true; from a page's address every parameter is a string, so "true".
        const onlyLate = args.late === true || args.late === "true"
        const found = shipments.filter(
          (s) => (!query || `${s.tracking} ${s.customer}`.toLowerCase().includes(query)) && (!courier || s.courier === courier) && (!onlyLate || s.late),
        )
        const rows = found.map(({ tracking, customer, courier, status }) => ({ tracking, customer, courier, status }))
        const late = found.filter((s) => s.late).length
        return answer({ rows, late, total: found.length }, `${found.length} spedizioni, ${late} in ritardo.`)
      }
      case "spedizioni_get": {
        const s = shipments.find((x) => x.tracking === text("tracking").toUpperCase())
        if (!s) return refuse(`Nessuna spedizione ${text("tracking")}.`)
        return answer({ ...s }, `${s.tracking}: ${s.customer}, ${s.status === "consegnata" ? "consegnata" : "in viaggio"}.`)
      }
      case "spedizioni_couriers": {
        const rows = couriers.map((c) => {
          const theirs = shipments.filter((s) => s.courier === c.courier)
          return { ...c, shipments: theirs.length, late: theirs.filter((s) => s.late).length }
        })
        return answer({ rows }, rows.map((r) => `${r.name}: ${r.shipments} spedizioni`).join("; "))
      }
      case "spedizioni_trend": {
        // The cost goes to everyone: the platform removes it (declared money) for who may not see prices.
        const rows = trendOf(who.company.id)
        return answer({ rows }, rows.map((r) => `${r.month}: ${r.shipments} spedizioni, ${r.cost} €`).join("; "))
      }
      case "spedizioni_book_pickup": {
        if (!who.permissions.includes("spedizioni_shipments.book")) return refuse("Non hai il permesso di prenotare un ritiro.")
        const date = text("date")
        if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return refuse("Il giorno del ritiro dev'essere AAAA-MM-GG.")
        if (args.courier !== undefined && !couriers.some((c) => c.courier === args.courier)) return refuse("Corriere sconosciuto.")
        if (args.parcels !== undefined && !(Number.isInteger(args.parcels) && Number(args.parcels) >= 1 && Number(args.parcels) <= 20)) return refuse("I colli sono da 1 a 20.")
        return answer({ pickup: "RT-0001", date }, `Ritiro RT-0001 prenotato per il ${date}.`)
      }
      default:
        return refuse(`Azione sconosciuta: ${params.name}.`)
    }
  })
  return server
}

async function handle(req: IncomingMessage, res: ServerResponse) {
  const path = new URL(req.url ?? "/", "http://localhost").pathname
  if (req.method === "GET" && path === "/manifest.json") return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(manifest))
  if (path !== "/mcp") return void res.writeHead(404).end()
  if (!token || req.headers.authorization !== `Bearer ${token}`) return void res.writeHead(401, { "www-authenticate": "Bearer" }).end()
  if (req.method !== "POST") return void res.writeHead(405, { allow: "POST" }).end()
  // Stateless: a new MCP server and transport for each request, answering in JSON.
  const server = mcpServer()
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
  res.on("close", () => void Promise.all([transport.close(), server.close()]))
  await server.connect(transport)
  await transport.handleRequest(req, res)
}

createServer((req, res) => void handle(req, res).catch(() => res.headersSent || res.writeHead(500).end()))
  .listen(port, host, () => console.log(`Spedizioni: MCP at ${base}/mcp`))
  • toMcpTool turns each action into the tool definition the platform expects (MCP mapping). Its schemas are plain JSON Schema objects. The SDK's Tool type doesn't accept them as they are, hence as never.
  • readEnvelope(params._meta) tells you who is asking: the company, the person, their permissions in your plugin, the company's plain settings (5.2). Without it, refuse the call.
  • who.company.id is your tenant key. Look up every piece of data by it, so one company never sees another's.
  • params.arguments is typed Record<string, unknown>: narrow each value before you use it. The platform doesn't check arguments against your inputSchema (Your server checks its input).
  • A result carries structuredContent (what pages, badges and widgets read; it must match the action's outputSchema) and the same in a text block (what the model reads). isError: true with a text is an error the person can act on (5.1).
  • A wrong or missing token gets 401. In production, compare tokens with crypto.timingSafeEqual, as the repository's example does.

Start it, then try it with curl in another terminal:

PLUGIN_TOKEN=una-chiave-di-prova npm start
# Spedizioni: MCP at http://localhost:4319/mcp
export PLUGIN_TOKEN=una-chiave-di-prova
curl -s http://localhost:4319/mcp -H "authorization: Bearer $PLUGIN_TOKEN" \
  -H "content-type: application/json" -H "accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'
# {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"Spedizioni","version":"0.1.0"}},"jsonrpc":"2.0","id":1}
curl -s http://localhost:4319/mcp -H "authorization: Bearer $PLUGIN_TOKEN" \
  -H "content-type: application/json" -H "accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# {"result":{"tools":[{"name":"spedizioni_search","title":"Ricerca spedizioni",…

Three environment variables change where it runs:

Variable Default Meaning
PORT 4319 The port it listens on.
HOST 127.0.0.1 The interface it listens on. Use 0.0.0.0 to be reached from a container.
PUBLIC_URL http://localhost:<PORT> The base address Company Brain reaches it at. The manifest it serves gets <PUBLIC_URL>/mcp as runtime.url.

The repository's examples/plugin-spedizioni is the same server made complete: in-memory shipments for each company, the permissions checked again on every call, a timing-safe token check, and answers as JSON or as an event stream. Its PUBLIC_URL has the same meaning.

1.4 What the example declares#

  • Identity. The plugin's id is spedizioni. Its name, description and labels are in Italian, because the people who use Company Brain read Italian. Descriptions written for the model may be in English.
  • One setting. The company types the courier's key when it installs the plugin. Its type is secret, so the platform stores it encrypted and never shows it to the model. It reaches your server as the bearer token.
  • One permission group with three permissions. spedizioni_shipments.view is given to every new person (byDefault: true); spedizioni_shipments.book (booking a pickup) and spedizioni_shipments.costs (seeing what shipments cost) are not.
  • Five actions. spedizioni_search reads data and runs at once. It returns rows (the table shows them), late (the sidebar badge shows it) and total (the widget shows it, called with late: true). spedizioni_get reads one shipment, for the detail page. spedizioni_couriers reads the couriers, for their cards. spedizioni_trend reads shipments and their cost per month, for the chart; its cost is money, so people without spedizioni_shipments.costs never get it (Prices). spedizioni_book_pickup writes data. Its confirmation is always, so it runs only after the person presses «Conferma» (Confirm).
  • Instructions that Brain loads when a question mentions shipments, or when the person asks from the /spedizioni page. They bring the other four actions into the chat.
  • An agent, @Logistica, that can use the plugin's five actions and the platform's chart action.
  • Five pages. A table of shipments with a search box; each row opens the detail page of its shipment (rowLink). «Corrieri», one card per courier; each card opens the table with that courier's shipments (link). «Prenota ritiro», a form whose answers are the input of spedizioni_book_pickup; as the action's confirmation is always, the form prepares the pickup and the person confirms it on a card. «Andamento», a bar chart of the shipments and their cost per month.
  • Sidebar entries under «Lavoro» (Work), one per page but the detail; the shipments' one with the number of late shipments beside it.
  • Two home widgets: one figure (the late shipments) and a line chart of the shipments per month.
  • Runtime. The actions run on https://acme.example/mcp, called over MCP with the company's key as a bearer token. The server puts its own address there when it serves /manifest.json (manifestFor).

1.5 Test, install, update#

Step What you do Available today
1. Build Write the manifest (1.2) and your server (1.3). Yes, for remote over mcp.
2. Validate npm run validate. Fix every problem; read every warning. Yes.
3. Test Run the mock host against your server, then install it in a test company on a development server of Company Brain (Testing). Yes.
4. Submit Send the manifest and your server's address for review and listing in the store. Not yet: the store is planned. Administrators can already install a plugin by its manifest. Contact the Company Brain team to be listed.

Testing#

Without the platform: the mock host. inspectServer in @brain/plugin-spec talks to your server exactly as Company Brain does:

  1. initialize: your answer must declare the tools capability and protocol 2025-06-18 (or 2025-03-26);
  2. notifications/initialized: it expects 202;
  3. tools/list: every action of your manifest must be listed;
  4. one tools/call, with an identity envelope for a test company and person, every permission of your manifest and origin page. It checks the result as the platform does: no isError, and structuredContent present and matching the action's outputSchema.

Save this as inspect.ts, next to manifest.ts. It is the example's src/inspect.ts:

import { inspectServer } from "@brain/plugin-spec"

import { manifest } from "./manifest"

// The mock host (docs/PLUGINS.md §1.5 «Testing»): npx tsx inspect.ts [url] [action] [json input]
const [url = "http://localhost:4319/mcp", action, input] = process.argv.slice(2)
const ok = await inspectServer(manifest, {
  url,
  action,
  input: input ? JSON.parse(input) : {},
  token: process.env.PLUGIN_TOKEN ?? process.env.SPEDIZIONI_TOKEN,
})
process.exit(ok ? 0 : 1)

With your server running, call one action:

PLUGIN_TOKEN=una-chiave-di-prova npm run inspect -- http://localhost:4319/mcp spedizioni_get '{"tracking":"SP-1001"}'

Against the server of 1.3 it prints:

ok initialize: Spedizioni, MCP 2025-06-18
ok notifications/initialized: 202
ok tools/list: spedizioni_search, spedizioni_get, spedizioni_book_pickup, spedizioni_couriers, spedizioni_trend
ok tools/call spedizioni_get: {"tracking":"SP-1001","customer":"Cliente di prova","courier":"BRT","status":"in_viaggio","late":true}
   text for the model: SP-1001: Cliente di prova, in viaggio.

The arguments default to http://localhost:4319/mcp, your first action and {}. The first failure prints FAIL … and exits with code 1.

On a development server of Company Brain. A Company Brain server calls only https addresses on public networks. The exception is the development hosts that its company server lists in PLUGIN_DEV_HOSTS (host[:port], comma-separated; ignored in production). For those hosts, a plain http address and a private address are allowed, so your server can run on your computer. The list must contain the host of your manifest's address and the host of its runtime.url.

Ask the Company Brain team for a test company on a development server that lists your host. If that company server runs in Docker on your own computer, it reaches yours at host.docker.internal:

HOST=0.0.0.0 PUBLIC_URL=http://host.docker.internal:4319 PLUGIN_TOKEN=una-chiave-di-prova npm start
# Spedizioni: MCP at http://host.docker.internal:4319/mcp

Its PLUGIN_DEV_HOSTS then contains host.docker.internal:4319. As the test company's administrator: «Moduli» → «Plugin» → «Installa un plugin» → paste http://host.docker.internal:4319/manifest.json (or the JSON it serves) → «Impostazioni», type the token → «Verifica» → «Accendi».

Installation checks#

When an administrator installs a plugin, the company's server reads the manifest. It refuses it, with a message in Italian, in these cases:

Case What the administrator reads
Nothing pasted «Incolla l'indirizzo del manifest o il suo JSON.»
Text starting with { that isn't JSON (only such text is read as JSON) «Il JSON del manifest non è valido.»
Neither JSON nor an address starting with http:// or https:// «Serve un indirizzo che inizia con https:// o il JSON del manifest.»
An http:// address whose host isn't a development host «L'indirizzo del manifest deve essere https.»
The address answers with an error status «L'indirizzo del manifest ha risposto 404.» (the status)
The answer isn't JSON «All'indirizzo non c'è un manifest JSON valido.»
Unreachable, too slow, too large, or not a public address «Manifest non raggiungibile: …» with the reason, e.g. «Indirizzo interno: non consentito.»
The validator finds problems «Il manifest non è valido.» with the validator's problems (in English, at most 30)
A runtime the platform doesn't run «Il plugin non si può installare in questa azienda.» with «runtime: per ora si installano solo i plugin remoti su MCP (http e container non sono ancora supportati)» or «runtime.auth: OAuth 2 non è ancora supportato: usa bearer»
Your id is one of the platform's modules the same, with «id: "…" è un modulo della piattaforma»
An action's name is one of Brain's tools or another installed plugin's action the same, with «actions: "…" è già il nome di uno strumento»
A permission group's id is the platform's or another installed plugin's the same, with «permissions: il gruppo "…" esiste già»

The manifest's address is fetched with Accept: application/json, within 10 seconds, at most 256 KB, following at most 3 redirects. It must be a public address, unless its host is a development host. The validator runs with the third-party rules, the platform's actions and models, and the development hosts as devHosts. A plugin is installed off: the administrators fill in its settings, check it and turn it on.

Common failures#

Once a plugin is installed, a failed call or check shows a message in «Moduli» (the plugin's state or last error). The person reads the same message, through Brain or on the page. A failed call's message names your plugin («Spedizioni: …» or «Il plugin Spedizioni …»); a failed check's doesn't. The limits named below are in 5.1.

Message Cause Fix
«Il plugin deve avere un indirizzo https.» runtime.url is plain http and its host isn't a development host (or the server runs in production). Use https, or add the host to PLUGIN_DEV_HOSTS on a development server.
«… porta a un indirizzo interno: non consentito.», «Indirizzo interno: non consentito.» The address is, or resolves to, a private or internal address, and its host isn't a development host. Serve it at a public address, or list it in PLUGIN_DEV_HOSTS while developing.
«Il server del plugin non è raggiungibile: …» Nothing answers: wrong host or port, server down, a firewall, or the server listening only on 127.0.0.1 while Company Brain runs in a container. Check runtime.url; start your server with HOST=0.0.0.0 when it must be reached from a container.
«Il server del plugin non ha risposto in tempo.» No answer within the time limit of the request. Answer faster; do slow work in the background.
«Il server del plugin ha rifiutato la chiave: controlla le impostazioni.» Your server answered 401 or 403, to any request, the notification included. Type the same token in the plugin's «Impostazioni» as your server expects.
«Manca la chiave del plugin: inseriscila nelle impostazioni.» runtime.auth names a setting that the company hasn't filled in. The administrators fill it in «Impostazioni».
«Il server del plugin ha risposto con un reindirizzamento: non seguito.» A 3xx answer: the platform never follows redirects. Put the final address in runtime.url (for example with or without the trailing /, as your server wants).
«Il server del plugin ha risposto 404.» (or another status) Any other status outside 2xx. A 404 usually means a wrong path. Check the path of runtime.url (the example's is /mcp).
«Il server del plugin non ha risposto in JSON.» The body is neither JSON nor an event stream. Answer with application/json or text/event-stream.
«Il server del plugin non ha dato la risposta attesa.» No JSON-RPC response with the request's id in the answer. Answer each request with its own id.
«Il plugin ha risposto con un errore: …» Your server answered with a JSON-RPC error; its message follows (at most 300 characters). See the message. For an error the person can act on, return isError: true instead.
«La risposta del plugin supera il limite consentito.» The answer is over the size limit. Return less: fewer rows, or a search.
«Il server del plugin parla MCP …: serve 2025-06-18.» initialize answered another protocol version. Answer 2025-06-18 (or 2025-03-26).
«Il server del plugin non offre strumenti (capability «tools»).» initialize declared no tools capability. Declare capabilities: { tools: {} }.
«Il server del plugin ha perso la sessione due volte.» Your server answered 404 to a request that carried its session id, twice in a row. Keep sessions alive as long as the platform's session lasts, or give no session id (stateless).
«Il server del plugin ha dato una risposta vuota.» A tools/call answered with no result object. Return a CallToolResult.
«Il server non offre: …» The check found actions of the manifest missing from tools/list. List every action, by its exact name.
«Il plugin Spedizioni ha dato un risultato che non rispetta il suo outputSchema.» structuredContent is missing or doesn't match the action's outputSchema. Return structuredContent that matches it. The mock host shows what is wrong.
«Il plugin Spedizioni ha risposto con un errore: …» Your result had isError: true; its text follows. Nothing to fix: this is your own error, shown to the person.
«Automazione: «Prenotazione ritiro» del plugin Spedizioni chiede conferma, e qui non c'è nessuno a confermarla.» An automation called an always write (Confirmation). Nothing is sent to your server. Leave such writes to a person in a chat.

Publishing a new version#

To update a plugin, raise its version and have the administrators install it again from the same address or JSON, in «Moduli» → «Plugin» → «Installa un plugin». The platform never fetches your manifest again by itself. A manifest with the id of an installed plugin replaces that plugin's manifest, after the same checks. The update:

  • keeps the company's settings (secrets included), the permissions given to people, and whether the plugin is on;
  • sets its state to «Da verificare» until the administrators press «Verifica»;
  • keeps the old address, if the new manifest was pasted as JSON.

The platform reads the version as one number, so each part must be below 100 (1.2.3, not 1.120.0); the validator refuses a larger part. When the version changes, a one-to-one chat with one of your agents says «Nuova versione» once.

1.6 A manifest in JSON#

A manifest can be a plain JSON file instead of TypeScript; the validator takes the parsed value. This is a minimal valid manifest.json, with only the required fields (permissions and actions are required but may be empty):

{
  "spec": 1,
  "id": "spedizioni",
  "name": "Spedizioni",
  "description": "Le spedizioni del corriere, lette in diretta.",
  "version": "0.1.0",
  "publisher": { "id": "acme", "name": "Acme Srl" },
  "icon": "truck",
  "category": "integration",
  "permissions": [],
  "actions": [],
  "runtime": { "type": "remote", "transport": "mcp", "url": "https://acme.example/mcp" }
}

To check it, read the file and pass the parsed object to validateManifest:

import { readFileSync } from "node:fs"
import { validateManifest } from "@brain/plugin-spec"

const result = validateManifest(JSON.parse(readFileSync("manifest.json", "utf8")))
console.log(result.ok ? "ok" : result.problems)

No JSON Schema is exported yet, so an editor can't complete or check manifest.json through a $schema key (and $schema is an unknown field, which the validator refuses). You can generate one from manifestSchema with zod 4, which you installed in 1.1. It describes the shape only (the validator's first pass, 4.3), not the rules between the parts:

import { writeFileSync } from "node:fs"
import { z } from "zod"
import { manifestSchema } from "@brain/plugin-spec"

writeFileSync("manifest.schema.json", JSON.stringify(z.toJSONSchema(manifestSchema), null, 2))

2. Concepts#

2.1 Terms you will meet#

Company Brain's interface is in Italian. This guide uses these words of the interface:

Italian term English meaning What it is
Brain Brain The company's assistant, the single entry point of every chat.
Moduli Modules The page where a company turns modules (built-in or plugins) on and off, and installs plugins. A plugin appears there with its title.
Persone People The administrators' page of the company's people.
Agenti Agents The page of the company's own agents. Plugin agents are listed beside them but can't be edited.
Chiedi conferma Ask for confirmation An option in the agent editor: the agent asks before it runs a write action. It covers the platform's own actions only (Confirmation).
Conferma Confirm The button on a prepared action's card in the chat.
Usa agente Use agent Opens a one-to-one chat with an agent.
Personalizza Customise The home page's edit mode, where people place, resize and hide widgets.
Nuova versione New version The note a chat shows once after a plugin agent was updated.
Lavoro, Gestionale, Contabilità, Assistente, Amministrazione Work, Management, Accounting, Assistant, Administration The sidebar's sections (Sidebar entries).
«non hai il permesso» "you don't have the permission" What Brain or an agent says when the person lacks a permission an action needs.

2.2 The manifest#

A manifest is a JSON object. In TypeScript, pass it to definePlugin, which returns it unchanged and keeps its literal types.

The validator refuses unknown fields at every level of the manifest's own objects, so a misspelled field is an error. The exceptions are the free-form objects, whose keys are yours: the JSON Schemas (inputSchema, outputSchema), a view's source.input, and labels.

Language rules:

  • Every string that people see (names, labels, titles, starters, labels of values) is in the plugin's locale. The only locale today is it (Italian).
  • Strings written for the model (an action's description, JSON Schema descriptions) may be in English. Instructions and agent prompts are read by the model too, but write them in Italian: the model answers in the language of its instructions.

Reference: 3.1.

2.3 Names and the prefix rule#

Your plugin's id is unique in the store. It follows the id pattern (3).

Permission group ids and action names are unique across the whole platform, not only inside your plugin. So each of them must start with your prefix: your id with every - replaced by _, followed by _.

Plugin id Prefix for permission groups and actions
spedizioni spedizioni_
acme-crm acme_crm_

Widget ids start with your id unchanged, followed by a dot: acme-crm.open_deals.

Page ids, page paths, agent ids, instruction ids and setting ids carry no prefix: they are scoped to your plugin.

2.4 Settings#

Settings are values the company fills in when it installs the plugin: an address, a key, an option.

settings: [
  { id: "key", label: "Chiave del corriere", type: "secret", required: true },
  { id: "depot", label: "Deposito di partenza", description: "Il codice del deposito del corriere.", type: "text" },
]
  • type is one of text, url, secret, number, boolean.
  • A secret is stored encrypted. It is never shown again after it is saved, and never given to the model.
  • runtime.auth.setting names the setting that holds your server's token (5.1). It must be a secret, whatever the auth.type. The token goes to your server only as the Authorization header.
  • The values of the other, non-secret settings reach your server with every call, in the identity envelope (5.2). Any other secret is stored but never sent.

Reference: 3.3.

2.5 Permissions#

Permissions come in groups. A group is one row in the plugin's permissions, for example «Spedizioni», and holds one or more permissions. Each permission is written, everywhere in the manifest, as "<group id>.<permission id>", for example spedizioni_shipments.view.

permissions: [
  {
    id: "spedizioni_shipments",
    label: "Spedizioni",
    description: "Le spedizioni e i ritiri del corriere.",
    icon: "package",
    actions: [
      { id: "view", label: "Vedere le spedizioni", byDefault: true },
      { id: "book", label: "Prenotare un ritiro" },
      { id: "costs", label: "Vedere i costi delle spedizioni" },
    ],
  },
]
  • A group's id starts with your prefix (2.3). The key that holds its permissions is called actions, but it has nothing to do with the plugin's top-level actions.
  • byDefault: true gives the permission to every person of the company, new people included. Administrators can take it away. Most permissions shouldn't have it.
  • The company's administrators grant permissions in the plugin's card in «Moduli». Administrators have every permission.
  • Only your own permissions. A plugin may name only the permissions and actions it declares. At installation, the validator refuses a permission of another plugin or of the platform, with actions[0].permissions[0]: permission "orders.view" is not declared (a plugin may name only the permissions it declares). At run time, too, a person's permissions are looked up among your plugin's own only.

Reference: 3.4.

2.6 Actions#

An action is a function that Brain and the agents can call. It is an MCP tool (name, title, description, inputSchema, outputSchema) plus what the platform needs: whether it reads or writes, whether it waits for a confirmation, and which permissions it needs.

{
  name: "spedizioni_book_pickup",
  title: "Prenotazione ritiro",
  description: "Books a courier pickup on the given day. Waits for the person's Conferma. Returns the pickup's number.",
  kind: "write",
  confirmation: "always",
  permissions: ["spedizioni_shipments.book"],
  inputSchema: {
    type: "object",
    properties: {
      date: { type: "string", format: "date", description: "The day of the pickup, YYYY-MM-DD" },
      courier: { type: "string", enum: ["BRT", "GLS", "SDA"], description: "The courier's code" },
      parcels: { type: "integer", minimum: 1, maximum: 20, description: "How many parcels" },
      notes: { type: "string", maxLength: 300, description: "Notes for the driver" },
    },
    required: ["date"],
  },
  outputSchema: { type: "object", properties: { pickup: { type: "string" } } },
}
  • name starts with your prefix. It has no hyphens, because models and MCP clients take tool names in this form.
  • title is shown in the chat while the action runs, in Italian.
  • description is written for the model. Say what the action does, when to use it and what it returns. Also say what the model must not do, for example "never add up the amounts yourself".
  • inputSchema is a JSON Schema (draft 2020-12) whose type is "object".
  • outputSchema is optional. Give it when the result has a fixed shape; pages, badges and widgets need it (Views).
  • kind: read means the action changes nothing, anywhere. write means it changes data, in Company Brain or outside.
  • permissions: every permission the action needs. A person who lacks one of them is never offered the action, and the model is told that the person lacks the permission. An action without permissions is available to everyone in the company.
  • prices: a permission. People without it get the action's results without the fields you declare as money (Prices).
  • loading: how the action reaches the model. always puts it in every chat from the first step; use it for your main look-ups. on-demand (the default) brings it only with an instructions block that lists it (2.7). An on-demand action that no block lists never reaches the model, and the validator warns about it.
  • chat: whether Brain and the agents get the action as a tool (default true). Set chat: false on an action that only your pages, badges or widgets read. Agents and instructions blocks may list only chat tools.

Confirmation#

confirmation says whether a call waits for the person.

Value What the platform does today Allowed for
never The action runs at once. Reads (their only value and default) and writes
optional The same as never: the action runs at once, for Brain and for every agent. The agent editor's «Chiedi conferma» lists only the platform's own actions, so it can't make a plugin's action wait. Writes only (their default)
always In a chat or from a form page, the call is prepared and shown as a card. It reaches your server only when the person presses «Conferma», with origin confirmation. In an automation, nobody is there to confirm: the call fails with an error, and nothing is prepared or sent. Writes only

So use always for anything that is hard to undo or that reaches outside the company: sending, booking, paying, deleting. Only a person (in a chat or on a form), or an agent in a chat with a person, can prepare an always write. A form whose action is never or optional runs it at once, with origin page.

What the platform guarantees around every call#

This list is the one place these guarantees are stated; other sections link here.

  • As the person. The call runs as the person who asked, with their company and their permissions. The permissions are read again for every call. If they don't include every permission the action names, your server isn't called.
  • Only where the plugin is on. Nothing is called in a company where your plugin is off.
  • Confirmations hold. An always write never runs without the person's click, whatever a prompt, an email or a web page says. In an automation it fails with an error, and nothing is prepared or sent (Confirmation). Reads never wait.
  • Prices. A person who may not see prices gets results without the fields declared as money, and the model gets no text of the result (Prices).
  • Kept. Every call from a chat is stored with the chat.

Your server checks its input#

The platform checks a call's arguments against your inputSchema in one case only: the answers of a form page (Views), before it prepares or sends the call. A model's tool call is usually shaped by your schema. But other pages, badges, widgets and chat writes confirmed later send their arguments as they are, and nothing on the platform's side guarantees the shape. So:

  • check every argument's type and value on your server, and refuse what is wrong with an isError result;
  • ignore keys you don't know;
  • remember that a page's parameters arrive as strings (Views): late from a page's address is "true", not true. A view's source.input keeps its JSON types.

Prices#

An action with prices hides money from the people who lack that permission. The platform removes only the fields you declare as money, never a field because of its name:

  • a property of the action's outputSchema with format: "money", at any depth (inside a list, every element loses the field);
  • a column with format: "money" of a table or detail view, a card field or a chart series with format: "money", of a view that reads the action (the field under the view's source.rows);
  • a number view with format: "money" that reads the action.

The example's spedizioni_trend declares its cost both ways. Another example, with the same permission:

{
  name: "spedizioni_costs",
  title: "Costi delle spedizioni",
  description: "The shipments with what each one costs.",
  kind: "read",
  loading: "always",
  permissions: ["spedizioni_shipments.view"],
  prices: "spedizioni_shipments.costs",
  inputSchema: { type: "object", properties: {} },
  outputSchema: {
    type: "object",
    properties: {
      rows: {
        type: "array",
        items: { type: "object", properties: { tracking: { type: "string" }, cost: { type: "number", format: "money" } } },
      },
    },
  },
}

A person without spedizioni_shipments.costs gets every row without cost (the path rows.cost, which moneyPathsOf returns). Everyone else gets the result unchanged. The validator refuses an action with prices that declares no money field, because nothing would be hidden. It warns about a view that shows money read from an action without prices, because everyone would see it.

MCP mapping#

The package converts between an action and an MCP tool definition (MCP 2025-06-18 Tool).

toMcpTool(action) gives the tool that your MCP server should list for an action:

MCP field Value
name, title, description, inputSchema, outputSchema The action's own fields.
annotations.title A copy of the action's title.
annotations.readOnlyHint true for a read action, false for a write.
annotations.idempotentHint true for a read action, false for a write.
annotations.destructiveHint true only for a write action whose confirmation is always.
annotations.openWorldHint Always false.

The manifest is what the platform runs. It calls your tools by the names in the manifest, with the manifest's schemas, kind, confirmation and permissions. Of your tools/list it checks only that every action's name is listed (at the health check, 5.1). Titles, schemas and annotations you list are not read.

fromMcpTool(tool, declared?) does the opposite, for your own tooling: it drafts an action from an MCP tool you already have, taking what you declare (declared) first. The platform doesn't use it.

Action field Comes from
name, inputSchema, outputSchema The tool.
title declared, else the tool's title, else annotations.title, else the tool's name.
description declared, else the tool's description, else an empty string.
kind declared, else read if the tool's annotations.readOnlyHint is true, else write.
confirmation declared, else never if readOnlyHint is true, else always.
permissions, prices, loading, chat declared only.

The default confirmation comes from readOnlyHint, not from the kind you declare. If you declare kind: "read" for a tool without readOnlyHint: true, the draft is a read whose confirmation is always, which the validator refuses. So for a read, either set readOnlyHint: true on the tool (as toMcpTool does) or also declare confirmation: "never".

Reference: 3.5.

2.7 Instructions for Brain#

Brain doesn't carry every plugin's procedures in every question. It sees a one-line summary of each instructions block. It loads the block's text, and the actions the block lists, only when a request needs them.

instructions: [
  {
    id: "spedizioni",
    summary: "spedizioni e ritiri del corriere",
    text: "Per lo stato di una spedizione usa spedizioni_search o spedizioni_get; per i corrieri spedizioni_couriers, per i mesi spedizioni_trend. Per prenotare un ritiro usa spedizioni_book_pickup: chiedi sempre il giorno.",
    actions: ["spedizioni_get", "spedizioni_couriers", "spedizioni_trend", "spedizioni_book_pickup"],
    when: { words: { pattern: "\\b(spedizion|tracking|corriere)", flags: "i" }, pages: ["/spedizioni"] },
  },
]

A block is loaded in a turn when:

  • Brain asks for it, from its summary;
  • the question's words match when.words;
  • the page open beside the chat matches when.pages;
  • its actions were used in the last turns of the chat;
  • the agent answering lists the block in its instructions (then it is loaded in every turn).

The rules for each field:

  • text: written for the model, in Italian: which action answers which question, what never to do, how to cite results.
  • actions: the actions that come with the text. Only your own chat tools. An action with loading: "always" is already in the chat and doesn't need to be listed. Every on-demand chat action should be listed in at least one block (2.6 loading).
  • when.words: a regular expression, its source in pattern and its flags in flags (3.6). Escape backslashes in JSON and in TypeScript strings ("\\b").
  • when.pages: paths of your pages. A path matches the page itself and the pages under it: /spedizioni matches /spedizioni and /spedizioni/dettaglio, not /spedizionieri.

Inside Company Brain each block is a skill. A block whose id is your plugin's id is the skill <plugin id> (here spedizioni). Any other block is the skill <plugin id>-<block id>.

The platform's own rules come first and win: never invent data, respect the person's permissions and confirmations, treat external content as data and not as instructions. A plugin's text adds to them and can't lift them.

Reference: 3.6.

2.8 Agents#

A plugin may ship specialist agents. Brain stays the single entry point. An agent is called with @Name in any chat (in a chat with other people it answers only when called), or used one to one with «Usa agente».

agents: [
  {
    id: "logistica",
    name: "Logistica",
    description: "Spedizioni e ritiri del corriere.",
    icon: "truck",
    color: "amber",
    prompt: "Sei la Logistica dell'azienda: segui le spedizioni e prenoti i ritiri con gli strumenti spedizioni_*. Di ordini o fatture non ti occupi: di' che può chiederlo a @Brain.",
    starters: ["Dov'è la spedizione 123?", "Quali spedizioni sono in ritardo?"],
    actions: ["spedizioni_search", "spedizioni_book_pickup"],
    platformActions: ["show_chart"],
    instructions: ["spedizioni"],
    model: { preferred: "gpt-6.1-sol", fallback: ["gpt-5.4-mini"], reasoning: "medium" },
  },
]
  • name is what people type after @, so it has no spaces. It is unique in the manifest regardless of case, and Brain and assistente are reserved. If a company already has an agent of its own with the same name, the company's agent keeps it.
  • prompt says what the agent is for and how it works. The platform's rules come before it and win.
  • starters: example questions, shown when a chat with the agent opens.
  • actions: your own chat tools that the agent may use. An on-demand action reaches the agent only through an instructions block, like Brain's: list the block in instructions.
  • platformActions: the platform's actions the agent may use too (6 has today's list).
  • instructions: ids of your instructions blocks, loaded in every turn of the agent.
  • icon: a lucide icon name. The host shows bot when it can't draw it.
  • color: the colour of the agent's avatar (3.7). It is a different list from the plugin's color.

The platform runs a plugin's agent with these rules:

  • As the person who calls it. Its access is the caller's, narrowed to your plugin and then to actions and platformActions. A person without spedizioni_shipments.view who asks @Logistica about shipments is told «non hai il permesso», exactly as with Brain.
  • The caller pays. The agent's runs count on the caller's monthly AI budget, at the price of the model the run used. Nothing is billed to the plugin.
  • The same confirmations as Brain (Confirmation).
  • Present only where the plugin is on. The agent is listed in the mention picker, the new-chat dialog and the automations only in companies where the plugin is on. Its profile page names the plugin.
  • Not editable by the company. A new version comes with a new version of the plugin (Publishing a new version).
  • A stable identity. The agent's id on every server is derived from your plugin's id and the agent's id, so one-to-one chats and automations keep pointing at it across updates. Don't rename those ids.
  • No access to the person's computer. A plugin agent never gets the tools that act on the person's own computer.

The agent's model#

model is optional. Use it when your agent works best with a particular model:

  • preferred: the model the agent is made for;
  • fallback: the next choices, in order;
  • reasoning: the reasoning effort asked of the model.

At each turn, the platform uses preferred if the company's AI key can use it, else the first usable fallback, else the company's own chat model. reasoning is sent only with your own model, not with the company's. Without model, the agent uses the company's chat model. A stronger model costs the caller more. Model ids are the platform's and can change (6 has today's list).

Reference: 3.7.

2.9 UI#

The UI is described as data and drawn by the platform with its own components, so a plugin looks like the rest of the app, on the web and in the desktop app.

Pages#

ui: {
  pages: [
    {
      id: "detail",
      path: "/spedizioni/dettaglio",
      title: "Spedizione",
      permission: "spedizioni_shipments.view",
      paths: ["/spedizioni/tracking"],
      view: {
        type: "detail",
        source: { action: "spedizioni_get" },
        sections: [{ title: "Spedizione", fields: [{ field: "tracking", label: "Tracking" }] }],
      },
    },
  ],
}
  • path names the page in your manifest, such as /spedizioni/dettaglio. A link (rowLink, a card's link) and instructions[].when.pages use it. In the app, the page's address is /plugin?_plugin=<plugin id>&_page=<page id>, followed by the page's parameters.
  • paths are other path prefixes that belong to the page. A link under one of them opens this page: the longest prefix among every page's path and paths wins. With the page above, /spedizioni/tracking/{tracking} opens the detail too. No path may be used twice across the pages.
  • permission says who may open the page. Only those people see its sidebar entry.
  • view says what the page shows (below).

Views#

A view is what a page or a widget shows. Every view but a form reads one of your read actions through its source:

  • source.action: the read action to call;
  • source.input: fixed arguments for it;
  • source.rows: the top-level field of the result that holds the list of rows; without it, the result itself is the list.

Because pages read through actions, a page and Brain see the same data under the same permissions.

Keep the views and the actions consistent yourself. The validator checks that a view's source.action and a badge's action are your read actions. It doesn't compare a view with that action's schemas: source.input isn't checked against the inputSchema, and source.rows and the fields of columns, cards, charts, numbers and badges aren't checked against the outputSchema. A view that names a field the action never returns still validates and shows nothing. Declare every field you show in the action's outputSchema, and every input you pass in its inputSchema. A form is the exception: its fields are checked against its submit action's inputSchema (below).

The views the platform draws (the limits are in 3.9):

type Shows Its fields
table Rows with columns, an optional search box and an optional link on each row. source, columns, rowLink, search
cards One card per row: a title, a subtitle, some fields, an optional link. source, title, subtitle, fields (columns), link
detail One record, in sections. source, sections (each with a title and its fields, which are columns)
form Fields whose answers are the input of one of your write actions. fields (form fields), submit, submitLabel, success
chart A bar, line, area or pie chart of the rows, as the chat draws its charts. source, chart, category, series
number One figure, made for home widgets. source, field, label, format: number or money

The frame view validates, but the platform doesn't draw it yet: the page says so and points to the chat (Appendix A).

A column (in columns, sections[].fields and a card's fields) is { field, label, format?, labels? }:

  • field: the field of the row to show;
  • label: the column's heading;
  • format: one of text, number, money, date, badge, link. A money column declares a money field of the action it reads (Prices);
  • labels: the name of a list in your manifest's labels, which gives the words for the values. Meant for badge columns (2.10).

A link (a table's rowLink, a card's link) is the path of one of your pages, followed by fields of the row in braces: "/spedizioni/dettaglio/{tracking}". It opens the page whose path (or one of its paths) is the longest prefix of the part before the braces. Each field becomes a parameter of that page with the same name: here the detail's source gets { "tracking": "SP-1001" }. The example's couriers link to "/spedizioni/{courier}", so the BRT card opens the shipments' table with { "courier": "BRT" }. Any name works, id and page included, except _plugin and _page, which are the page address's own (RESERVED_PAGE_PARAMS; the validator refuses them).

cards: title and subtitle are the names of the row's fields shown as the card's heading and under it (as text); fields are columns, shown as label and value; link opens a page, as above (the card's title is the link).

chart: one point per row. category names the row's field that labels it (a month, a courier); series are one to four numeric fields of the row, each { field, label, format? }, format being number (the default) or money (drawn in euro, on a second axis when mixed with numbers). chart is bar (the default), line, area or pie; a pie has one series. A money series is a declared money field (Prices).

form: no source. Its fields are the input of the write action submit, each { id, label, type, required?, options? }:

  • id is the name of a property of the action's inputSchema, and so follows the id pattern: name a form's input properties in snake_case (pickup_date, not pickupDate);
  • type is text, long-text, number, date or choice. A number field fills a number or integer property, the others a string one;
  • options lists the values of a choice (within the property's enum, when it has one);
  • required: the field must be answered. A property the inputSchema lists as required is required anyway, and must have a field.

submitLabel is the button's word (default «Invia»); success is the message once the action has run (default: your result's text, else «Fatto.»):

ui: {
  pages: [
    {
      id: "pickup",
      path: "/spedizioni/ritiro",
      title: "Prenota ritiro",
      permission: "spedizioni_shipments.book",
      view: {
        type: "form",
        fields: [{ id: "date", label: "Giorno del ritiro", type: "date", required: true }],
        submit: "spedizioni_book_pickup",
        submitLabel: "Prenota",
        success: "Ritiro prenotato.",
      },
    },
  ],
}

A form never goes on a widget.

How the views reach their data:

  • A page calls its source action with source.input and, on top of it, the page's parameters (they win for the same name): those a link passed and, for a table with search, the search box's text as query. Only parameters whose name matches ^[A-Za-z0-9_]{1,40}$ and whose value is at most 200 characters reach the action. They are always strings, so declare them as string in the inputSchema, or accept both forms on your server, as the example does with late.
  • A widget calls its source action with source.input only, with its JSON types.
  • table: the rows are the result's source.rows field (or the result, when it is a list). An entry that isn't an object is dropped. At most 500 rows are shown, each with only its columns' fields.
  • cards: the rows as for a table, at most 500 cards, each with only its title, subtitle and fields (a home widget shows the first 12).
  • A link: a row whose linked field is missing, null or an object gets no link, and so does a link whose path names none of your pages.
  • detail: with source.rows, the first row; without it, the result itself, when it is an object.
  • chart: the rows as for a table, at most 120 points (12 slices for a pie): the rest is left out, and the chart says so. A category that is missing shows «—»; a value that isn't a finite number is a gap. For a person without the action's prices permission, the money series are left out and the chart says why; a chart of money only then shows a message instead. No rows: «Nessun dato da mostrare.»
  • form: the page's parameters fill in the answers of the fields with the same id (?courier=BRT). On «Invia», the answers are read as their field's type says (a number from «12,5» too; an empty answer is left out) and checked against the property that receives each one (type, enum, minimum, maximum, the exclusive ones, minLength, maxLength, pattern, format date or email), first in the browser and then again on the company's server against the whole inputSchema, before anything is called. A wrong answer gets its message under its field (checkFormAnswers, 6). Then the action's confirmation holds: always prepares the call and shows the confirmation card, and your server gets it on «Conferma» with origin confirmation; never and optional call it at once with origin page, and the form shows success, or your error's text. The page's permission and every permission of the action are needed.
  • number (and a badge): a top-level field of the result that is a finite number. Anything else shows no figure.
  • A call that fails shows its error on the page (Common failures).
sidebar: [{ page: "shipments", section: "work", icon: "truck", badge: { action: "spedizioni_search", field: "late" } }]
  • page: the id of one of your pages. The entry's label is the page's title. A page has at most one entry.
  • section: where the entry goes: work (Lavoro), management (Gestionale), accounting (Contabilità), assistant (Assistente) or administration (Amministrazione). Sections with no entries aren't shown.
  • icon: a lucide icon name. Without it, the entry uses the icon of the page's permission group, else the plugin's icon.
  • badge: a number beside the entry, such as what is late or what waits. action is a read action and field is the top-level field of its result that holds the number. The platform calls the action when it draws the sidebar: with input {}, as the person, with origin page, at most once a minute for each person and entry (in between it shows the last value). Nothing refreshes it on a timer. A failed call shows no number.

Home widgets#

Widgets go on the company's home page, under ui.widgets:

ui: {
  widgets: [
    {
      id: "spedizioni.late",
      label: "Spedizioni in ritardo",
      sizes: ["small", "medium"],
      defaultSize: "small",
      permission: "spedizioni_shipments.view",
      view: {
        type: "number",
        source: { action: "spedizioni_search", input: { late: true } },
        field: "total",
        label: "In ritardo",
      },
    },
  ],
}
  • id is <plugin id>.<name>.
  • sizes are the sizes the widget supports on the home grid: small (one cell), medium (two cells side by side), large (two by two). defaultSize is one of them.
  • permission (optional): the permission a person needs to see the widget.
  • view: a number, cards or chart view (a table or detail says it isn't available on the home; a form is refused). Give a chart the medium or large size.

People place, resize and hide widgets in «Personalizza».

Reference: 3.8 and 3.9.

2.10 Labels#

labels gives the words for the values your data carry, grouped in lists:

labels: {
  status: { in_viaggio: "In viaggio", consegnata: "Consegnata", in_ritardo: "In ritardo" },
}

A badge column names a list (labels: "status") and shows the word for each value.

Reference: labels in 3.1.


3. Field reference#

Every field of the manifest, as the validator's schema (manifestSchema) defines it. "Text" means a string that is not empty after trimming spaces; the validator returns it trimmed, and a length limit applies to the trimmed string. Limits on text are in characters. A list without a minimum may be empty. Fields marked spec only validate, but the platform doesn't run them yet (Appendix A); fields marked built-in are refused or meaningless in a third-party plugin (Appendix B).

Patterns used below:

Name Pattern Means
id ^[a-z][a-z0-9_-]{1,39}$ 2 to 40 characters: lowercase letters, digits, _, -; starts with a letter
action name ^[a-z][a-z0-9_]{1,63}$ 2 to 64 characters: lowercase letters, digits, _; starts with a letter
icon ^[a-z0-9]+(?:-[a-z0-9]+)*$ A lucide icon name in kebab-case
path ^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$ /one or /one/two: lowercase letters, digits, -
semver ^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$ 1.2.0, 2.0.0-beta.1
agent name ^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$ (flag u) Letters (any alphabet), digits and hyphens; no hyphen at the start or end
env ^[A-Z][A-Z0-9_]*$ An environment variable name
widget id ^[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)?$ name or plugin.name (the rules then require <id>.)
JSON Schema any object Any JSON object; its content is checked only where noted

3.1 Top level#

Field Type Required Limits and values
spec number yes Exactly 1
id id yes
name text yes At most 40
title text no At most 80
description text yes At most 600
version semver yes Each of the three numbers below 100
publisher object yes 3.2
icon icon yes
color enum no sky, amber, violet, emerald, orange, blue, rose, fuchsia, teal; tints the plugin's icon in lists and in the store
category enum yes accounting, sales, production, communication, productivity, erp, assistant, integration
locale enum no it
requires list of id no At most 10; not your own id. Entries are not checked to exist
settings list no At most 20; 3.3
permissions list yes At most 30 groups; 3.4
actions list yes At most 100; 3.5
instructions list no At most 10; 3.6
agents list no At most 10; 3.7
ui object no 3.8
labels object of objects of strings no { list: { value: word } }; no limits
events object no spec only; 3.10
runtime object yes 3.11

3.2 Publisher#

Field Type Required Limits
id id yes
name text yes At most 80
url URL no A valid URL
email email no A valid email address

3.3 Settings#

Field Type Required Limits and values
id id yes Unique among settings
label text yes At most 80
description text no At most 300
type enum yes text, url, secret, number, boolean
required boolean no
env env no built-in: accepted in any plugin, but don't set it in yours

3.4 Permissions#

A permission group:

Field Type Required Limits
id id yes Unique; starts with your prefix
label text yes At most 60
description text no At most 300
icon icon no
actions list yes 1 to 10 permissions (not the plugin's actions)

A permission (in a group's actions):

Field Type Required Limits
id id yes Unique in its group
label text yes At most 120
byDefault boolean no

3.5 Actions#

Field Type Required Limits and values
name action name yes Unique; starts with your prefix
title text yes At most 60
description text yes At most 2000
inputSchema JSON Schema yes type must be "object"
outputSchema JSON Schema no Must have a type (a string) or a $ref
kind enum yes read, write
confirmation enum no never, optional, always; a read may only be never. Default: never for reads, optional for writes
permissions list of strings no At most 5; each one of your permissions
prices string no One of your permissions; at least one field of the result must be declared as money
loading enum no always, on-demand (default)
chat boolean no Default true (false for an event handler, spec only)

3.6 Instructions#

Field Type Required Limits and values
id id yes Unique among instructions
summary text yes At most 300
text text yes At most 8000
actions list of strings yes At most 60 (may be empty); each one of your chat tools
when object no
when.words object no { pattern, flags? }
when.words.pattern string yes 1 to 1000; must compile as a regular expression
when.words.flags string no Only the letters i, m, s, u
when.pages list of path no At most 20

3.7 Agents#

Field Type Required Limits and values
id id yes Unique among agents
name agent name yes At most 32; unique in any case; not Brain or assistente
description text yes At most 200
icon icon no
color enum no slate, blue, green, amber, red, violet, pink, teal
prompt text yes At most 8000
starters list of text no At most 4, each at most 160
actions list of strings yes 1 to 60; each one of your chat tools
platformActions list of strings no At most 20; each a platform action (checked when the host passes platformActions)
instructions list of strings no At most 10; each one of your instructions' ids
model object no
model.preferred text yes At most 80
model.fallback list of text no At most 5, each at most 80; no id named twice (including preferred)
model.reasoning enum no minimal, low, medium, high

3.8 UI#

ui holds three optional lists.

A page (ui.pages, at most 30):

Field Type Required Limits
id id yes Unique among pages
path path yes Unique among pages
title text yes At most 80
permission string yes One of your permissions
paths list of path no No limit on count; unique across every page's path and paths
view view yes 3.9

A sidebar entry (ui.sidebar, at most 20):

Field Type Required Limits and values
page string yes One of your page ids; at most one entry per page
section enum yes work, management, accounting, assistant, administration
icon icon no
badge object no { action, field }, both strings; action is one of your read actions

A widget (ui.widgets, at most 20):

Field Type Required Limits and values
id widget id yes Unique; starts with <id>.
label text yes At most 80
sizes list of enum yes At least 1 of small, medium, large; no maximum, and the same size may be listed twice
defaultSize enum yes One of sizes
permission string no One of your permissions
view view yes 3.9; not a form

3.9 Views#

Every view has a type. source is { action, input?, rows? }: action is one of your read actions, input an object, rows a string.

type Fields (required in bold) Limits
table source, columns, rowLink (string; no {_plugin} or {_page}), search (boolean) 1 to 20 columns
detail source, sections: each title (text, at most 80) and fields (columns) 1 to 12 sections; 1 to 30 fields each
number source, field (string, not empty), label (text, at most 80), format: number, money
cards source, title (string, not empty: a field of the row), subtitle (string: a field of the row), fields (columns), link (string; no {_plugin} or {_page}) At most 12 fields
form fields (form fields), submit (string: one of your write actions), submitLabel (text, at most 40), success (text, at most 300) 1 to 20 fields, ids unique
chart source, category (string, not empty: a field of the row), series (chart series), chart: bar, line, area, pie 1 to 4 series; a pie has 1
frame spec only: url (https URL), height (integer) height 100 to 4000
native built-in: component (text, at most 120) Refused for third parties

A column:

Field Type Required Limits and values
field string yes Not empty
label text yes At most 80
format enum no text, number, money, date, badge, link
labels string no The name of a list in labels, which must exist (checked on every column of a table, cards or detail view, whatever its format)

A form field:

Field Type Required Limits and values
id id yes A property of the submit action's inputSchema; every required property has a field
label text yes At most 80
type enum yes text, long-text, number, date, choice; number for a number or integer property, the others for a string one (when the property has a type)
required boolean no
options list of text for choice At most 20, each at most 80; only on a choice; each in the property's enum, when it has one

A chart series:

Field Type Required Limits and values
field string yes Not empty: a numeric field of the row
label text yes At most 80
format enum no number (default), money

3.10 Events#

Spec only (A.2). An emitted event (events.emits, at most 30):

Field Type Required Limits
name string yes Starts with <id>.
description text yes At most 300
payloadSchema JSON Schema no

A subscription (events.subscribes, at most 30):

Field Type Required Limits
event string yes At least 3 characters
action string yes One of your actions; not a write whose confirmation is always

3.11 Runtime#

type Field Type Required Limits and values
remote transport enum yes mcp; http is spec only
url URL yes https only; http also on the hosts in the option devHosts (4.2)
auth object no { type, setting? }
auth.type enum yes bearer; oauth2 is spec only
auth.setting string yes for bearer The id of one of your settings, whose type is secret (for any auth.type)
container spec only (A.3)
in-process (none) built-in

4. Validation#

4.1 Calling the validator#

import { assertManifest, validateManifest, type ValidateOptions } from "@brain/plugin-spec"
import { manifest } from "./manifest"

const options: ValidateOptions = {
  platformActions: ["show_chart", "search_chats"],
  knownModels: ["gpt-6-luna", "gpt-5.4-mini", "gpt-6.1-sol"],
}

const result = validateManifest(manifest, options)
if (result.ok) console.log("valid", result.warnings)
else console.error(result.problems, result.warnings)

// The same check, throwing an Error whose message lists every problem:
assertManifest(manifest, options)

validateManifest(input, options?) takes a parsed JSON value or a TypeScript object. It returns one of:

  • { ok: true, manifest, warnings }: the manifest is valid. manifest is the checked manifest, with its text fields trimmed.
  • { ok: false, problems, warnings }: the manifest is not valid. problems lists every problem found.

Problems stop the manifest. Warnings don't, but read them. Today there are three warnings, all for third-party plugins:

  • an agent model that the platform doesn't know (the platform then falls back to the next model);
  • a view that shows money read from an action without prices (Prices);
  • an on-demand chat action that no instructions block lists (it never reaches the model, 2.6 loading).

Each problem and warning is one string: where it is, a colon, and what is wrong.

4.2 Options#

Option Type Effect
platformActions list of strings The platform's actions. When given, every agent's platformActions must be among them; when not given, they are not checked.
knownModels list of strings The platform's models. When given, an agent's model that is not among them is a warning; when not given, model ids are not checked.
devHosts list of strings Development only: hosts (localhost, 127.0.0.1:4319) on which a remote runtime's url may be plain http. A development server of Company Brain passes the hosts it is configured with; a production one never passes any.
knownPermissions list of strings Permissions of others that your manifest may name. The platform passes none when it installs a third-party plugin (2.5).
knownActions list of strings Actions of others that your instructions may list. The platform passes none when it installs a third-party plugin.
builtIn boolean For Company Brain's own plugins only (Appendix B). Third-party plugins never pass it.

4.3 What is checked, in order#

The validator works in two passes. If the first pass finds problems, it stops there: the second pass doesn't run and there are no warnings.

First pass: the shape. Every field against the field reference: types, required fields, lengths, counts, patterns, enums, https URLs, and no unknown fields.

Second pass: the rules between the parts.

Names:

  • Permission group ids are unique, and each permission id is unique in its group. Action names are unique.
  • Ids of instructions, agents, pages, widgets and settings are unique in their lists. Page paths are unique across every page's path and paths. Agent names are unique regardless of case.
  • Permission group ids and action names start with your prefix; widget ids start with <id>. (2.3).
  • An agent isn't called Brain or assistente (in any case).
  • requires doesn't list your own id.
  • Each part of version is below 100.

References:

  • Every permission named by an action (permissions, prices), a page or a widget is one of yours.
  • Every action listed by instructions or by an agent is one of your chat tools; every agent's platformActions entry is in the option platformActions (when it is given); every instructions id of an agent is declared.
  • Every sidebar entry opens one of your pages, and no page has two entries.
  • Every labels list named by a column exists.
  • A rowLink or a card's link passes no {_plugin} or {_page}.

Runtime:

  • A remote runtime's url is https, or http on a host of devHosts.
  • A bearer runtime names its setting (auth.setting). Any auth.setting is declared and is a secret.

Actions and views:

  • Every inputSchema has type: "object"; every outputSchema has a type or a $ref.
  • A read action's confirmation is never.
  • An action with prices declares at least one money field.
  • Every view's source.action and every badge's action is a read action.
  • A form's submit is a write action. Its field ids are unique, and each is a property of that action's inputSchema of a fitting type (3.9); every property the schema lists as required has a field; a choice has options, only a choice has them, and they are within the property's enum.
  • A pie chart has one series.
  • A widget's defaultSize is one of its sizes, and its view isn't a form.
  • when.words compiles as a regular expression.

Agent models:

  • An agent's preferred and fallback don't name the same model twice.
  • With knownModels, an unknown model is a warning.

The rules on specification-only and built-in parts are in the appendices (A, B).

4.4 Where a problem is: two path formats#

Each problem starts with its place in the manifest. The two passes write the place differently:

Pass Format Examples
Shape Dot path: names and list positions joined by .. A problem with the whole object is at manifest. agents.0.name: letters, digits and hyphens, no spaces: it is typed after @, runtime.url: Invalid URL, manifest: Unrecognized key: "extra"
Rules Bracket path, as you would write it in code. A problem with a whole list is at the list's name. actions[3].permissions[0]: permission "orders.view" is not declared, ui.widgets[0].defaultSize: must be one of its sizes, ui.pages: path "/spedizioni" is used twice

Positions start at 0 in both formats.


5. Runtime and transport#

runtime says where your actions run. Today the platform runs one kind: remote over mcp, with a bearer token or no auth. Its wire contract is fixed in 5.1 and 5.2. The other kinds are specification only (A.3, A.4).

5.1 Remote: on your server#

runtime: {
  type: "remote",
  transport: "mcp",
  url: "https://acme.example/mcp",
  auth: { type: "bearer", setting: "key" },
}
Field Required Meaning
transport yes mcp: an MCP server over Streamable HTTP (below).
url yes Your MCP endpoint. https only; plain http only on the development hosts (Testing).
auth.type yes, if auth is given bearer (below).
auth.setting yes for bearer The id of the secret setting that holds the token.

The transport: MCP Streamable HTTP, revision 2025-06-18. The platform is an MCP client and uses three methods, nothing else:

Method When
initialize, then the notification notifications/initialized When it opens a session: on the first call, after 10 minutes, after your server forgot the session, and at every health check. Your answer must declare the tools capability and protocol version 2025-06-18 (or 2025-03-26). Answer the notification with 202 Accepted and no body. The platform reads no status of that answer, but a 401 or 403, a network error or a timeout there fails the session.
tools/list The health check: every action of your manifest must be a tool you list, by name. Pages (nextCursor) are followed, up to 10.
tools/call Every call of an action, with name = the action's name, arguments = its input, and _meta = the identity envelope (5.2).

Each request is an HTTP POST of one JSON-RPC message to url, with Content-Type: application/json and Accept: application/json, text/event-stream. After initialize it also carries MCP-Protocol-Version and, if your server gave one, Mcp-Session-Id. Answer with application/json, or with a text/event-stream that carries the response (the platform reads the stream until the response with the request's id, and ignores the rest).

  • Stateless servers (no session id) are fine.
  • The platform never opens the GET stream, offers no client capabilities (no sampling, roots or elicitation) and doesn't send DELETE.
  • It never follows a redirect: answer at the address itself.

The credential. With auth: { type: "bearer", setting: "key" }, every request carries Authorization: Bearer <the value of the setting "key">. Answer 401 (or 403) to a missing or wrong token. Without auth, nothing is sent, and your server is open to anyone who knows its address.

What the platform reads of a tools/call result (MCP CallToolResult):

Part Used for
structuredContent What pages, sidebar badges and widgets read. When the action declares an outputSchema, structuredContent is required and must match it, or the call fails. Without structuredContent and without an outputSchema, the text blocks, joined, are parsed as JSON and used instead (text that isn't JSON leaves no structured content). If the platform can't read your outputSchema (a JSON Schema feature its checker doesn't convert), it only checks that structuredContent is an object.
content, its text blocks What the model (Brain or an agent) reads, joined. Without text, the model gets structuredContent. Other block types (images, resources) are ignored for now.
isError: true The action failed. Its text blocks, joined and cut at 500 characters, are shown as «Il plugin Spedizioni ha risposto con un errore: …» to the model (which tells the person) or on the page. Use it for errors the person can act on («Nessuna spedizione SP-9»).
A JSON-RPC error A failure of your server. Its error.message, cut at 300 characters, is shown as «Spedizioni: Il plugin ha risposto con un errore: …».

Both error texts reach people: write them in Italian, for the person. Never put internals in them (stack traces, hosts, keys, SQL). Every other failure, and what to do about it, is in Common failures.

The platform's limits on a call (WIRE_LIMITS, and the client's own):

Limit Value
initialize, notifications/initialized and tools/list, each 10 seconds
tools/call, the whole answer 30 seconds
An answer's body (JSON or stream) 1 MB; longer, the call fails
Text given to the model of one result 50 000 characters; longer, it is cut and the model is told so
A session (sessionTtlMs) 10 minutes; then the platform sends initialize again
tools/list pages followed 10
Retries None, for reads and writes alike. The one exception: when your server answers 404 to a request that carried its session id (MCP: the session is gone and the request was not processed), the platform opens a new session and sends that request once more.

A slow or failing plugin fails only its own actions: the person gets a message that names your plugin. Each call is counted on the installation (calls, failures, the last error), which its administrators see in «Moduli».

Health. The platform checks your server when the administrators save the settings and when they press «Verifica»: initialize, then tools/list, and every action of the manifest among the tools, by name only. «Moduli» shows «Collegato» with your server's serverInfo.name, or «Errore» with what failed (Common failures).

5.2 What every call carries#

Every tools/call carries who is asking in params._meta, under keys prefixed companybrain/ (ENVELOPE_KEYS). This is the identity envelope:

{
  "companybrain/company": { "id": "3f0c9a52-6d1e-4b7a-9c1e-2a7b5d0e8f41", "name": "Mobili Rossi Srl" },
  "companybrain/person": { "id": "u_8Kq2", "name": "Ada Rinaldi" },
  "companybrain/permissions": ["spedizioni_shipments.book", "spedizioni_shipments.view"],
  "companybrain/locale": "it",
  "companybrain/settings": { "depot": "MI1" },
  "companybrain/origin": "chat",
  "companybrain/call": { "id": "0b6f8e1c-5a77-4c55-9d8a-1f2e3c4b5a69" }
}
Key Value
companybrain/company The company: id (stable: your tenant key) and name.
companybrain/person Who asked: id (stable, opaque) and name. For an automation, the person who owns it.
companybrain/permissions The person's permissions among your plugin's own, sorted.
companybrain/locale The people's language: it.
companybrain/settings The company's values of your settings that are not secret, by id ({} when there are none).
companybrain/origin chat (Brain or an agent), page (one of your pages, a badge or a widget), confirmation (the person pressed «Conferma» on a prepared write), automation (nobody at the screen).
companybrain/call An id for your logs. For a confirmed write it is the confirmation's id, the same if the person's click is retried.

Nothing else is sent: no email address, no session, no token of the person, no secret setting (the bearer one travels only as the Authorization header), no data of other modules. Your server sees the company's data only through what its actions receive.

readEnvelope(meta) reads the envelope back. It returns null when a part is missing or malformed, including an empty company or person id (a company server that can't read its own company sends an empty id). Refuse such a call.

The envelope is the platform's statement, authenticated by your bearer token: trust it as far as you trust the token. What the platform has already checked is in What the platform guarantees. You may check the permissions again on your side, as the example server does.


6. Package reference#

@brain/plugin-spec exports everything below from its entry point (src/index.ts). Four subpaths export a part of it:

  • @brain/plugin-spec/types: the types, the constants, definePlugin, confirmationOf, inChat, subscribedActions, permissionKeysOf;
  • @brain/plugin-spec/validate: the validator;
  • @brain/plugin-spec/wire: the wire contract (5);
  • @brain/plugin-spec/inspect: the mock host.

toMcpTool, fromMcpTool, McpTool, moneyPathsOf, withoutMoney and checkFormAnswers are only in the entry point.

Functions:

Export What it does
definePlugin(manifest) Returns the manifest unchanged and keeps its literal types.
validateManifest(input, options?) Checks a manifest and returns { ok, … } (4.1).
assertManifest(input, options?) The same check; returns the manifest or throws with every problem.
inspectServer(manifest, options) The mock host (Testing): options is { url, action?, input?, token?, log? }; resolves to true when every step passed.
toMcpTool(action) The MCP tool definition of an action (MCP mapping).
fromMcpTool(tool, declared?) An action drafted from an MCP tool and what you declare.
confirmationOf(action) An action's confirmation with its default filled in: never for reads, optional for writes.
inChat(manifest, action) Whether the action is a chat tool (2.6 chat).
subscribedActions(manifest) The actions that events.subscribes names (spec only).
moneyPathsOf(manifest, action) The fields of an action's result declared as money (Prices).
withoutMoney(result, paths) A result without those fields, as people without the prices permission get it.
checkFormAnswers(fields, inputSchema, answers) A form's answers (field id → text) as its submit action's input, { ok: true, input }, or { ok: false, errors } with an Italian message per field: the check the platform runs (Views).
permissionKeysOf(manifest) Every permission the manifest declares, as "<group>.<permission>".
readEnvelope(meta) For your server: the identity envelope of a tools/call's _meta, or null (5.2).
envelopeMeta(envelope) The envelope as the _meta object the platform sends (for your tests).
manifestSchema The zod schema of the manifest's shape (the validator's first pass).

Constants. These are the authoritative sources of the values that this guide lists; read them from the package rather than copying the lists:

Export Value
PLUGIN_SPEC_VERSION 1
PLUGIN_CATEGORIES The values of category
PLUGIN_COLORS The values of the plugin's color
AGENT_COLORS The values of an agent's color
SIDEBAR_SECTIONS The values of a sidebar entry's section
WIDGET_SIZES The values of a widget's sizes and defaultSize
REASONING_EFFORTS The values of an agent's model.reasoning
ID_PATTERN, ACTION_NAME_PATTERN, PATH_PATTERN, SEMVER_PATTERN The patterns of id, action name, path and semver (3)
RESERVED_PAGE_PARAMS _plugin, _page: the page address's own parameters (Views)
MCP_PROTOCOL_VERSION "2025-06-18": the MCP revision the platform speaks
ENVELOPE_KEYS, CALL_ORIGINS The envelope's _meta keys and the values of its origin (5.2)
WIRE_LIMITS The platform's timeouts and sizes (5.1)

Types: PluginManifest and its parts (Publisher, PluginSetting, PermissionGroup, PermissionAction, PluginAction, Confirmation, PluginInstructions, PluginAgent, PluginAgentModel, PluginUI, PluginPage, SidebarEntry, PluginWidget, View, DataSource, Column, FormField, ChartValue, PluginEvents, EmittedEvent, EventSubscription, PluginRuntime, JsonSchema), the enum types (PluginCategory, PluginColor, PluginAgentColor, SidebarSection, WidgetSize, ReasoningEffort), McpTool, ValidateOptions, ValidationResult, InspectOptions, FormAnswers, FormCheck, CallEnvelope and CallOrigin.

Not in the package: the platform's actions and models. The host gives them to the validator (platformActions and knownModels, 4.2), and they can change from one release of the platform to the next. This is the one place this guide lists them. Today they are:

  • platform actions: show_chart (draws a chart from an action's dataset), search_chats;
  • models: gpt-6-luna, gpt-5.4-mini, gpt-6.1-sol.

7. Status of features#

Feature Status
Manifest types, definePlugin, validateManifest, assertManifest Implemented
MCP mapping (toMcpTool, fromMcpTool), the wire constants (readEnvelope, WIRE_LIMITS, …) and the mock host (inspectServer) Implemented
remote runtime over mcp, with bearer or no auth: actions for Brain and agents, the identity envelope, always writes on «Conferma», health check Implemented
Installing a plugin in a company from «Moduli» (manifest's address or JSON), its settings form, permissions per person, on/off, uninstall Implemented
Instructions, agents with their own model Implemented
Views: table (with search and rowLink), cards (with link), detail, form (answers checked against the submit action's inputSchema, its confirmation kept), chart (bar, line, area, pie; money in euro, hidden without prices), number; sidebar badges; home widgets with a number, cards or chart view Implemented
Prices hidden on the fields declared as money Implemented
confirmation: "optional" waiting for «Chiedi conferma» on a plugin's action Not yet: runs like never (Confirmation)
Checking views against the actions' schemas; checking call arguments against inputSchema (but a form's answers) Not done (Views, Your server checks its input)
The isolated frame Specification only (A.1)
Events (emits, subscribes) and the catalog of the platform's events Specification only; payloads to be defined (A.2)
remote over http, oauth2, container To be defined (A.3, A.4)
A JSON Schema of the manifest, exported by the package Not available (1.6)
Store, review and submission Planned
Publishing @brain/plugin-spec to npm Planned

Appendix A. Specification only#

Everything here validates, so you can see where the format is going, but the platform doesn't run it yet. Don't build a plugin that depends on it. The examples assume the manifest of 1.2.

A.1 The isolated frame#

For what data can't describe (a map, a designer, a viewer), a frame view will show your own web page.

ui: {
  pages: [{ id: "map", path: "/spedizioni/mappa", title: "Mappa", permission: "spedizioni_shipments.view", view: { type: "frame", url: "https://acme.example/map" } }],
}

The design:

  • The page is shown in an <iframe sandbox="allow-scripts allow-forms">, served from your own origin and never from the app's, so it can't read the app's storage, cookies or session.
  • The frame gets no session of the person. It receives, by postMessage, a short-lived token valid only for your plugin's actions as that person, and calls them through the platform, for example window.parent.postMessage({ type: "brain.call", action, input }). The guarantees apply as for Brain.
  • The frame may ask the app to open one of its pages, show a short message, or start a chat with a prepared question. Nothing else of the app is reachable.
  • The URL is https only. Its origin will be listed in your store entry, and your server must send a CSP frame-ancestors header that allows the app's origins.

A.2 Events#

The validator checks events; the platform doesn't deliver them yet. To listen to the platform's jobs.created, add a handler to your actions:

{
  name: "spedizioni_on_job_created",
  title: "Nuovo lavoro",
  description: "Called by the platform when a job is created (event jobs.created), with the event's payload as input. Links the job to its shipment when the job names a tracking number.",
  kind: "write",
  confirmation: "never",
  // Illustrative: the payload of jobs.created is to be defined, so this accepts any object.
  inputSchema: { type: "object", description: "The payload of jobs.created (shape to be defined)" },
}

and declare what you emit and what you listen to:

events: {
  emits: [
    {
      name: "spedizioni.delivered",
      description: "Una spedizione è stata consegnata.",
      payloadSchema: { type: "object", properties: { tracking: { type: "string" } } },
    },
  ],
  subscribes: [{ event: "jobs.created", action: "spedizioni_on_job_created" }],
}
  • emits: what your plugin says has happened. Each name starts with your id and a dot (spedizioni.delivered). payloadSchema is the JSON Schema of the event's data.
  • subscribes: an event of the platform or of another plugin, and the action of yours that the platform will call with the event's payload as input, on behalf of the company rather than of a person.
  • The payloads of the platform's events are «to be defined». No catalog of the platform's events exists yet; it will be published with the runtime. The pairing above is illustrative.
  • An event handler is not a chat tool, unless it says chat: true; agents and instructions can't list it.
  • It can't wait for «Conferma»: nobody is there to press it. The validator refuses an always write as a handler.

A.3 Container runtime#

For companies that keep everything on their premises, a plugin will be able to be an image that runs beside the company's server. The platform doesn't start images yet.

runtime: {
  type: "container",
  image: "ghcr.io/acme/spedizioni:0.1.0",
  port: 8080,
  transport: "http",
  healthPath: "/health",
}
Field Required Meaning
image yes The container image, at least 3 characters.
port yes The port your server listens on, 1 to 65535.
transport yes mcp or http, as for remote.
healthPath no A path that answers when your server is ready, starting with /.

A.4 Open points of the wire contract#

Point Applies to Status
HTTP request and response: method, path, body, headers, status codes, error format transport: "http" To be defined
Identity envelope over plain HTTP transport: "http" To be defined (over MCP: 5.2)
OAuth 2: flow, scopes, token endpoint auth.type: "oauth2" (refused at installation today) To be defined
Running images; the health check (method, expected answer, interval, failure) container To be defined
Events: when the platform calls a subscribed action, and the payloads of its events events.subscribes To be defined

Appendix B. Built-in plugins only#

Company Brain's own modules are plugins too. They are validated with the option builtIn: true, which no third-party plugin gets. It allows what follows; a third-party plugin can't use any of it.

  • The in-process runtime: runtime: { type: "in-process" }. The actions' code is part of the company's server. Refused for third parties.
  • native views: { type: "native", component }, a page in the platform's own code. Refused for third parties.
  • No prefix on permission groups and actions.
  • env on settings: the setting is read from the company server's environment. The validator accepts env in any plugin, but it means nothing in yours.
  • An unknown agent model is a problem, not a warning, and the money rules of Prices don't apply (the built-ins hide prices their own way).

Arca, the accounting module, is a built-in plugin. Its manifest uses all of the above:

settings: [
  { id: "url", label: "Indirizzo di Arca Evolution Connect", type: "url", required: true, env: "ARCA_URL" },
  { id: "key", label: "Chiave di Arca Evolution Connect", type: "secret", required: true, env: "ARCA_KEY" },
],
ui: {
  pages: [{ id: "invoices", path: "/invoices", title: "Fatture", permission: "invoices.view", view: { type: "native", component: "arca/invoices" } }],
  sidebar: [{ page: "invoices", section: "accounting" }],
},
runtime: { type: "in-process" },