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
@Namein 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,
remoteovermcp(MCP Streamable HTTP), with abearertoken or noauth(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(withsearchandrowLink),cards,detail,form,chartandnumberpages; sidebar badges; home widgets with anumber,cardsorchartview (2.9).Everything else the validator accepts is specification only (Appendix A). Status of features has the full list.
Contents#
- Your first plugin
- 1.1 Set up the project · 1.2 The manifest · 1.3 Your server · 1.4 What the example declares · 1.5 Test, install, update (testing, installation checks, common failures, new versions) · 1.6 A manifest in JSON
- Concepts
- 2.1 Terms · 2.2 The manifest · 2.3 Names and the prefix rule · 2.4 Settings · 2.5 Permissions · 2.6 Actions (confirmation, guarantees, input, prices, MCP mapping) · 2.7 Instructions · 2.8 Agents · 2.9 UI (pages, views, sidebar, widgets) · 2.10 Labels
- Field reference
- Validation
- Runtime and transport
- Package reference
- 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, andinspect.tsuses top-levelawait, which works only in an ES module.tsx. The package's entry points are TypeScript files (src/index.ts), not compiled JavaScript, so plainnodecan't load them. Run your files withtsx, 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`))
toMcpToolturns each action into the tool definition the platform expects (MCP mapping). Its schemas are plain JSON Schema objects. The SDK'sTooltype doesn't accept them as they are, henceas 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.idis your tenant key. Look up every piece of data by it, so one company never sees another's.params.argumentsis typedRecord<string, unknown>: narrow each value before you use it. The platform doesn't check arguments against yourinputSchema(Your server checks its input).- A result carries
structuredContent(what pages, badges and widgets read; it must match the action'soutputSchema) and the same in a text block (what the model reads).isError: truewith a text is an error the person can act on (5.1). - A wrong or missing token gets
401. In production, compare tokens withcrypto.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
idisspedizioni. 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
typeissecret, 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.viewis given to every new person (byDefault: true);spedizioni_shipments.book(booking a pickup) andspedizioni_shipments.costs(seeing what shipments cost) are not. - Five actions.
spedizioni_searchreads data and runs at once. It returnsrows(the table shows them),late(the sidebar badge shows it) andtotal(the widget shows it, called withlate: true).spedizioni_getreads one shipment, for the detail page.spedizioni_couriersreads the couriers, for their cards.spedizioni_trendreads shipments and their cost per month, for the chart; itscostis money, so people withoutspedizioni_shipments.costsnever get it (Prices).spedizioni_book_pickupwrites data. Itsconfirmationisalways, 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
/spedizionipage. 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 ofspedizioni_book_pickup; as the action's confirmation isalways, 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:
initialize: your answer must declare thetoolscapability and protocol2025-06-18(or2025-03-26);notifications/initialized: it expects202;tools/list: every action of your manifest must be listed;- one
tools/call, with an identity envelope for a test company and person, every permission of your manifest and originpage. It checks the result as the platform does: noisError, andstructuredContentpresent and matching the action'soutputSchema.
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 isit(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" },
]
typeis one oftext,url,secret,number,boolean.- A
secretis stored encrypted. It is never shown again after it is saved, and never given to the model. runtime.auth.settingnames the setting that holds your server's token (5.1). It must be asecret, whatever theauth.type. The token goes to your server only as theAuthorizationheader.- 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
idstarts with your prefix (2.3). The key that holds its permissions is calledactions, but it has nothing to do with the plugin's top-levelactions. byDefault: truegives 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" } } },
}
namestarts with your prefix. It has no hyphens, because models and MCP clients take tool names in this form.titleis shown in the chat while the action runs, in Italian.descriptionis 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".inputSchemais a JSON Schema (draft 2020-12) whosetypeis"object".outputSchemais optional. Give it when the result has a fixed shape; pages, badges and widgets need it (Views).kind:readmeans the action changes nothing, anywhere.writemeans 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 withoutpermissionsis 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.alwaysputs 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 (defaulttrue). Setchat: falseon 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
alwayswrite 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
isErrorresult; - ignore keys you don't know;
- remember that a page's parameters arrive as strings
(Views):
latefrom a page's address is"true", nottrue. A view'ssource.inputkeeps 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
outputSchemawithformat: "money", at any depth (inside a list, every element loses the field); - a column with
format: "money"of atableordetailview, a card field or a chart series withformat: "money", of a view that reads the action (the field under the view'ssource.rows); - a
numberview withformat: "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 withloading: "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.6loading).when.words: a regular expression, its source inpatternand its flags inflags(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:/spedizionimatches/spedizioniand/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" },
},
]
nameis what people type after@, so it has no spaces. It is unique in the manifest regardless of case, andBrainandassistenteare reserved. If a company already has an agent of its own with the same name, the company's agent keeps it.promptsays 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 ininstructions.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 showsbotwhen it can't draw it.color: the colour of the agent's avatar (3.7). It is a different list from the plugin'scolor.
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
actionsandplatformActions. A person withoutspedizioni_shipments.viewwho 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" }] }],
},
},
],
}
pathnames the page in your manifest, such as/spedizioni/dettaglio. A link (rowLink, a card'slink) andinstructions[].when.pagesuse it. In the app, the page's address is/plugin?_plugin=<plugin id>&_page=<page id>, followed by the page's parameters.pathsare other path prefixes that belong to the page. A link under one of them opens this page: the longest prefix among every page'spathandpathswins. With the page above,/spedizioni/tracking/{tracking}opens the detail too. No path may be used twice across the pages.permissionsays who may open the page. Only those people see its sidebar entry.viewsays 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 oftext,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'slabels, which gives the words for the values. Meant forbadgecolumns (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? }:
idis the name of a property of the action'sinputSchema, and so follows the id pattern: name a form's input properties in snake_case (pickup_date, notpickupDate);typeistext,long-text,number,dateorchoice. Anumberfield fills anumberorintegerproperty, the others astringone;optionslists the values of achoice(within the property'senum, when it has one);required: the field must be answered. A property theinputSchemalists asrequiredis 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.inputand, on top of it, the page's parameters (they win for the same name): those a link passed and, for atablewithsearch, the search box's text asquery. 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 asstringin theinputSchema, or accept both forms on your server, as the example does withlate. - A widget calls its source action with
source.inputonly, with its JSON types. table: the rows are the result'ssource.rowsfield (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,
nullor an object gets no link, and so does a link whose path names none of your pages. detail: withsource.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'spricespermission, 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 sameid(?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,formatdateoremail), first in the browser and then again on the company's server against the wholeinputSchema, before anything is called. A wrong answer gets its message under its field (checkFormAnswers, 6). Then the action's confirmation holds:alwaysprepares the call and shows the confirmation card, and your server gets it on «Conferma» with originconfirmation;neverandoptionalcall it at once with originpage, and the form showssuccess, or your error's text. The page'spermissionand 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 entries#
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'stitle. A page has at most one entry.section: where the entry goes:work(Lavoro),management(Gestionale),accounting(Contabilità),assistant(Assistente) oradministration(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'sicon.badge: a number beside the entry, such as what is late or what waits.actionis a read action andfieldis 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 originpage, 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",
},
},
],
}
idis<plugin id>.<name>.sizesare the sizes the widget supports on the home grid:small(one cell),medium(two cells side by side),large(two by two).defaultSizeis one of them.permission(optional): the permission a person needs to see the widget.view: anumber,cardsorchartview (atableordetailsays it isn't available on the home; aformis refused). Give a chart themediumorlargesize.
People place, resize and hide widgets in «Personalizza».
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 |
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.manifestis the checked manifest, with its text fields trimmed.{ ok: false, problems, warnings }: the manifest is not valid.problemslists 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
pathandpaths. 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
Brainorassistente(in any case). requiresdoesn't list your own id.- Each part of
versionis 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
platformActionsentry is in the optionplatformActions(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
labelslist named by a column exists. - A
rowLinkor a card'slinkpasses no{_plugin}or{_page}.
Runtime:
- A
remoteruntime'surlis https, or http on a host ofdevHosts. - A
bearerruntime names its setting (auth.setting). Anyauth.settingis declared and is asecret.
Actions and views:
- Every
inputSchemahastype: "object"; everyoutputSchemahas atypeor a$ref. - A read action's confirmation is
never. - An action with
pricesdeclares at least one money field. - Every view's
source.actionand every badge'sactionis a read action. - A form's
submitis a write action. Its field ids are unique, and each is a property of that action'sinputSchemaof a fitting type (3.9); every property the schema lists asrequiredhas a field; achoicehasoptions, only achoicehas them, and they are within the property'senum. - A
piechart has one series. - A widget's
defaultSizeis one of itssizes, and its view isn't aform. when.wordscompiles as a regular expression.
Agent models:
- An agent's
preferredandfallbackdon'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
GETstream, offers no client capabilities (no sampling, roots or elicitation) and doesn't sendDELETE. - 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 examplewindow.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-ancestorsheader 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).payloadSchemais 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
alwayswrite 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-processruntime:runtime: { type: "in-process" }. The actions' code is part of the company's server. Refused for third parties. nativeviews:{ type: "native", component }, a page in the platform's own code. Refused for third parties.- No prefix on permission groups and actions.
envon settings: the setting is read from the company server's environment. The validator acceptsenvin 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" },