A plugin is an app that does more than show pages. Besides its own pages, it can add things to the places your team already works:
- Commands in Chat, such as
/call-prepor/brief. - Actions in menus: "Summarise this file" on a file, "Explain this query" on a workbook query, and so on.
- Triggers that run on a schedule or when something happens: a row is added, a file is uploaded, a Relay finishes a task, a workflow run ends.
- Settings an admin fills in once per install.
- Connectors: tools from other services, through MCP (the Model Context Protocol, an open standard for connecting AI agents to outside tools).
- Skills: instructions Chat and Relays follow, added to the workspace skill library.
There is nothing separate to learn. A plugin is an app with more sections in its manifest: the same bundle, the same agentserve app deploy, the same install review and the same security model.
A worked example
The first-party File briefs plugin (plugins/file-briefs/ in the repository) uses most of the sections:
# yaml-language-server: $schema=https://agentserve.valnoraelric.com/schemas/agentserve.app.schema.json
name: file-briefs
version: 1.0.0
title: File briefs
description: Short, plain briefs of your files, on demand from any file and for every new file in Inbox.
accent: violet
symbol: document
pages:
- id: briefs
title: Briefs
entry: ./web/index.html
permissions:
tables: [File briefs]
files: read
ai:
models: [claude-haiku-4-5]
requires:
tables: ./tables/*.yaml
tools: ./tools/file_brief.*
triggers:
- id: inbox-upload
title: Log every file uploaded to Inbox, ready for a brief
on:
event: file.uploaded
folder: Inbox
types: [.md, .txt, .csv, .json]
handler: file_brief
commands:
- name: brief
description: Open your briefs, or find one by file name
arguments:
- name: file
description: Part of a file name to look for
run:
open:
page: briefs
actions:
- id: summarise-file
title: Summarise this file
on: file
types: [.md, .txt, .csv, .json]
run:
handler: file_brief
settings:
type: object
properties:
tone:
type: string
title: Brief style
enum: [Plain, Bullet points, Executive summary]
default: Plain
maxLines:
type: integer
title: Longest brief
minimum: 3
maximum: 12
default: 5
required: [tone]
skills:
- name: brief-writing
description: Write a short, plain brief of any document for a busy reader.
file: ./skills/brief-writing.md
background:
concurrency: 2
timeoutSeconds: 60
monthlyUsd: 5
listing:
tagline: A brief of any file in five lines.
categories: [productivity]
packs: [everyone, legal, ops]
budget:
monthlyUsd: 20Every field is listed in the manifest reference.
Handlers
A handler is one of the app's own code tools, named by its tool name in a trigger, command or action (handler: file_brief). The upload is refused if a handler names a tool the version does not ship.
What a handler receives
Every handler gets the same input, so one tool can handle several entries:
interface PluginHandlerInput {
kind: 'trigger' | 'command' | 'action'
install: { id: string; workspaceId: string; version: string }
/** Non-secret settings. Secret settings are environment variables of the same name. */
settings: Record<string, string | number | boolean | null>
trigger?: { id: string } // kind: trigger
event?: { type: string; [key: string]: unknown }
command?: { name: string; args: Record<string, string> } // kind: command
action?: { id: string; target: ActionTarget } // kind: action
viewer?: { userId: string } // absent in the background
}For an action, the target comes with the content the plugin may read already attached, read as the person who chose it:
| Action on | target |
|---|---|
file | { kind, fileId, name, contentType, text? }. text is the file's text, up to 200,000 characters, when the manifest has files: read and the file is text. |
table.row | { kind, table, rowId, row }. row holds the row's values by column name. |
workbook.query | { kind, workbookId, cellId, title, sql } |
dashboard.panel | { kind, dashboardId, panelId, title } |
The tool's .tool.yaml input schema must accept this shape. The simplest is an object with kind, install and settings required and the rest optional, as plugins/file-briefs/tools/file_brief.tool.yaml does.
What a handler returns
A handler never calls the network or Agent Serve itself. It returns what to do, and Agent Serve does it:
interface PluginHandlerResult {
/** Shown to the person: in Chat for a command, in a notice for an action. */
message?: string
/** Open one of the plugin's pages; it reads what it was opened for with launch.get. */
open?: { page: string; params?: Record<string, string> }
/** Up to 20 SDK calls, made in order after the handler finishes. */
calls?: Array<{ method: string; params: object }>
/** The index of a call whose answer becomes the message. */
answerWith?: number
}calls are the same calls an app page makes, such as tables.insertRows, ai.complete, relays.assignTask or mcp.call. answerWith turns the text of an ai.complete, chat.answer or mcp.call into the message, so a handler can build a prompt and let Agent Serve run it. File briefs answers "Summarise this file" this way:
export function run(input: PluginHandlerInput): PluginHandlerResult {
const target = input.action?.target
if (!target?.text?.trim()) return { message: 'This file has no text to brief.' }
return {
calls: [
{
method: 'ai.complete',
params: {
model: 'claude-haiku-4-5',
messages: [
{ role: 'system', content: `Write a brief in at most ${input.settings.maxLines ?? 5} lines.` },
{ role: 'user', content: target.text },
],
},
},
],
answerWith: 0,
}
}The SDK exports these types and a definePluginHandler helper; see App SDK.
How calls are authorized
Every call a handler hands back goes through the same checks as a call from an app page: the person it acts for, narrowed to what the manifest names, with the app's budget and per-install limits. When a person ran the command or action, the calls act as them. A call that would need their approval (an integration action or MCP tool that changes something) is skipped and logged; open the plugin's page to make it with their approval. A handler cannot run another code tool through its calls.
Calls run after the handler finishes, so a handler cannot read a call's result. For work in several steps, hand it to a Relay with relays.assignTask.
Commands
A command adds /name to Chat's slash menu, with its description and arguments. When someone chooses it, Chat asks for the arguments, then either:
- runs the handler (
run: { handler: … }) and shows its message in Chat, or - opens a page (
run: { open: { page: … } }). The page reads the command and its arguments withlaunch.get.
Actions
An action adds a menu item where the manifest says (on):
on | Where it appears |
|---|---|
file | The menu on a file in Files, and the file viewer's menu. types limits it to extensions (.pdf) or media types (image/*). |
table.row | The menu on a table row. tables limits it to some tables; each must be in permissions.tables. |
workbook.query | A query cell's toolbar in a workbook. |
dashboard.panel | A dashboard panel's menu. |
Like a command, an action runs its handler and shows the message, or opens a page that reads the target with launch.get.
A plugin page that needs a file the action did not bring asks for one with pickFiles: Agent Serve shows its own file picker, and the plugin receives only what the person chose. A page can also put text on the clipboard with copyText and save a file with download; the app frame has no clipboard or downloads of its own, so Agent Serve does both from the host page. See Files and Clipboard and downloads in the SDK reference.
Triggers
A trigger runs its handler in the background when its event happens. The event's details arrive as the handler's event:
event | Fires when | Filters | event carries |
|---|---|---|---|
schedule | The cron expression comes round | cron (five fields), timezone (IANA, default UTC) | scheduledFor, cron, timezone |
table.row.inserted | A row is added to the table | table, which must be in permissions.tables | table, row (values by column name) |
table.row.updated | A row of the table changes | table; columns fires only when one of them changes | table, row, previous, changedColumns |
file.uploaded | A file is uploaded to workspace files | folder (a path such as Contracts/2026, including its subfolders), types | file (id, name, contentType, size, folderId, folderPath) |
relay.task.completed | A Relay completes a task | relay, which must be in permissions.relays | relay, task (id, title, state) |
workflow.run.completed | A workflow run ends | workflow (in permissions.workflows), status (success, error) | workflow, executionId, status, durationMs |
calendar.event.ended | A calendar event ends | calendar | — |
calendar.event.ended is defined so plugins can declare it, but it is not delivered yet: the connected calendars report changes, not when an event ends. A trigger on it installs, shows on the Manage page as waiting for its event source, and never runs.
Rows written by a bulk import do not fire table triggers. Each delivery runs a trigger once, even if it is retried.
Background runs
A trigger runs with nobody in the app. Its runs act as the admin who approved background runs for the install, and only with the scopes that admin approved: a subset of the manifest's permissions and connectors, ticked on the install review or the Manage page. Every run is authorized again, when it starts, as that admin: their current workspace role, their permission group, and the Apps module. If they lose access, background runs stop and the Manage page says why; another admin approves them again to take over. A scope the manifest does not ask for is never granted, even if an approval names it.
Because the admin approved the scopes in advance, a background run's calls do not wait for anyone's approval.
| Limit | What it does |
|---|---|
background.concurrency | Most runs of one install at once (1 to 5, default 1). Others wait their turn, for up to an hour. |
background.timeoutSeconds | Longest one run may take (10 to 480 seconds, default 120). |
| Monthly background cap | Set by the admin. When background spend reaches it, background runs pause until next month or until an admin raises it. background.monthlyUsd suggests one. |
| App budget | Background runs count against the app's monthly budget like any other call. |
| Rate | At most 60 runs per install per minute from events, so a plugin cannot loop on its own activity. |
| Kill switch | An admin pauses and resumes background runs on the Manage page. |
Every run — background or a person's command or action — appears in the install's run history on the Manage page: when it started and ended, its outcome (succeeded, failed, timed out, or refused before it started), what it cost and a short log of what Agent Serve did. The log never holds the handler's input, its output values or secrets.
Settings
settings is a small JSON Schema form an admin fills in on the Manage page (and can fill in on the install review). Fields are string (optionally an enum, or format: textarea, email or uri), number, integer or boolean, each with a title, an optional description and default. required lists the fields that must have a value.
- Pages read the values with
settings.get/useSettings(). - Handlers get them in their input as
settings. - A field with
format: secretis kept in the install's secrets, never sent to a page and never shown again after it is saved. Its key is the secret's name (API_TOKEN), and code tools read it as an environment variable of that name.
Connectors
connectors names MCP servers the plugin brings: by a connectors directory entry (directory: linear) or by URL (url: https://…, with auth).
- On install, each connector becomes an MCP server in the workspace, through the workspace's own MCP settings: the admin's
mcp_tools.usepermission and the workspace's allowed MCP domains apply. A server the workspace already had at the same address is used, not replaced. - The install review shows each connector's host, how it signs in and the tools it allows (
tools, or every tool it lists). - An
oauthconnector is signed in from the Manage page. Aheaderconnector sends a secret setting as a header (header: { name: Authorization, secret: LINEAR_TOKEN, prefix: 'Bearer ' }). - The plugin calls a connector with
mcp.call(callMcpin the SDK): only its own connectors, only the tools the manifest allows, as the person using it or, in the background, as the approving admin with the connectors they approved. - A connector the plugin created is its own: Chat and Relays do not see it unless an admin shares it with them on the Manage page. Uninstalling the plugin removes it.
Skills
skills adds instruction packs, one Markdown file each, to the workspace's skill library. People use them in Chat as /name, and Relays can use them like any other workspace skill. A name the workspace already uses is skipped and listed on the install review. Uninstalling the plugin removes the skills it added.
Slots
A plugin that reads a workspace's own data — its pipeline table, its docs — declares slots instead of naming them. The install review asks for each one ("Needs a knowledge base. Questions are answered from this one."), with a searchable list of the workspace's own tables, knowledge bases, workflows or Relays, and a link to create one when there is none. Handlers and their calls use the slot names too, and background runs reach a slot only when the admin approved it among the background scopes.
Installing and reviewing
Admins install a plugin the same way as any app. The review lists, in plain words:
-
the resource each slot needs, with a picker;
-
what the plugin may read and change, and the tables, rules and Relays it creates, with each Relay's instructions and a Publish now switch (see Relays);
-
the commands, actions and triggers it adds;
-
its connectors (host, sign-in, tools) and skills;
-
its settings;
-
and, when it has triggers, the background scopes to approve and the monthly cap.
Background runs can also be approved later from the Manage page.
The plugin catalog
The Apps hub's Discover view lists the first-party plugins this deployment ships, by category and by team pack (Everyone, Sales, Finance, Legal, Ops), with a detail page for each: what it does, screenshots, the permissions in plain words, what it adds, the publisher and its version history. An admin installs one with one click and the same review.
First-party plugins live in the repository under plugins/<name>/, in the same layout as an app (agentserve.app.yaml, sources, tools/, a README.md and an optional CHANGELOG.md with ## 1.2.0 (2026-10-01) headings). To publish one:
- Add or change
plugins/<name>/(the manifestnamemust be the folder name). - Run
bun run generate:plugin-catalog. It validates every manifest and writes the catalog Discover reads;--checkfails when the catalog is stale. - The image build runs
bun run build:plugin-bundles, which builds each plugin and packs exactly whatagentserve app deploywould upload, with its checksum. Installing reads that bundle and checks the checksum.
The first install in an organization creates the plugin's app; later installs, and newer catalog versions, use the same app. A workspace app that already has the plugin's name is never replaced.
Connectors directory
Discover also lists popular remote MCP servers that any workspace can connect with one click, without a plugin. Each entry shows its publisher, its tools, how it signs in, the scopes it asks for and a risk level worked out from what its tools do:
| Risk | Means |
|---|---|
| Read only | Its tools only read. |
| Can change data | Some tools create, update or delete in the service. |
| Can act outside | Some tools send messages, act on live websites or move money. |
Connecting shows that review first. A server that signs in with OAuth opens the vendor's sign-in; one that needs an API key or a registered OAuth app asks for it. Every entry's address and sign-in were checked against the vendor's own documentation.
Developing a plugin
agentserve app dev runs a plugin's pages like any app's. For plugins it also:
- prints what the plugin adds;
- answers
settings.getwith each field's default, overridden by.agentserve/settings.jsonin the app folder; - answers
launch.getwith--launch '{"kind":"command","name":"brief","args":{"file":"q3"}}'when you pass it; - lists files with
listFilesthrough the workspace's files API as you, and shows a simple picker forpickFiles; - copies and downloads from the dev page, with the same rules for unsafe file types;
- refuses
mcp.call, which runs only in the workspace.
Handlers are code tools, so they run on the server from an installed version. Deploy, publish and install the plugin, then try its commands, actions and triggers in the workspace; the Manage page's run history shows each run.