PLAIOS · Developer guide

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.

InputsMessages inMemoryRemembersOrchestratorPlans the turnAgent loopDoes the workOutputsAnswers outDiscoveryKnows every toolActionsDoes thingsFast modelSnap judgmentsModelReasons, writesDatabasePrivate tables per pluginEnvironmentConfig and secrets
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.

Start with One message, start to finish. It is the map everything else hangs on.

02

One message, start to finish

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 buildingYou are called at step
An input1: you start the turn
Memory2, 3, 5 and 8
An orchestrator4
An agent loop6
A capability (action)6, every time the model calls you
An output7
Discovery, models, database, environmentwhenever 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:

FieldRequiredWhat it does
schemaVersionyesMust be 1.
id, name, versionyesNon-empty strings. Keep version equal to package.json.
interfaces[]yesAt least one. Each becomes a component you can enable in Build.
interfaces[].idyesUnique id, conventionally <package-id>.<kind>.
interfaces[].kindyesOne of env, system1, system2, discovery, database, memory, orchestrator, agent, input, output, action.
entrynoPackage export holding the class. Default . (your main).
classNamenoThe exported class to construct with new.
exportnoUsed as the export name when className is absent. Default ..
config[]noSettings with UI, env fallback and defaults. See Configuration and secrets.
required, optionalnoKinds you want handed to your constructor. See What the host hands you.
startup.whennoalways, ifConfigured or manual. Only manual changes behaviour today: the Store does not enable it on install.
webnoPage microfrontend. Defaults to { entry: ./web/<kind>, export: default, styles: ./web/styles.css }.

How the host finds your code

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.

package.json
{
  "type": "module",
  "main": "./dist/src/index.js",
  "exports": {
    ".": { "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" },
    "./web/*": { "types": "./dist/web/*/index.d.ts", "import": "./dist/web/*/index.js" },
    "./web/styles.css": "./web/styles.css"
  },
  "files": ["dist", "web", "agent-os.package.yaml"]
}

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:

env → database → memory → system1 / system2 → discovery → input / output / action → agent → orchestrator

04

Your first plugin in five minutes

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:

FieldRead byWrite it as
name, description, tagsthe orchestrator, to pick tools"Use it when..." plus every word a user might say
name, description, inputSchemathe model, as a function definitionprecise argument descriptions with an example
tags, examplesdiscovery, for keyword searchsynonyms, other languages, nouns
docs()the model, on demand, via read_capability_docsthe manual: when not to use it, limits, examples
uiComponents()the model, in the web chat promptsee Chat components

The result is a conversation

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.

The contract

@plaios/core/domain
export type MemoryKind = "working" | "semantic" | "episodic" | "procedural";

export interface MemoryEntry<T = unknown> {
  id: string; kind: MemoryKind; content: T; createdAt: Date;
  addedMemoryDate?: Date; factDate?: Date; updatedAt?: Date;
  userId?: string; sessionId?: string;
  importance?: number; confidence?: number;
  metadata?: Readonly<Record<string, unknown>>;
}

export interface MemoryQuery {
  text?: string; kinds?: readonly MemoryKind[];
  userId?: string; sessionId?: string;
  limit?: number; minImportance?: number;
  metadata?: Readonly<Record<string, unknown>>;
  order?: "oldest" | "newest";
}

export interface Memory {
  remember(entry: MemoryEntry): Promise<void>;
  get(id: string): Promise<MemoryEntry | undefined>;
  recall(query?: MemoryQuery): Promise<readonly MemoryEntry[]>;
  forget(id: string): Promise<boolean>;
  clear(query?: MemoryQuery): Promise<number>;
  close?(): Promise<void>;
}

Exactly how the host uses you

WhenCallQuery or entry
Before thinkingrecall{ sessionId, kinds: ["working"], metadata: { namespace: "chat-history" }, limit: 10, order: "newest" }
Before thinking, in parallelrecall{ text, kinds: ["semantic", "episodic", "procedural"], userId, limit: 10, order: "newest" } (only with a userId)
User turnrememberkind: "working", id chat-user-<id>, metadata: { namespace: "chat-history", role: "user", channel }
After the decisionremembereach proposal, id user-memory-<uuid>, metadata: { namespace: "user-memory", reason, sourceChannel }
After the answerrememberkind: "working", role: "assistant"

So the minimum viable memory must:

  1. Filter by `metadata` (exact match on the keys given). Chat history depends on it.
  2. Honour `order` and `limit`. The host reverses "newest 10" into chronological history.
  3. 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.

