agentserve.app.yaml sits at the root of your app. It says what the app is called, which pages it shows, what it may reach, what to create when it is installed, and how much it may spend each month. The platform validates it on every upload, and the install screen lists its permissions for the admin.
A complete example
# yaml-language-server: $schema=https://agentserve.valnoraelric.com/schemas/agentserve.app.schema.json
name: field-visit-logger
version: 0.1.0
title: Field visit logger
icon: ./dist/icon.svg
pages:
- id: visits
title: Visits
entry: ./dist/index.html#/visits
- id: new-visit
title: New visit
entry: ./dist/index.html#/new
permissions:
tables: [visits]
files: write
relays: [Field follow-up]
requires:
tables: ./tables/*.yaml
relays: ./relays/*.yaml
budget:
monthlyUsd: 25The first line points editors that use the YAML language server (for example VS Code with the YAML extension) at the published JSON Schema, so you get completion and errors as you type. Every template already has it.
Fields
The table below is generated from the manifest's JSON Schema, so it always matches what the platform accepts. A field marked Required inside an optional object (such as budget.monthlyUsd) is required only when that object is present. A manifest with any field not listed here is refused.
| Field | Type | Required | Default | Limits | Description |
|---|---|---|---|---|---|
name | string | Yes | — | matches ^[a-z][a-z0-9-]{1,62}$ | Stable identifier of the app across versions. Choose it once; every version you upload under the same name belongs to the same app. |
version | string | Yes | — | a semantic version such as 1.4.0 or 2.0.0-beta.1; at most 64 characters | Version of this build. Each upload needs a new version; installs move between published versions. |
title | string | Yes | — | 1–80 characters | Name people see in the Apps section and on the install screen. |
description | string | No | — | 1–160 characters | One line on what the app is for, shown on its card in the Apps hub. |
accent | one of blue, violet, teal, green, amber, orange, rose or slate | No | — | — | Accent color of the app’s card and tile in the Apps hub. |
symbol | one of apps, chart, dashboard, table, briefcase, bank, shield, users, inbox, calendar, compass, sparkles, zap, rocket, workflow or document | No | — | — | Symbol shown on the app’s tile in the Apps hub. |
icon | string | No | — | 1–512 characters | Path to an image in the bundle, relative to the manifest. |
pages | object[] | Yes | — | 1–20 items | The pages people open from the Apps section, in order. |
pages[].id | string | Yes | — | matches ^[a-z][a-z0-9-]{0,62}$ | Page identifier, unique within the app. |
pages[].title | string | Yes | — | 1–80 characters | Page name shown in the app’s navigation. |
pages[].entry | string | Yes | — | 1–512 characters | HTML file in the bundle, relative to the manifest, optionally with a #/route for client-side routing (./dist/index.html#/home). |
permissions | object | No | see fields | — | What the app may reach. It only narrows: the app always acts as the person using it and never reaches more than they can. |
permissions.tables | string[] | No | [] | at most 50 items; each 1–255 characters | Tables the app may list, query and add rows to, by table name. |
permissions.model | one of read or none | No | "none" | — | read lets the app read and query the semantic model. |
permissions.workflows | string[] | No | [] | at most 50 items; each 1–255 characters | Deployed workflows the app may run, by workflow name. |
permissions.relays | string[] | No | [] | at most 50 items; each 1–255 characters | Relays the app may assign tasks to, by Relay name. |
permissions.chat | one of ask, answer or none | No | "none" | — | ask lets the app open Chat with a question for the person to send; answer also lets it get Chat answers back, read-only. |
permissions.files | one of read, write or none | No | "none" | — | Workspace files: read reads files by id, write also creates new files. |
permissions.ai | object | No | see fields | — | Platform AI the app may call. Every call counts against the app budget. |
permissions.ai.models | string[] | No | [] | at most 50 items; each 1–255 characters | Model ids the app may name for ai.complete and ai.media. Workspace and permission-group model rules still apply. |
permissions.ai.media | string[] | No | [] | at most 2 items | Media the app may generate with ai.media: image, speech. |
permissions.knowledge | string[] | No | [] | at most 50 items; each 1–255 characters | Knowledge bases the app may search, by name. |
permissions.integrations | string[] | No | [] | at most 50 items; each matches ^[a-z][a-z0-9_]{1,127}$ | Integration actions the app may run with the connected accounts of the person using it, by tool id. Actions that change anything ask the person first. |
requires | object | No | — | — | Definitions the install creates when the workspace does not already have them. |
requires.tables | string or string[] | No | — | 1–512 characters; at most 50 items; each 1–512 characters | Glob of table definition files (YAML) in the bundle. |
requires.rules | string or string[] | No | — | 1–512 characters; at most 50 items; each 1–512 characters | Glob of rule definition files (YAML or JSON) in the bundle. |
requires.relays | string or string[] | No | — | 1–512 characters; at most 50 items; each 1–512 characters | Glob of Relay definition files (YAML or JSON) in the bundle. |
tools | string or string[] | No | — | 1–512 characters; at most 50 items; each 1–512 characters | Globs naming your code tools: each <name>.tool.yaml and its <name>.ts, .js, .mjs or .py source. The files are stored with the version and run on the server; they are never served to the app frame. See Code tools. |
chatBlocks | string, string[] or object[] | No | — | 1–512 characters; at most 50 items; each 1–512 characters | Chat cards the app provides: a glob of HTML files (each named after its file) or a list of cards. |
chatBlocks[].id | string | Yes | — | matches ^[a-z][a-z0-9-]{0,62}$ | Card identifier, unique within the app. |
chatBlocks[].title | string | Yes | — | 1–80 characters | Card name. |
chatBlocks[].entry | string | Yes | — | 1–512 characters | HTML file in the bundle. |
panels | string, string[] or object[] | No | — | 1–512 characters; at most 50 items; each 1–512 characters | Dashboard panels the app provides: a glob of HTML files (each named after its file) or a list of panels. |
panels[].id | string | Yes | — | matches ^[a-z][a-z0-9-]{0,62}$ | Panel identifier, unique within the app. |
panels[].title | string | Yes | — | 1–80 characters | Panel name. |
panels[].entry | string | Yes | — | 1–512 characters | HTML file in the bundle. |
build | object | No | — | — | How a Git deploy builds the app. agentserve app deploy builds on your machine and ignores it. See Git deploys. |
build.command | string | No | — | 1–500 characters | Command run from the repository root after dependencies are installed. Defaults to bun run build. |
build.output | string or string[] | No | — | 1–512 characters; 1–20 items; each 1–512 characters | Files and folders that make up the bundle, besides the manifest. Defaults to the folders holding the page, panel and Chat card entries, the icon and the requires files. The tools files are always added. |
build.allowVulnerable | boolean | No | — | — | Let a build through when the dependency audit finds high or critical vulnerabilities. The version records that it was allowed, and shows it wherever the version is listed. |
budget | object | No | — | — | Monthly spending cap for the app’s paid work. An admin can override it per install. |
budget.monthlyUsd | number | Yes | — | 0–1,000,000 | Cap in US dollars per calendar month (UTC). |
triggers | object[] | No | — | at most 20 items | Platform events and schedules that run a handler in the background, as the admin who approved background runs and only with the scopes they approved. See Plugins. |
triggers[].id | string | Yes | — | matches ^[a-z][a-z0-9-]{0,62}$ | Trigger identifier, unique within the app. |
triggers[].title | string | Yes | — | 1–60 characters | What the trigger does, shown on the install review and the Manage page. |
triggers[].on | object | Yes | — | — | The event: schedule (with cron and timezone), table.row.inserted or table.row.updated (with table), file.uploaded, relay.task.completed, workflow.run.completed, or calendar.event.ended (not delivered yet). |
triggers[].handler | string | Yes | — | matches ^[a-z][a-z0-9_]{0,39}$ | The code tool that handles the event, by tool name. |
commands | object[] | No | — | at most 20 items | Slash commands added to Chat, such as /call-prep. |
commands[].name | string | Yes | — | matches ^[a-z][a-z0-9-]{1,31}$ | What people type after the slash in Chat. |
commands[].description | string | Yes | — | 1–160 characters | Shown next to the command in Chat’s slash menu. |
commands[].arguments | object[] | No | [] | at most 5 items | Arguments the command asks for, in order. |
commands[].arguments[].name | string | Yes | — | matches ^[a-z][a-z0-9_]{0,31}$ | Argument name, as the handler or page receives it. |
commands[].arguments[].description | string | Yes | — | 1–160 characters | Shown as the argument’s hint in Chat. |
commands[].arguments[].required | boolean | No | false | — | Whether the command needs it before it runs. |
commands[].run | object | Yes | — | — | { handler: <tool> } runs a code tool and shows its answer in Chat; { open: { page: <id> } } opens a page, which reads the arguments with launch.get. |
actions | object[] | No | — | at most 20 items | Context-menu items on files, table rows, workbook queries and dashboard panels, such as “Summarise this file”. |
actions[].id | string | Yes | — | matches ^[a-z][a-z0-9-]{0,62}$ | Action identifier, unique within the app. |
actions[].title | string | Yes | — | 1–60 characters | The menu item. |
actions[].description | string | No | — | 1–160 characters | A short note shown with the item. |
actions[].on | one of file, table.row, workbook.query or dashboard.panel | Yes | — | — | Where it appears: file, table.row, workbook.query or dashboard.panel. |
actions[].types | string[] | No | — | at most 20 items; each an extension such as .pdf or a media type such as image/* | For file: only files with these extensions (.pdf) or media types (image/*). |
actions[].tables | string[] | No | — | at most 50 items; each 1–255 characters | For table.row: only rows of these tables, each also named in permissions.tables. Defaults to every table there. |
actions[].run | object | Yes | — | — | { handler: <tool> } runs a code tool with the file, row, query or panel; { open: { page: <id> } } opens a page that reads it with launch.get. |
settings | object | No | — | — | A JSON Schema form an admin fills in on the Manage page. Pages read it with settings.get; code tools get it in their input. |
settings.type | string | No | "object" | — | Always object. |
settings.properties | object | Yes | — | — | The fields, by key: string (with enum, format: textarea, email, uri or secret), number, integer or boolean, each with a title. A secret field’s key is a secret name, such as API_TOKEN; its value is kept in the install’s secrets. |
settings.required | string[] | No | [] | at most 30 items | Keys of the fields an admin must fill in. |
connectors | object[] | No | — | at most 10 items | MCP servers the plugin brings: created on install through the workspace’s MCP stack, owned by the install and removed when it is uninstalled. The plugin calls them with mcp.call. |
connectors[].id | string | Yes | — | matches ^[a-z][a-z0-9-]{0,62}$ | How the plugin names the connector in mcp.call. |
connectors[].title | string | Yes | — | 1–60 characters | Shown on the install review and the Manage page. |
connectors[].directory | string | No | — | matches ^[a-z][a-z0-9-]{0,62}$ | A connectors directory entry, such as linear, which supplies the address and sign-in. |
connectors[].url | string | No | — | at most 2048 characters | The remote MCP endpoint (https), when no directory entry is named. |
connectors[].auth | one of none, oauth or header | No | — | — | oauth: an admin connects an account on the Manage page. header: a secret setting is sent as a header. none: no sign-in. |
connectors[].header | object | No | — | — | For a key sent as a header: which secret setting, under which header. |
connectors[].header.name | string | Yes | — | matches ^[A-Za-z][A-Za-z0-9-]{0,63}$ | The header, such as Authorization. |
connectors[].header.secret | string | Yes | — | matches ^[A-Z][A-Z0-9_]{0,63}$ | The secret setting whose value is sent. |
connectors[].header.prefix | string | No | — | at most 32 characters | Sent before the value, such as Bearer . |
connectors[].tools | string[] | No | — | at most 100 items; each 1–128 characters | Only these tools may be called. Omitted means every tool the server lists. |
skills | object[] | No | — | at most 20 items | Instruction packs added to the workspace skill library, usable as /name in Chat and by Relays. |
skills[].name | string | Yes | — | matches ^[a-z0-9]+(-[a-z0-9]+)*$; at most 64 characters | Skill name, as Chat types it after a slash. |
skills[].description | string | Yes | — | 1–1024 characters | What the skill is for, shown in the skill library. |
skills[].file | string | Yes | — | matches \.md$; 1–512 characters | The Markdown instructions in the bundle. |
background | object | No | — | — | Limits for background runs: how many at once and how long each may take. |
background.concurrency | integer | No | 1 | 1–5 | Most background runs of one install at once. |
background.timeoutSeconds | integer | No | 120 | 10–480 | Longest one run may take before it is stopped. |
background.monthlyUsd | number | No | — | 0–100,000 | Suggested monthly cap on background spend, shown on the install review. The admin sets the real cap. |
listing | object | No | — | — | What the plugin catalog shows about a first-party plugin. |
listing.tagline | string | No | — | 1–120 characters | One short line under the plugin’s name in Discover. |
listing.categories | string[] | No | [] | at most 3 items | Catalog categories, at most three. |
listing.packs | string[] | No | [] | at most 5 items | Team packs the plugin belongs to: everyone, sales, finance, legal, ops. |
listing.screenshots | string[] | No | [] | at most 8 items; each a .png, .jpg or .webp file; each 1–512 characters | Screenshots in the bundle, shown on the plugin’s detail page. |
listing.publisher | object | No | — | — | Who publishes the plugin. Defaults to Agent Serve for first-party plugins. |
listing.publisher.name | string | Yes | — | 1–80 characters | Publisher name. |
listing.publisher.url | string | No | — | at most 512 characters | Publisher web address. |
slots | object | No | — | — | Workspace resources the admin picks at install, by slot name: each has a kind (table, knowledge_base, workflow, relay or model), a title, a description, required (default true) and an optional default name. The app addresses each by its slot name. |
Paths and globs
Every path in the manifest is relative to the manifest, such as ./dist/index.html or ./tables/*.yaml. A path may not start with /, may not be a URL, and may not leave the bundle with ...
Globs match files in the bundle: * matches within one folder, ** matches any number of folders, and ? matches one character. Fields that take a glob also take a list of globs.
When you upload, the platform checks that:
- every page entry, panel entry and Chat card entry is an HTML file in the bundle,
- the icon, when set, is in the bundle,
requires.tables, when set, matches at least one file.
Pages
Each page is one entry in the Apps section's navigation. Most apps are a single-page application with one index.html and a route per page, written as a fragment: ./dist/index.html#/visits. The app reads the fragment to decide what to show. You can have up to 20 pages, and their ids must be unique.
Permissions
permissions names what the app may reach. The app always acts as the person using it, so a permission never gives anyone more access than they already have. It only narrows. See Security model.
| Permission | What it opens |
|---|---|
tables: [name, …] | List those tables and their columns, query their rows, and add rows. |
model: read | Read the semantic model's entities, attributes and measures, and run governed queries on it. |
workflows: [name, …] | Run those deployed workflows, waiting for the result or queued. |
relays: [name, …] | Assign tasks to those Relays. |
chat: ask | Open Chat with a question. The person decides whether to send it. |
chat: answer | Everything ask opens, and get Chat's answer back in the app. Chat answers read-only: it cannot change anything from there. |
files: read | Read workspace files by id. |
files: write | Read workspace files and create new ones. |
ai: { models: [id, …] } | Ask those chat models for completions, whole or streamed. |
ai: { media: [image, speech] } | Generate images or speech with the workspace's media models, or with a model also listed in ai.models. |
knowledge: [name, …] | Search those knowledge bases. |
integrations: [action, …] | Run those integration actions, such as slack_send_message, with the person's own connected account. An action that changes anything asks the person first. |
permissions:
ai:
models: [gpt-5.5]
media: [image]
knowledge: [Product docs]
integrations: [slack_send_message]
chat: answerModel, media and Chat calls run on the workspace's own provider key when it has one, and on Agent Serve's hosted key otherwise. Either way the person's permission group decides which providers and models they may use, so an app cannot reach a model the person could not. The app never sees a credential: integration actions use the person's connected account on the server, and the frame only gets the result.
Names are matched against the workspace the app is installed in, when each call is made:
- A name with no match grants nothing. Calls naming it fail with
not_found. - If several workflows share a name, the app cannot run any of them until one is renamed.
- A call that names a resource the manifest does not list fails with
forbidden, even if the person could reach it.
Slots
A name under permissions must match a resource in every workspace the app is installed in. When workspaces call the same thing by different names — a pipeline table, a docs knowledge base — declare a slot instead. The admin picks the workspace's own resource for each slot on the install review, and can change it later on the Manage page. The app calls methods with the slot's name.
slots:
pipeline:
kind: table
title: Pipeline table
description: Accounts and their open deals come from this table.
default: pipeline_opportunities
docs:
kind: knowledge_base
title: Knowledge base
description: Questions are answered from this one.
required: false| Field | What it means |
|---|---|
kind | What the admin picks: table, knowledge_base, workflow, relay or model. |
title, description | What the review and the Manage page show next to the picker. Say why the app needs it. |
required | Defaults to true. The app installs only once a required slot is bound, or its default exists. |
default | A name the slot uses when the admin binds nothing and the workspace has a resource called that. The review starts on it. |
- A slot is a permission by itself. A
tableslot lets the app reach the one table the admin bound to it, exactly as listing that table underpermissions.tableswould, and every call is still held to the person's own access. - Call methods with the slot name:
tables.query({ table: 'pipeline' }),knowledge.search({ knowledgeBases: ['docs'] }),workflows.run('intake'),relays.assignTask({ relay: 'reviewers' }). - A
modelslot names a chat model the admin picks; pass the slot name asmodelinai.complete. The workspace's semantic model is not a slot: there is one per workspace, reached withpermissions.model: read. - A slot whose resource is deleted or archived falls back to its default, else it is unbound. Calls to an unbound slot fail with
not_foundand say an admin picks it on the Manage page, where it shows as Needs setup. tables.listmarks a table bound to a slot with that slot's name, andviewer.getlists each slot and whether it is set up.- A slot's name must not also appear under
permissionsfor the same kind. - Triggers, actions and background scopes may name a slot wherever they name a table, workflow, Relay or knowledge base.
Literal names keep working: a name under permissions is a fixed slot whose only binding is that name.
Requires: tables, rules and Relays
requires lists definitions the install creates when the workspace does not already have one with the same name. Existing ones are left as they are. The install is safe to retry: anything it already created is skipped.
Tables
One YAML file per table:
name: visits
description: Customer visits logged in the field
columns:
- { name: customer, type: string, required: true }
- { name: visited_on, type: date, required: true }
- { name: outcome, type: string }
- { name: deal_value, type: currency }
- { name: follow_up, type: boolean }| Field | Rules |
|---|---|
name | Required, 1–255 characters. List the same name under permissions.tables to use the table. |
description | Optional, up to 2,000 characters. |
columns | 1–200 columns, each with a unique name (1–255 characters, compared without case). |
columns[].type | One of string, number, boolean, date, json, currency. |
columns[].required | Optional true or false. |
columns[].unique | Optional true or false. |
A table that cannot be created fails the whole install, with the file and field in the error. The installing admin's own plan limits apply.
Rules
One YAML or JSON file per rule. The file is a rule definition exactly as the Rules API takes it, with a non-empty name; the rule service validates it in full when the install creates it.
Rules need model management. If the installing admin's permission group does not include it, or a definition is invalid, the install still succeeds and reports the rule as skipped with the reason, so someone can create it in Rules.
Relays
One YAML or JSON file per Relay. The install creates the Relay, sets it up from the file, and publishes it when the definition is complete and the admin leaves Publish now on:
# yaml-language-server: $schema=https://agentserve.valnoraelric.com/schemas/agentserve.relay.schema.json
name: Legal review
description: Reviews contracts flagged by Contract triage
specialization: legal
instructions: |
You review contracts that Contract triage flagged against the team's playbook.
For every flagged clause, accept, counter with exact wording, or escalate.
delivers:
summary: A decision for every flagged clause, with the reason and any counter wording.
as: task_result
escalation: Escalate anything that touches liability caps, indemnities or personal data.
autonomy: assist
approvals: { approvers: installer, expireAfterHours: 48 }
tools: [workspace_read, tables, knowledge]
workflows:
- workflow: Send redlines
responsibilities:
- name: Weekly review backlog
objective: List contracts that have waited more than five working days.
schedule: { cron: '0 9 * * 1', timezone: UTC }| Field | Rules |
|---|---|
name | Required, 1–120 characters. List the same name under permissions.relays to use it. |
description | Optional, up to 2,000 characters. |
specialization | Optional. Defaults to custom. |
instructions | What the Relay does and how, in Markdown. Together with delivers, at most 8,000 characters. |
delivers | What it hands back when a task is done: a sentence, or { summary, as, folder } where as is task_result (default) or library_file, which also saves a file in folder. It is added to the instructions under "What you deliver". |
escalation | Optional. When it stops and asks a person. |
language, exampleTasks | Optional. Its working language and up to 10 example tasks. |
autonomy | observe (default: drafts only), assist (acts within its rules, asks for the rest) or operate. |
approvals | Optional. approvers: installer (default) makes the installing admin its approver; none leaves approvers for later. expireAfterHours is 1–168. |
tools | Optional tool groups: workspace_read, knowledge, tables, web, integrations, workflows. |
integrations.services | Optional integration services it may call, by id, such as slack. |
workflows | Optional deployed workflows it may run, by name, with autonomous (default false). A name that matches no workflow, or several, is left for the admin to choose. |
limits | Optional maxConcurrentTasks (1–10) and maxTasksPerDay (1–200). |
responsibilities | Optional scheduled routines (name, objective, schedule.cron, schedule.timezone). They are created turned off. |
The editor schema is published as agentserve.relay.schema.json.
A definition is complete when it has instructions and delivers. The install review shows each Relay with its instructions and a Publish now switch, on by default for a complete definition when the admin may publish Relays. Everything happens as the installing admin, through the same steps as the Relays page: the Relay is created, its setup saved, its routines added and, when chosen, published, with the same setup and go-live checks. When a step cannot finish (a workflow the workspace does not have, a check that fails, a permission group without Relays), the Relay stays a draft and the review lists exactly what is missing, with a link to finish its setup.
Relays are created only when the app is installed from the Apps page in the workspace. An install from the CLI or the API reports each declared Relay as skipped, with the reason, so create it in Relays or install from the workspace instead.
Panels and Chat cards
An app can provide small views that render outside its own pages, in the same sandboxed frame:
panels— dashboard panels people add to a dashboard.chatBlocks— cards Chat can show in a reply.
Declare them as a list with an id, title and entry each, or as a glob of HTML files:
panels:
- id: pipeline
title: Pipeline
entry: ./dist/panels/pipeline.html
chatBlocks: ./dist/cards/*.htmlWith a glob, each matching HTML file becomes one component whose id and title come from its file name, lowercased, with other characters turned into -. A file whose name does not make a valid id is left out, and at most 50 are used. A panel or card can call resize from the SDK to fit its height to its content.
Tools
tools names your code tools, such as ./tools/*: each <name>.tool.yaml and its <name>.ts, .mts, .js, .mjs or .py source. They are uploaded with the version, checked on upload, stored with it, and run on Agent Serve for the app, Chat, Relays and workflows. They are never served to the app's frame. See Code tools.
Plugin sections
An app becomes a plugin when it also adds things to the workspace's own surfaces. Each section is optional:
triggersrun a handler on a schedule or when something happens in the workspace, in the background.commandsadd slash commands to Chat.actionsadd menu items to files, table rows, workbook queries and dashboard panels.settingsis a form an admin fills in for the install.connectorsbring MCP servers (tools from other services) the plugin calls.skillsadd instruction packs to the workspace's skill library.backgroundlimits background runs;listingis what the plugin catalog shows.
A handler is one of the app's own code tools, named by its tool name; an upload is refused when a handler names a tool the version does not ship. Every resource a trigger or action names must also be in permissions. See Plugins for each section, the handler input and output, and how background runs are authorized.
Build
build tells a Git deploy how to build the app: the command to run and which paths make up the bundle. agentserve app deploy builds on your machine and ignores it.
Budget
budget.monthlyUsd caps what the app's paid work may spend in a calendar month, measured in UTC. Paid work is the workflow runs, Relay tasks and code tool runs the app starts, its model completions, generated media, Chat answers and knowledge searches, and the hosted-key cost of its integration actions. Once the month's spend reaches the cap, those calls fail with budget_exhausted until the next month.
An admin can set a different budget for an install on its Manage page, which takes precedence over the manifest. With no budget in either place, the app's spend is not capped. See Budgets.