Every part of PLAIOS is a plugin. Including the part you are about to write.
The memory, the model, the thing that decides what happens next, the chat you are reading this in: all of it is a package with a manifest, a class and a page. Swap any of them. Add your own. This guide shows you exactly how, down to the bytes the host hands your constructor.
11 component kinds
1 manifest per package
0 lines of glue code
The map
Eleven kinds of component, one message path. Point at any box to see who it talks to. Click it to read its chapter.
01
Plugins all the way down
There is no core you are not allowed to replace.
Most agent frameworks give you a brain and let you bolt tools onto it.
PLAIOS does the opposite.
There is no brain. There is a host that loads packages, hands each one the others it asked for, and gets out of the way. The model is a package. The memory is a package. The thing that decides which tools to use is a package. Even the chat you are reading this in sits on the same contracts you are about to use.
That changes what "writing a plugin" means.
You are not writing an extension to someone else's agent. You are writing a part of the operating system, on equal footing with every part that shipped with it.
Three ideas that make it work
Contracts, not conventions. Every kind of component is a TypeScript interface in @plaios/core/domain. If your class satisfies it, the host can run it. No base classes, no decorators, no registration calls.
Wiring is declared, not coded. Your agent-os.package.yaml says what you are (kind: input) and what you need (required: [system2]). The host builds your constructor options from that. You never import another plugin.
Every part has a face. Each interface ships a page microfrontend, and capabilities can teach the model to draw live components right inside its answers.
What you will be able to do by the end
Bring messages in from anywhere: a webhook, a mailbox, a sensor, a cron line.
Give the agent a new power, with typed inputs it cannot get wrong.
Store your own tables in a database the host scoped just for you.
Show a page in Build, and cards the model can place in the chat.
Swap the memory, the router or the model for your own.
Publish it, and install it from the Store with one click.
Follow a single "turn on the garage light" through every component it touches.
Before you build a part, watch the machine run.
Here is exactly what happens, in order, when any input hands the host a message. This is AgentRuntime.process, step by step.
1. An input fires
Every input's start(listener) is called once at boot, under a supervisor. When your input calls listener(message), the turn begins. If start throws, the host calls stop() and tries again one second later. Your input never needs its own retry loop for crashes.
2. Memory adds context
Before anyone thinks, memory is asked two things in parallel:
the last 10 turns of this session (kinds: ["working"], metadata.namespace: "chat-history")
up to 10 relevant long-term memories for this user (kinds: ["semantic", "episodic", "procedural"], only when the message has a userId)
The host rebuilds message.text into three blocks: Relevant memory, Conversation history, Current request. The original text stays in metadata.originalText.
3. The user's turn is saved
Right away, as a working entry with id chat-user-<message id>. If the request was a "remember this protocol" instruction, the host confirms it and the turn ends here.
4. The orchestrator decides
orchestrator.orchestrate(message, outputs) returns an OrchestrationDecision: which capability ids the agent may use, which output gets the answer, what to remember, and whether the model can be skipped entirely.
5. Proposed memories are stored
Every remember proposal in the decision is saved for the user, unless an entry with the same content already exists.
6. The agent loop does the work
agentLoop.run(message, { capabilityIds, stream, bypassModel }). Before the call, the host adds metadata.deliveryOutputChannel and, if the chosen output has instructions, metadata.instructions, so the model knows how to format for its destination.
7. Outputs deliver
One output gets the stream live, token by token. Several outputs get the finished text. Extra copies the orchestrator asked for (additionalOutputs) go out too.
8. Memory keeps the answer
The assistant turn is saved. If the orchestrator set deferMemoryProposal, it is asked for memories after the reply, without making the user wait.
Where your plugin sits
You are building
You are called at step
An input
1: you start the turn
Memory
2, 3, 5 and 8
An orchestrator
4
An agent loop
6
A capability (action)
6, every time the model calls you
An output
7
Discovery, models, database, environment
whenever the parts above ask
03
Anatomy of a package
One npm package, one manifest, any number of interfaces.
A plugin is an ordinary npm package with one extra file: agent-os.package.yaml. That file is the whole contract between you and the host.
package layout
my-plugin/
├─ agent-os.package.yaml what you are, what you need
├─ package.json exports for the class and the web entry
├─ src/
│ └─ index.ts your class(es)
└─ web/
├─ styles.css optional, Tailwind-ready
└─ action/index.tsx the page microfrontend (default ./web/<kind>)
The manifest
agent-os.package.yaml
schemaVersion: 1
id: weather
name: Weather
version: 0.1.0
description: Live forecasts for the agent
interfaces:
- id: weather.action
kind: action
name: WeatherCapability
className: WeatherCapability
description: Answers forecast questions for any city
required: [env]
config:
- key: apiKey
env: WEATHER_API_KEY
type: secret
required: true
description: Your weather provider key
startup: { when: ifConfigured }
Every field the host actually reads:
Field
Required
What it does
schemaVersion
yes
Must be 1.
id, name, version
yes
Non-empty strings. Keep version equal to package.json.
interfaces[]
yes
At least one. Each becomes a component you can enable in Build.
interfaces[].id
yes
Unique id, conventionally <package-id>.<kind>.
interfaces[].kind
yes
One of env, system1, system2, discovery, database, memory, orchestrator, agent, input, output, action.
entry
no
Package export holding the class. Default . (your main).
className
no
The exported class to construct with new.
export
no
Used as the export name when className is absent. Default ..
The host resolves entry and web.entry through `package.json` exports, exactly like Node does, including * wildcards. Both must start with ./ and stay inside your package.
Then it constructs you: new YourClass(options). That is the entire lifecycle contract. No registry, no global, no singleton.
One class, several interfaces
Two interfaces with the same package and the same className share one instance. The host constructs it once and reuses it.
This is how the cron plugin is an input (it fires scheduled prompts) and a capability (the agent can add jobs) with a single object. A job added by the agent is live immediately, because the listener and the tool are the same object.
Load order
The host loads kinds in dependency order, so whatever you require already exists:
From an empty folder to a tool the agent actually calls.
Let's build a real capability: the agent will be able to tell you the weather.
1. Scaffold it
From the repository root:
terminal
plaios create action weather
You get packages/weather with a manifest, a class named WeatherCapability, a page and a stylesheet. Valid kinds are input, output, action, agent, orchestrator, discovery, database, memory, system1, system2, env. You can scaffold several at once: plaios create --kind input,output telegram-clone.
2. Give it a real job
src/action.ts
import type {
Capability, CapabilityExecutionContext, CapabilityManifest, CapabilityResult,
Environment, UIComponentSpec,
} from "@plaios/core/domain";
export interface WeatherInput { city: string; days: number | null }
export interface WeatherOutput { summary: string; days: Array<{ date: string; high: number; low: number }> }
const manifest: CapabilityManifest = {
id: "weather.forecast",
version: "1.0.0",
name: "get_forecast",
description: "Gets the weather forecast for a city. Use it whenever the user asks about weather, rain or temperature.",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "City name, e.g. \"Treviso\"." },
days: { type: ["number", "null"], description: "1 to 7, or null for 3." },
},
required: ["city", "days"],
additionalProperties: false,
},
tags: ["weather", "forecast", "rain", "temperature", "meteo"],
};
export class WeatherCapability implements Capability<WeatherInput, WeatherOutput> {
readonly manifest = manifest;
private readonly apiKey: string;
constructor(options: { env: Environment; apiKey?: string }) {
this.apiKey = options.apiKey ?? options.env.getRequired("WEATHER_API_KEY");
}
uiComponents(): readonly UIComponentSpec[] { return []; }
docs(): string {
return "Use for any weather question. Pass the city as the user wrote it. Days default to 3.";
}
async execute(input: WeatherInput, context: CapabilityExecutionContext): Promise<CapabilityResult<WeatherOutput>> {
if (!input.city?.trim()) return { success: false, error: { code: "VALIDATION_ERROR", message: "city is required", retryable: false } };
const response = await fetch(`https://api.example.com/forecast?q=${encodeURIComponent(input.city)}&days=${input.days ?? 3}&key=${this.apiKey}`, { signal: context.signal });
if (!response.ok) return { success: false, error: { code: "WEATHER_FAILED", message: `Provider said ${response.status}`, retryable: true } };
const days = await response.json() as WeatherOutput["days"];
return { success: true, data: { summary: `${days.length} days for ${input.city}`, days } };
}
}
3. Build and publish
terminal
cd packages/weather
pnpm build
pnpm publish --registry http://<your-registry>:4873
4. Install it
Open Store, find weather, press Install. The host installs the package, checks that its microfrontend resolves, adds a component to plaios.yaml with your config defaults, and reloads.
Set WEATHER_API_KEY in .env (or on the component page), and ask the chat: "Will it rain in Treviso tomorrow?"
That's it. No restart scripts, no code in the host, no prompt editing. The orchestrator finds you through your name, description and tags, and the agent loop calls you with typed arguments.
05
Inputs
Anything that can produce a sentence can talk to your agent.
Telegram, the HTTP API, cron jobs, the voice line and this very chat are all inputs. An input has exactly one job: notice something and hand the host a message.
The contract
@plaios/core/domain
export interface InputInterface {
readonly channel: InputChannel; // "telegram", "cron", "web"...
start(listener: InputListener): Promise<void>;
stop(): Promise<void>;
}
export type InputListener = (message: InputMessage) => void | Promise<void>;
export interface InputMessage {
id: string;
channel: InputChannel;
sessionId: string; // groups turns into a conversation
userId?: string; // enables long-term memory for this person
text: string;
attachments?: readonly InputAttachment[]; // { type: "image"; path; mediaType? }
createdAt: Date;
metadata?: Readonly<Record<string, unknown>>;
}
A complete webhook input
src/input.ts
import { createServer, type Server } from "node:http";
import { randomUUID } from "node:crypto";
import type { InputInterface, InputListener } from "@plaios/core/domain";
export class WebhookInput implements InputInterface {
readonly channel = "webhook";
private server?: Server;
constructor(private readonly options: { port: number }) {}
async start(listener: InputListener): Promise<void> {
this.server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const { text, sender } = JSON.parse(body) as { text: string; sender: string };
response.writeHead(202).end();
await listener({
id: randomUUID(),
channel: this.channel,
sessionId: `webhook:${sender}`,
userId: sender,
text,
createdAt: new Date(),
});
});
await new Promise<void>((resolve) => this.server!.listen(this.options.port, resolve));
}
async stop(): Promise<void> {
await new Promise<void>((resolve) => this.server ? this.server.close(() => resolve()) : resolve());
}
}
How it plugs in
`start` is called once, when the runtime boots, under a supervisor. If it throws, the host calls stop() and starts you again after one second.
start may resolve right away (like above, a server keeps running) or stay pending (a polling loop). Either works.
`stop` is called on every reload: saving config, installing a package, toggling a component. Release ports, timers and sockets there, or the next start will collide with your own ghost.
Your channel is your name in routing. The orchestrator replies to the input's channel by default, so an input usually ships with an output of the same channel.
Choosing ids that make memory work
`sessionId` decides which history the model sees. One per chat, per thread, per caller.
`userId` decides whose long-term memory is recalled. Leave it out and the agent remembers the conversation but not the person.
`metadata.preferredOutputChannel` is a hint some orchestrators honour (the web chat sets "web").
06
Outputs
The last mile, and the only part that knows what "sent" means.
An output receives the answer and delivers it. That is all. But two optional fields make it smarter than it looks.
The contract
@plaios/core/domain
export type OutputContent = string | AsyncIterable<string>;
export interface OutputInterface {
readonly channel: OutputChannel;
/** Routing guidance: tells an orchestrator when to pick this output. */
readonly description?: string;
/** Formatting guidance: handed to the model when this output is chosen. */
readonly instructions?: string;
write(content: OutputContent): Promise<void>;
}
Streaming, or not
write gets either a string or an async iterable of chunks:
When the answer goes to one output, you get the live stream. Render it as it arrives.
When it goes to several, the host buffers it and every output gets the finished string.
Handle both, always:
src/output.ts
import type { OutputContent, OutputInterface } from "@plaios/core/domain";
export class SlackOutput implements OutputInterface {
readonly channel = "slack";
readonly description = "Posts to the team's #agent channel. Use when the user says 'tell the team' or 'post on Slack'.";
readonly instructions = "Slack message: short, Slack markdown (*bold*, bullet lists), no headings.";
constructor(private readonly options: { webhookUrl: string }) {}
async write(content: OutputContent): Promise<void> {
const text = typeof content === "string" ? content : await collect(content);
await fetch(this.options.webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });
}
}
async function collect(stream: AsyncIterable<string>): Promise<string> {
let text = "";
for await (const chunk of stream) text += chunk;
return text;
}
Why description and instructions matter
`description` is how "send this to my team" ends up in Slack instead of the chat it was typed in. The orchestrator reads every output's description when it picks outputChannel and any additionalOutputs.
`instructions` reaches the model as metadata.instructions for this turn only. Telegram can ask for short plain text while the web chat asks for rich components, from the very same agent.
07
Capabilities and actions
Give the agent a new power, with inputs it cannot get wrong.
A capability is a typed function the model can call. Home Assistant, SSH, web search, cron management and memory retrieval are all capabilities. It's where most plugins start, and where most of the magic happens.
The contract
@plaios/core/domain
export interface Capability<TInput = unknown, TOutput = unknown> {
readonly manifest: CapabilityManifest;
initialize?(): Promise<void>;
execute(input: TInput, context: CapabilityExecutionContext): Promise<CapabilityResult<TOutput>>;
/** Chat components the model may write into an answer. [] when none. */
uiComponents(): readonly UIComponentSpec[];
/** Markdown the agent reads on demand before using you. */
docs(): string;
}
export interface CapabilityManifest {
id: string; // stable id, e.g. "web.search"
name: string; // the function name the model sees: search_web
description: string; // when to use it, in one or two sentences
inputSchema: JsonSchema; // strict JSON Schema for the arguments
version?: string;
outputSchema?: JsonSchema;
permissions?: readonly string[];
tags?: readonly string[]; // keywords discovery matches on
execution?: { timeoutMs?: number; idempotent?: boolean };
examples?: ReadonlyArray<{ request: string; arguments?: unknown }>;
}
export type CapabilityResult<T = unknown> =
| { success: true; data: T }
| { success: false; error: { code: string; message: string; retryable?: boolean; details?: unknown } };
export interface CapabilityExecutionContext {
runId: string; callId: string;
sessionId?: string; userId?: string;
startedAt: Date;
signal?: AbortSignal; // pass it to fetch: cancelled turns stop your work
metadata?: Readonly<Record<string, unknown>>;
}
Who reads what
Each field of your manifest has exactly one audience. Write each for that reader:
data goes back to the model as the function result. Make it small and meaningful:
Return what the model needs to answer, not your raw API payload. A summary string is a gift.
Return success: false with a stable code and a human message. Set retryable: true only if trying again can actually help.
Never throw for expected failures. Throwing works, but the model gets a generic error instead of your helpful one.
The direct path: skipping the model
If your capability can act on raw user text by itself (like the Jev Home Assistant action, which turns "spegni la luce in cucina" into a service call), tag it direct. When an orchestrator is confident only you are needed, it sets bypassModel, and the agent loop calls execute with the raw text and history, then returns your summary as the answer. A sub-second reply, no generative model involved.
Being a capability without being an action
The host registers every component instance that has a manifest and an execute, whatever its kind. The cron input and the graph memory are capabilities too. So if your memory, input or output would benefit from a tool ("list my scheduled jobs"), just implement Capability on the same class.
08
Memory
The only component that is called before, during and after every turn.
Memory is the most connected box on the map. Replace it, and you change what the agent knows about everyone it has ever talked to.
each proposal, id user-memory-<uuid>, metadata: { namespace: "user-memory", reason, sourceChannel }
After the answer
remember
kind: "working", role: "assistant"
So the minimum viable memory must:
Filter by `metadata` (exact match on the keys given). Chat history depends on it.
Honour `order` and `limit`. The host reverses "newest 10" into chronological history.
Rank by `text` when it is present. This is your semantic search: keywords, embeddings, a graph walk, your choice.
Bonus hooks
If your memory has a link(...) method, the host calls it to connect a new turn to the memories that were recalled for it. The graph memory uses this to grow edges.
Expose a capability on the same class (like memory.retrieval) and the agent can search you mid-thought, with follow-up queries.
Storage
Memory loads right after the database, so options.database is ready in your constructor. Create your tables on first use. See The database.
09
Orchestrators
The decision before the thinking: which tools, which destination, what to remember.
The orchestrator sees the message once, before the agent loop, and writes the plan. A great orchestrator makes a cheap model feel brilliant, because the model only ever sees the five tools that matter.
The contract
@plaios/core/domain
export interface Orchestrator {
orchestrate(
message: InputMessage,
outputs: readonly OutputInterface[],
options?: OrchestratorOptions,
): Promise<OrchestrationDecision>;
proposeMemory?(message: InputMessage, options?: OrchestratorOptions): Promise<readonly MemoryProposal[]>;
}
export interface OrchestrationDecision {
capabilityIds: readonly string[]; // what the agent loop may use
outputChannel: OutputChannel; // must be one of `outputs`
additionalOutputs?: readonly { outputChannel: OutputChannel; content: "response" | "text"; text?: string }[];
memoryProposals?: readonly MemoryProposal[];
deferMemoryProposal?: boolean; // call proposeMemory after replying
bypassModel?: boolean; // let a `direct` capability answer alone
reason?: string;
}
export type MemoryProposal =
| { operation: "remember"; kind: MemoryKind; content: unknown; reason: string; confidence?: number; factDate?: Date }
| { operation: "forget"; memoryId: string; reason: string };
What you are given
Orchestrators always receive model (the System 2 instance) and capabilityDiscovery in their options, without asking. Add system1 by declaring it:
agent-os.package.yaml
kind: orchestrator
optional: [system1] # options.system1, when one is installed
The two shipped strategies
Default orchestrator: one forced function call to the main model (route_agent_message) with every capability's id, name, description and tags. Accurate, about one model round trip.
Jev orchestrator: asks System 1 one yes/no question per capability plus memory, multiStep and output, keeps everything scoring 0.5 or more, and sets bypassModel when one direct capability scores 0.7 or more on a single-step request. Fast, and it falls back to System 2 when System 1 fails.
The agent loop takes the plan and makes it happen: it loads the chosen capabilities, hands them to the model as functions, runs every call the model makes, and returns the final answer. Write your own when you want a different way of thinking, like planning first, voting between models, or a hard budget.
The contract
@plaios/core/domain
export interface AgentLoop {
run(message: InputMessage, options: AgentLoopOptions): Promise<AIProcessResult>;
}
export interface AgentLoopOptions {
capabilityIds: readonly string[]; // from the orchestrator
stream: boolean; // false when AI_STREAM=false
signal?: AbortSignal;
onEvent?: AgentLoopEventHandler; // feeds telemetry
bypassModel?: boolean; // run the capability directly
}
export type AIProcessResult =
| { type: "text"; text: string }
| { type: "stream"; stream: AsyncIterable<string> };
Like orchestrators, agent loops always receive model and capabilityDiscovery in their options.
`metadata.instructions` carries the chosen output's formatting rules, including the full chat component library in the web chat. Append it to your system instructions or the web chat cannot draw cards.
Always load `memory.retrieval` and `agent-os.docs` (the default loop's alwaysCapabilityIds). The first lets the model dig deeper into memory; the second lets it read any capability's manual.
Emit `onEvent` (capability.started, capability.completed, capability.failed) if you want your runs to show in telemetry.
Images: when message.attachments holds images, pass { text, images } as the AIInput so vision models can see them.
11
Discovery
The phone book of every capability, and the reason you never register anything by hand.
At boot, after every component is constructed, the host walks all of them and calls discovery.register(instance) for each one that is a capability. Then the orchestrator asks discovery what exists, and the agent loop asks it for the chosen ones by id.
discovery-memory keeps capabilities in memory and scores discover({ text }) by keyword overlap with each manifest's name, description, tags, examples and input schema. That is why tags matter: ["weather", "meteo", "pioggia"] lets an Italian request find an English-named tool.
Writing your own
Embeddings, usage-based ranking, per-user allowlists: discovery is the place.
discover() with no query should return everything. Orchestrators use it to list all capabilities.
get(id) must be fast and exact; the agent loop calls it for every tool on every turn.
Every reload builds a fresh discovery instance and registers everything again, so keep register cheap and do not rely on state surviving a reload.
12
System 1 and System 2
A fast mind for judgments, a slow mind for words. Use both.
PLAIOS splits the model in two, like the psychology it is named after.
System 1 answers typed questions instantly: yes/no as a probability, a choice between labels, a score. It cannot write a sentence, and that is the point.
System 2 is the generative model. It writes, reasons and calls tools, and it can answer the same typed questions, just slower.
That fallback line is the whole philosophy. Try the fast mind, use the slow one when it is missing. Both speak the same question format, so your code does not care which one answered.
Picking an instance
With several models installed, set system1Instance or system2Instance in your component config to an instance id. Otherwise the host gives you the one with config.role: routing, or the first.
Writing a provider
Implement AIProvider plus ask and you are a System 2: Codex, OpenRouter or a local model all fit. processWithFunctions owns the tool loop: call the model, run each execute it asks for, feed results back, stop after maxSteps, and stream the final text when stream is true.
13
The database
Your own tables, in a namespace nobody else can touch.
Every component gets a database. Not a connection string, not a shared pool: a scoped view of the installed database provider, named after your instance. Your searches table and someone else's searches table never meet.
You already have it
If a database component is installed, every plugin receives options.database, already scoped. The namespace is your instance id with : replaced by -, so action:web-search gets action-web-search. List database under optional to say you use it.
The shipped one is SQLite through node:sqlite. A Postgres provider is a scope() that prefixes table names, or uses one schema per namespace.
14
The environment
Where configuration and secrets come from, before anything else exists.
Environment components load first, because everything else reads from them. The shipped ones read process.env, a .env file, a fixed map, and a composite that layers the others.
Every component gets options.env: the composite environment when one is installed, otherwise the first environment. Declaring required: [env] documents that you depend on it.
The host also uses it for you: a config field with env: WEATHER_API_KEY is filled from env.get("WEATHER_API_KEY"), then process.env, before your constructor runs. See Configuration and secrets.
With several models, the host picks by your component config system1Instance / system2Instance (an instance id), then the one whose config has role: routing, then the first.
A typed options interface
Spell it out, so a missing dependency is a type error and not a runtime surprise:
Declare a field once. Get a settings form, an env fallback and a default for free.
Every config entry in your manifest becomes three things at once: a field on your component page, a value in your constructor options, and a line in the docs the agent can read.
used when nothing else is set; written into plaios.yaml on install
required
true means the component will not start without a value
description
shown under the field, and to the agent
How a value is resolved
For each field, in this order:
the value saved on the component page (in plaios.yaml)
the environment variable named by env, read through the environment component, then process.env
default
still empty and required: true: the component does not start, and Build shows Missing required config "token" (HOME_ASSISTANT_TOKEN)
Then number values go through Number() and boolean values accept true/false/"1"/"0". Anything else is rejected.
Secrets stay secret
Put secrets in .env and reference them with env:. The page then shows $HOME_ASSISTANT_TOKEN instead of a value.
read_capability_docs shows a secret field only as set or not set, never the value.
Saving the page rewrites the whole config object and reloads the runtime, so your constructor always sees a consistent set.
17
Your page and the data bridge
A React page in Build that can call your class like a local object.
Every interface has a page. It is required: the Store refuses to install a package whose page cannot be found, and Build flags one that goes missing. The good news is that the page can do almost anything, because of the data bridge.
The page
The default export of your web entry (./web/<kind> by default) is a React component:
@plaios/microfrontend gives you the building blocks the shipped plugins use: MicrofrontendFrame, ConfigurationEditor, InstanceSummary, Card, Badge, Button, Input, Label, Textarea, and cn. The two-line page is a real, useful page:
web/action/index.tsx
import { CapabilityMicrofrontend, type MicrofrontendProps } from "@plaios/microfrontend";
export default function WeatherPage(props: MicrofrontendProps) {
return <CapabilityMicrofrontend {...props} />; // config form + instance summary
}
The data bridge
Give your class a microfrontendData() method, and whatever it returns arrives as data in your page. Functions inside it become callable from the browser.
export default function HomePage({ data }: MicrofrontendProps<HomeData>) {
return <button onClick={() => data?.actions.control({ entityId: "light.desk", action: "toggle" })}>Toggle</button>;
}
What happens under the hood:
Each function is replaced by a marker with its path (actions.control). In the browser, the marker becomes an async function that posts { path, args } to /api/components/<instanceId>/action.
After an action, the page data is refetched. Calls under queries. skip the refetch, which is what you want for polling.
Dates become ISO strings, bigint becomes a string, Uint8Array becomes { type: "bytes", byteLength }. Class instances and cycles become undefined, so return plain objects.
Saving settings
Call onSaveConfig(config) with the whole config object. The host writes plaios.yaml, reloads the runtime, and your page re-renders with the new values. ConfigurationEditor does all of this for you.
That import gives you Tailwind with the host's theme tokens (bg-background, text-muted-foreground, border, primary...), so your page follows light and dark mode with no work. Prefix your own classes (ha-, cron-, ws-) so you never collide with another plugin.
18
Chat components
Teach the model to draw. Live cards, inside its answers.
This is the part that feels like magic. Ask "show me the garage cameras" and three live camera cards appear in the answer. Ask for the news and a row of source cards slides in. The model did not output HTML. It wrote one line of a tiny language, and your plugin rendered it.
Two halves, one name
1. The capability declares the component with uiComponents(). This is what the model learns:
src/ui-components.ts
import type { UIComponentSpec } from "@plaios/core/domain";
export const WEB_SOURCES: UIComponentSpec = {
name: "WebSources",
description: "The sources of one web search as a row of cards. Use it right after search_web: WebSources(\"<id returned by search_web>\"). Never invent an id.",
props: [{ name: "searchId", type: "string", description: "The id field search_web returned." }],
};
args: the props the model wrote, validated against your spec (string, string[], number, boolean).
data: your live bridge data, the same object your page gets. The camera cards poll data.queries.snapshot(...). The source cards call data.queries.search(id).
onOpen(instanceId): jump to your component page.
Writing specs the model uses well
Describe when to use it, with an example call in the description. HomeCameras("garage") teaches more than a paragraph.
Pass ids, not data.WebSources(searchId) costs a handful of tokens, and the model cannot invent URLs. Store the data server-side (see The database) and look it up in the renderer.
Say whether a tool call is needed first. "It needs no tool call" lets the model answer instantly.
The quieter sibling: Chat
Export a Chat component from your web entry, and when the agent uses your capability in a turn, the web chat shows it as a small card under the answer, with an Open link to your page. Cron uses it to show its schedule right where you asked about it.
19
Docs for the agent
Write the manual once. The agent reads it exactly when it needs to.
Your description gets you chosen. Your docs() gets you used well.
Every agent loop has read_capability_docs (id agent-os.docs) loaded on every turn. When the model is unsure about your capability, or a call just failed, it reads your manual instead of guessing.
What the agent gets
read_capability_docs({ capabilityId: "web.search" }) returns markdown assembled by the host, in this order:
# search_web (web.search) and your description
your `docs()`
your input schema
your examples
your configuration, with current values, and secrets shown only as set or not set
your chat components, as Name(prop?: type) signatures
So do not repeat the schema, the config or the components in docs(). Spend the words on what only you know.
A great docs() has four parts
src/index.ts
docs(): string {
return `Searches the live web and returns ranked sources.
**Use it** for anything that may have changed recently: news, prices, releases.
**Don't use it** for things memory or another capability already knows.
**Inputs**
- query: write it like a search: {"query":"Node.js 26 release date"}
- searchRecencyFilter for fresh topics: {"query":"Serie A results","searchRecencyFilter":"day"}
**Limits**: 30 s timeout. WEB_SEARCH_FAILED is retryable once.`;
}
When to use it, and when not, naming the sibling to use instead.
How the inputs really behave, with one to three example calls as JSON.
What to do after: which chat component to show, what to tell the user.
Limits and failure codes, and which ones are worth a retry.
20
Build, publish, install
From your laptop to the Store to a running agent.
PLAIOS installs plugins from an npm registry, a private Verdaccio by default. Publishing is just npm publishing.
1. Build
terminal
pnpm build # tsc: src and web compile into dist
pnpm test
Check files in package.json includes everything the exports point to: dist, web (for styles.css), and agent-os.package.yaml. A package without its manifest installs as an empty box.
2. Version
Bump version in bothpackage.json and agent-os.package.yaml. Breaking a capability's input schema is a major change for the agent too.
workspace:* dependencies are rewritten to the exact versions in your workspace, so publish @plaios/core first if you changed it.
4. Install
From the Store, press Install. The host then:
runs pnpm add for your package into .agent-os/runtime
reads your manifest and checks every interface's page resolves (or uninstalls and tells you why)
adds one component per interface to plaios.yaml, with id <kind>:<package-id> and your config defaults (interfaces with startup.when: manual are skipped)
On boot the host installs any pinned version that is missing, so the same plaios.yaml reproduces the same agent on any machine.
Updating
The Store shows Update when the registry has a newer version than the one installed. Updating rewrites the pin and restarts the host.
21
Sharp edges, honestly
Everything the code does that you might not expect. Read it once, save a day.
Every system has corners. Here are ours, straight from the source, so none of them surprises you in production.
`initialize()` is never called. It is in the Capability type, but the host does not call it. Set up lazily on first use.
Only `startup.when: manual` does anything. It keeps an interface out of a Store install. always and ifConfigured behave the same today.
`surfaces`, `permissions` and a yaml `capability:` block are ignored. The capability manifest that counts is the one on your class.
Values typed on the page beat coercion. A number saved as "10" arrives as a string. Coerce in your constructor.
A reload rebuilds everything. Saving any config, installing, toggling: every component gets close() (or stop()) and is constructed again. Keep no state you cannot rebuild, and release every port and timer in close/stop.
Shared instances keep the first options. Interfaces sharing a className share one object, constructed with the first interface's options.
One memory, one orchestrator, one agent loop. Only the first of each is used. A second one is silently ignored.
`outputChannel` must exist. An orchestrator naming a missing output fails the whole turn.
Bridge data must be plain. Class instances and cycles in microfrontendData() turn into undefined on the way to the browser.
Chat components need a data bridge. No microfrontendData(), no chat components, even if uiComponents() returns some.
Strict schemas. Tool schemas go to the model in strict mode: list every property in required and use null for "not given".