A tiny one to learn from

src/orchestrator.ts
import type { CapabilityDiscovery, InputMessage, OrchestrationDecision, Orchestrator, OutputInterface } from "@plaios/core/domain";

export class KeywordOrchestrator implements Orchestrator {
  constructor(private readonly options: { capabilityDiscovery: CapabilityDiscovery }) {}

  async orchestrate(message: InputMessage, outputs: readonly OutputInterface[]): Promise<OrchestrationDecision> {
    const text = String(message.metadata?.originalText ?? message.text);
    const found = await this.options.capabilityDiscovery.discover({ text, limit: 8 });
    const reply = outputs.find((output) => output.channel === message.channel) ?? outputs[0]!;
    return { capabilityIds: found.map((capability) => capability.id), outputChannel: reply.channel, reason: "keyword match" };
  }
}
10

The agent loop

Where the model meets the tools, step after step.

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.

The default loop, in 20 lines

src/agent.ts
import type { AgentLoop, AgentLoopOptions, AIExecutableFunctionDefinition, AIProcessResult, CapabilityDiscovery, InputMessage, System2 } from "@plaios/core/domain";

export class SimpleAgentLoop implements AgentLoop {
  constructor(private readonly options: { model: System2; capabilityDiscovery: CapabilityDiscovery }) {}

  async run(message: InputMessage, options: AgentLoopOptions): Promise<AIProcessResult> {
    const ids = ["memory.retrieval", "agent-os.docs", ...options.capabilityIds];
    const capabilities = (await Promise.all([...new Set(ids)].map((id) => this.options.capabilityDiscovery.get(id)))).filter((item) => item !== undefined);
    const functions: AIExecutableFunctionDefinition[] = capabilities.map((capability) => ({
      name: capability.manifest.name,
      description: capability.manifest.description,
      inputSchema: capability.manifest.inputSchema,
      strict: true,
      execute: (args, context) => capability.execute(args, { runId: message.id, callId: context.callId, sessionId: message.sessionId, userId: message.userId, startedAt: new Date(), signal: context.signal }),
    }));
    const instructions = ["You are PLAIOS.", String(message.metadata?.instructions ?? "")].join("\n");
    return this.options.model.processWithFunctions(message.text, functions, { stream: options.stream, instructions, signal: options.signal, maxSteps: 25 });
  }
}

Things the host expects you to respect

  • `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.

The contract

@plaios/core/domain
export interface CapabilityDiscovery {
  register(capability: Capability): Promise<void>;
  get(id: string, version?: string): Promise<Capability | undefined>;
  discover(query?: CapabilityDiscoveryQuery): Promise<readonly CapabilityManifest[]>;
}

export interface CapabilityDiscoveryQuery {
  text?: string;
  tags?: readonly string[];
  limit?: number;
}

What the default one does

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.

The contracts

@plaios/core/domain
export type SystemQuestion =
  | { type: "noul"; instructions?: string; criteria?: { true?: unknown; false?: unknown } | null }
  | { type: "choice"; instructions?: string; criteria: Record<string, unknown> }
  | { type: "score"; instructions?: string; criteria: readonly unknown[] };

export type SystemQuestions = Record<string, SystemQuestion>;
export interface SystemAnswer { type?: string; choice?: string; noul?: number; score?: number }

export interface System1 {
  readonly model: string;
  ask(state: Record<string, unknown>, questions: SystemQuestions, options?: { signal?: AbortSignal }): Promise<Record<string, SystemAnswer>>;
}

export interface System2 extends AIProvider {
  ask(state: Record<string, unknown>, questions: SystemQuestions, options?: { signal?: AbortSignal }): Promise<Record<string, SystemAnswer>>;
}

export interface AIProvider {
  readonly provider: string; readonly model: string; readonly settings: Readonly<AIModelSettings>;
  processInput(input: AIInput, options?: AIProcessOptions): Promise<AIProcessResult>;
  functionCall<T>(input: string, definition: AIFunctionDefinition<T>, options?: Omit<AIProcessOptions, "stream">): Promise<AIFunctionCallResult<T>>;
  processWithFunctions(input: AIInput, definitions: readonly AIExecutableFunctionDefinition[], options?: AIFunctionProcessOptions): Promise<AIProcessResult>;
}

Using them from any plugin

Declare what you need and it appears in your options:

agent-os.package.yaml
    required: [system2]
    optional: [system1]
src/action.ts
const answers = await (this.options.system1 ?? this.options.system2).ask(
  { request: text, devices: ["kitchen light", "garage door"] },
  {
    isCommand: { type: "noul", instructions: "Is the user asking to change a device?" },
    device: { type: "choice", instructions: "Which device?", criteria: { kitchen: "kitchen light", garage: "garage door" } },
  },
);
if ((answers.isCommand?.noul ?? 0) > 0.7) await this.toggle(answers.device?.choice);

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.

agent-os.package.yaml
    optional: [database]

The contract

@plaios/core/domain
export interface Database {
  readonly namespace: string;
  init(structure: DatabaseStructure): Promise<void>;
  add<T extends DatabaseRecord>(table: string, value: T): Promise<T>;
  get<T extends DatabaseRecord>(table: string, id: DatabaseKey): Promise<T | undefined>;
  get<T extends DatabaseRecord>(table: string, query?: DatabaseQuery): Promise<readonly T[]>;
  set<T extends DatabaseRecord>(table: string, id: DatabaseKey, value: T): Promise<T>;
  update<T extends DatabaseRecord>(table: string, id: DatabaseKey, patch: Partial<T>): Promise<T | undefined>;
  delete(table: string, target: DatabaseKey | DatabaseQuery): Promise<number>;
}

export interface DatabaseQuery {
  where?: Readonly<Record<string, unknown>>;
  orderBy?: readonly { field: string; direction?: "asc" | "desc" }[];
  limit?: number;
  offset?: number;
}

// column types: "string" | "number" | "boolean" | "datetime" | "json"

A real example: keep the last 200 searches

This is the pattern the Perplexity plugin uses for its WebSources cards:

src/search-store.ts
const TABLE = "searches";

export class SearchStore {
  private ready?: Promise<void>;
  constructor(private readonly database: Database) {}

  private init() {
    this.ready ??= this.database.init({
      tables: {
        [TABLE]: {
          primaryKey: "id",
          columns: {
            id: { type: "string", required: true },
            query: { type: "string", required: true },
            results: { type: "json", required: true },
            createdAt: { type: "datetime", required: true },
          },
          indexes: [{ columns: ["createdAt"] }],
        },
      },
    });
    return this.ready;
  }

  async save(search: StoredSearch) {
    await this.init();
    await this.database.set(TABLE, search.id, { ...search });
    const stale = await this.database.get<StoredSearch>(TABLE, { orderBy: [{ field: "createdAt", direction: "desc" }], offset: 200 });
    await Promise.all(stale.map((row) => this.database.delete(TABLE, row.id)));
  }

  async find(id: string) {
    await this.init();
    return this.database.get<StoredSearch>(TABLE, id);
  }
}

Three habits worth stealing:

  • Init lazily, once: keep the init promise and await it everywhere. It is idempotent, so calling it on every boot is fine.
  • `set` for upserts, `add` for inserts that must not overwrite.
  • Trim as you write: a delete with a query keeps the table bounded forever.

Writing a provider

A database component implements DatabaseProvider:

@plaios/core/domain
export interface DatabaseProvider {
  scope(namespace: string): Database;
  close?(): Promise<void>;
}

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.

The contract

@plaios/core/domain
export interface Environment {
  get(key: string): string | undefined;
  getRequired(key: string): string;               // throws when missing
  getOrDefault(key: string, fallback: string): string;
  has(key: string): boolean;
  getAll(): Record<string, string>;
}

How you receive it

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.

Writing one

A vault-backed environment is the classic case:

src/env.ts
import type { Environment } from "@plaios/core/domain";

export class VaultEnvironment implements Environment {
  private readonly values: Record<string, string>;
  constructor(options: { values?: Record<string, string> }) { this.values = options.values ?? {}; }
  get(key: string) { return this.values[key]; }
  has(key: string) { return key in this.values; }
  getRequired(key: string) { const value = this.get(key); if (value === undefined) throw new Error(`Missing ${key}`); return value; }
  getOrDefault(key: string, fallback: string) { return this.get(key) ?? fallback; }
  getAll() { return { ...this.values }; }
}
15

What the host hands your constructor

One options object, assembled from your manifest. Every key, and exactly when you get it.

You never import another plugin. You declare, and the host injects. Everything arrives in the single object passed to new YourClass(options).

Every key

KeyYou get it whenWhat it is
cwd, workingDirectoryalwaysthe folder holding plaios.yaml
envan environment is installedthe composite environment, else the first one
memorya memory is installedthe first memory component
databasea database is installeda view scoped to your instance id (action:foo → action-foo)
modelyou are an orchestrator or agent, or list discoverya System 2 instance
capabilityDiscoverysame as modelthe capability registry
system1, system2you list them in required or optionalthe chosen model instance
providersyour class is CompositeEnvironmentevery other environment
your config keysalwaysresolved config values, see Configuration and secrets

Which model you get

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:

src/action.ts
import type { Database, Environment, Memory, System1, System2 } from "@plaios/core/domain";

export interface HomeLightsOptions {
  env: Environment;
  system2: System2;          // required: [system2]
  system1?: System1;         // optional: [system1]
  database?: Database;       // optional: [database]
  memory?: Memory;
  baseUrl: string;           // config key
  timeoutMs?: number;        // config key with a default
}
16

Configuration and secrets

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.

agent-os.package.yaml
    config:
      - key: baseUrl
        env: HOME_ASSISTANT_URL
        type: string
        required: true
        description: Home Assistant origin, e.g. http://homeassistant.local:8123
      - key: token
        env: HOME_ASSISTANT_TOKEN
        type: secret
        required: true
        description: Long-lived access token
      - key: timeoutMs
        type: number
        default: 10000
        description: Request timeout in milliseconds

Field shape

PropertyMeaning
keythe option name your constructor receives
typestring, number, boolean or secret
envenvironment variable to fall back on
defaultused when nothing else is set; written into plaios.yaml on install
requiredtrue means the component will not start without a value
descriptionshown under the field, and to the agent

How a value is resolved

For each field, in this order:

  1. the value saved on the component page (in plaios.yaml)
  2. the environment variable named by env, read through the environment component, then process.env
  3. default
  4. 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
export interface MicrofrontendProps<TData = unknown> {
  component: ComponentInstance;   // instanceId, kind, id, version, config, fields
  data?: TData;                   // whatever your class's microfrontendData() returned
  onSaveConfig?: (config: Record<string, unknown>) => void | Promise<void>;
}

@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.

src/action.ts
microfrontendData() {
  return {
    connected: this.connected,
    devices: this.devices,                    // plain data, serialized as JSON
    queries: {
      snapshot: (entityId: string) => this.cameraSnapshot(entityId),   // read-only
    },
    actions: {
      control: async (input: ControlInput) => { await this.control(input); return this.dashboard(); },
    },
  };
}
web/action/index.tsx
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.

Styling

web/styles.css is loaded once, next to your page:

web/styles.css
@import "@plaios/microfrontend/styles.css";
@source ".";

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." }],
};
src/index.ts
uiComponents(): readonly UIComponentSpec[] {
  return [WEB_SOURCES];
}

2. The web entry exports a renderer with the same name, in openui:

web/action/index.tsx
import type { OpenUIComponentModule } from "@plaios/microfrontend";
import { WEB_SOURCES } from "../../src/ui-components.js";
import { WebSources } from "./chat/web-sources.js";

export const openui: OpenUIComponentModule<SearchBridge, { searchId?: string }>[] = [
  { ...WEB_SOURCES, component: WebSources },
];

Keeping the spec in one React-free file, imported by both halves, means the model and the browser can never disagree about the name or the props.

What the renderer receives

@plaios/microfrontend
component: ComponentType<MicrofrontendProps<TData> & { args: TArgs; onOpen?(instanceId: string): void }>
  • 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:

  1. # search_web (web.search) and your description
  2. your `docs()`
  3. your input schema
  4. your examples
  5. your configuration, with current values, and secrets shown only as set or not set
  6. 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.`;
}
  1. When to use it, and when not, naming the sibling to use instead.
  2. How the inputs really behave, with one to three example calls as JSON.
  3. What to do after: which chat component to show, what to tell the user.
  4. 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 both package.json and agent-os.package.yaml. Breaking a capability's input schema is a major change for the agent too.

3. Publish

terminal
pnpm publish --registry http://<your-registry>:4873

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:

  1. runs pnpm add for your package into .agent-os/runtime
  2. reads your manifest and checks every interface's page resolves (or uninstalls and tells you why)
  3. adds one component per interface to plaios.yaml, with id <kind>:<package-id> and your config defaults (interfaces with startup.when: manual are skipped)
  4. reloads the runtime

Or pin it by hand:

plaios.yaml
components:
  - package: "@plaios/weather"
    version: 0.1.0
    interface: weather.action
    instanceId: action:weather
    config:
      days: 3

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".