# Quickstart — your first plugin In about ten minutes you will build a plugin that makes every agent on your account end its replies with a sign-off you choose, publish it privately, install it, and see it work in a chat. It is the same shape as the official **Caveman** and **My Dude** plugins: one `systemPrompt` middleware that adds a line to the agent's instructions before each turn. You need an AgentParley account with a model provider connected and one agent that replies. ## 1. Create the plugin Skip this step if you take option B below: uploading a zip creates the plugin from its readme. In the console, open **Plugins → Created → Write in the browser**. Fill in: - **Name** — `Sign-off` - **Address** — `sign-off` (lowercase letters, digits, hyphens and underscores, up to 64 characters; it becomes part of your plugin's identity and of any tool names it registers) - **One line about it** — `Ends every reply with a sign-off.` - **Start from** — any card; you will replace the code. Press **Create and open editor**. ## 2. Write the code You can write it in the console editor (option A) or on your machine and upload a zip (option B). The code is the same. ```ts globalThis.manifest = { settings: [ { key: "signOff", title: "Sign-off", description: "The words every reply ends with.", type: "text", default: "Fair winds.", }, ], }; globalThis.middleware.systemPrompt = async (context) => { const signOff = host.settings.get("signOff"); host.log("debug", `adding sign-off for agent ${context.agentId}`); return ( context.systemPrompt + "\n\n## Sign-off\n" + `End every reply with the words "${signOff}", as the last words of your final sentence.` ); }; ``` What each part does: - `globalThis.manifest.settings` declares a settings form. The user sees a **Sign-off** text field when they configure the plugin; your code reads it with `host.settings.get("signOff")`. - `globalThis.middleware.systemPrompt` is a **middleware hook**. It runs every time an agent on the installing account starts a turn, receives the system prompt that turn is about to use, and returns the new one. - `host.log` writes a line to the plugin's log panel. It needs no permission. The code calls no `host.fetch`, `host.session` or `host.agents`, so the plugin asks for **no permissions**. ### Option A — the console editor On the plugin's page, paste the code into the **Code** card (the editor's single file is `index.js`; TypeScript-only syntax such as type annotations won't work there — the code above is plain JavaScript and works in both). The card's egress and permission-override fields are added to your manifest, and they win over the same keys in your code. Press **Save** (⌘S) to check that it builds. ### Option B — a local folder and a zip ``` sign-off/ readme.md index.ts tsconfig.json (for your editor only — leave it out of the zip) ``` `readme.md`: ```markdown --- name: Sign-off description: Ends every reply with a sign-off. --- # Sign-off Adds one line to every agent's instructions so each reply ends with the sign-off you set. ## Settings - **Sign-off** — the words every reply ends with. Default: "Fair winds." ## Permissions None. ``` `index.ts` is the code above. For type-checking, press **Download SDK types** on the plugin's page (it saves `agentparley-plugin-sdk.d.ts`) and use a `tsconfig.json` like this: ```json { "compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], "types": [], "skipLibCheck": true, "noEmit": true }, "include": ["index.ts", "agentparley-plugin-sdk.d.ts"] } ``` Run `npx tsc -p .` to check it. The platform does **not** type-check at publish — it only strips the types — so this is your safety net. Then zip `readme.md` and `index.ts` (not `tsconfig.json` — the publish refuses files it doesn't recognize — and leave the `.d.ts` out too, since it would only count as an extra source file). ## 3. Publish - **Editor:** press **Publish…**. The first version becomes `1.0.0`. - **Zip:** on **Plugins → Created** press **Upload a plugin** (or drop the zip there) and pick the zip. The readme has no `version:`, so the platform uses `1.0` and publishes `1.0.0`. Publishing bundles your code, discovers the capability (one `systemPrompt` middleware), derives the permissions (none), and stores version `1.0.0`. Versions are immutable: the next change is `1.0.1` or higher. Your plugin is **private**: only your account can see and install it. That is exactly what you want while testing. ## 4. Install and configure Open **Plugins → Browse**. Your plugin is listed marked *private*. Open it — you'll see its capabilities, **What it can reach** (nothing), and your readme — and press **Install Sign-off**. Leave **All agents** selected and press **Install on all agents**. On the installed plugin's page, set **Sign-off** to something you'll recognize, for example `Ahoy, matey.` ## 5. See it work Open a new chat with any agent and ask it anything. The reply ends with *Ahoy, matey.* There is nothing to enable per agent: middleware runs for every agent the plugin is installed on, and **All agents** is the default. (Tools, commands and skills are different — each agent's **Access** tab can narrow those.) Back on the installed plugin's page, the **logs** panel shows your `adding sign-off for agent …` lines. Logs are kept for 24 hours. ## 6. Change it Edit the text and publish the next version, and your install moves to it within about five minutes (the install's version policy is **Latest** by default). Zip: upload it again; the platform publishes `1.0.1`. Editor: **Publish…** offers patch, minor and major once the plugin is listed in Browse; until then, upload a zip. If a new version asks for *more* access — say you add `host.fetch` and an egress host — it waits on the installed plugin's page under **Update waiting for your OK** until you accept it. To test changes before publishing, use **Save** in the editor and switch your install to your draft (**Use my draft**). ## Where next - Add a model-callable function: [Tools](plugin-tools.md). - The other 16 middleware hooks: [Middleware](plugin-middleware.md). - Make it public: [Publishing](publishing.md). - Ship a whole crew: [Agent crews](plugin-agent-crews.md). # Plugin SDK A plugin is one package that changes what your agents can do. It can give them a new tool, rewrite a message before the model sees it, install a whole crew of specialist agents, connect them to a chat app, replace the web-search engine, give them a voice, or put a 3D face on them. You write TypeScript or JavaScript against a typed SDK, upload the package, and anyone who installs it gets exactly what the code does — nothing more. This page is the mental model. The [Quickstart](plugin-quickstart.md) gets a plugin running in about ten minutes. ## One package, many capabilities A single package can carry any mix of these. Each is a *capability*: something the platform discovers in your package at publish time and switches on when someone installs it. | Capability | What it does for the user | How you declare it | Guide | | --- | --- | --- | --- | | Tool | A function the model can call, next to the built-in tools | `tools. = { description, parameters, handler }` | [Tools](plugin-tools.md) | | Middleware | Rewrites or vetoes a message, prompt, todo, timer, SSH command or scheduled run as it happens — 17 hooks | `middleware. = (context, tools) => …` | [Middleware](plugin-middleware.md) | | Lifecycle event | Runs when your plugin is installed, updated or removed, or a chat starts, is cleared or deleted — 6 events | `events. = (context) => …` | [Lifecycle events](plugin-lifecycle-events.md) | | Agent crew | Creates and heals a team of real agents your plugin owns | `host.agents.*` + `manifest.agents` | [Agent crews](plugin-agent-crews.md) | | Command | A slash command such as `/research ` | `commands/.md` | [Commands and skills](plugin-commands-and-skills.md) | | Skill | Instructions (and files) an agent loads only when it needs them | `skills//skill.md` | [Commands and skills](plugin-commands-and-skills.md) | | Channel | A two-way chat app: people message the agent there, it answers there | `channel.{receive, send, typing, fetchAttachment}` | [Channels](plugin-channels.md) | | Live connection | A socket AgentParley holds open for you for days (Discord, IRC-style protocols) | `connection.{open, receive, timer}` | [Live connections](plugin-live-connections.md) | | Chat model provider | Your vendor's models become selectable agent brains | `modelProvider.complete` | [Model providers](plugin-model-providers.md) | | Decision model provider | Fast typed yes/no, choose and score answers | `modelProvider.{noul, choose, score}` | [Model providers](plugin-model-providers.md) | | Voice provider | Text-to-speech voices for companions | `modelProvider.{speak, voices}` | [Voice and speech-to-text](plugin-voice-and-speech.md) | | Speech-to-text provider | Turns what the user says to a companion into text | `modelProvider.transcribe` (plus optional `modelProvider.startLiveTranscription` for live words) | [Voice and speech-to-text](plugin-voice-and-speech.md) | | Web search / web fetch provider | Replaces the engine behind the built-in `web_search` / `web_fetch` tools | `web.search`, `web.fetch` | [Web providers](plugin-web-providers.md) | | Compaction provider | Replaces how long conversations are summarized | `compaction.summarize` | [Compaction](plugin-compaction.md) | | Companion model | A look: a 3D avatar (VRM or GLB) or a Live2D model (`.zip`) with its own motions — idle, talk, listen, think, gestures | `companion-models//model.json` | [Companion packs](plugin-companion-packs.md) | | Companion character | A ready-made face: one look + suggested personality and voice | `companion-characters//character.json` | [Companion packs](plugin-companion-packs.md) | Companion packs, commands and skills are **data**: no code runs for them. Everything else is code you write. ## Code declares itself There is no capability list to maintain. The platform gives your code a set of empty objects — `middleware`, `events`, `tools`, `compaction`, `web`, `modelProvider`, `channel`, `connection` — and you assign handlers to them. At publish time the platform runs your bundle once, reads back what you assigned, and turns each assignment into a capability. ```ts // plugins/caveman/index.ts — the whole plugin const GUIDANCE = "\n\n## Caveman mode (output-brevity skill)\n" + "Rewrite your responses to be dramatically shorter while keeping technical accuracy.\n" + // … "Say the least that fully answers. Example: 'New ref each render. Wrap in useMemo.' not a paragraph."; globalThis.middleware.systemPrompt = async (context) => { return context.systemPrompt + GUIDANCE; }; ``` That one assignment is a complete, publishable plugin: one middleware capability, zero permissions. Two rules follow from "the platform reads what you assigned": - **Register at the top level.** An assignment made inside a handler, or computed from a `host.*` call, is invisible at publish time and is never switched on. See [Package format](plugin-package-format.md#register-at-the-top-level). - **Everything else goes in `globalThis.manifest`** — the hosts you call, the settings form users fill in, the agents you will create, the provider you register as. See the [Manifest reference](plugin-manifest.md). ## Your code talks to the world through `host` Plugin code runs in a sandbox with no network, no filesystem and no process access. Everything goes through one object, `host`: `host.fetch` for HTTP (only to hosts you declared), `host.settings` for the installer's settings and secrets, `host.session` to read and act on the current chat, `host.agents` to manage your crew, `host.cronJobs` to own schedules, `host.oauth` for customer sign-ins, `host.llm` to call a model, `host.crypto` to check webhook signatures, and so on. The [Host API reference](plugin-host-api.md) lists every member. **You only get what you called.** You don't write a permission list. At publish time the platform reads your code, finds every `host.x.y(...)` call, and derives the permissions from them. The installer sees that list before installing. At run time, `host.workspace`, `host.media`, `host.ssh` and `host.connection` are absent unless granted, and a call to `host.session`, `host.agents`, `host.models` or `host.cronJobs` that wasn't derived is rejected with a 403. See [Permissions](plugin-permissions.md). ## Lifecycle of a plugin 1. **Write** — in the console's plugin editor (one script) or locally (any number of `.ts`/`.js` files). 2. **Publish** — upload a zip or press Publish. The platform bundles your code, discovers the capabilities, derives the permissions, and stores an immutable version. A version is private to your account until you choose **List in Browse**. 3. **Install** — the user sees what your plugin can reach and what it adds (for example "Adds 7 agents"), then installs. Installing switches on every capability in the version. 4. **Configure** — the user fills in your settings form; secrets are write-only and encrypted. 5. **Use** — middleware and lifecycle hooks run for the whole account; tools, commands and skills appear on every agent (each agent's **Access** tab can narrow them); providers are picked under **AI Providers → Add provider** or **Settings → Engines**; channels under **Channels → Connect**; companion characters in the Companions gallery. 6. **Update** — publish a new version. Installs on the **Latest** policy move to it automatically — unless it asks for more access (a new permission, a new host, a new agent, a new schedule, a new `failClosed` hook, or the `anyone` sender policy). Then it waits for the user to accept it. ## What makes this different - **A real sandbox, per call.** Every invocation runs in a fresh V8 isolate with a memory cap and a wall-clock limit, and is thrown away afterwards. Secrets and the platform's API token never enter it. See [Sandbox model](sandbox-model.md). - **Permissions you can't under-declare.** They come from your code. A call the scanner can't see is not granted — it fails at run time. - **Updates can't sneak in new access.** A version that asks for more waits for the owner's OK. - **Breadth.** Tools, 17 middleware hooks (including SSH command rewriting and output redaction, and vetoing scheduled missions), lifecycle events, crews, commands, skills, channels, held sockets, chat / decision / voice / speech-to-text providers, web search, web fetch, compaction and 3D companion assets — all in one package format. - **Plugins can ship a team.** A crew plugin creates real agents — each with its own model, persona and instructions — keeps them healthy, and shapes how they work together. See [Agent crews](plugin-agent-crews.md). - **Official plugins use the same SDK.** Discord, Brave Search and Wiki Web are ordinary plugins built on exactly what this documentation describes. ## Next - [Quickstart](plugin-quickstart.md) - [Package format](plugin-package-format.md) · [Manifest](plugin-manifest.md) · [Permissions](plugin-permissions.md) · [Sandbox model](sandbox-model.md) - [Host API reference](plugin-host-api.md) # Sandbox model Users install plugins without reading their code. That only works if the code can't do more than it says. Here is exactly what plugin code runs inside, what it can reach, and the limits it runs under. ## One fresh isolate per call Every invocation — one tool call, one middleware hook, one lifecycle event, one channel message — runs in a **new V8 isolate** that is created for that call and thrown away after it. Nothing survives between calls: no module-level variables, no caches, no timers. State that must persist goes in your own storage (via `host.fetch`), in the agent's workspace (`host.workspace`), in a live connection's `state`, or in the settings the user set. Your code sits behind a gRPC boundary in a separate service. It never runs inside the platform's own process. ## What the isolate doesn't have A bare JavaScript engine (ES2022): no `fetch`, no `XMLHttpRequest`, no `require` or `import()` at run time, no `process`, no filesystem, no `Buffer`, no `atob`/`btoa`, no `TextEncoder`/`TextDecoder`. The only way out is `host`: | Need | Use | | --- | --- | | HTTP | `host.fetch` — only to hosts you declared ([egress](plugin-manifest.md#egress)) | | Hashes, HMACs, AES-CBC decrypt, Ed25519, JWT (RS256) checks, base64, hex | `host.crypto` (synchronous) | | Settings and secrets | `host.settings.get` / `getSecret` | | The chat, todos, timers, other agents | `host.session.*`, `host.agents.*` | | Schedules, customer sign-ins, your licence | `host.cronJobs.*`, `host.oauth.*`, `host.license()` | | Files | `host.workspace.*` | | A model | `host.session.llm`, `host.llm`, `host.decide` | | Logs | `host.log` | For binary data, `host.fetch` can send and receive base64 (`bodyEncoding` / `responseEncoding`). ## Secrets and tokens stay outside - `host.settings.getSecret` fetches a secret into your code only when you call it, and only a secret your plugin declared and the user bound. - The credential behind `host.session`, `host.agents` and `host.models` — a short-lived token scoped to one account (and one session when there is one) — is held by the runtime outside the isolate. Your code calls a function; the runtime makes the request. - Upload URLs used by `host.media` also stay outside; your code only sees opaque tokens. ## Limits ### Per invocation | Invocation | Wall clock | Memory | | --- | --- | --- | | Tool, lifecycle event, web search / fetch, chat `complete` | 30 s | 128 MB | | Middleware hook | 5 s (1 s for `companionSpeech`) | 128 MB | | Compaction `summarize` | 120 s | 128 MB | | Decision `noul` / `choose` / `score` | 10 s | 128 MB | | `speak` / `voices` / `transcribe` / `startLiveTranscription` | 15 s / 10 s / 20 s / 10 s | 128 MB | | Channel `receive` | 10 s (15 s per attachment fetch, 25 s for the whole request) | 128 MB | | Any invocation holding `host:media:transcode` | raised to at least 60 s | raised to 512 MB | | SSH hooks holding `host:ssh:command` | raised to at least 120 s | 128 MB | The wall clock is a real timer: it also counts time your code spends waiting on `host.fetch` or other host calls. When it runs out, the isolate is destroyed mid-flight. ### Per call to `host` | Surface | Limit | | --- | --- | | `host.fetch` | 60 requests per minute per installation (rolling), 10 s timeout, 5 MB response | | `host.session.sendError` | 5 per invocation | | other `host.session.*`, `host.agents.*`, `host.models.*`, `host.cronJobs.*` operations | no per-invocation count (bounded by your wall clock) | | `host.crypto.*` | 200 operations and 8 MB of input per invocation; 1 MB of data per call; 10 `rsaSha256Sign` | | `host.session.llm.complete` | 5 per invocation | | `host.llm.complete` | 5 per invocation (counted separately) | | `host.decide.*` | no per-invocation cap (bounded by your wall clock) | | `host.media.*` | 10 per invocation | | `host.ssh.command` | 10 per invocation, 60 s default / 90 s max each | | `host.connection.send` | 20 frames per call, 20 calls per invocation | ### Per plan (per account) | | Free | Solo | Entrepreneur | | --- | --- | --- | --- | | Plugin wakes per hour (each channel `receive`, each live-connection open / receive / timer) | 60 | 600 | 3,000 | | Live connections held at once | 1 | 5 | 20 | ## What happens when your code fails Faults are one of: **timeout**, **memory limit**, **bad request** (your handler returned the wrong shape), or **crash** (it threw; the message is cut to 500 characters). The platform is built so a broken plugin degrades one feature, never the agent: | Capability | On failure | | --- | --- | | Middleware | **Skipped** — the value passes through unchanged and the chain continues ("fail-open"). A handler that returns the wrong shape is skipped the same way. A hook the plugin lists in `manifest.failClosed` blocks the operation instead. | | Lifecycle event | Logged and skipped; the handler is not run again. Only an infrastructure failure delivering the event is retried (exponential backoff, 30 s base, up to 8 attempts). The install or chat that triggered it is never blocked. | | Tool | The error becomes the tool's result; the model sees it and the turn continues. | | Web search / fetch provider | The model sees a "provider failed" tool error. It is **not** silently swapped for the built-in engine. | | Compaction | The built-in summarizer runs instead and a notice is posted in the chat. | | Model provider | Treated as the vendor being unreachable; use `tools.fail(…)` for a precise error. | | Channel `send` | Retried for a few minutes; throw an error containing `[rejected]` to stop retrying. | | Channel `receive` | A timeout is retried; any other error is final and logged. | ## Network boundary — precisely Outbound HTTP from `host.fetch` is **deny-by-default**: only hosts you declared in `manifest.egress`, plus any the installer added through a `hosts` setting, are reachable — and the installer sees that list before installing. The check is by host name (and port) against that list, and it is repeated on every redirect hop. `host.fetch` also refuses private, loopback, link-local and cloud-metadata addresses, checked at the moment it connects. Live connections are dialled by a separate service with the same refusals. ## Being a good citizen - Keep middleware fast. It runs in the middle of an agent's turn with a 5 s budget; a slow hook delays every reply on the account and is skipped anyway when it runs out. - Make lifecycle handlers idempotent — they can run more than once. - Log sparingly with `host.log` (`debug`, `info`, `warn`, `error`). Logs show on the installed plugin's page and are kept for 24 hours. # Permissions You don't write a permission checklist. At publish time the platform reads your code, finds every call to a `host` member, and derives the permissions from those calls. The installer sees the result before installing, and at run time your code gets exactly those members — any other member of `host` is simply absent. ## How derivation works 1. Your files are merged into one bundle. 2. The bundle is parsed and every **literal** member chain that starts at `host` (or `globalThis.host`) is recorded: `host.agents.list`, `host.session.todo.create`, `host.fetch`, and so on. 3. Each recorded path is mapped to a permission through a fixed table (below). The union of all of them is the plugin's permission set — one set for the whole package, shared by every capability in it. 4. If the code calls `host.fetch`, the plugin must declare `manifest.egress`, or the publish fails. | Call in your code | Permission it derives | | --- | --- | | `host.session.get` / `.channelChat` | `agentApi:session.read` | | `host.session.conversation.read` | `agentApi:conversation.read` (+ `agentApi:session.read.cross` to pass another session's id — see below) | | `host.session.message.post` | `agentApi:message.post` | | `host.session.todo.list` | `agentApi:todo.read` | | `host.session.todo.create` / `.update` / `.complete` / `.delete` | `agentApi:todo.write` | | `host.session.subagent.spawn` | `agentApi:subagent.spawn` | | `host.session.peer.send` | `agentApi:peer.send` | | `host.session.peer.get` | `agentApi:session.read.cross` | | `host.session.notify.schedule` | `agentApi:notify.write` | | `host.session.sendError` | `host:session:sendError` | | `host.agents.list` | `agentApi:agents.read` | | `host.agents.create` / `.update` / `.delete` | `agentApi:agents.write` | | `host.cronJobs.list` / `.listEventSources` | `agentApi:cronJobs.read` | | `host.cronJobs.create` / `.update` / `.delete` | `agentApi:cronJobs.write` | | `host.models.list` | `agentApi:models.read` | | `host.workspace.list` / `.read` | `host:workspace:read` | | `host.workspace.write` / `.edit` / `.delete` | `host:workspace:write` | | `host.media.uploadAudio` / `.transcodeAudio` | `host:media:transcode` | | `host.ssh.command` | `host:ssh:command` | | `host.connection.send` | `host:connection:send` | | `host.fetch` | no permission — requires `manifest.egress` | | `host.log`, `host.settings.*`, `host.crypto.*`, `host.oauth.*`, `host.license` | none — always available | | `host.llm.complete`, `host.session.llm.complete`, `host.decide.*` | none — granted by context (see [Host API](plugin-host-api.md#models-from-plugin-code)) | `agentApi:session.read.cross` is not derived from `conversation.read`: to read *another* session's conversation with `host.session.conversation.read({ sessionId })`, add it through `manifest.permissions` (or call `host.session.peer.get` somewhere, which derives it). ## Always call `host` literally The scanner sees only literal member access. These are **not** seen: ```ts const agents = host.agents; // alias await agents.list(); // → not observed const { workspace } = host; // destructuring → not observed host[area].list(); // computed name → not observed ``` A call the scanner misses is not granted. For `host.workspace`, `host.media`, `host.ssh` and `host.connection` the member is `undefined` at run time and the call throws; for `host.session`, `host.agents`, `host.models` and `host.cronJobs` the platform rejects the call with a 403. Write `host.agents.list()`, `host.workspace.read({ path })`, every time. TypeScript's non-null assertion is fine — `host.agents!.list()` compiles to `host.agents.list()`: ```ts // infrastructure/plugins/oh-my-openagents/src/reconcile.ts // host.agents / host.models are non-null here: the plugin's capabilities declare agentApi:agents.* + models.read, // so the platform binds them. … Never destructure/alias host.agents — that would hide the call from the scan. const [{ agents }, { models }] = await Promise.all([host.agents!.list(), host.models!.list()]); ``` If you truly must reach a member indirectly, add the permission to `manifest.permissions`. ## Presence and permission At run time, `host.workspace`, `host.media`, `host.ssh` and `host.connection` are present only when you were granted them **and** the invocation has the context they need. The platform-API members (`host.session`, `host.agents`, `host.models`, `host.cronJobs`) are present in tool, middleware, lifecycle, compaction and web-provider runs whatever you were granted, and each call is checked against its permission; they are absent in channel, live-connection and model-provider runs and once the hop-depth fuse has tripped: | Member | Present when | | --- | --- | | `host.log`, `host.settings`, `host.fetch`, `host.crypto`, `host.oauth` | always | | `host.session` | tool, middleware, lifecycle, compaction and web-provider runs, until the [hop-depth fuse](plugin-host-api.md#hop-depth-fuse) trips; each operation needs its own permission. Operations on "your session" fail when the invocation has none — for example in `pluginInstalled` | | `host.session.sendError` | you hold `host:session:sendError` and the invocation has a session | | `host.session.llm` | the invocation has a session and agent — no permission needed | | `host.agents`, `host.models`, `host.cronJobs`, `host.license` | same runs as `host.session` — no session needed (works in `pluginInstalled`); `host.license` needs no permission | | `host.workspace` | you hold `host:workspace:*` **and** the invocation carries an agent (tools, the session lifecycle events, and every middleware hook) | | `host.media` | you hold `host:media:transcode` | | `host.llm` | the account has a **Utility model** set on the AI Providers page | | `host.decide` | tools, middleware, lifecycle, compaction and web-provider invocations | | `host.ssh` | you hold `host:ssh:command` and the invocation is one of the three SSH middleware hooks | | `host.connection` | you hold `host:connection:send` and the invocation is for a channel whose live connection you own | Each `host.session.*` operation checks its own permission: `host.session.message.post` rejects with a 403 unless your code calls it literally somewhere (or you list `agentApi:message.post` in `manifest.permissions`). In practice: if you call it, you have it. Check optional members before use when the context may be missing: ```ts if (host.llm) { const reply = await host.llm.complete({ messages: [{ role: "user", content: "…" }] }); } ``` ## Model-provider plugins can't act as agents A plugin that registers any `modelProvider.*` handler translates a vendor's API; it never acts on an agent's behalf. Publishing it with any of these derived permissions fails: `agentApi:message.post`, `agentApi:todo.write`, `agentApi:subagent.spawn`, `agentApi:peer.send`, `agentApi:notify.write`, `agentApi:agents.write`, `agentApi:cronJobs.write`. Read-only account access (for example `agentApi:models.read`) is still allowed. ## What the installer sees Before installing, the plugin's Browse page shows: - **Capabilities** — what your package does (a tool, three middleware hooks, a channel…). - **What it can reach** — every permission in plain language, every egress host, and "Adds N agents" / "Adds N schedules" from `manifest.agents` / `manifest.schedules`. - Your readme. ## Updates that ask for more Every version records its *access*: permissions, egress hosts, declared agents and schedules, `failClosed` hooks and the `anyone` sender policy. When a newer version's access is **covered** by what the owner already accepted, installs on the **Latest** policy move to it automatically (within about five minutes). When it asks for **more** — a new permission, a new host, a new agent or schedule, a new `failClosed` hook, the `anyone` sender policy — the install stays on the old version and the plugin's page shows **Update waiting for your OK**, listing exactly what the new version adds. Nothing from the new version runs until the owner accepts. The same rule applies to your own drafts: a draft that asks for more access shows *"Your draft asks for access this account hasn't agreed to yet"*. ## Secrets never enter your code's reach by accident - Secrets are write-only in the console and encrypted at rest. Your code reads only the secrets **your** plugin declared, only via `host.settings.getSecret`. - The token your plugin's `host.session` / `host.agents` calls use is held outside the sandbox and scoped to one account (and one session where there is one). It never enters your code. # Publishing ## The path from code to installs ``` Save draft → Publish x.y.z → (private: only your account) → List in Browse → anyone can install ↑ ↓ next version ←─────────── installs on "Latest" follow (with consent for new access) ``` 1. **Create** the plugin. **Plugins → Created → Upload a plugin** takes a `.zip`: the readme's `name` (and optional `slug`) creates it for you. **Write in the browser** asks for a name, an **address** (the slug) and a one-line description. The slug (lowercase letters, digits, hyphens and underscores, up to 64 characters, not starting with `agentparley-` or `mcp_`) is your plugin's permanent identity; tool names are prefixed with it. 2. **Draft** (optional): **Save** (⌘S) in the editor builds and validates exactly like a publish but takes no version number. Switch your own install to it with **Use my draft** to test on your account. 3. **Publish** a version: **Publish…** in the editor (choose patch, minor or major; the first is `1.0.0`), or upload a `.zip` — on the Created page, or **Upload a new version** on the plugin's page. A zip's readme `version:` decides the number (see Versions below). The editor only sends text files, so a plugin with models, images or several script files shows **Upload a .zip…** in place of **Publish…**. A plugin with a gallery can still publish from the editor, which warns first that the gallery will be removed. 4. **List in Browse** (in the **⋯** menu at the top of the plugin's page) when you're ready for everyone: requires at least one published version. ## What a publish does In one step, all or nothing: 1. Checks every file fits the [package format](plugin-package-format.md) and requires a `readme.md`. 2. Bundles your code with esbuild into one readable script. 3. Runs it once to discover its capabilities and manifest; derives permissions from its `host.*` calls. 4. Validates the manifest, tools, commands, skills and companion assets. 5. Stores the version. A crash half-way leaves no half-visible version. A readme-only package is refused too (*"version declares no capabilities"*): it needs at least one command, skill, code capability or companion/portrait folder. Any problem fails the publish with a message naming the file, key or line: *"tool 'Lookup' must match ^[a-z][a-z0-9_]{0,63}$"*, *"unknown globalThis.manifest key 'egres'"*, *"the code calls host.fetch but declares no egress hosts"*, *"a decision provider must implement modelProvider.noul, choose and score together"*. If the platform is busy bundling other plugins you may get a *retry shortly* response — just publish again. ## Versions - A version is **`major.minor.patch`**, digits only (`1.0.0`, `1.4.12`). The first publish from the editor is `1.0.0`. - **Versions are immutable.** Publishing the same number twice is refused. Fix forward with a new version. - Installs on **Latest** follow the highest published version. Use the numbers the usual way: patch for fixes, minor for additions, major for anything that breaks users — a renamed setting, a removed tool. The `version:` key in `readme.md` (`.` only, default `1.0`) is used whenever you upload a zip to `POST /plugins/upload` (the console's zip uploads) and by the official-plugin pipeline. The platform picks the patch automatically: unchanged files publish nothing, changed files publish the next patch, a raised `major.minor` publishes `..0`, and a lowered one is refused. When you publish from the editor's **Publish…** or through `POST /plugins/{id}/versions` or `…/versions/zip`, you choose the number and this key is ignored. ## Visibility | State | Who can find and install it | | --- | --- | | **Private** (default) | only your account — Browse shows it to you with a **Private** tag | | **Listed in Browse** | every AgentParley account | **Remove from Browse** takes it off the catalog: accounts that already installed it keep working on their version and see the optional message you write ("Why you're taking it down"). You can list it again later. ## How installs follow your updates Each install has a version policy: | Policy | Behaviour | | --- | --- | | **Latest** (default) | moves to your newest version within about five minutes | | **Pinned** | stays on one version until the user changes it | | **Draft** | your own account only: follows your latest draft | **New access waits for consent.** If a new version asks for anything the owner hasn't agreed to — a permission, an egress host, an agent in `manifest.agents`, a schedule in `manifest.schedules`, a `failClosed` hook, or `channelSenderPolicy: "anyone"` — installs stay on the old version and show **Update waiting for your OK**, listing exactly what the new version adds. Nothing from the new version runs until they accept. A version that asks for the same or less moves silently. On every version change your plugin receives `events.pluginUpdated` — see [Lifecycle events](plugin-lifecycle-events.md). ## Yanking Pull a bad version with **yank** (on the version in the console, or the API). A yanked version stops being offered: installs on **Latest** move to the highest remaining version they have already agreed to, and an install pinned to it has no version to run until its owner picks another. You can't yank a plugin's last remaining published version. A plugin that has ever published a version, even a yanked one, can't be deleted; only a plugin that never published can. To take a published plugin out of sight, use **Remove from Browse**. ## Official plugins Plugins with the **Official** badge — Brave Search, Caveman, DC Portraits, DevOps on-call, Discord, Google Workspace, Inbox Triage, Microsoft 365, Morning Briefing, My Dude, Ponytail, Sales Lead Crew, Wiki Web and Yuzuki — are published by AgentParley from its own repository as `agentparley-`, through the same bundling, validation and SDK described here. Community slugs can't start with `agentparley-`. ## Checklist before you list - [ ] The readme explains setup, every setting, and what the plugin reaches. - [ ] `npx tsc -p .` passes against the downloaded SDK types. - [ ] You tested the published version on your own account, including a fresh install. - [ ] Lifecycle handlers are idempotent; middleware is fast; tool descriptions say *when* to use the tool. - [ ] You call `host.*` literally (no aliases), so the permission list is complete. - [ ] Nothing secret is stored in live-connection `state` or logged with `host.log`. # Package format A plugin package is a folder of files — uploaded as a zip, or composed for you by the console editor. There is no manifest file to maintain: every file's **location** says what it is, and the code declares the rest. ## Layout ``` readme.md required setup.md optional install instructions shown on the plugin's page icon.svg (or icon.png) optional plugin icon, one of the two gallery/… optional pictures and videos shown above the readme index.ts (or index.js, or one single .ts/.js file) src/… more .ts/.js files, imported from index.ts commands/.md one slash command per file skills//skill.md one skill per folder skills//… files that skill can read on demand companion-models//model.json + the avatar (.vrm / .glb, or a Live2D .zip), its clip files (.vrma / .glb / .fbx) and a thumbnail companion-characters//character.json + an optional thumbnail companion-items//item.json + the item's .glb model and an optional thumbnail portraits//portrait.json + one portrait.png, portrait.jpg or portrait.webp ``` **Every file must fit one of these slots.** A file anywhere else — a `package.json`, a `tsconfig.json`, a stray `notes.txt` — refuses the publish and the error names the file. Nothing is silently dropped. (A zip whose entries all sit inside one wrapper folder is fine, and OS junk such as `.DS_Store` is ignored.) Only `readme.md` is always required, but a package that adds nothing is refused: it needs at least one command, skill, code capability or companion/portrait folder. The rest is whatever your plugin needs: a companion pack has no code at all, and a prompt-only plugin has no commands or skills. ## `readme.md` Required for every publish from the console or the Portal API. It is what people read before they install. ```markdown --- name: Brave Search description: A bring-your-own-key web search provider — calls the Brave Search API with your own key. categories: research, developer version: 1.0 --- # Brave Search What it does, how to set it up, what each setting means, and what it can reach. ``` | Frontmatter key | Used for | | --- | --- | | `name` | The display name on the Browse card and the plugin page. Required for a zip upload; otherwise it falls back to the name you gave the plugin when you created it. | | `description` | The one-line summary on the Browse card. Falls back to the plugin's existing description. | | `slug` | Only for a zip upload (`POST /plugins/upload`): the plugin's slug. Default: the `name` lowercased with other characters turned into `-`. | | `categories` | Up to three, comma-separated, from `productivity`, `developer`, `communication`, `research`, `data`, `home`, `writing`, `support`, `finance`, `fun`. Anything else fails the publish. | | `gallery` | The order and captions of the files in `gallery/`, one `- file: name.png` line per item with an optional indented `caption:` (at most 200 characters). Files you don't list still show, after the listed ones. | | `version` | `.` only (default `1.0`), for zip uploads and the official-plugin pipeline: the platform picks the patch. See [Publishing](publishing.md#versions). When you give the full version yourself (the editor's **Publish…**, or `POST /plugins/{id}/versions`), this key is ignored. | The body (everything after the frontmatter) is shown verbatim as the plugin's readme in the console. It is your user documentation, so write it like one. A `setup.md` is shown on the Browse page as a collapsible **Install instructions** card, so keep setup steps there and leave the readme for what the plugin does. The official **Discord** readme is a good model: numbered setup steps, every setting explained, and what each error means. ## Icon and gallery - `icon.svg` or `icon.png` at the package root (one of them, not both), at most 512 KB. An SVG is a text file; a PNG is binary, so it needs a zip upload. - `gallery/` holds at most 12 files and no subfolders: `.png`, `.jpg`/`.jpeg` and `.webp` pictures up to 5 MB each, and `.mp4` or `.webm` videos up to 50 MB each. A listed `gallery` file that isn't in the folder fails the publish. Binary files only travel in a zip: publishing from the editor (a JSON file list) gives that version no gallery, and its icon is the one set with **Add icon** in the editor (`PUT /plugins/{id}/draft/icon`). ## Code: the entry file - **One source file** (`.ts` or `.js`): it is the entry point, whatever its name. - **More than one**: a root `index.ts` or `index.js` is required and is the entry point (`index.ts` wins if both exist). The other files are pulled in through imports. ```ts // infrastructure/plugins/oh-my-openagents/index.ts import { reconcileCrew } from "./src/reconcile"; import { ORCHESTRATION_DOCTRINE } from "./src/doctrine"; import { CREW } from "./src/crew"; ``` **Imports resolve only inside your package.** A relative import (`./src/crew`) is looked up against your own files, trying the exact path, then `.ts`, then `.js`, then `/index.ts`, then `/index.js`. A bare import (`lodash`), a `node:` import or a URL fails the publish with *"plugins bundle only their own files"*. There is no npm and no `node_modules`; if you need a small helper, copy it into your package. At publish time esbuild merges your files into one readable (not minified) ES2022 script. TypeScript types are stripped — **not checked**. Type-check locally (below). ### Globals, not imports The SDK is **ambient**: `host`, `middleware`, `events`, `tools`, `compaction`, `web`, `modelProvider`, `channel`, `connection` and `manifest` are globals. You never import them. Assign to them through `globalThis`: ```ts globalThis.middleware.systemPrompt = async (context) => context.systemPrompt + GUIDANCE; globalThis.web.search = async (input) => { /* … */ }; globalThis.channel = { receive: async (request, tools) => { /* … */ }, send: async (request) => { /* … */ } }; ``` Replacing a whole registry object (as the last line does) works the same as assigning its members one by one. ### What the sandbox does not have Plugin code runs in a bare JavaScript engine: no `fetch`, `require`, `process`, `fs`, `Buffer`, `atob`/`btoa` or `TextEncoder`. Use `host.fetch` for HTTP and `host.crypto` for hashing, HMACs, signatures and base64. See [Sandbox model](sandbox-model.md). ## Register at the top level At publish time the platform evaluates your bundle once, in a throwaway sandbox where every `host.*` call either does nothing (`host.log`, `host.settings.get`) or throws *"unavailable at publish"*. It then reads what your code assigned to the registries and to `globalThis.manifest`. So: - **Do** assign handlers and the manifest at the top level, from constants or simple logic. - **Don't** register inside a handler — e.g. assigning `middleware.userMessageInbound` inside `events.pluginInstalled`. Nothing runs that handler at publish time, so the capability never exists. - **Don't** compute registrations from `host.*` — `host.settings.get()` returns `undefined` at publish time, and a `host.fetch` at the top level fails the publish. - **Keep the manifest and tool metadata plain JSON.** Functions, class instances or cyclic objects in `globalThis.manifest` or in a tool's `description`/`parameters` fail the publish. ## Type-checking with TypeScript 1. On your plugin's page in the console, press **Download SDK types**. You get `agentparley-plugin-sdk.d.ts` — the full typed contract, with documentation on every type. 2. Put it next to your code and add a `tsconfig.json` (for your editor only — never in the zip): ```json { "compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], "types": [], "skipLibCheck": true, "noEmit": true }, "include": ["index.ts", "src/**/*.ts", "agentparley-plugin-sdk.d.ts"] } ``` `"lib": ["ES2022"]` with no DOM and `"types": []` with no Node matches the sandbox: if you reach for `fetch` or `Buffer`, the compiler tells you. 3. Run `npx tsc -p .` before every publish. ## Commands, skills, companion and portrait folders | Folder | Key | Required inside | Details | | --- | --- | --- | --- | | `commands/.md` | the file name (`deploy.md` → `/deploy`) | frontmatter `description`, a non-empty body | [Commands and skills](plugin-commands-and-skills.md) | | `skills//` | the folder name | exactly one `skill.md` (any letter case) with frontmatter `description` | [Commands and skills](plugin-commands-and-skills.md) | | `companion-models//` | the folder name | `model.json` | [Companion packs](plugin-companion-packs.md) | | `companion-characters//` | the folder name | `character.json` | [Companion packs](plugin-companion-packs.md) | | `companion-items//` | the folder name | `item.json` | [Companion packs](plugin-companion-packs.md#item--itemjson) | | `portraits//` | the folder name | `portrait.json` (`{ "name": "…" }`, 1–60 characters, no other member) and exactly one `portrait.png`, `.jpg` or `.webp` of at most 512 KB | agent pictures; at most 100 per package. Installers pick them under an agent's **Picture** | Keys match `^[a-z0-9][a-z0-9_-]{0,63}$`. **Keys are unique across the whole package, not per folder.** A command `research` and a skill `research` collide, and so do `companion-characters/fox/` and `companion-models/fox/`. Give them different keys (`fox`, `fox-model`). ## Limits | What | Limit | | --- | --- | | Source files (`.ts`/`.js`) | 50 files, 1 MB each | | Bundled code after merging | 2 MB | | Skill files and `setup.md` | 256 KB each | | Any other text file (readme, command, companion JSON) | 1 MB each in a zip | | Files per skill folder | 100 | | Zip entries | 4,000 | | Whole package, uncompressed | 256 MB | | Companion avatar model | 100 MB | | Companion animation clip | 32 MB | | Companion item model (`.glb`) | 3 MB | | Thumbnail image (PNG, JPEG or WebP) | 2 MB | | Plugin icon | 512 KB | | Gallery picture / video | 5 MB / 50 MB (12 files) | | Portrait image | 512 KB (100 per package) | Text files must be UTF-8. Binary files are accepted only inside the `companion-*` folders, and every one of them must be referenced by a `model.json`, `character.json` or `item.json` — an unreferenced file refuses the publish, naming it. # Manifest reference `globalThis.manifest` is one optional, plugin-wide object. It carries what the platform can't learn by reading your handlers: which hosts you call, what settings the user fills in, which agents you'll create, and which provider you register as. ```ts globalThis.manifest = { egress: ["api.example.com", "api.example.com:8443", "*.discord.gg"], settings: [{ key: "apiKey", title: "API key", type: "secret", required: true }], agents: [{ key: "oracle", name: "Oracle" }], schedules: [{ key: "daily", name: "Daily digest" }], modelProvider: { kindId: "my-vendor", displayName: "My Vendor" }, installOwnedConnection: true, failClosed: ["sshCommandSending"], channelSenderPolicy: "anyone", permissions: ["agentApi:agents.write"], }; ``` Assign it at the top level as a plain JSON value. It is **validated strictly**: an unknown key — a typo such as `egres` — fails the publish with *"unknown globalThis.manifest key 'egres'"*. Many plugins need no manifest at all. | Key | Needed when | Section | | --- | --- | --- | | `egress` | your code calls `host.fetch`, or opens a live connection | [egress](#egress) | | `settings` | users must configure something (an API key, a toggle) | [settings](#settings) | | `agents` | your plugin creates agents with `host.agents.create` | [agents](#agents) | | `schedules` | your plugin creates schedules with `host.cronJobs.create` | [schedules](#schedules) | | `oauth` | your plugin calls a vendor API as the installer, through a sign-in | [oauth](#oauth) | | `modelProvider` | you register any `modelProvider.*` handler | [modelProvider](#modelprovider) | | `installOwnedConnection` | your live connection belongs to the install, not to a channel | [installOwnedConnection](#installownedconnection) | | `failClosed` | a middleware handler is a guard, and a crash must block the operation | [failClosed](#failclosed) | | `channelSenderPolicy` | your channel's senders are machines (webhooks), not people | [channelSenderPolicy](#channelsenderpolicy) | | `permissions` | rarely — a `host.*` call the scanner can't see | [permissions](#permissions) | ## `egress` `string[]` — the hosts `host.fetch` may reach. Outbound HTTP is **deny-by-default**: a request to any host not on this list (or on an installer-provided `hosts` setting) is refused. Publishing code that calls `host.fetch` with an empty `egress` fails: *"the code calls host.fetch but declares no egress hosts"*. | Entry | Matches | | --- | --- | | `api.example.com` | `https://api.example.com/…` and `http://api.example.com/…` on the default port (443 / 80) only | | `api.example.com:8443` | that host on port 8443 only | | `*.discord.gg` | any subdomain (`gateway.discord.gg`, `a.b.discord.gg`) — **never** the bare `discord.gg` | | `*.example.com:9000` | any subdomain on port 9000 | No scheme, no path, no trailing slash. A wildcard needs at least two labels after `*.`. Matching is case-insensitive. The installer sees these hosts under **What it can reach** before installing, and a later version that adds a host waits for their OK. Examples: Brave Search declares `["api.search.brave.com"]`; Discord declares `["discord.com", "gateway.discord.gg", "*.discord.gg", "cdn.discordapp.com", "media.discordapp.net"]`. ## `settings` An array of fields that become the plugin's settings form in the console. Values reach your code through `host.settings.get(key)`; secrets through `await host.settings.getSecret(key)`. ```ts // plugins/discord/index.ts settings: [ { key: "botToken", title: "Bot token", type: "secret", required: true, scope: "channel" }, { key: "readMessageContent", title: "Use the Message Content intent", type: "bool", default: true, scope: "channel", description: 'Needs "Message Content Intent" on in the Developer Portal. DMs and @mentions work without it.', }, { key: "respondToBots", title: "Answer other bots", type: "bool", default: false, scope: "channel" }, ], ``` | Field | Type | Rules | | --- | --- | --- | | `key` | string | required; `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`; unique | | `title` | string | the label in the form | | `description` | string | help text under the field | | `type` | `"text"` (default), `"secret"`, `"bool"`, `"int"`, `"enum"`, `"hosts"`, `"channelTarget"`, `"generatedSecret"` | see below | | `required` | boolean | the user must fill it in | | `default` | string / integer / boolean | must match the type (`int` an integer, `bool` a boolean, `enum` one of `options`, the rest a string); **not allowed on `secret`, `channelTarget` or `generatedSecret`** | | `options` | string[] | required (non-empty) for `enum`, not allowed otherwise; `default` must be one of them | | `scope` | `"install"` (default) or `"channel"` | `channel` = filled in per connected channel, never inherited from the install | Any other field key refuses the publish. **Types:** - `text` → a string. `int` → a number. `bool` → a boolean. `enum` → one of your `options`. - `secret` → write-only in the console, stored encrypted, never returned to the browser. Read it with `await host.settings.getSecret(key)`; `host.settings.get` never returns secrets. - `generatedSecret` → a secret the platform fills in itself: 32 random bytes (base64url) when the plugin is installed, or on the first update that declares it. The user never sees or edits it, and it stays the same across updates; a reinstall gets a new one. Read it with `await host.settings.getSecret(key)`. Use it for a signing key you hand to a vendor. Install scope only, with no `required`, `default` or `options`, and not allowed in a plugin that has `modelProvider`. - `hosts` → up to 10 `host[:port]` values (no wildcards) that are **added to this installation's egress allowlist**. Use it when the user picks the server — a self-hosted instance, a home gateway. `host.settings.get` returns it as a **JSON array encoded in a string**: `JSON.parse` it. - `channelTarget` → a person or chat on one of the installer's channels, picked from a list in the console. It cannot declare a `default` (an id would belong to your own account). `host.settings.get` returns a **JSON string** `{"channelId":"381","personId":null,"chatId":"9127"}` with exactly one of `personId` / `chatId` set, or `""` when nothing is picked. Pass `channelId`, and the person or chat id as `to`, to `send_to_channel`. ```ts globalThis.manifest = { settings: [{ key: "teamChannel", title: "Where to report", type: "channelTarget" }] }; const raw = String(host.settings.get("teamChannel") ?? ""); const target = raw ? (JSON.parse(raw) as { channelId: string; personId: string | null; chatId: string | null }) : null; ``` ```ts // infrastructure/plugins/hello-live/index.ts function gatewayHost(): string { const raw = host.settings.get("gatewayHost"); const hosts = typeof raw === "string" ? (JSON.parse(raw) as string[]) : []; if (!hosts[0]) { throw new Error("hello-live: no gatewayHost configured"); } return hosts[0]; } ``` `host.settings.get` on a key you didn't declare **throws** — it's a bug in your plugin, not a missing value. Defaults apply until the user saves a value. **Scope `channel`** is for per-bot credentials: the official Discord plugin needs one bot token per connected channel, so every Discord field is `scope: "channel"` and is entered on **Channels → Connect a channel**. ## `agents` `{ key, name }[]` — the agents your plugin will create with `host.agents.create`. Declaring them up front lets the console say **"Adds 7 agents"** and check the account has room on its plan *before* install, instead of your setup hook discovering it half-way. ```ts // infrastructure/plugins/oh-my-openagents/index.ts globalThis.manifest = { agents: CREW.map((member) => ({ key: member.key, name: member.name })), }; ``` - At most 20 entries. `key` matches `^[a-z0-9][a-z0-9_-]{0,63}$` and is unique; `name` is 1–100 characters. - Use the same `key` you pass to `host.agents.create`. See [Agent crews](plugin-agent-crews.md). - A new version that declares an extra agent counts as new access and waits for the owner's OK. ## `schedules` `{ key, name }[]` — the schedules your plugin will create with `host.cronJobs.create`. Like `agents`, declaring them up front lets the console say **"Adds 2 schedules"** and check the account has room *before* install. The platform never creates a schedule from this entry; your setup code does. - At most 20 entries. `key` matches `^[a-z0-9][a-z0-9_-]{0,63}$` and is unique; `name` is 1–100 characters. - A new version that declares an extra schedule counts as new access and waits for the owner's OK. ## `oauth` An array of up to 5 sign-ins your plugin needs, such as the installer's Google account. The installer connects each one on the plugin's page, and you call the vendor with `host.fetch({ url, oauth: key })`; the platform adds the `Authorization: Bearer` header and renews the token, and you never see it. See [Live connections](plugin-live-connections.md) for the same `oauth` field on a connection request. ```ts globalThis.manifest = { egress: ["gmail.googleapis.com"], oauth: [{ key: "google", preset: "google", client: "installer", scopes: ["https://www.googleapis.com/auth/gmail.readonly"], apiHosts: ["gmail.googleapis.com"], }], }; ``` | Field | Rules | | --- | --- | | `key` | required; `^[a-z][a-z0-9-]{0,31}$`; unique | | `displayName` | optional, 1–80 characters | | `preset` | `"google"` or `"microsoft"`: built-in vendor endpoints. Don't combine with `authorizeUrl`/`tokenUrl` | | `client` | `"installer"` (the installer pastes their own client id and secret), `"discover"` (sign in to the server at `resource`, nothing to paste), `"platform"` or `"platform-or-installer"` (AgentParley's own app registration; needs a `preset`, and only official plugins may use them). Default `"platform"` when a `preset` is set, otherwise required | | `authorizeUrl`, `tokenUrl` | https URLs, declared together. `tokenUrl` (and `revokeUrl`) must be on the same registered domain as `authorizeUrl` | | `revokeUrl` | https; only together with `authorizeUrl` and `tokenUrl` | | `resource` | https; required for `client: "discover"`, which can't be combined with `preset`, `authorizeUrl`, `tokenUrl` or `revokeUrl` | | `scopes` | always requested; up to 50 entries of 1–512 characters, no spaces | | `scopeSets` | `{ setting, scopes }[]`, up to 20: extra scopes requested only while the named `bool` setting is on | | `apiHosts` | up to 20 hosts the token may be sent to, no ports. Each must also appear in `egress` (a wildcard `*.host` entry must match `egress` exactly). Defaults to the preset's hosts (Google: `*.googleapis.com`, Microsoft: `graph.microsoft.com`) or the `resource` host, so name them yourself when the default wildcard isn't in `egress`. A preset allows only its own hosts; required with explicit endpoints | | `multiple` | boolean: allow several connected accounts for this key | Declare one of `preset`, `authorizeUrl` + `tokenUrl`, or `resource`. Any other field fails the publish. The calls are `host.fetch` with `oauth` (and `account` when several accounts are connected) and `host.oauth.accounts()`. ## `modelProvider` Required when you register any `modelProvider.*` handler, and forbidden when you don't. It makes your plugin a selectable provider kind under **AI Providers → Add provider**, shown as "*displayName* from plugin". ```ts // infrastructure/plugins/openai-voice/index.ts modelProvider: { kindId: "openai-voice", displayName: "OpenAI Voice" }, ``` | Field | Rules | | --- | --- | | `kindId` | required; `^[a-z][a-z0-9-]{1,63}$` (2–64 characters); built-in kind ids such as `openai-compatible`, `anthropic`, `fish-audio`, `elevenlabs`, `deepgram` are reserved | | `displayName` | optional, ≤ 100 characters | | `description` | optional, ≤ 500 characters | | `mediaSupport` | optional; any of `"image"`, `"audio"` — the media your chat models accept. The platform strips media you don't support from the request before calling you. | | `voiceDirection` | optional, only with `modelProvider.speak` and `voices`: text that tells the model how to write for your voice. A string, or `{ default, models? }` where `models` maps a model identifier to its own text (at most 20; case-insensitive keys). Each text is non-empty and at most 4000 characters | See [Model providers](plugin-model-providers.md) and [Voice and speech-to-text](plugin-voice-and-speech.md). ## `installOwnedConnection` `boolean`. Set `true` when your `connection.*` handlers serve a connection that belongs to the **installation** rather than to a chat channel — for example a smart-home event stream that fires schedules. Declaring it without `connection.*` handlers fails the publish. See [Live connections](plugin-live-connections.md#two-kinds-of-owner). ## `failClosed` `string[]` of middleware hook names. By default a middleware handler is [fail-open](plugin-middleware.md): if it crashes, times out or returns something invalid, the operation goes ahead as if it were not there. List a hook here and the same fault **blocks** the operation instead, with a plain message to the agent ("*Name's safety check could not run (timed out), so this was blocked. Try again; if it keeps failing, check the plugin or turn it off.*"). Use it for a guard, never for an observer. Allowed values: `userMessageInbound`, `interAgentMessage`, `assistantMessageOutbound`, `todoAdded`, `notifyScheduled`, `notifyDelivered`, `sshConnectionOpening`, `sshCommandSending`, `sshCommandCompleted`, `cronJobFiring`. The others (`systemPrompt`, `sessionClearing`, `sessionClearCompleted`, `cronJobCompleted`, `companionBriefing`, `companionPerformance`, `companionSpeech`) never cancel, so listing them fails the publish, as does a duplicate or a hook your code has no handler for. ```ts globalThis.manifest = { failClosed: ["sshCommandSending"] }; globalThis.middleware.sshCommandSending = async (context, tools) => checkCommand(context, tools); ``` The installer sees "Blocks SSH commands and file changes if its safety check can't run" on the consent panel, and a later version that adds a hook here asks them to agree again. ## `channelSenderPolicy` `"anyone"` or `"authorized"` (the default; any other value, including `"Anyone"`, fails the publish). It sets who may message a channel created from your plugin, at creation; the owner can change it later. A webhook from GitHub is a machine and can never be on a people list, so a plugin whose channel receives webhooks declares `"anyone"`. Declaring it without any channel handlers fails the publish. ```ts globalThis.manifest = { channelSenderPolicy: "anyone" }; ``` The Connect page shows which policy a new channel starts on. ## `permissions` `string[]` — an escape hatch. Permissions are normally [derived from your code](plugin-permissions.md). If you reach a `host` member in a way the scanner can't see (through an alias or a computed property), list the permission here. Values must come from the known set; anything else fails the publish: `agentApi:session.read`, `agentApi:conversation.read`, `agentApi:message.post`, `agentApi:todo.read`, `agentApi:todo.write`, `agentApi:subagent.spawn`, `agentApi:peer.send`, `agentApi:notify.write`, `agentApi:session.read.cross`, `agentApi:agents.read`, `agentApi:agents.write`, `agentApi:cronJobs.read`, `agentApi:cronJobs.write`, `agentApi:models.read`, `host:workspace:read`, `host:workspace:write`, `host:media:transcode`, `host:session:sendError`, `host:ssh:command`, `host:connection:send`. The better fix is almost always to call `host.x.y(...)` literally. # Host API reference `host` is the only way plugin code reaches anything outside the sandbox. It's a global — never import it, never alias it (`const a = host.agents` hides the call from the [permission scan](plugin-permissions.md)). **Presence and permission.** `host.workspace`, `host.media`, `host.ssh`, `host.connection`, `host.llm` and `host.session.llm` are `undefined` when you weren't granted them or the invocation has no context for them. The platform-API members (`host.session`, `host.agents`, `host.models`, `host.cronJobs`, `host.license`) are present in tool, middleware, lifecycle, compaction and web-provider runs, and each call needs its permission or is rejected with a 403. Use `host.x!.y()` when you know it's there, `if (host.x)` when it may not be. **Ids are strings.** Every id (session, agent, todo, message, notify, model, installation…) is a string, because the underlying 64-bit numbers don't fit a JavaScript number. Compare and store them as strings. **Errors.** Host calls reject with an `Error` whose message starts with a code, e.g. `"rate_limited: …"`, `"bad_arguments: …"`, `"agent op agents.update failed: 404"`. Failures of `host.session`, `host.agents`, `host.models`, `host.cronJobs` and `host.license` also carry the HTTP status as a numeric `error.status`. | Member | Needs | Section | | --- | --- | --- | | `host.log` | — | [log](#hostlog) | | `host.settings` | — | [settings](#hostsettings) | | `host.fetch` | `manifest.egress` | [fetch](#hostfetch) | | `host.oauth` | — | [oauth](#hostoauth) | | `host.crypto` | — | [crypto](#hostcrypto) | | `host.license` | — | [license](#hostlicense) | | `host.session` | an `agentApi:*` permission per operation | [session](#hostsession) | | `host.agents` | `agentApi:agents.*` | [agents](#hostagents) | | `host.cronJobs` | `agentApi:cronJobs.*` | [cronJobs](#hostcronjobs) | | `host.models` | `agentApi:models.read` | [models](#hostmodels) | | `host.workspace` | `host:workspace:*` + an agent in context | [workspace](#hostworkspace) | | `host.media` | `host:media:transcode` | [media](#hostmedia) | | `host.session.llm`, `host.llm`, `host.decide` | context | [models from plugin code](#models-from-plugin-code) | | `host.ssh` | `host:ssh:command`, SSH hooks only | [ssh](#hostssh) | | `host.connection` | `host:connection:send`, a live connection you own | [connection](#hostconnection) | --- ## `host.log` ```ts host.log(level: "debug" | "info" | "warn" | "error", message: string): void ``` Fire-and-forget. Lines appear in the logs panel of the installed plugin's page for 24 hours. ## `host.settings` ```ts host.settings.get(key: string): string | number | boolean | null host.settings.getSecret(key: string): Promise ``` - `get` returns a non-secret setting's value (its `default` until the user saves one). A `hosts` field returns a **JSON array encoded as a string** — `JSON.parse` it. A key with no saved value and no `default`, or one you didn't declare, returns `null`. - `getSecret` returns a `secret` field's value. It is fetched only when you call it. It throws `MissingCredential: …` for a key you didn't declare or the user hasn't set. - In a [model provider](plugin-model-providers.md) handler these read the provider the user added; in a channel handler, `scope: "channel"` fields read that channel's values. ## `host.fetch` ```ts host.fetch(request: { method?: string; // default "GET" url: string; // http or https, host must be allowed headers?: Record; body?: string; bodyEncoding?: "utf8" | "base64"; // "base64" to send binary responseEncoding?: "utf8" | "base64"; // "base64" to receive binary (audio, images) oauth?: string; // a manifest.oauth key: the platform adds the Bearer token account?: string; // host.oauth.accounts() id; required when several are connected }): Promise<{ status: number; headers: Record; body: string }> ``` - Only hosts in `manifest.egress` or a `hosts` setting ([rules](plugin-manifest.md#egress)). - 60 requests per minute per installation, 10 s timeout, 5 MB response. - Any HTTP status resolves — check `status`. Redirects are followed (at most 5), and every hop must also be an allowed host; with `oauth` they are not followed and a 3xx comes back to you. - The call rejects with an error whose `name` says why, so you can branch on it: | `error.name` | When | `error.message` | | --- | --- | --- | | `EgressDenied` | the host is refused, or is a private, loopback, link-local or metadata address | `host not allowlisted: ` or `blocked address for ` | | `RateLimited` | over the rate limit | `fetch rate limit exceeded` | | `FetchTimeout` | it timed out | `fetch timed out` | | `ResponseTooLarge` | the response is too big | `response exceeded bytes` | | `TooManyRedirects` | more than 5 redirects | `more than 5 redirects` | | `Internal` | anything else went wrong with the request | `fetch failed: ` | - With `oauth`, the URL host must be in that key's `apiHosts`, any `Authorization` header you set is replaced, and a 401 triggers one token refresh and retry. You never see the token. Errors have `err.name` of `OAuthNotConnected`, `OAuthAccountRequired`, `OAuthReconnectRequired` or `OAuthUnavailable`. ## `host.oauth` ```ts host.oauth.accounts(key?: string) : Promise<{ id: string; key: string; label: string | null; scopes: string[]; status: "connected" | "needsReconnect" }[]> ``` Always present. The sign-ins the customer connected for this installation, for one `manifest.oauth` key or all of them; `[]` when none. Pass an `id` as `account` to `host.fetch` when more than one is connected for a key. ## `host.crypto` All synchronous. Encodings: `"utf8" | "hex" | "base64" | "base64url"` (inputs default to `utf8`; digests are `hex | base64 | base64url`). Failures throw `BAD_REQUEST: host.crypto.: `; the verify operations return `false` / `{ valid: false }` instead of throwing. Budget: 200 operations and 8 MB of input per invocation. ```ts host.crypto.hmacSha256({ key, keyEncoding?, data, dataEncoding?, outputEncoding? }): string host.crypto.hmacSha1({ key, keyEncoding?, data, dataEncoding?, outputEncoding? }): string host.crypto.sha256({ data, dataEncoding?, outputEncoding? }): string host.crypto.sha1({ data, dataEncoding?, outputEncoding? }): string host.crypto.aesCbcDecrypt({ key, keyEncoding?, iv, ivEncoding?, data, dataEncoding?, padding?: "pkcs7" | "none", outputEncoding? }): string // 16/24/32-byte key, 16-byte iv host.crypto.ed25519Verify({ publicKey, publicKeyEncoding?, signature, signatureEncoding?, data, dataEncoding? }): boolean host.crypto.ed25519Sign({ seed, seedEncoding?, data, dataEncoding?, outputEncoding? }): string host.crypto.rsaSha256Sign({ privateKey, data, dataEncoding?, outputEncoding? }): string // RSASSA-PKCS1-v1_5 + SHA-256 (JWT "RS256"). privateKey: PEM, PKCS#1 or PKCS#8, line breaks optional. At most 10 per run. host.crypto.verifyJwtRs256({ token, jwks: { keys: object[] }, issuer: string | string[], audience: string | string[] }) : { valid: true; header: object; payload: object } | { valid: false; reason: string } // 60 s clock skew host.crypto.timingSafeEqual(a: string, b: string): boolean host.crypto.base64Encode(text: string): string host.crypto.base64Decode(base64: string): string host.crypto.encode({ data: string; from: Encoding; to: Encoding }): string ``` Verify a webhook signature over the **exact bytes** the vendor sent: ```ts // infrastructure/plugins/hello-channel/index.ts const expected = host.crypto.hmacSha256({ key: secret, data: request.rawBodyBase64, dataEncoding: "base64", outputEncoding: "hex" }); if (!host.crypto.timingSafeEqual(signature, expected)) { return tools.reject("bad signature"); } ``` Sign a GitHub App JWT (RS256) with the app's private key: ```ts const b64 = (value: object) => host.crypto.encode({ data: JSON.stringify(value), from: "utf8", to: "base64url" }); const now = Math.floor(Date.now() / 1000); const unsigned = `${b64({ alg: "RS256", typ: "JWT" })}.${b64({ iss: appId, iat: now - 60, exp: now + 540 })}`; const signature = host.crypto.rsaSha256Sign({ privateKey: await host.settings.getSecret("privateKey"), data: unsigned, outputEncoding: "base64url" }); const jwt = `${unsigned}.${signature}`; ``` Derive a short idempotency key: ```ts // plugins/discord/index.ts host.crypto.sha256({ data: `${deliveryId}:${index}`, outputEncoding: "hex" }).slice(0, 25); ``` --- ## `host.session` Acts on the chat the invocation belongs to. Present in tool, middleware, lifecycle, compaction and web-provider runs; each operation requires its own permission (a call to one that wasn't derived rejects with a 403). `sendError` needs `host:session:sendError` and is present only with that. Operations on "your" session fail in invocations that have none (for example `pluginInstalled`). ```ts host.session.get(): Promise<{ id: string; agentId: string; state: string }> ``` `agentApi:session.read` — your session, with its effective state. ```ts host.session.channelChat(): Promise<{ chat: { channelId: string; chatId: string; targetAgentId: string | null } | null }> ``` `agentApi:session.read` — the channel chat your session belongs to (any channel kind), or `chat: null` for a console session or one with no chat. `targetAgentId` is the agent the channel points at, `null` if it points at none. The ids are the ones `list_channels` and `host.cronJobs` `deliverTo` use. ```ts host.session.conversation.read(args?: { sessionId?: string; limit?: number; cursor?: string }) : Promise<{ messages: ConversationMessage[]; nextCursor: string | null }> interface ConversationMessage { id: string; role: string; messageType: string; content: string; senderSessionId: string | null; // set when another agent's session sent it senderAgentName: string | null; channelSender: { displayName: string; handle: string | null; isVerified: boolean; isSharedConversation: boolean } | null; senderUserName: string | null; // which console user typed it createdAt: string; } ``` `agentApi:conversation.read`. Newest `limit` messages first page; pass `nextCursor` back as `cursor` for older pages (`null` = no more). Text lines only. Passing another `sessionId` also needs `agentApi:session.read.cross`; an unknown or foreign id returns an empty page. ```ts host.session.message.post(args: { text: string }): Promise<{ messageId: string }> ``` `agentApi:message.post` — post into your session. ```ts host.session.todo.create(args: { texts: string[] }): Promise<{ todos: { id: string; text: string }[] }> // todo.write host.session.todo.list(): Promise<{ todos: { id: string; text: string }[] }> // todo.read host.session.todo.update(args: { todoId: string; text: string }): Promise<{ id: string; text: string }> // todo.write host.session.todo.complete(args: { todoId: string }): Promise // todo.write — marks done host.session.todo.delete(args: { todoId: string }): Promise // todo.write — removes ``` Open todos keep the agent working turn after turn. A missing or foreign todo rejects (404). ```ts host.session.notify.schedule(args: { message: string; delaySeconds: number }) : Promise<{ notifyId: string | null; fireAt: string | null; isCanceled: boolean; cancelReason: string | null }> ``` `agentApi:notify.write` — wake this session later with a note. Another plugin's middleware may rewrite, delay or cancel it; a blanked note comes back `{ notifyId: null, isCanceled: false }`. ```ts host.session.subagent.spawn(args: { targetAgentId?: string; task: string; isFireAndForget?: boolean }) : Promise<{ outcome: "created" | "forbidden" | "depthExceeded"; sessionId: string | null }> ``` `agentApi:subagent.spawn` — start a subagent; never blocks. `targetAgentId` omitted = a copy of the current agent. `isFireAndForget: true` = no report expected. Same reach and depth rules as the `start_subagent` tool. ```ts host.session.peer.send(args: { sessionId: string; text: string }) : Promise<{ isDelivered: boolean; isCanceled: boolean; cancelReason: string | null }> host.session.peer.get(args: { sessionId: string }): Promise<{ id: string; agentId: string; state: string }> ``` `agentApi:peer.send` / `agentApi:session.read.cross`. `send` delivers into another visible session in the account and wakes it (fires `interAgentMessage`); not delivering is data, not an error. `get` works without a session of your own; a closed session is readable but not messageable; a foreign id is "not found". ```ts host.session.sendError?(message: string): Promise ``` `host:session:sendError` — report a failure into the invoking session. At most 5 per invocation. Stays available even when the hop-depth fuse withholds the rest. ### Hop-depth fuse When plugin code sends a message that triggers plugin code that sends a message… each generation is a hop. Past 4 hops from one original event, the platform-API members (`host.session` operations, `host.agents`, `host.models`, `host.cronJobs`, `host.license`) are absent for that generation; `host.session.sendError` and `host.session.llm` stay. Normal use — reacting once to one event — is one hop. --- ## `host.agents` Your installation's own agents. Each operation needs `agentApi:agents.read` or `agentApi:agents.write`; works without a session. Never reaches an agent your installation didn't create (those are "not found"). ```ts host.agents.create(args: { key: string; name: string; description?: string; modelId?: string; soul?: string; instructions?: string; isSubagent?: boolean; parentAgentId?: string | null; isGroupOnlyCallable?: boolean; replyMode?: "implicit" | "auto" | "all"; thinkingLevel?: "off" | "low" | "medium" | "high"; tools?: string[]; }): Promise // agents.write — get-or-create by key (key 1..200 chars) host.agents.list(): Promise<{ agents: PluginAgentListItem[] }> // agents.read — match on `key` host.agents.update(args: { agentId: string; name?; description?; modelId?; soul?; instructions?; enabled?: boolean; isSubagent?: boolean; parent?: { parentAgentId: string | null }; isGroupOnlyCallable?; replyMode?; thinkingLevel?; tools?: string[]; }): Promise // agents.write — omitted = unchanged host.agents.delete(args: { agentId: string }): Promise // agents.write — 409 if it's the account default interface PluginAgent { id: string; slug: string; name: string; description: string; modelId: string | null; soul: string; instructions: string; enabled: boolean; isSubagent: boolean; parentAgentId: string | null; isGroupOnlyCallable: boolean; replyMode: "implicit" | "auto" | "all"; thinkingLevel: "off" | "low" | "medium" | "high"; unresolvedTools: string[]; // names in `tools` that matched no installed tool } interface PluginAgentListItem { id: string; slug: string; name: string; description: string; modelId: string | null; enabled: boolean; key: string | null; isSubagent: boolean; parentAgentId: string | null; isGroupOnlyCallable: boolean; } ``` `tools` limits the agent to exactly those tools: a built-in by bare name (`web_search`), your own tool by bare name, another plugin's tool by its full `slug__tool` name. Passing it makes the agent's tool list **managed by your plugin**: the console shows it read-only, the user can no longer change it, and every `update({ tools })` replaces the whole list. `tools: []` means no tools. Names that match nothing come back in `unresolvedTools` and are not granted; re-apply `tools` in your setup on `sessionStarted` and `pluginUpdated`. On a `create` whose key already exists, `tools` is ignored like every other field. Omit `tools` on `update` to leave the list alone. Guide: [Agent crews](plugin-agent-crews.md). ## `host.cronJobs` Schedules your installation owns, addressed by your own `key` (`^[a-z0-9][a-z0-9_-]{0,63}$`). ```ts host.cronJobs.create(args: { key: string; agentId: string; name: string; prompt: string; cron?: string; eventSourceId?: string; // exactly one deliverTo?: { channelId: string; personId?: string; chatId?: string }; enabled?: boolean; }): Promise // cronJobs.write — get-or-create by key host.cronJobs.list(): Promise<{ cronJobs: PluginCronJob[] }> // cronJobs.read host.cronJobs.update(args: { key: string; name?: string; prompt?: string; cron?: string; deliverTo?: { channelId?: string; personId?: string; chatId?: string }; enabled?: boolean }): Promise // cronJobs.write host.cronJobs.delete(args: { key: string }): Promise // cronJobs.write — an unknown key succeeds host.cronJobs.listEventSources(): Promise<{ eventSources: { id: string; pluginSlug: string; pluginName: string; enabled: boolean }[] }> // cronJobs.read interface PluginCronJob { id: string; key: string; agentId: string; name: string; prompt: string; cron: string | null; // 5 fields, in the account's timezone; null on an On event schedule eventSourceId: string | null; // the live connection that triggers an On event schedule enabled: boolean; deliverTo: { channelId: string; personId: string | null; chatId: string | null } | null; nextRunAt: string | null; } ``` An existing key comes back untouched with `wasCreated: false`. `agentId` must be an agent your installation created. `deliverTo` must be a person or chat that agent can already reach (the ids `list_channels` shows), otherwise the call rejects with 400. It rejects with 402 when the plan has no room or the cron is denser than the plan allows. `update` changes only what you pass; `deliverTo: {}` stops delivering, and `cron` is refused on an On event schedule. A schedule is deleted with its agent. The console shows it as "From " and the user may pause, edit or delete it, so don't re-create a deleted one on every `sessionStarted`. ## `host.models` ```ts host.models.list(): Promise<{ models: { id: string; modelIdentifier: string; displayName: string; inputUsdPer1M: number | null; outputUsdPer1M: number | null; cachedUsdPer1M: number | null; }[] }> ``` `agentApi:models.read` — the account's enabled models and the prices the user set. Pass `id` as `modelId` to `host.agents.create`/`update`. ## `host.license` ```ts host.license(): Promise<{ model: "OneTime" | "Monthly"; status: "Active" | "Cancelling" | "PastDue" | "Ended"; paidThrough: string | null } | null> ``` This installation's own licence, read-only, no permission needed. `null` means nothing to pay: the plugin is free, the account owns it, or it was installed while the plugin was free. `paidThrough` is an ISO timestamp (monthly only). --- ## `host.workspace` The files of the agent the invocation runs for — the same files and rules as the agent's own `workspace_*` tools (including the account's shared area). Present with `host:workspace:read` and/or `host:workspace:write` **and** an agent in context: tools, every middleware hook (for `interAgentMessage` it is the sender) and the session lifecycle events. ```ts host.workspace.list(args?: { dir?: string }) : Promise<{ path: string; entries: { name: string; dir: boolean; sizeBytes: number; modified: string; contentHash: string | null }[]; truncated: boolean }> // read host.workspace.read(args: { path: string }) : Promise<{ path: string; binary: boolean; tooLarge: boolean; sizeBytes: number; contentHash: string; text: string | null; totalLines: number }> // read host.workspace.read(args: { path: string; encoding: "base64"; offset?: number; length?: number }) : Promise<{ path: string; sizeBytes: number; contentHash: string; offset: number; base64: string }> // read host.workspace.write(args: { path: string; content: string; encoding?: "utf8" | "base64" }) : Promise<{ path: string; sizeBytes: number; contentHash: string }> // write host.workspace.edit(args: { path: string; operation: "replace_lines" | "insert_at_line" | "str_replace"; expectedHash?: string; // refuse if the file changed since you read it fromLine?: number; toLine?: number; // replace_lines line?: number; // insert_at_line content?: string; // replace_lines / insert_at_line oldText?: string; newText?: string; // str_replace }): Promise<{ path: string; sizeBytes: number; contentHash: string }> // write host.workspace.delete(args: { path: string }): Promise<{ deleted: true }> // write ``` `write` stores UTF-8 text, or the decoded bytes when `encoding` is `"base64"` (invalid base64 is `bad_arguments`). A base64 `read` returns the byte range starting at `offset` (default 0), at most 2 MiB per call (`length` is clamped to that; read a larger file in chunks, using `sizeBytes` and `contentHash` to know when you are done and that the file did not change). An `offset` past the end gives an empty `base64`. Errors carry codes such as `invalid_path`, `file_not_found`, `bad_arguments` and `workspace_storage_exceeded`. ## `host.media` Upload audio and have the platform transcode it. Requires `host:media:transcode`, which also raises the invocation's limits (wall clock at least 60 s, more memory). ```ts host.media.uploadAudio(args: { contentType: string; dataBase64: string }): Promise<{ uploadToken: string }> host.media.transcodeAudio(args: { uploadToken: string }): Promise<{ downloadUrl?: string; /* … */ }> ``` One upload at a time: call `transcodeAudio` before the next `uploadAudio`. The returned `downloadUrl` can be fetched once with `host.fetch`. 10 media operations per invocation. --- ## Models from plugin code Three surfaces let tools, middleware, lifecycle events, compaction and web providers use a model without an API key. None needs a permission; usage is metered to the account. None takes a model argument — the platform decides which model runs. Model-provider plugins don't get them. ```ts host.session.llm?.complete(args: LlmCompletionArgs): Promise // the chat's own agent model host.llm?.complete(args: LlmCompletionArgs): Promise // the account's Utility model interface LlmCompletionArgs { messages: { role: string; content: string }[]; params?: { temperature?: number; maxTokens?: number; topP?: number; stop?: string[]; thinking?: "off" | "low" | "medium" | "high" }; } interface LlmCompletion { schemaVersion: number; assistant: { text: string | null; toolCalls: { id: string; name: string; argumentsJson: string }[]; thinking?: { text: string | null; replayJson: string | null } }; usage: { inputTokens: number; outputTokens: number; cachedTokens: number }; stopReason: string; } ``` - `host.session.llm` — present whenever the invocation has a chat and agent, even without any other `host.session` member. 5 calls per invocation. - `host.llm` — present only when the account has a **Utility model** set (AI Providers page). No fallback to the agent's model. 5 calls per invocation, counted separately. ```ts host.decide.noul({ state, questions: { instructions: string; trueMeans?: string; falseMeans?: string }[] }) host.decide.choose({ state, questions: { instructions: string; options: Record }[] }) host.decide.score({ state, questions: { instructions: string; levels: string[] }[] }) : Promise<{ answeredBy: "systemOne" | "llmFallback"; fallbackReason?: string; answers: … }> ``` Fast, typed decisions on the account's (or agent's) **Decision model**. `state` is a string, object or array read once for all questions; `answers` come back in the same order: `{ probability }`, `{ choice, confidence?, probabilities? }`, `{ score, confidence?, probabilities? }`. With no decision model, a chat model answers instead (`answeredBy: "llmFallback"`, slower, no calibrated `confidence`). If nothing can answer, the call throws `not_configured: …`. No per-invocation cap. --- ## `host.ssh` ```ts host.ssh.command(command: string, options?: { timeoutSeconds?: number }) : Promise<{ exitCode: number; output: string; errorOutput: string; isTruncated: boolean; durationMs: number }> ``` Only inside `sshConnectionOpening`, `sshCommandSending` and `sshCommandCompleted`, only on the server that hook fired for. `host:ssh:command`. 60 s default, 90 s max, 10 calls per invocation, stateless. A non-zero `exitCode` is a normal result; an unreachable host rejects. See [Middleware](plugin-middleware.md#hostsshcommand--run-your-own-command-on-that-server). ## `host.connection` ```ts host.connection.send(frames: ({ link?: string; text: string } | { link?: string; base64: string })[], options?: { idempotencyKey?: string }): Promise ``` Write frames onto a live connection your plugin owns, from a channel handler of that connection's channel. `host:connection:send`. At most 20 frames per call, all accepted or all refused; 20 calls per invocation. Reuse the same `idempotencyKey` on a retry. See [Live connections](plugin-live-connections.md#sending-outside-a-step--hostconnectionsend). # API reference Everything on this page is also a button in the console. Use the API to publish from a script or CI. **Base URL:** `https://services.agentparley.ai/platform` **Authentication:** `Authorization: Bearer ` — the same signed-in user token the console uses. Every request acts as that user on that user's account. Ids in requests and responses are strings. **Errors:** a failed request returns a JSON body `{ "message": "…", "traceId": "…" }`. `400` is a validation error (the message says what and where), `404` is a plugin, version or installation that is not yours or does not exist, `409` is a conflict, `503` means retry shortly. ### Get a token ```http POST /auth/login Content-Type: application/json { "email": "you@example.com", "password": "…" } ``` If the email belongs to one account the response is `{ "status": "authenticated", "authenticated": { "token": "…", "expiresAt": "…", "refreshToken": "…", "refreshExpiresAt": "…", "user": { … }, "account": { … } } }`. If it belongs to several, it is `{ "status": "selectionRequired", "selectionRequired": { "selectionToken": "…", "candidates": [{ "userId", "accountId", "accountName", "name" }] } }`; pick one with `POST /auth/login/select { "selectionToken": "…", "userId": "…" }`, which returns the same `authenticated` object. `POST /auth/refresh { "refreshToken": "…" }` returns a fresh `{ token, expiresAt, refreshToken, refreshExpiresAt }`; the old refresh token is replaced, so store the new one. ## Authoring ### Create a plugin ```http POST /plugins Content-Type: application/json { "slug": "weather", "displayName": "Weather", "description": "Forecasts for your agents." } ``` `201 Created` with the plugin, including its `id`. `slug` and `displayName` are required. The slug matches `^[a-z0-9][a-z0-9_-]{0,63}$`, must be unused (`409` otherwise), and may not start with `agentparley-` (reserved for official plugins) or `mcp_`. A new plugin is private. ### List and read your plugins ```http GET /plugins GET /plugins/{id}?includeFiles=true ``` `GET /plugins` takes an optional `isPriced=true|false`. The detail includes every version (`semVer`, status, `sourceSha256`), visibility, and — with `includeFiles` — the source text of the latest published version's files. ### Upload a package (create or version in one call) ```http POST /plugins/upload Content-Type: multipart/form-data file=@weather.zip ``` The one call a script or CI needs. The zip's `readme.md` frontmatter names the plugin: `name` is required, `slug` is optional (default: the name lowercased, with runs of other characters turned into `-`, at most 64 characters), and `version` is `.` (default `1.0`). The platform creates the plugin if the slug is new, or versions the one you own, and picks the patch itself: changed content publishes the latest patch + 1, a raised `major.minor` publishes `..0`, unchanged content publishes nothing. ```bash curl -X POST "$BASE/plugins/upload" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@weather.zip;type=application/zip" ``` `200` with `{ "pluginId": "…", "slug": "weather", "semVer": "1.0.3", "outcome": "Created" }`. `outcome` is `Created`, `NewVersion` or `Unchanged` (then `semVer` is `null`). `409` if the slug belongs to another account, or the readme's `major.minor` is lower than the latest published one. If a new plugin's package fails to build, the empty plugin is removed again. The plugin stays private until you list it in Browse (below). ### Publish a version from a zip ```http POST /plugins/{id}/versions/zip Content-Type: multipart/form-data file=@weather.zip semVer=1.0.0 ``` The zip holds the [package](plugin-package-format.md) — `readme.md` is required — optionally inside one wrapper folder. Up to 256 MB. `semVer` is digits-only `major.minor.patch`; you choose it. Zips are the only way to publish binary files (companion packs, portraits, `icon.png`, the `gallery/` folder). ```bash curl -X POST "$BASE/plugins/$PLUGIN_ID/versions/zip" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@weather.zip;type=application/zip" \ -F "semVer=1.0.1" ``` | Response | Meaning | | --- | --- | | `201` | published; the body is `{ id, semVer, status, commandCount, skillCount, skillFileCount, middlewareCount, lifecycleHookCount, toolCount, hasCode }` | | `400` | a validation error — the message says what and where | | `409` | that version already exists (versions are immutable) | | `503` | bundler busy — retry shortly | ### Publish a version from a file list ```http POST /plugins/{id}/versions Content-Type: application/json { "semVer": "1.0.1", "files": [ { "path": "readme.md", "contentText": "---\nname: Weather\n---\n# Weather\n…" }, { "path": "index.ts", "contentText": "globalThis.tools.get_forecast = { … };" } ] } ``` Text files only, up to 64 MB of request body. `201` returns the same summary as the zip publish. ### Save a draft ```http POST /plugins/{id}/draft Content-Type: application/json { "files": [ … ] } ``` Builds and validates like a publish, without a version number. `201` returns the same summary as a publish. Installs on the **Draft** policy (your own account only) follow it. An icon for a JSON publish or draft goes up separately, because the file list is text only: ```http PUT /plugins/{id}/draft/icon multipart/form-data, file=@icon.png DELETE /plugins/{id}/draft/icon ``` The file is a PNG or SVG of at most 512 KB. `PUT` returns `{ "draftIconUrl": "…" }`; the next draft save or publish carries it. A zip's own `icon.svg` or `icon.png` replaces it. ### List in Browse / remove from Browse ```http POST /plugins/{id}/listing DELETE /plugins/{id}/listing { "deprecationMessage": "Replaced by weather-pro." } ``` Both return `204`. Listing requires a published version (`409` otherwise). Removing keeps existing installs working and shows them the message. ### Yank a version / delete a plugin ```http POST /plugins/{id}/versions/{versionId}/yank DELETE /plugins/{id} ``` Yank returns the version summary. You can't yank the last published version (`409`). Installs on **Latest** that sit on a yanked version move to the highest remaining version they have already agreed to; an install pinned to it has no version to run until its owner picks another. Delete returns `204`, and `409` for any plugin that has ever had a published version, yanked or not: only a plugin that never published can be deleted. ### Set a price ```http PUT /plugins/{id}/price Content-Type: application/json { "price": { "model": "Monthly", "amountUsd": 4.99 }, "payoutCountry": "US" } ``` `model` is `OneTime` or `Monthly`. The amount is between $2.99 and $999. `"price": null` makes the plugin free. `payoutCountry` (a country code) is required the first time you price a plugin. The platform keeps 30% of each sale. An account that installs a priced plugin without a licence gets `402` with `"code": "plugin_purchase_required"`. `GET /earnings/summary`, `GET /earnings/sales` and `GET /payouts` report what you have earned. ## Catalog and installs ```http GET /marketplace every listed plugin, plus your own private ones GET /marketplace/{slug} readme, capabilities, what it can reach, installed or not ``` `GET /marketplace` takes `q` (search text), repeated `kinds`, `categories` and `madeBy` (`Official`, `Verified` or `Community`), `isPriced`, `hideInstalled`, `sort` (`installs` by default, `newest`, `updated` or `name`), `offset`, and `limit` (24 by default, 60 at most). ```http POST /installations { "slug": "weather", "versionPolicy": "Latest", "agentIds": ["…"] } GET /installations optional pluginId, state (needs-you, update or pinned), q GET /installations/{id} POST /installations/{id}/configure { "settings": { "units": "metric" }, "secrets": { "apiKey": "…" } } PATCH /installations/{id} { "versionPolicy": "Pinned", "pinnedVersionId": "…" } PUT /installations/{id}/agents { "scope": "Chosen", "agentIds": ["…"] } PATCH /installations/{id}/capabilities/{activationId} { "isEnabled": false } POST /installations/{id}/accept-update { "versionId": "…" } POST /installations/{id}/rerun-setup DELETE /installations/{id} ``` - `POST /installations` returns `201` with `{ installationId, slug }`; `409` if the account already has the plugin. - `versionPolicy`: `Latest` (default, also used when the value is missing or unrecognised), `Pinned` (needs `pinnedVersionId` on `PATCH`), or `Draft` (your own plugin only; `403` otherwise). - `agentIds` limits the install to those agents; leave it out for all agents. `PUT …/agents` changes it later: `scope` is `All` or `Chosen`, and `agentIds` is read only for `Chosen`. - `PATCH …/capabilities/{activationId}` turns one of the install's capabilities on or off. - `configure` takes setting values as strings and replaces the installation's non-secret settings, so send every value; secrets are write-only and never returned, and a blank secret leaves the stored one untouched. - `accept-update` accepts a version that asks for more access. - `rerun-setup` sends `pluginInstalled` again (after a plan upgrade, for example). - `DELETE` uninstalls: the plugin's agents (and their chats), providers, channels, live connections, sign-ins and stored secrets are removed, and `pluginUninstalled` is sent. ## Logs ```http GET /plugin-logs?installationId={id}&levels=warn&levels=error&limit=100&cursor=… GET /plugin-logs/counts?installationId={id}&since=2026-01-01T00:00:00Z ``` The `host.log` lines your account's installations produced, newest first, kept 24 hours. Filters: `installationId`, `sessionId`, repeated `levels` (`debug`, `info`, `warn`, `error`), `since`, `search`, repeated `entrypoints`. `limit` is 50 by default; page with `cursor`. `counts` needs `installationId` and `since`. ## Inbound webhooks for plugin channels A [channel plugin](plugin-channels.md) that receives by webhook gets one address per connected channel: ``` https://webhook.agentparley.ai/webhook/plugin/{routingId} ``` The channel's page in the console shows it (and can replace it with a new address). Everything the vendor POSTs there reaches your `channel.receive` — headers, the text body, and the exact bytes for signature checks. Requests are queued on arrival, so messages sent while AgentParley deploys are delivered afterwards rather than bounced. # Tools A tool is a function the model can decide to call, exactly like the built-in `web_search` or `workspace_read_file`. You describe it with a name, a description and a JSON Schema for its input; the model decides when to call it and with what arguments; your handler runs in the sandbox and whatever it returns goes back to the model. ## Register a tool > Written for these docs. Official plugins register tools the same way — for example `briefing_weather` in > `plugins/morning-briefing/src/weather.ts`. The shape below is exactly what the runtime and the publish check > enforce. ```ts globalThis.manifest = { egress: ["api.open-meteo.com"], }; globalThis.tools.get_forecast = { description: "Get tomorrow's temperature range for a latitude/longitude. Use when the user asks about weather.", parameters: { type: "object", properties: { latitude: { type: "number", description: "Decimal degrees, north positive" }, longitude: { type: "number", description: "Decimal degrees, east positive" }, }, required: ["latitude", "longitude"], }, handler: async (input: { latitude: number; longitude: number }) => { const response = await host.fetch({ url: "https://api.open-meteo.com/v1/forecast" + `?latitude=${input.latitude}&longitude=${input.longitude}` + "&daily=temperature_2m_max,temperature_2m_min&forecast_days=2", }); if (response.status !== 200) throw new Error(`forecast service returned ${response.status}`); const daily = JSON.parse(response.body).daily; return { minC: daily.temperature_2m_min[1], maxC: daily.temperature_2m_max[1] }; }, }; ``` | Field | Rules | | --- | --- | | name (the key) | `^[a-z][a-z0-9_]{0,63}$` — lowercase, digits, underscores, starts with a letter | | `description` | **required**, non-empty. This is what the model reads to decide when to call you: say what it does *and when to use it*. | | `parameters` | optional JSON Schema for the input. Defaults to `{ "type": "object", "properties": {} }` (no arguments). Must be plain JSON. | | `handler` | **required**, a function `(input, context) => result` (sync or async). | | `alwaysListed` | optional boolean. `true` keeps the tool in the agent's tool list when an agent has more tools than its model takes and your other tools move behind `search_tools`. At most 3 per plugin; a 4th fails the publish. | A bare function (`globalThis.tools.x = async () => …`) or a registration without a description fails the publish. ## What your handler receives and returns - `input` is the model's arguments object, parsed from JSON. The platform doesn't validate it against your schema — check what you depend on. - `context` is `{ callId: string; initiator: TurnInitiator }`, frozen. `initiator` says what started the turn, set by the platform and never by the model: `kind` is `"user"`, `"channelSender"`, `"schedule"`, `"event"`, `"agent"` or `"platform"`, and `isVerifiedPerson` is true when every input came from a signed-in user or a verified contact. It does not see text the model read through tools or earlier turns, so it is not proof that an action is free of planted instructions. Use it to refuse actions that a schedule, an event or another agent must not trigger on its own. - Return any JSON-serializable value. It is serialized and handed to the model as the tool's result. Returning `undefined` sends `null`. - **Throwing is fine.** A thrown error, a timeout or a memory fault becomes a failed tool result the model can see and react to; the agent's turn carries on. Keep results compact: everything you return lands in the model's context. ## How the model sees it Your tool joins the agent's tool list next to the built-ins, named **`__`** — for example `weather__get_forecast` for a plugin with slug `weather`. Two plugins can therefore both have a `search` tool. (A tool whose final name equals a built-in tool's name is dropped with a warning.) ## Which agents get it Installing the plugin makes the tool available to **every agent on the account**. On each agent's **Access** tab the owner can switch **Tools** from "all" to a short list, deny your tool on one agent, or pin it. Nothing to do on your side. A crew plugin can instead fix an agent's tool list itself with `tools` on [`host.agents.create`/`update`](plugin-host-api.md#hostagents). ## What a tool can reach A tool call runs inside a chat, on behalf of one agent, so it gets the richest context of any capability: - `host.fetch`, `host.settings`, `host.crypto`, `host.log` — always. - `host.session.*` — act on the calling chat: read the conversation, post a message, manage todos, schedule a timer, spawn a subagent, message a peer (each by permission — see [Permissions](plugin-permissions.md)). - `host.workspace.*` — read and write the calling agent's files (and the shared workspace). - `host.agents.*`, `host.cronJobs.*`, `host.models.list()` — your plugin's own crew and schedules. - `host.oauth.accounts()`, `host.license()` — the customer's connected sign-ins and your licence. - `host.session.llm`, `host.llm`, `host.decide` — call a model. - `host.media.*` — upload and transcode audio. Written for these docs — a tool that saves a note into the agent's workspace: ```ts globalThis.tools.save_note = { description: "Save a short note into the agent's workspace under notes/.", parameters: { type: "object", properties: { title: { type: "string" }, text: { type: "string" } }, required: ["title", "text"], }, handler: async (input: { title: string; text: string }) => { const path = `notes/${input.title.replace(/[^a-z0-9-]+/gi, "-").toLowerCase()}.md`; await host.workspace!.write({ path, content: input.text }); return { saved: path }; }, }; ``` Calling `host.workspace.write` derives `host:workspace:write`, so the installer sees "can write the agent's files" before installing. ## Limits 30 s wall clock and 128 MB per call (at least 60 s and 512 MB with `host:media:transcode`). Each call is metered as a plugin invocation. ## TypeScript The downloadable SDK types declare the registry: `declare var tools: Record`, with `ToolDefinition` = `{ description: string; parameters: Record; handler: (input: any, context: ToolCallContext) => unknown | Promise; alwaysListed?: boolean }`. The types require `parameters`; the runtime defaults it to no arguments, so a tool with none still writes `parameters: { type: "object", properties: {} }` when you type-check. # Middleware hooks Middleware runs **inside** an agent's work, at a fixed point, and can change the value passing through it — the system prompt, an incoming message, a message between two agents, a todo, a timer, a shell command about to run on a server, a scheduled mission about to fire. Most hooks can also **veto** the operation. This is how the official **Caveman** plugin makes every answer terse, and how a plugin can redact secrets from SSH output, block `rm -rf /`, audit every message between agents, or hold scheduled missions during a maintenance window. ## The shape of a hook ```ts globalThis.middleware. = async (context, tools) => { // read context, maybe call host.* return newValue; // or: return tools.cancel("why"); }; ``` - `context` — what is happening, as plain JSON. **Ids are strings** (they are 64-bit numbers that JavaScript can't hold exactly). - `tools.cancel(reason?)` — returns a veto token. Return it to stop the operation. The reason (≤ 500 characters) is recorded. - `tools.initiator` — on the ten turn hooks (`userMessageInbound`, `interAgentMessage`, `assistantMessageOutbound`, `todoAdded`, `notifyScheduled`, `notifyDelivered`, `sshConnectionOpening`, `sshCommandSending`, `sshCommandCompleted`, `cronJobFiring`): what started the turn, set by the platform and never by the model. Its `kind` is `user`, `channelSender`, `schedule`, `event`, `agent` or `platform`, and `isVerifiedPerson` is true only when every input came from a signed-in user or a verified contact. Use it to gate an action that a schedule or another agent must not trigger on its own. It does not see text the model read through tools, so it is not proof that an action is free of planted instructions. - **What to return** depends on the hook's *mutable fields* (the table below): - **One field** — return the new value itself (a string for `text`, a `string[]` for `texts`). - **Several fields** — return an object with the fields you change; omitted fields keep their value. - **No fields** — observe-only; the return value is ignored. Return the unchanged value to pass through: `return context.text`. Returning `undefined` from a hook that has a field to fill is treated as a malformed result: your handler is skipped and the original value is kept. ## Rules that apply to every hook - **Agent scope.** Your hooks run only for the agents your installation reaches (all agents, or the agents chosen at install). A change takes effect on the next request. - **Ordered chain.** When several plugins hook the same point they run one after another; each sees the previous one's output. A cancel stops the chain. - **Fail-open.** If your handler throws, times out (5 s; 1 s on `companionSpeech`), or returns the wrong shape, it is skipped with a warning and the value continues unchanged. A broken middleware can't take an agent down. A guard can opt out: list its hook in [`manifest.failClosed`](plugin-manifest.md#failclosed) and the same fault **blocks** the operation instead. Only hooks that can cancel are allowed there. Keep observers fail-open. - **5 seconds** per handler (1 second on `companionSpeech`). Middleware sits on the agent's critical path — keep it fast. - **Full host surface.** A hook gets the same `host` members a tool with the same permissions would — including `host.session` (the session the hook fired for), `host.fetch`, `host.llm`, `host.decide`. `host.workspace` is present on every hook, because every hook carries an agent. - Metered only when a handler actually ran. ## All 17 hooks | Hook | Fires | Mutable (what you return) | Cancel means | Agent (workspace) | | --- | --- | --- | --- | --- | | `systemPrompt` | every turn, after the system prompt is composed | `systemPrompt` → a string | *(ignored — a turn always needs a prompt)* | yes | | `companionBriefing` | once per turn, for an agent with a companion, as the "Speaking aloud" instructions are built | `{ parts?, moves? }` | *(ignored — a briefing always has to exist)* | yes | | `companionPerformance` | once per reply, when the companion's animation plan is built | `groups` → a `CompanionPerformanceGroup[]` | *(ignored)* | yes | | `companionSpeech` | before each group of sentences is sent to the voice provider | `text` → a string | *(ignored)* | yes | | `userMessageInbound` | a person's message arrives (console or a channel) | `text` → a string | the message is dropped | yes | | `interAgentMessage` | one agent's message is delivered into another agent's conversation | `text` → a string | the message is not delivered | yes (the sender) | | `assistantMessageOutbound` | before an agent's reply goes out to a person | `text` → a string | the reply is not sent | yes | | `todoAdded` | an agent adds todos | `texts` → a `string[]` | the todos are not added | yes | | `notifyScheduled` | an agent schedules a timer to wake itself | `{ message?, delaySeconds? }` | the timer is not scheduled | yes | | `notifyDelivered` | that timer fires | `message` → a string | the wake-up is dropped | yes | | `sshConnectionOpening` | once per server per turn, before the first command connects | *(none)* | the agent's work on that server is refused | yes | | `sshCommandSending` | before every remote command or file operation | `{ command?, path?, content? }` | the operation is refused | yes | | `sshCommandCompleted` | after every remote command or file operation | `{ output?, errorOutput? }` | the output is withheld from the model (the command already ran) | yes | | `sessionClearing` | `/new` is about to clear a chat | *(none)* | *(ignored — a clear can't be vetoed)* | yes | | `sessionClearCompleted` | `/new` finished clearing | *(none)* | *(ignored)* | yes | | `cronJobFiring` | a schedule or heartbeat is about to fire | `prompt` → a string | the run is recorded as **Cancelled**; nothing runs | yes | | `cronJobCompleted` | a scheduled run that actually ran has closed | *(none)* | *(ignored)* | yes | ## Hook reference ### `systemPrompt` ```ts context: { sessionId: string; agentId: string; sessionKind: string; systemPrompt: string } returns: string ``` The most-used hook: add doctrine, style, or context to what the agent is told every turn. `sessionKind` is a snake_case string such as `"direct"` or `"channel"`. The signature has no `tools` argument in the types — there is nothing to cancel. ```ts // plugins/caveman/index.ts globalThis.middleware.systemPrompt = async (context) => { return context.systemPrompt + GUIDANCE; }; ``` Because the prompt is recomposed every turn, you can decide per agent. **Oh My OpenAgents** gives its doctrine only to agents that are *not* part of its own crew: ```ts // infrastructure/plugins/oh-my-openagents/index.ts globalThis.middleware.systemPrompt = async (context) => { const { agents } = await host.agents!.list(); const isOwnedCrewMember = agents.some((agent) => agent.id === context.agentId); return isOwnedCrewMember ? context.systemPrompt : context.systemPrompt + ORCHESTRATION_DOCTRINE; }; ``` ### `companionBriefing` ```ts context: { sessionId: string; agentId: string; companionId: string; parts: { name: string; text: string }[]; moves: { name: string; description: string; motionId: string | null }[]; // motionId is read-only voice: { providerKindId: string; model: string } | null; // read-only; null = no working voice wardrobe: { outfits: { key: string; name: string }[]; items: { itemId: string; tagName: string; name: string; bone: string }[]; outfitKey: string | null; wornItemIds: string[] } | null; // read-only; null when her look has no wardrobe } returns: { parts?: { name: string; text: string }[]; moves?: { name: string; description: string; motionId: string | null }[] } ``` Edits the "Speaking aloud" instructions an agent with a companion is given. The built-in parts, in order, are `speaking`, `cues`, `moves`, `wardrobe` (only when her look has outfits or items), `stage` (3D looks only: the camera tags, plus the pace tags when she has a `walk` motion) and `closing`; the list of moves is printed right after the part named `moves`. Edit, remove or add parts and moves; blank names or texts are dropped and descriptions are shortened to 80 characters. There is no cancel. Fires once per turn, and the system prompt is cached, so the output **must be the same for the same input** — adding the time, for example, makes the prompt cache miss every turn. A move you add that the look doesn't have may be requested by the agent, but nothing plays. The `wardrobe` context lists her outfits, the installed items (up to 30 are named in the briefing) and what she wears now; you can remove the `wardrobe` part, and the tags still work whenever the agent or an `assistantMessageOutbound` plugin writes them. ```ts // Written for these docs — ask for a pirate-style closing globalThis.middleware.companionBriefing = async (context) => { return { parts: context.parts.map((part) => part.name === "closing" ? { ...part, text: part.text + "\n- Speak like a pirate." } : part) }; }; ``` ### `companionPerformance` ```ts context: { sessionId: string; agentId: string; companionId: string; messageId: string; motions: { id: string; name: string; tagName: string | null; description: string; roles: string[]; canPlayWhileSpeaking: boolean }[]; groups: { index: number; caption: string; words: string[]; // read-only mood: { emotion: string; intensity: number } | null; cues: { wordIndex: number; kind: "emotion" | "tone" | "effect" | "pause" | "motion" | "pace" | "camera"; name: string; intensity: number; motionId: string | null; endWordIndex: number | null }[]; gesture: { motionId: string; anchorWordIndex: number | null } | null; partsGestureMotionId: string | null; }[]; } returns: the edited groups array ``` Edits a reply's animation plan before the browser gets it: per group of sentences, the mood, the cues, the body gesture and the extra-parts gesture. `caption`, `words` and `index` are always taken from the original. The server checks the result; if it has a different number of groups, an unknown motion id, a word index out of range, or a gesture that cannot play while speaking (`canPlayWhileSpeaking: false`), the **whole edit is discarded** and the original plan is used. Fires once per plan, off the path to first sound. There is no cancel; to remove all animation, return groups with no gesture and no cues. The cue kind `motion` is a body move the agent asked for with `[motion:name]`. A `pace` cue's `name` is `left`, `right` or `home` (she walks across the floor on the Unity stage); a `camera` cue's `name` is a whole yaw in degrees from -75 to 75 (`"30"`), `front` or `reset`. A `name` outside those values discards the whole edit, and so does an `intensity` outside 0 to 1. `endWordIndex` is for `tone` cues only: the word the tone stops before (`words.length` = the group's end), `null` = until another tone or the reply ends. **Wardrobe cues (`[outfit:…]`, `[hold:…]`) are not passed to this hook, and cannot be added by it** — an edit containing one is discarded like any other invalid edit. A plugin changes what she wears through the reply text instead (see `assistantMessageOutbound`). ### `companionSpeech` ```ts context: { sessionId: string; agentId: string; companionId: string; messageId: string; segmentIndex: number; caption: string; // caption is read-only: what is shown text: string; // what the voice provider will read providerKindId: string; model: string; // which voice provider kind and model will speak this group (read-only) } returns: string ``` Rewrites the text the voice provider reads for one group of sentences, for example to fix a pronunciation. The caption stays the same, and the platform lines the new spoken words up with the caption words. Blank text keeps the original. Not fired for thinking sounds ("hmm…"). There is no cancel. This hook sits on the **path to first sound**: the first group's voice cannot start until it returns, so keep it well under 100 ms (the limit is 1 s, after which it is skipped and the original text is spoken). The hook runs **before** the platform's own per-provider tag translation: write our cues (`[laughing]`, `[whispering]`) and the platform translates them for the provider, or write the vendor's own tags (`[mischievously]` for ElevenLabs), which tag-aware providers pass through unchanged. **Keep rewrites local** — a word or two ("SQL" to "sequel"); rewriting or reordering a whole sentence breaks the word timing from that point on. ```ts // Written for these docs globalThis.middleware.companionSpeech = async (context) => context.text.replace(/\bSQL\b/g, "sequel"); ``` ### `userMessageInbound` ```ts context: { sessionId: string; agentId: string; channelId: string | null; text: string; mediaIds: string[]; sender: { displayName: string; handle: string | null; isVerified: boolean; isSharedConversation: boolean }; } returns: string | Cancellation ``` `channelId` is `null` for the web console. `mediaIds` are the ids of attached files (read-only). `sender` (read-only) is who typed it: a console message from a signed-in user is verified and not shared; on a channel, `isVerified` means the person is on that channel's people list and `isSharedConversation` means a group chat rather than a direct one. Written for these docs — mask card numbers before the model ever sees them: ```ts globalThis.middleware.userMessageInbound = async (context) => { return context.text.replace(/\b(?:\d[ -]?){13,19}\b/g, "[card number removed]"); }; ``` ### `interAgentMessage` ```ts context: { senderSessionId: string; senderAgentId: string; targetSessionId: string; isSubagentReport: boolean; // true when a subagent reports back to the agent that spawned it text: string; } returns: string | Cancellation ``` Fires for every message one agent's conversation sends into another's: `message_agent`, `send_message_to_session`, `report_to_caller`, the automatic reply-back when a subagent or peer finishes, and `host.session.peer.send` from plugin code. It does **not** fire for the task text a lead hands a brand-new subagent through `start_subagent`. This is the crew's governance point: guardrails, redaction, routing notes, an audit trail. See [Agent crews](plugin-agent-crews.md#watching-and-shaping-crew-traffic) for a worked example. `host.workspace` and `host.session` here belong to the **sender**. ### `assistantMessageOutbound` ```ts context: { sessionId: string; agentId: string; channelId: string | null; text: string } returns: string | Cancellation ``` The last look before a reply reaches a person — in the console or on a channel, including the agent's explicit `reply_to_human` and `send_to_channel`. Cancel to stop it being delivered. This is also how a plugin changes a companion's wardrobe. A spoken reply may carry `[outfit:]`, `[hold:]` and `[hold:none]` tags (see [Companion packs](plugin-companion-packs.md#how-the-agent-changes-her-wardrobe)); a plugin that writes one into the text makes the change, saved with the reply, exactly as if the agent had written it. ```ts // Written for these docs — a Friday reply always starts in the party hat globalThis.middleware.assistantMessageOutbound = async (context) => { return new Date().getDay() === 5 ? `[hold:party-hat] ${context.text}` : context.text; }; ``` ### `todoAdded` ```ts context: { sessionId: string; agentId: string; texts: string[] } returns: string[] | Cancellation ``` Rewrite, split, filter or reject the todos an agent writes itself. Because open todos keep an agent working across turns, this is how a plugin shapes long missions. ### `notifyScheduled` / `notifyDelivered` ```ts notifyScheduled: context: { sessionId: string; agentId: string; message: string; delaySeconds: number } returns: { message?: string; delaySeconds?: number } | Cancellation notifyDelivered: context: { sessionId: string; agentId: string; notifyId: string; message: string } returns: string | Cancellation ``` An agent's timers to itself (`notify_session_in`). Clamp delays, add context to the wake-up note, or drop it. ### `sshConnectionOpening` ```ts context: { sessionId: string; agentId: string; sshHostId: string; hostDisplayName: string; hostname: string; port: number | null; // null for a tunnel host connectionType: "direct" | "tunnel"; username: string; } returns: void | Cancellation ``` Fires once per server per turn. Observe, log, or refuse — you can't redirect the connection. This is the place for one-time setup with `host.ssh.command` (below). ### `sshCommandSending` ```ts context: { sessionId: string; agentId: string; sshHostId: string; hostDisplayName: string; kind: "command" | "list_files" | "read_file" | "write_file" | "edit_file" | "delete_file"; command: string | null; // set for kind "command" path: string | null; // set for every file operation content: string | null; // the text being written (write_file / edit_file); null otherwise and for binary writes intent?: string; // what the agent said the command is for (read-only) isBackground: boolean; // true for a background run: its output skips sshCommandCompleted (read-only) } returns: { command?: string; path?: string; content?: string } | Cancellation ``` Written for these docs — refuse destructive commands and force a safer flag: ```ts globalThis.middleware.sshCommandSending = async (context, tools) => { if (context.kind !== "command" || !context.command) return {}; if (/\brm\s+-[a-z]*r[a-z]*f?\s+\/(\s|$)/.test(context.command)) { return tools.cancel("refusing to delete the root filesystem"); } return { command: context.command.replace(/\bapt-get install\b/, "apt-get install -y") }; }; ``` (Returning `{}` changes nothing.) ### `sshCommandCompleted` ```ts context: { sessionId: string; agentId: string; sshHostId: string; hostDisplayName: string; kind: …; command: string | null; path: string | null; exitCode: number; output: string | null; // stdout, a read file's text, or a directory's entry names errorOutput: string | null; // stderr for a command isTruncated: boolean; durationMs: number; } returns: { output?: string; errorOutput?: string } | Cancellation ``` Rewrite what the **model** sees. Written for these docs — redact AWS keys: ```ts globalThis.middleware.sshCommandCompleted = async (context) => { const redact = (s: string | null) => s?.replace(/AKIA[0-9A-Z]{16}/g, "[redacted AWS key]"); return { output: redact(context.output), errorOutput: redact(context.errorOutput) }; }; ``` Cancelling here withholds the output — the command already ran and can't be undone. #### `host.ssh.command` — run your own command on that server Inside the three SSH hooks only, with permission `host:ssh:command` (derived from the call), a plugin can run a command on **the server the hook fired for** — no other. ```ts host.ssh.command(command: string, options?: { timeoutSeconds?: number }) : Promise<{ exitCode: number; output: string; errorOutput: string; isTruncated: boolean; durationMs: number }> ``` - Runs through the user's login shell like the agent's own commands, but doesn't fire any SSH hook. - Stateless (no working directory or environment carried between calls), 60 s default / 90 s max, 10 calls per invocation, each opening its own connection. - Do setup in `sshConnectionOpening` (once per server per turn), not in the per-command hooks. - A crashed invocation can be re-run: make setup idempotent. ```ts // Written for these docs globalThis.middleware.sshConnectionOpening = async () => { await host.ssh!.command("command -v rg || sudo apt-get install -y ripgrep", { timeoutSeconds: 90 }); }; ``` A non-zero `exitCode` is a normal result. An unreachable host or a refusal rejects the promise. ### `sessionClearing` / `sessionClearCompleted` ```ts sessionClearing: context: { sessionId; agentId; trailingText: string | null; messageCount; subagentCount; peerThreadCount } sessionClearCompleted: context: { sessionId; agentId; deletedMessageCount; deletedSubagentCount; deletedPeerThreadCount } returns: nothing ``` `sessionClearing` is your last chance to read a transcript before `/new` deletes it (with `host.session.conversation.read`) — for example to save a summary somewhere. `trailingText` is the message the user typed after `/new`, if any. Counts include direct subagents and peer threads only. Both hooks can fire more than once for one clear after a crash; for a durable "it was cleared" signal use the [`sessionCleared` event](plugin-lifecycle-events.md). ### `cronJobFiring` ```ts context: { cronJobId: string; agentId: string; kind: string /* "cron_job" | "heartbeat" */; name: string; prompt: string } returns: string | Cancellation ``` Fires only for a slot that has passed the overlap check and is about to run. Rewrite the prompt, or cancel — the run shows as **Cancelled** in the schedule's history and the mission never starts. ```ts // infrastructure/plugins/cron-sentinel/index.ts globalThis.middleware.cronJobFiring = async (context, tools) => { host.log("info", `${MARKER} firing ${context.kind} "${context.name}" (cronJob ${context.cronJobId})`); if (context.prompt.indexOf(CANCEL_TOKEN) !== -1) { return tools.cancel(`${MARKER} refused: prompt carries ${CANCEL_TOKEN}`); } return `${context.prompt}\n\n${MARKER} fired ${context.kind} "${context.name}".`; }; ``` A real plugin would gate on a maintenance window, an on-call calendar or a feature flag. ### `cronJobCompleted` ```ts context: { cronJobId: string; cronJobRunId: string; sessionId: string; agentId: string; status: string } returns: nothing ``` Fires when a run that actually ran closes (`status` as shown in the run history). **At most once**: if the platform crashes between closing the run and calling you, the notification is lost. Don't use it for anything that must happen. ```ts // infrastructure/plugins/cron-sentinel/index.ts globalThis.middleware.cronJobCompleted = async (context) => { host.log( "info", `${MARKER} completed run ${context.cronJobRunId} of cronJob ${context.cronJobId}: ${context.status}`, ); }; ``` ## Loop protection If your hook reacts to a message by sending another one (for example `interAgentMessage` calling `host.session.peer.send`, which fires `interAgentMessage` again), each generation counts as a hop. Past 4 hops from one original event, `host.session` is withheld for that generation — a fuse against plugins ping-ponging each other forever. One reaction per event never comes close. # Lifecycle events Lifecycle events are notifications: *"you were just installed"*, *"a chat just started"*. Unlike middleware there is nothing to transform or veto — your handler runs, returns nothing, and the platform moves on. They are where a plugin sets itself up, heals itself, and cleans up. ```ts globalThis.events. = async (context) => { // do the work; return nothing }; ``` Assigning the handler at the top level is all it takes — the event is discovered at publish time like any other capability. ## The six events | Event | Fires | Sent to | `host.session` | | --- | --- | --- | --- | | `pluginInstalled` | your plugin was installed on an account (also on **Re-run setup**) | your plugin only | none — there is no chat | | `pluginUpdated` | the install moved to a different version (auto-update, accepted update, policy change, a new draft) | your plugin only | none | | `pluginUninstalled` | your plugin was uninstalled | your plugin only | none | | `sessionStarted` | a new interactive chat was created | every plugin on the account that handles it | that chat | | `sessionCleared` | a chat was cleared in place with `/new` | every plugin on the account that handles it | that (now empty) chat | | `sessionDeleted` | a chat was deleted | every plugin on the account that handles it | none usable — the chat is already gone | The session events fire for **interactive** chats only — not for scheduled runs, subagent sessions or agent-to-agent threads. ### Contexts ```ts pluginInstalled: { installationId: string; pluginSlug: string; versionId: string; semVer: string } pluginUpdated: { installationId: string; previousVersionId: string; newVersionId: string; previousSemVer: string; newSemVer: string } pluginUninstalled: { installationId: string; pluginSlug: string } sessionStarted: { sessionId: string; agentId: string; sessionKind: string } // "interactive" today sessionCleared: { sessionId: string; agentId: string } sessionDeleted: { sessionId: string; agentId: string } // the only record left ``` ## Delivery: at least once, never blocking - Each event is written to a durable outbox right after the change that caused it is saved, then delivered by a background worker. If AgentParley restarts after that, the event is still delivered. - A handler that throws, times out or runs out of memory is logged and **skipped**; it is not run again for that event. Catch errors you can recover from inside the handler, and make the next event (`sessionStarted`, **Re-run setup**) able to finish the job. - If AgentParley itself can't deliver the event (the plugin runtime is unreachable, a database error), the delivery is retried with exponential backoff (30 s base, up to 8 attempts). - The action that caused the event is never held up by your handler: the install completes, the chat opens. **At least once** means your handler can run more than once for the same occurrence (a redelivery after such an infrastructure failure). Write every handler to be **idempotent** — running it twice must leave the same result as running it once. For crews that's built in: `host.agents.create` is get-or-create by `key`. ## What a handler can reach The full host surface your permissions earn: `host.fetch`, `host.settings`, `host.crypto`, `host.log`, `host.agents.*`, `host.cronJobs.*` and `host.models.list()` (these work without a chat), `host.oauth`, `host.llm` and `host.decide`. The session events additionally get `host.session.*` for their chat, `host.session.llm` (the chat's agent model) and `host.workspace.*` (the chat's agent's files). Wall clock: 30 s per delivery attempt. ## Patterns ### Provision and heal a crew The **Oh My OpenAgents** crew plugin uses three events to keep its seven agents correct — created on install, re-asserted after every update, and re-created if the user deleted one, the next time any chat starts: ```ts // infrastructure/plugins/oh-my-openagents/index.ts globalThis.events.pluginInstalled = async () => { await reconcileCrew(); }; globalThis.events.sessionStarted = async () => { await reconcileCrew(); }; globalThis.events.pluginUpdated = async () => { await reconcileCrew(); }; ``` `reconcileCrew` lists the plugin's agents, creates missing ones by key, updates drifted ones and deletes retired ones. See [Agent crews](plugin-agent-crews.md#self-healing-crews). If the account didn't have room for every agent on install (plan limit), the owner can upgrade and press **Re-run setup** on the installed plugin's page — that sends `pluginInstalled` again. ### Greet a new chat Written for these docs — post a first message into every new interactive chat: ```ts globalThis.events.sessionStarted = async () => { await host.session!.message.post({ text: "Tip: type /orchestrate to put the whole crew on it." }); }; ``` Remember this can run twice; a greeting posted twice is harmless, but a counter incremented twice is not. ### Clean up external state `pluginUninstalled` is where to revoke a webhook you registered with a vendor or delete a remote record. Your plugin's agents, channels, live connections and stored secrets are removed by the platform itself on uninstall — you don't need to delete them. ## Events vs. the session-clear middleware `sessionClearing` (middleware) fires **before** `/new` deletes anything, so you can still read the transcript. `sessionCleared` (event) fires **after**, durably. Use the hook to read, the event to react. # Commands and skills Commands and skills are markdown files in your package. No code runs for them, and they need no permissions. - A **command** is a shortcut the user types: `/research why is the build slow?` expands into a full instruction for the agent. - A **skill** is a body of instructions (plus supporting files) an agent loads **only when it needs it**. The agent always sees the skill's key and one-line description; the full text costs context only when the agent decides to use it. ## Commands — `commands/.md` ```markdown --- description: Grounded, cited research — read the sources, and separate what was verified from what was inferred. argument-hint: --- Send the question below to Librarian (research): 1. Search broadly first — the codebase, its docs and commit history, and external sources when relevant. 2. READ the sources that matter; never answer from a search snippet alone. 3. Return a structured answer with EXACT pointers: file:line, function names, URLs, commit hashes — whatever lets me verify without redoing the search. 4. Separate what was VERIFIED from what was inferred, and name the gaps that could not be closed. Question: $ARGUMENTS ``` *(`infrastructure/plugins/oh-my-openagents/commands/research.md`)* - **The file name is the command.** `commands/research.md` registers `/research`. Names match `^[a-z0-9][a-z0-9_-]{0,63}$`. - **`description`** (required) — shown in the command menu. - **`argument-hint`** (optional) — the placeholder shown after the name, e.g. ``. - **The body** (required, non-empty) is the template sent to the agent in place of what the user typed. ### Placeholders | In the template | Replaced with | | --- | --- | | `$ARGUMENTS` | everything the user typed after the command name | | `$1` … `$9` | the 1st … 9th whitespace-separated word (empty if missing) | If the template uses none of them and the user typed something after the command, it is appended to the end of the template as a new paragraph — a plain template still receives the user's words. So `/research why is the build slow?` becomes the template above ending with `Question: why is the build slow?`. ### Which agents get them Commands appear on every agent of the account; each agent's **Access → Commands** can narrow the list. Command names are **not** prefixed with your plugin's slug. Built-in commands (such as `/new`) keep their names, and if two installed plugins both ship `/research`, the first one wins; the other is dropped. Pick distinctive names. ## Skills — `skills//skill.md` ``` skills/ parallel-orchestration/ skill.md references/ routing.md verification.md root-cause-analysis/ skill.md ``` `skill.md` (the file name's letter case doesn't matter; exactly one per folder): ```markdown --- name: Parallel Orchestration description: "The full conductor protocol for leading the crew: decompose by category, delegate in parallel, integrate, and finish on evidence. Includes the routing table and the done-checklist." --- # Parallel Orchestration The full conductor protocol for leading the Oh My OpenAgents crew. Your always-on doctrine is the short version; load this skill when you are about to conduct real, multi-part work and want the complete method, the routing table, and the verification checklist. … 2. **Decompose by category.** … The full table is in `references/routing.md`. ``` *(`infrastructure/plugins/oh-my-openagents/skills/parallel-orchestration/skill.md`)* - **The folder name is the skill's key** (`^[a-z0-9][a-z0-9_-]{0,63}$`). - **`description`** (required) — what the agent reads to decide whether to load it. Write it as "when to use me". - **`name`** (optional) — the display name shown in the console; defaults to the key. The agent sees and loads a skill by its **key**, not by this name. - **Supporting files** — anything else in the folder, up to 100 files per skill, 256 KB each. Refer to them by relative path in `skill.md`. ### How an agent uses a skill Every turn, the agent's system prompt carries a **Skills** section listing each available skill's name and description. When the agent decides it needs one, it calls the built-in tools: | Tool | Does | | --- | --- | | `use_skill(name)` | loads the skill's `skill.md` body into context and lists its supporting files (`name` is the skill's key) | | `list_skill_files(skill)` | lists the supporting files | | `read_skill_file(skill, path)` | reads one supporting file | This is *progressive disclosure*: a 40-line doctrine can point to a 300-line playbook that is only read when it matters. Oh My OpenAgents does exactly that — its always-on doctrine (injected by middleware) ends with *"load the `parallel-orchestration` skill (use_skill) for the complete method"*. Skills appear on every agent; **Access → Skills** can narrow them per agent. Like commands, skill names are not prefixed — first installed wins on a clash. The skill tools disappear from an agent that has no skills enabled. ## Keys are shared across the package Command names, skill keys and companion asset keys all live in one namespace per package: a command `research` and a skill `research` in the same package refuse the publish. ## Combining with code Commands and skills are most powerful next to code: - A **command** routes a goal to a specialist your plugin created (`/research` → Librarian). - **Middleware** adds a short always-on doctrine, and points to a **skill** for the long version. - A **lifecycle event** keeps the agents those commands refer to alive. See the [Oh My OpenAgents walkthrough](example-oh-my-openagents.md). # Channels A channel plugin connects agents to a chat app the platform doesn't support natively. People message the bot there; the message reaches an agent; the agent's reply goes back to the same conversation. It works exactly like the built-in Telegram, WhatsApp and Slack channels — the official **Discord** plugin is a channel plugin. You write only the translation between the vendor's format and AgentParley's. The platform does the rest for every channel alike: the people list and who-may-talk rule, link codes for new people, one chat per outside conversation, "only wake on @mention in group chats", de-duplication, a retrying outbound queue, parking messages that keep failing, and inbound files. ## Two ways in | | Webhook | Live connection | | --- | --- | --- | | The vendor… | POSTs each message to a URL | only delivers over a socket you keep open (Discord Gateway, IRC-style protocols) | | You implement | `channel.receive` + `channel.send` | `connection.open` + `connection.receive` + `channel.send` | | Example | `hello-channel` (below) | [Discord](example-discord.md) | A channel plugin must implement `channel.send` and one way in; otherwise the publish fails with *"a channel plugin needs a way in"*. This page covers the webhook path and the parts both share; see [Live connections](plugin-live-connections.md) for sockets. ## The handlers ```ts globalThis.channel = { receive?: (request, tools) => { messages: ChannelInboundMessage[] } | ChannelRejection, send: (request) => void, typing?: (request) => void, fetchAttachment?: (request) => { contentBase64: string; contentType?: string }, }; ``` ### `receive` — a webhook request arrived ```ts request: { headers: Record; body: string; // the body as text rawBodyBase64: string; // the exact bytes — verify signatures over THIS, not over `body` webhookUrl: string | null; } tools: { reject(reason: string): ChannelRejection } ``` Verify the request, then return the messages in it: ```ts // infrastructure/plugins/hello-channel/index.ts globalThis.channel = { receive: async (request, tools) => { const header = request.headers["x-hello-signature"] ?? ""; const signature = header.startsWith("sha256=") ? header.slice("sha256=".length) : ""; const secret = await host.settings.getSecret("sharedSecret"); const expected = host.crypto.hmacSha256({ key: secret, data: request.rawBodyBase64, dataEncoding: "base64", outputEncoding: "hex" }); if (!host.crypto.timingSafeEqual(signature, expected)) { return tools.reject("bad signature"); } const body = JSON.parse(request.body) as HelloChannelBody; return { messages: [ { conversationId: body.conversationId, messageId: body.messageId, sender: { id: body.sender.id, displayName: body.sender.name }, text: body.text, isGroup: body.isGroup, attachments: body.attachments?.map((attachment) => ({ ref: attachment.dataBase64, fileName: attachment.fileName, contentType: attachment.contentType, })), }, ], }; }, // … }; ``` Return `{ messages: [] }` for a request that carries nothing to deliver (a delivery receipt, a ping). `host.crypto` covers the signature schemes chat vendors use: HMAC-SHA1/SHA256, Ed25519 verification, RS256 JWT verification against a JWKS, AES-CBC decryption, and a timing-safe compare. See the [Host API](plugin-host-api.md#hostcrypto). ### The inbound message ```ts interface ChannelInboundMessage { conversationId: string; // the chat/thread on the vendor side — one AgentParley chat per conversation messageId: string; // the vendor's message id — used to drop duplicates sender: { id: string; displayName: string; handle?: string }; // required: drives the people list text: string; isGroup?: boolean; // a shared conversation (group, server channel) mentionsBot?: boolean; // in a group, the agent only wakes when this is true conversationName?: string; // e.g. "#general", shown in the console attachments?: { ref: string; fileName?: string; contentType?: string }[]; } ``` - `sender.id` is how the platform recognizes a person. The channel's **people list** decides who may talk to the agent (by default, only linked people; a stranger is told once how to link with a code). - By default only linked people may talk to the agent. A plugin whose senders are machines declares [`channelSenderPolicy: "anyone"`](plugin-manifest.md#channelsenderpolicy), and its channels start open. - In a group conversation the agent only wakes when `mentionsBot` is true — the same rule for every channel. - Attachments are **references**: the platform calls your `fetchAttachment` with a `ref` only when it wants the file. - A message is skipped (and a note logged) when `conversationId` (up to 200 characters), `messageId` (up to 64) or `sender.id` (up to 100) is missing or too long, or when it has neither text nor an attachment. `ref` is up to 500 characters; `conversationName` is cut to 100. ### `send` — deliver the agent's reply ```ts request: { conversationId: string; text: string; deliveryId: string | null } ``` - `deliveryId` is stable across retries of the same reply. Use it as the vendor's idempotency key or nonce so a retry never posts twice. - **To stop retrying**, throw an error whose message contains `[rejected]` — for a 403, a blocked user, a deleted conversation. Any other thrown error is retried for a few minutes. - Split long replies yourself if the vendor has a length limit. ```ts // plugins/discord/index.ts (abridged) send: async (request) => { const token = await host.settings.getSecret("botToken"); const chunks: string[] = []; for (let offset = 0; offset < request.text.length; offset += 2000) { chunks.push(request.text.slice(offset, offset + 2000)); } for (const [index, chunk] of chunks.entries()) { const nonce = request.deliveryId ? chunkNonce(request.deliveryId, index) : undefined; const response = await discordFetch(token, "POST", `/channels/${request.conversationId}/messages`, { content: chunk, allowed_mentions: { parse: [] }, nonce, enforce_nonce: nonce ? true : undefined, }); if (response.status >= 400 && response.status < 500 && response.status !== 429 && response.status !== 408) { throw new Error(`[rejected] Discord returned ${response.status}`); } if (response.status < 200 || response.status >= 300) { throw new Error(`Discord returned ${response.status}`); } } }, ``` Plugin channels send **text**. Sending files out through a plugin channel is not supported. ### `typing` (optional) ```ts request: { conversationId: string; lastInboundMessageId: string | null } ``` Called while the agent is working, so the user sees "typing…". Failures are harmless — swallow them: ```ts // plugins/discord/index.ts typing: async (request) => { try { const token = await host.settings.getSecret("botToken"); await discordFetch(token, "POST", `/channels/${request.conversationId}/typing`); } catch (error) { host.log("debug", `typing indicator failed: ${error}`); } }, ``` ### `fetchAttachment` (optional) ```ts request: { ref: string; maxBytes: number } returns: { contentBase64: string; contentType?: string } ``` Download the file behind a `ref` you returned. Use `host.fetch` with `responseEncoding: "base64"`: ```ts // plugins/discord/index.ts fetchAttachment: async (request) => { const response = await host.fetch({ url: request.ref, responseEncoding: "base64" }); return { contentBase64: response.body }; }, ``` Images, voice notes and documents then go through the same pipeline as on every channel (voice notes are transcoded, documents converted to text). ## Per-channel settings A user can connect several bots — one per channel — so credentials usually belong to the **channel**, not the install. Declare them with `scope: "channel"`: ```ts // plugins/discord/index.ts { key: "botToken", title: "Bot token", type: "secret", required: true, scope: "channel" }, ``` Channel-scoped fields are filled in on **Channels → Connect a channel**, per channel, and are never inherited from the install's settings. ## How a user connects it 1. Install the plugin. 2. **Channels → Connect a channel**, choose your plugin, pick the agent that answers, fill in the channel's settings. 3. For a webhook channel, the channel's page shows its **webhook URL** — the user gives it to the vendor. It can be replaced with a new address if it leaks. Only main agents (not subagents) can front a channel. ## Limits | | | | --- | --- | | Inbound webhook body | up to 1 MB | | Messages per `receive` | 20 | | Text per message | 32,000 characters (longer text is cut and ends with `[message truncated]`) | | Attachments per message | 5, about 3 MB each | | `receive` | 10 s; 15 s per attachment fetch; 25 s for the whole request | | `send` / `typing` | the ordinary 30 s wall clock each | | Cost | each `receive` is one **plugin wake** against the account's hourly allowance (Free 60, Solo 600, Entrepreneur 3,000) — charged even on timeout | A `receive` that times out (or throws an error mentioning "timeout"/"timed out") is retried; any other error is final and logged. A message that keeps failing is parked rather than retried forever. ## What a channel handler can reach `host.fetch`, `host.settings` (including channel-scoped values), `host.crypto`, `host.log`, and `host.connection.send` when the channel runs over a live connection you own. Channel handlers don't act as an agent — the platform turns your messages into chats and wakes the agent itself. # Live connections Some platforms only deliver messages over a connection the receiver keeps open for days: Discord's Gateway, IRC, Matrix sync, Slack Socket Mode, a Home Assistant event stream. A plugin runs for seconds at a time and can't hold a socket. So AgentParley holds it for you. You describe **how to connect** and **what to do with what arrives**. A dedicated service opens the socket, keeps it alive, sends your heartbeats, answers keep-alives, drops the noise you told it to drop, reconnects with backoff, survives AgentParley's own deploys, and wakes your plugin only for frames you care about. The official **Discord** plugin is built on this — see the [walkthrough](example-discord.md). ## The handlers ```ts globalThis.connection = { open: (request) => ConnectionOpenResult, // required receive: (request) => ConnectionStepResult, // required timer?: (request) => ConnectionStepResult, // only if you use wakeAfterMs }; ``` Every call gets your stored `state` and returns what to do next. Nothing else survives between calls. ### `open` — connect (or reconnect) ```ts request: { owner: string; // "channel" or "install" — see "Two kinds of owner" reason: "start" | "reconnect" | "handoff"; // handoff = the platform moved you (deploy, rebalance) state: unknown | null; // your stored state; null on the very first open lastClose: { code: number | null; reason: string; by: "remote" | "plugin" | "platform"; link: string | null } | null; lastSequence: unknown | null; // the last sequence number seen (see `sequence`) } returns: ConnectionStepResult & { links: LinkSpec[]; // at most 5 sockets resumable?: boolean; // your next open may resume the vendor session after a platform move connectsPerMinute?: number; // 1–600; may only LOWER the platform's connect rate } ``` Return `{ links: [], fail: "…" }` to stop for good (for example, a rejected token) — nothing reconnects until the user acts. ### Links ```ts type LinkSpec = | { transport: "websocket"; url: string; headers?: Record; protocols?: string[] } | { transport: "tcp"; host: string; port: number; tls: boolean } // line-based; never put CR/LF inside a frame | { transport: "sse"; url: string; headers?: Record } | { transport: "poll"; everyMs: number; request: HttpRequest }; // everyMs: 30 000 – 86 400 000 type HttpRequest = { method?: "GET" | "POST" | "PUT"; url: string; headers?: Record; body?: string; timeoutMs?: number; oauth?: string; // a manifest `oauth` key: the service adds `Authorization: Bearer `; you never see it account?: string; // the connection id from host.oauth.accounts(), when more than one is connected }; // Every link also takes: { key?: string; // name it if you have several; unnamed links are "link0", "link1", … in order idleTimeoutMs?: number; // reconnect if nothing arrives for this long maxDetachedSeconds?: number; // 30–300 (default 300): keep the socket while cut off from AgentParley filters?: FrameRule[]; // keep/drop rules, applied before any wake (on a poll link: to 2xx replies) autoReply?: { textPrefix: string; replacePrefixWith: string }[]; // e.g. IRC PING → PONG, costs no wake sequence?: { path: string }; // JSON path of the vendor's sequence number, tracked on every frame } ``` ### Poll links: quiet polling The service repeats `request` every `everyMs` (the first poll comes after a random delay of up to `everyMs`) and does **not** wake you for a poll that finds nothing. A poll costs one HTTPS request from the service and nothing else — no call to AgentParley, no wake, no allowance. Requests time out at 120 s (set a lower `timeoutMs` if you like), redirects are not followed, a reply over 2 MB counts as a network failure, and the request host must be on your `manifest.egress` list. You are woken (with an `InboundFrame` of kind `response`) for: - a **2xx** reply that your `filters` keep (rules match the first 256 KB of the body; a reply matching no rule is kept), and - any **3xx or 4xx** except 401, 408 and 429. After a wake the link pauses until `receive` returns; polling then resumes on its schedule. Return `next` to change what it asks for (a new cursor, a new `pageToken`). You are **not** woken for failures the service retries for you: **408, 429 and 5xx** replies, and network errors. Retries back off from `everyMs` upward (doubling, at most 30 minutes between tries) and honour `Retry-After` (up to one hour). After **5 quiet failures in a row** (408/429/5xx) the service posts one note to the connection card so the user can see the outage; the next good reply clears the streak. **Five network failures in a row** (no HTTP reply at all) close the connection and it reconnects through the normal back-off; any HTTP reply, even a 5xx, resets that counter. With `oauth` on the request, a **401** is not delivered to you: the service gets a fresh token and sends the request once more. A second 401 stops the connection with "sign-in required" until the user reconnects the sign-in. A 401 on a request without `oauth` is an ordinary 4xx and wakes you. Example: a Gmail watcher that wakes only when the mailbox history moves. ```ts { transport: "poll", everyMs: 120_000, request: { url: "https://gmail.googleapis.com/gmail/v1/users/me/history?startHistoryId=" + cursor + "&historyTypes=messageAdded", oauth: "google", account }, filters: [ { when: [{ path: "history", exists: true }], action: "keep" }, // something changed { when: [{ textPrefix: "" }], action: "drop" }, // nothing new: stay asleep ], } ``` ### `receive` — frames arrived ```ts request: { state: unknown | null; link: string; // which link frames: InboundFrame[]; // up to 50 per call lastSequence: unknown | null; } type InboundFrame = | { id: string; kind: "text"; text: string } | { id: string; kind: "binary"; base64: string } | { id: string; kind: "sse"; event: string; data: string; lastEventId: string | null } | { id: string; kind: "response"; status: number; headers: Record; body: string }; ``` A frame's `id` is the platform's de-duplication key across redelivery (not the vendor's id). ### `timer` — nothing arrived for `wakeAfterMs` ```ts request: { state: unknown | null; lastSequence: unknown | null } ``` ### What you return — `ConnectionStepResult` Every field is optional; omit what you don't change. | Field | Does | | --- | --- | | `state` | your new state (≤ 64 KB; a larger one is ignored and the old state kept). Omit to keep the old one. **Not encrypted — never store a secret in it.** | | `send` | frames to write: `{ link?, text }` or `{ link?, base64 }` — ≤ 50 per step; frames beyond that are dropped. Leave out `link` for the link this step answers; in `open` or `timer`, it means the connection's only link, and with several links the frame is dropped | | `heartbeat` | `{ link?, everyMs, jitterFirst?, frame }` — the service sends `frame` every `everyMs`, no wake. `null` clears it. A JSON frame may set `sequenceAt` to have the latest sequence written into it. | | `filters` | `{ link?, rules }` — replace a link's keep/drop rules. `link` defaults as for `send`, and so does `heartbeat`'s | | `wakeAfterMs` | call `timer` after this long with no inbound frame (periodic logic); `null` clears it | | `next` | `{ link, request }[]` — replace a poll link's repeating request (`request` may carry `oauth`) | | `messages` | chat messages (the `ChannelInboundMessage` shape) — for a **channel-owned** connection | | `events` | `{ id, text }[]`, ≤ 50 per step — for an **install-owned** connection; stored once per `id`. `id` is up to 100 characters (an event without one is dropped); `text` is cut at 4,000 characters | | `reconnect` | `{ reason, resetState? }` — ask for a reconnect (the platform decides when) | | `fail` | a fatal error: stop until the user acts | ### Filters The service applies your rules to every frame before deciding whether to wake you. A frame you drop costs nothing. ```ts type FrameRule = { when: FrameCondition[]; action: "keep" | "drop" }; type FrameCondition = | { path: string; equals: string | number | boolean | null } // dot path into JSON; "a[].b" = any element | { path: string; exists: boolean } | { textPrefix: string } | { binaryPrefixBase64: string }; ``` The first rule whose conditions **all** match decides; a frame matching no rule is **kept**. Discord's rules keep direct messages, @mentions and replies to the bot, and drop everything else in a server: ```ts // plugins/discord/index.ts function baseFilters(botId: string | undefined, respondToBots: boolean): FrameRule[] { const rules: FrameRule[] = [ { when: [{ path: "op", equals: 11 }], action: "drop" }, // heartbeat ACKs { when: [{ path: "t", equals: "RESUMED" }], action: "drop" }, ]; if (!botId) { return rules; } if (!respondToBots) { rules.push({ when: [{ path: "d.author.bot", equals: true }], action: "drop" }); } rules.push( { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.guild_id", exists: false }], action: "keep" }, { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.mentions[].id", equals: botId }], action: "keep" }, { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.referenced_message.author.id", equals: botId }], action: "keep" }, { when: [{ path: "t", equals: "READY" }], action: "keep" }, { when: [{ path: "op", equals: 0 }], action: "drop" }, ); return rules; } ``` ### Heartbeats, sequences and resume A typical gateway: on HELLO, identify (or resume) and start a heartbeat; track the sequence number; on READY, store the session id so the next `open` can resume. ```ts // plugins/discord/index.ts (abridged) if (payload.op === 10) { // HELLO const identify = canResume ? { op: 6, d: { token, session_id: state.sessionId, seq: sequence } } : { op: 2, d: { token, intents: /* … */, properties: { os: "linux", browser: "agentparley", device: "agentparley" } } }; send.push({ link: request.link, text: JSON.stringify(identify) }); heartbeat = { link: request.link, everyMs: payload.d.heartbeat_interval, jitterFirst: true, frame: { json: { op: 1, d: null }, sequenceAt: "d" }, // the service writes the latest sequence into d }; } ``` With `sequence: { path: "s" }` on the link, the service tracks `s` on every frame — even dropped ones — and hands it back as `lastSequence`. Declare `resumable: true` and, after a deploy moves your socket (`reason: "handoff"`), you resume the vendor session instead of logging in again. ## Two kinds of owner | Owner | Created by | Your `receive` returns | Used for | | --- | --- | --- | --- | | `"channel"` | the user connecting a channel (Channels → Connect a channel) | `messages` → chats with the agent | chat platforms: Discord, IRC | | `"install"` | the user pressing **Connect** under **Live connection** on the installed plugin's page | `events` → fires **On event** schedules | event streams: "when the front door opens, tell my House agent" | An install-owned connection requires `manifest.installOwnedConnection: true`. The same code can serve both — `hello-live` branches on `request.owner`: ```ts // infrastructure/plugins/hello-live/index.ts if (state.owner === "install") { events.push({ id: dispatch.id, text: dispatch.text }); } else { messages.push({ conversationId: dispatch.conversationId, messageId: dispatch.id, sender: { id: dispatch.senderId ?? "tester", displayName: dispatch.senderName ?? "Tester" }, text: dispatch.text, isGroup: !!dispatch.guildId, mentionsBot: !!dispatch.mention, }); } ``` Each event you emit fires the account's schedules of kind **On event** that listen on that connection; the schedule's prompt runs on its agent with your event text. ## Sending outside a step — `host.connection.send` From a channel's `send` handler (a reply the agent wrote), write straight onto the open socket: ```ts // infrastructure/plugins/hello-live/index.ts globalThis.channel = { send: async (request) => { const frame = { op: 99, d: { conversationId: request.conversationId, text: request.text } }; await host.connection!.send([{ text: JSON.stringify(frame) }], { idempotencyKey: request.deliveryId ?? undefined }); }, }; ``` At most 20 frames per call, accepted or refused whole; pass the same `idempotencyKey` on a retry so nothing is written twice. Permission `host:connection:send` is derived from the call. (Discord sends replies over REST instead, which is why it doesn't need this.) ## Where it may connect Links may only reach hosts in your `manifest.egress` or a `hosts` setting the user filled in. The service also refuses private, loopback, link-local and cloud-metadata addresses, checked on the address it actually dials. ## Limits | | | | --- | --- | | Links per connection | 5 | | Inbound frames per `receive` call | 50 | | Frames in `send` per step | 50 | | Frames per `host.connection.send` call | 20 | | `state` | 64 KB, not encrypted | | Inbound frame / TCP line | 1 MB / 64 KB | | Outbound frame | 64 KB | | Outbound frames | 120 per minute (heartbeats and auto-replies don't count) | | Heartbeat interval / idle timeout | at least 5 s / at least 10 s | | `wakeAfterMs` | 1 s – 24 h | | Poll `everyMs` | 30 s – 24 h | | Filters | 30 rules per link, 5 conditions per rule, 8 path segments; 5 auto-reply rules | | Opens | 300 per day | | `open` / `receive` / `timer` wall clock | 15 s / 10 s / 10 s | | TCP ports | 25, 465 and 587 (mail) are refused | | Detached budget | 30–300 s | | Live connections per account | Free 1, Solo 5, Entrepreneur 20 | | Wakes (open / receive / timer) | count against the hourly plugin-wake allowance; reconnects the platform causes are free | # Model providers A model-provider plugin makes a vendor selectable under **AI Providers → Add provider**, next to the built-in ones. The user adds it with its own settings (typically their API key), adds models under it, and picks those models for agents — exactly like a native provider. Your plugin translates between AgentParley's request format and the vendor's API. A provider plugin can host any combination of four groups: | Group | Handlers | What the user gets | | --- | --- | --- | | Chat | `complete` | models an agent can think with | | Decision ("System One") | `noul` + `choose` + `score` — all three or none | fast typed answers for `decide` and `host.decide` | | Voice | `speak` + `voices` — both or neither | companion voices — see [Voice and speech-to-text](plugin-voice-and-speech.md) | | Speech-to-text | `transcribe` (+ optional `startLiveTranscription`) | companion listening — see [Voice and speech-to-text](plugin-voice-and-speech.md) | This page covers chat and decision models. ## Declare the provider ```ts // infrastructure/plugins/keyword-decider/index.ts globalThis.manifest = { modelProvider: { kindId: "keyword-decider", displayName: "Keyword Decider (test)" }, settings: [ { key: "simulateFailure", title: "Simulate failure", type: "enum", options: ["none", "keyRejected", "unavailable"], default: "none", }, ], }; ``` `manifest.modelProvider` is required as soon as you assign any `modelProvider.*` handler (see [Manifest](plugin-manifest.md#modelprovider)). `kindId` is your provider's permanent id: lowercase letters, digits and dashes, 2–64 characters, starting with a letter, and not the id of a built-in provider. `displayName` (up to 100 characters) is shown as "*Keyword Decider (test)* · from plugin"; `description` is up to 500. **Settings belong to the provider the user added, not to the install.** When a user adds your provider they fill in your settings form; `host.settings.get` / `getSecret` in a provider handler read *that provider's* configured values, not the installation's. ## Reporting vendor failures — `tools.fail` Every provider handler receives `tools` with one helper: ```ts tools.fail(kind, message): ModelProviderFailure ``` | `kind` | Means | User sees | | --- | --- | --- | | `keyRejected` | bad or revoked key (401/403) | the red provider-failure banner | | `noCredit` | out of credit (402) | the red banner | | `modelNotFound` | the saved model id doesn't exist | the red banner | | `requestRejected` | the vendor refused this request (400/422) | the red banner | | `rateLimited` | 429 | no banner — transient | | `unavailable` | 5xx, down | no banner — transient | Return the value — `return tools.fail("keyRejected", "…")` — instead of throwing. A thrown error or a timeout is treated as `unavailable`. The OpenAI Voice plugin maps HTTP status codes like this: ```ts // infrastructure/plugins/openai-voice/index.ts function mapFailure(status: number, tools: ModelProviderTools): ModelProviderFailure { if (status === 401 || status === 403) return tools.fail("keyRejected", "OpenAI rejected the API key"); if (status === 402) return tools.fail("noCredit", "OpenAI account is out of credit"); if (status === 429) return tools.fail("rateLimited", "OpenAI rate limit exceeded"); if (status >= 500) return tools.fail("unavailable", `OpenAI returned ${status}`); return tools.fail("requestRejected", `OpenAI returned ${status}`); } ``` ## Chat models — `complete` > Written for these docs against the SDK types — no shipped plugin implements `complete` yet. ```ts modelProvider.complete(request: CompletionRequest, tools): CompletionResponse | ModelProviderFailure ``` The request is AgentParley's normalized chat request: ```ts interface CompletionRequest { schemaVersion: number; model: string; // the model identifier the user saved messages: { role: "system" | "user" | "assistant" | "tool"; content: string | null; toolCalls?: { id: string; name: string; argumentsJson: string }[]; // on assistant messages toolCallId?: string; // on tool results media?: { type: "image" | "audio"; url: string; contentType: string }[]; thinking?: { text: string | null; replayJson: string | null }; }[]; tools: { name: string; description: string; parametersJsonSchema: string }[]; params: { temperature: number | null; maxTokens: number | null; topP: number | null; stop: string[] | null; thinking: string }; } ``` Return the assistant turn and the token usage: ```ts interface CompletionResponse { schemaVersion: 1; assistant: { text: string | null; toolCalls: { id: string; name: string; argumentsJson: string }[]; thinking?: { text: string | null; replayJson: string | null } }; usage: { inputTokens: number; outputTokens: number; cachedTokens: number }; stopReason: "stop" | "tool_calls" | "length" | "content_filter" | "error"; } ``` A minimal adapter for an OpenAI-style vendor: ```ts globalThis.manifest = { egress: ["api.example-llm.com"], modelProvider: { kindId: "example-llm", displayName: "Example LLM", mediaSupport: ["image"] }, settings: [{ key: "apiKey", title: "API key", type: "secret", required: true }], }; globalThis.modelProvider.complete = async (request, tools) => { const apiKey = await host.settings.getSecret("apiKey"); const response = await host.fetch({ method: "POST", url: "https://api.example-llm.com/v1/chat/completions", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: request.model, messages: request.messages.map((m) => ({ role: m.role, content: m.content, tool_call_id: m.toolCallId })), tools: request.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: JSON.parse(t.parametersJsonSchema) }, })), temperature: request.params.temperature ?? undefined, max_tokens: request.params.maxTokens ?? undefined, }), }); if (response.status === 401) return tools.fail("keyRejected", "Example LLM rejected the API key"); if (response.status !== 200) return tools.fail("unavailable", `Example LLM returned ${response.status}`); const body = JSON.parse(response.body); const choice = body.choices[0]; const toolCalls = (choice.message.tool_calls ?? []).map((c: any) => ({ id: c.id, name: c.function.name, argumentsJson: c.function.arguments, })); return { schemaVersion: 1, assistant: { text: choice.message.content, toolCalls }, usage: { inputTokens: body.usage.prompt_tokens, outputTokens: body.usage.completion_tokens, cachedTokens: 0 }, stopReason: toolCalls.length > 0 ? "tool_calls" : "stop", }; }; ``` Notes: - **No credential crosses the request.** Read your key with `host.settings.getSecret`. - **`mediaSupport`** in the manifest says which media your models accept. The platform strips unsupported media from `messages` before calling you, the same as for native providers. For a call that carries media, the media URLs' hosts are added to your egress for that call only, so `host.fetch` can download them. - **Usage is what the user pays.** You report tokens; the platform prices them at the per-token rates the user entered for the model. Plugins never report a cost. `inputTokens` is **uncached input only**: report cached tokens in `cachedTokens` and leave them out of `inputTokens` (an OpenAI-style `prompt_tokens` includes them, so send `prompt_tokens - cached_tokens`), or they are billed twice. - `complete` is not streamed: the reply appears when your handler returns. It gets the ordinary 30 s wall clock, and each `host.fetch` inside it times out after **10 s** — a vendor reply that takes longer fails as `unavailable`. - Your handler must return an object with `assistant` and `usage`, or the call fails as a bad request. ## Decision models — `noul`, `choose`, `score` Decision models answer typed questions about a piece of state, fast and with calibrated numbers. Agents use them through the built-in `decide` tool; plugins through `host.decide`; companions to pick gestures. Your plugin can host them. All three handlers take the same request shape — **one call answers every question**, reading `state` once: ```ts request: { model: string; state: string | Record | unknown[]; questions: NoulQuestion[] | ChoiceQuestion[] | ScoreQuestion[]; } returns: { answers: …[]; inputTokens: number } // same order and count as `questions` ``` | Handler | Question | Answer | | --- | --- | --- | | `noul` — how likely is this true? | `{ instructions, trueMeans?, falseMeans? }` | `{ probability }` in 0–1 | | `choose` — pick one option | `{ instructions, options: { [key]: description \| null } }` | `{ choice, confidence?, probabilities? }` — `choice` must be one of the keys | | `score` — place on a scale | `{ instructions, levels: string[] }` (low → high) | `{ score, confidence?, probabilities? }` — `score` in 0…levels.length − 1, may be fractional | ```ts // infrastructure/plugins/keyword-decider/index.ts (abridged) globalThis.modelProvider = { noul(request, tools) { const failure = simulatedFailure(tools); if (failure) return failure; const text = stateText(request.state); const answers = request.questions.map((question) => { const positive = overlap(text, question.trueMeans ?? question.instructions); const negative = overlap(text, question.falseMeans ?? ""); const sign = positive === negative ? 0 : positive > negative ? 1 : -1; return { probability: 0.5 + 0.4 * sign }; }); return { answers, inputTokens: Math.ceil(text.length / 4) }; }, choose(request, tools) { /* … */ }, score(request, tools) { /* … */ }, }; ``` - Implement **all three** or none — the platform may call any of them, and adding a model runs a test `noul`. - 10 s wall clock per call. `inputTokens` is what the user is charged for, at their rate. ## Rules for provider plugins - **No agent powers.** A provider translates a vendor's API; it doesn't act as an agent. Provider handlers get no `host.session`, `host.agents`, `host.llm` or `host.decide`, and publishing a provider plugin that calls `host.session.message.post`, `todo` writes, `subagent.spawn`, `peer.send`, `notify.schedule` or `host.agents.create/update/delete` fails. Account reads such as `host.models.list()` are allowed. - **No silent fallback.** If your model fails, the agent's turn fails visibly (with the banner for the four "hard" kinds). The platform never quietly switches the user to another model. ## How a user sets it up 1. Install the plugin. 2. **AI Providers → Add provider → "*Your name* · from plugin"**, fill in your settings (their API key). 3. Add a model under it, entering the identifier your vendor uses (it reaches you as `request.model`), with its per-token prices. 4. Pick that model on an agent — or as the account's decision model for decision providers. # Web search and web fetch providers Every agent has two built-in tools, `web_search` and `web_fetch`. By default they run on AgentParley's own engine. A web-provider plugin replaces the engine behind one or both — a user's own Brave key, a private search index, a reader service that turns pages into clean text. Agents keep calling the same tools; only what answers them changes. `search` and `fetch` are **independent**: implement one or both. Each becomes its own capability that the account owner selects separately under **Settings → Engines** ("Web search" and "Reading web pages"). ## `web.search` ```ts web.search(input: { query: string; maxResults: number }) : { results: { title: string; url: string; snippet?: string }[] } ``` The official **Brave Search** plugin, complete: ```ts // plugins/brave-search/index.ts globalThis.manifest = { egress: ["api.search.brave.com"], settings: [ { key: "apiKey", title: "Brave API key", description: "From https://api-dashboard.search.brave.com — the free tier works.", type: "secret", required: true, }, ], }; globalThis.web.search = async (input) => { const apiKey = await host.settings.getSecret("apiKey"); const count = Math.min(Math.max(input.maxResults, 1), 20); // Brave caps count at 20 const response = await host.fetch({ url: `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(input.query)}&count=${count}`, headers: { "X-Subscription-Token": apiKey, Accept: "application/json" }, }); if (response.status !== 200) throw new Error(`Brave search API returned ${response.status}`); const parsed = JSON.parse(response.body) as { web?: { results?: { title?: string; url?: string; description?: string }[] } }; const results = (parsed.web?.results ?? []) .filter((result) => result.title && result.url) .slice(0, input.maxResults) .map((result) => ({ title: result.title!, url: result.url!, snippet: result.description ?? "" })); return { results }; }; ``` Whatever you return, the platform re-caps it: at most **10 results**, titles cut to 300 characters, snippets to 500. The handler must return an object whose `results` is an array. ## `web.fetch` ```ts web.fetch(input: { url: string }) : { content: string; url?: string; truncated?: boolean } ``` `url` in the result defaults to the input URL; `truncated` defaults to `false`. The platform caps `content` at 50,000 characters and sets `truncated` itself when it cuts. **The sandbox can't reach an arbitrary URL** — `host.fetch` only reaches hosts you declared. A fetch provider therefore goes through a vendor that fetches on your behalf. **Wiki Web** uses a reader service: ```ts // plugins/wiki-web/index.ts globalThis.manifest = { egress: ["en.wikipedia.org", "r.jina.ai"], }; globalThis.web.search = async (input) => { const url = "https://en.wikipedia.org/w/api.php?action=opensearch" + `&search=${encodeURIComponent(input.query)}&limit=${input.maxResults}&format=json`; const response = await host.fetch({ url }); const [, titles, snippets, urls] = JSON.parse(response.body) as [string, string[], string[], string[]]; return { results: titles.map((title, index) => ({ title, url: urls[index], snippet: snippets[index] ?? "", })), }; }; globalThis.web.fetch = async (input) => { const response = await host.fetch({ url: `https://r.jina.ai/${input.url}` }); return { content: response.body }; }; ``` ## Failures are visible, never swapped If your handler throws or times out, the agent sees a *provider failed* error from `web_search` / `web_fetch`. The platform does **not** quietly fall back to the built-in engine: the user chose your vendor, your egress and your quota, and that choice is respected. Throw a clear message — it's what the model reads. ## What a web provider can reach The same host surface as a middleware hook with the same permissions: `host.fetch`, `host.settings`, `host.crypto`, `host.log`, and anything else your code's calls derive. 30 s wall clock per call. ## Setup for the user 1. Install the plugin and fill in its settings (for example the API key). 2. **Settings → Engines**: choose it for **Web search** and/or **Reading web pages**. The selection is per account: every agent's `web_search` / `web_fetch` then goes through your plugin. See the [Brave Search walkthrough](example-brave-search.md). # Compaction provider When a conversation grows toward the model's context limit — automatically at the agent's threshold, or when the user types `/compact` — AgentParley replaces older history with a summary. A compaction plugin replaces **how that summary is written**. That is the difference between a long mission that remembers which files it touched and what it decided, and one that starts forgetting. The platform owns the mechanics: when to compact, what to hand you, and what to keep verbatim. You write one function that turns history into a summary. ## `compaction.summarize` ```ts compaction.summarize(context: { messages: { role: string; content: string }[]; // the history to summarize, oldest first focus?: string; // present when the user typed /compact }): string | Promise ``` - `messages` is the historical conversation since, and including, the last checkpoint. **On a repeat compaction the first entry is your own previous summary** — refine it rather than starting over. - The current turn's live request is **not** in `messages`; the platform keeps it verbatim after your summary. - `focus` is the user's instruction about what to preserve; honour it. - Return **one string**. Anything else fails the call. ## When your summary is refused The platform runs its built-in summarizer instead, and posts a notice in the chat, when: - you return an empty string, - your summary is estimated at more than half of the compaction threshold (it wouldn't free enough room), or - your handler throws or runs out of time. The agent is never left without a compaction. (If the history is too large to hand to the sandbox at all, the built-in summarizer is used for that compaction without calling you.) ## Use the agent's own model A summarizer usually needs a model. `host.session.llm.complete` runs **the chat's own agent model** — no API key, no model choice, no permission, metered to the account: ```ts // infrastructure/plugins/pi-compactor/index.ts (abridged) globalThis.compaction.summarize = async (context) => { const messages = context.messages || []; if (messages.length === 0) { return ""; } // The platform re-includes our previous summary as messages[0] on repeated compactions. const first = messages[0]; const hasPriorSummary = first.content.indexOf(SUMMARY_MARKER) === 0; const priorSummary = hasPriorSummary ? first.content : null; const transcript = serialize(hasPriorSummary ? messages.slice(1) : messages); const system = "You compact a long agent session into a single structured summary that preserves everything needed to " + "continue with NO loss of intent. Output EXACTLY the template below … Your FIRST " + 'line MUST be "' + SUMMARY_MARKER + '". Output only the summary — no preamble.\n\n' + "TEMPLATE:\n" + SUMMARY_MARKER + "\n\n" + TEMPLATE + (priorSummary ? "\n\nPREVIOUS SUMMARY — refine and extend this; carry its file lists forward:\n" + priorSummary : ""); const result = await host.session!.llm!.complete({ messages: [ { role: "system", content: system }, { role: "user", content: "Conversation to summarize (oldest first):\n\n" + transcript }, ], params: { temperature: 0.2 }, }); return result.assistant.text || ""; }; ``` Up to 5 `host.session.llm.complete` calls per compaction. Alternatives: `host.llm` (the account's utility model, if set) or your own vendor through `host.fetch`. ## What else it can reach A compaction handler gets the full host surface your permissions earn — `host.session.*`, `host.agents.*`, `host.workspace.*` (when the chat has an agent), `host.fetch`, `host.media` — so it can, for example, also write the summary into a file. It has **120 seconds**. ## Setup for the user Install the plugin, then **Settings → Engines → Long conversations** and choose it. The choice is per account. See the [Pi Compactor walkthrough](example-pi-compactor.md). # Agent crews On AgentParley, agents work as a crew: a lead hands scoped tasks to specialists, each specialist runs on its own model with its own persona and tools, reports back, and the lead integrates the results. Delegation, reporting back, reach control and "don't answer the human until your crew has reported" are enforced by the platform, not by prompt tricks. A plugin can plug into every part of that: | You want to… | Use | | --- | --- | | Install a whole team of agents and keep it healthy | `manifest.agents` + `host.agents.*` + lifecycle events | | Put each member on the right model | `host.models.list()` | | Teach the lead how to conduct | `middleware.systemPrompt` + a skill + commands | | Start work from code — spawn a subagent, message a colleague | `host.session.subagent.spawn`, `host.session.peer.send` | | Drive long missions | `host.session.todo.*`, `host.session.notify.schedule` | | See, rewrite or block what agents say to each other | `middleware.interAgentMessage` | | Shape todos and timers agents set themselves | `middleware.todoAdded`, `notifyScheduled`, `notifyDelivered` | The reference crew plugin is **Oh My OpenAgents** — seven agents, a doctrine, five commands and three skills. See the [walkthrough](example-oh-my-openagents.md). ## How crews work (the platform side) - **Agents are real.** Each has a name, a *soul* (persona), instructions, its own model, a thinking level, and a reply mode. A spawned subagent runs as the *target* agent — its persona, tools and model. - **One level of hierarchy.** A main agent (the lead) can have subagents under it. A subagent can be **group-only callable**: only its lead and sibling specialists can reach it. Only main agents can front a chat channel. - **Delegation is asynchronous.** A lead's `start_subagent` returns immediately with the child's id; the lead keeps working; the child's result arrives later as a message that wakes the lead. Independent specialists run in parallel (up to the plan's concurrent-run limit — Free 1, Solo 3, Entrepreneur 10; beyond that they queue). - **Nobody is left hanging.** A subagent reports with `report_to_caller`. If it forgets, its final message is delivered anyway (in the default *auto* reply mode); if it fails, the lead is told. - **The lead doesn't answer early.** While any live subagent still owes it a report, or it has open todos, the lead's answer to its human is held back. - **Colleagues keep a chat.** `message_agent` chooses where the message lands, in this order: the chat of the agent that messaged you this turn (so a reply goes back to whoever asked); else, if the target talks to an account user directly on a channel (a Slack DM, say), that chat, which is visible where the person reads it. With several such chats the most recently active one wins, and the optional `channel` argument picks the one on a named channel (it never falls back to another); else one standing thread kept between the two agents and reused. Only the standing thread's reply comes back automatically; in a channel chat or a reply, the target answers with `message_agent` when it needs to. So a crew prompt must not count on an automatic answer. - **Reach is controlled.** Each agent's owner decides which agents it may call. Nothing crosses accounts. - **Depth.** By default a subagent cannot spawn subagents of its own. The built-in tools your doctrine should name: `list_agents`, `start_subagent`, `start_self_subagent`, `report_to_caller`, `message_agent`, `send_message_to_session`, `read_session`, `list_agent_sessions`, `close_subagent`, `reply_to_human`, `todo_write` / `todo_list` / `todo_remove` / `todo_snooze`, `notify_session_in` / `notify_list` / `notify_cancel`. ## Declare the crew — `manifest.agents` ```ts // infrastructure/plugins/oh-my-openagents/index.ts globalThis.manifest = { agents: CREW.map((member) => ({ key: member.key, name: member.name })), }; ``` Before installing, the user sees **"Adds 7 agents"** and the console checks the plan has room (agents per account: Free 2, Solo 5, Entrepreneur 25). At most 20 entries; each `key` is your stable id for that member (`a-z`, `0-9`, `_` and `-`, starting with a letter or digit, up to 64 characters) and each `name` is 1–100 characters. A new version that adds an agent waits for the owner's OK like any new access. ## Manage the crew — `host.agents` Permission `agentApi:agents.read` (for `list`) and `agentApi:agents.write` (for `create`/`update`/`delete`), derived from the calls. `host.agents` works **without a chat** — in `pluginInstalled`, for example. **Your plugin only ever sees its own agents.** An agent the user created, or another plugin's agent, is "not found" — its existence never leaks. ### `create` — get-or-create by key ```ts host.agents.create({ key: string; // your stable id, 1–200 chars — e.g. "oracle" name: string; description?: string; modelId?: string; // from host.models.list(); omit = the account default model soul?: string; instructions?: string; isSubagent?: boolean; // default false (a main agent) parentAgentId?: string | null; // a main agent YOU own; requires isSubagent: true isGroupOnlyCallable?: boolean; // only its lead and siblings may call it replyMode?: "implicit" | "auto" | "all"; // default "auto" thinkingLevel?: "off" | "low" | "medium" | "high"; // default "off"; applied when the model supports it tools?: string[]; // the agent may use ONLY these; see below }): Promise ``` Calling `create` again with the same `key` returns **the existing agent, untouched** (`wasCreated: false`) — the user's edits are never overwritten. That makes re-running your setup safe under at-least-once delivery. To re-assert something deliberately, call `update`. `tools` limits the agent to the tools you list: a built-in by bare name (`web_search`), your own tool by bare name, another plugin's tool by its full `slug__tool` name. Passing it makes the agent's tool list managed by your plugin: the console shows it read-only, the user can't change it, and every `update({ tools })` replaces the whole list. `tools: []` means no tools at all, and listing an opt-in tool such as `decide` grants it. Names that match no installed tool come back in `unresolvedTools` and are not granted, so re-apply `tools` in your setup (a name starts working the next time setup runs after that tool's plugin is installed). On a `create` whose key already exists, `tools` is ignored like every other field; call `update`. Reply modes: `implicit` delivers only what the agent sends explicitly; `auto` (default) delivers its final message to whoever is owed an answer; `all` streams every message as it is written. ### `list` ```ts host.agents.list(): Promise<{ agents: { id: string; slug: string; name: string; description: string; modelId: string | null; enabled: boolean; key: string | null; // the key you created it with — reconcile on THIS, never on name isSubagent: boolean; parentAgentId: string | null; isGroupOnlyCallable: boolean; }[] }> ``` ### `update` ```ts host.agents.update({ agentId: string; name?; description?; modelId?; soul?; instructions?; enabled?: boolean; isSubagent?: boolean; parent?: { parentAgentId: string | null }; // omit = unchanged; { parentAgentId: null } = no parent isGroupOnlyCallable?; replyMode?; thinkingLevel?; tools?: string[]; // omit = leave the tool list alone }): Promise ``` Omitted fields keep their value. `parent` replaces the whole parent selection, so "clear the parent" (`null`) and "leave it" (omitted) are different. ### `delete` ```ts host.agents.delete({ agentId: string }): Promise ``` Rejects (409) if the user made that agent the account default; the agent then stays. ### Users stay in charge Your agents show in the console badged as coming from your plugin. The user can edit or delete them like any other agent. Uninstalling your plugin deletes them, with every chat they were in. ## Pick models — `host.models.list()` ```ts host.models.list(): Promise<{ models: { id: string; modelIdentifier: string; displayName: string; inputUsdPer1M: number | null; outputUsdPer1M: number | null; cachedUsdPer1M: number | null; }[] }> ``` The account's enabled models with the user's prices. Permission `agentApi:models.read`. Oh My OpenAgents walks a preference list per member — flagship models for the planner, cheap fast ones for the navigator — and falls back to the account default: ```ts // infrastructure/plugins/oh-my-openagents/src/models.ts export function resolveModel(member: CrewMember, models: PluginModel[]): ResolvedModel { for (const pattern of member.preferredModels) { const match = models.find((model) => pattern.test(model.modelIdentifier) || pattern.test(model.displayName)); if (match) { return { modelId: match.id, family: detectFamily(match.modelIdentifier), label: match.modelIdentifier }; } } return { family: "generic", label: "account default" }; } ``` ## Self-healing crews Run one idempotent **reconcile** from three lifecycle events — install, update, and every new chat — and the crew converges to your definition: missing members re-created, drifted ones re-asserted, retired ones removed. ```ts // infrastructure/plugins/oh-my-openagents/src/reconcile.ts (abridged) export async function reconcileCrew(): Promise { const [{ agents }, { models }] = await Promise.all([host.agents!.list(), host.models!.list()]); const ownedByKey = new Map(agents.map((agent) => [agent.key, agent])); const desiredKeys = new Set(CREW.map((member) => member.key)); const provision = async (member: CrewMember, placement: Placement): Promise => { const resolved = resolveModel(member, models); const shared = { name: member.name, description: member.description, soul: member.soul, instructions: member.baseInstructions + REPORT_CONTRACT + overlayFor(resolved.family), replyMode: member.replyMode, ...(resolved.modelId ? { modelId: resolved.modelId } : {}), }; const existing = ownedByKey.get(member.key); if (!existing) { const created = await host.agents!.create({ key: member.key, ...shared, isSubagent: placement.isSubagent, ...(placement.isSubagent ? { parentAgentId: placement.parentAgentId } : {}), }); return created.id; } await host.agents!.update({ agentId: existing.id, enabled: true, ...shared, isSubagent: placement.isSubagent, parent: { parentAgentId: placement.isSubagent ? placement.parentAgentId : null }, }); return existing.id; }; // The lead first, so its id is the parent every specialist points at. const orchestrator = CREW.find((member) => member.category === "orchestration"); const specialists = CREW.filter((member) => member.category !== "orchestration"); const orchestratorId = orchestrator ? await provision(orchestrator, { isSubagent: false }) : null; for (const member of specialists) { await provision(member, orchestratorId ? { isSubagent: true, parentAgentId: orchestratorId } : { isSubagent: false }); } for (const agent of agents) { if (agent.key !== null && !desiredKeys.has(agent.key)) { await host.agents!.delete({ agentId: agent.id }); } } } ``` A design choice to make consciously: this reconcile **re-asserts** each member's definition on every run, so a user's edits to a crew member's persona are replaced at the next chat start. If you'd rather let users customize your agents, only `create` missing members and skip the `update`. ## Teach the lead to conduct Three layers, cheapest first: 1. **An always-on doctrine** via `middleware.systemPrompt`, injected into every agent that is not part of the crew (so specialists don't try to orchestrate recursively): ```ts // infrastructure/plugins/oh-my-openagents/index.ts globalThis.middleware.systemPrompt = async (context) => { const { agents } = await host.agents!.list(); const isOwnedCrewMember = agents.some((agent) => agent.id === context.agentId); return isOwnedCrewMember ? context.systemPrompt : context.systemPrompt + ORCHESTRATION_DOCTRINE; }; ``` Note that this doctrine reaches every agent the plugin is installed on that isn't in the crew. 2. **A skill** with the full method, loaded with `use_skill` only when the lead actually conducts. 3. **Commands** that route a goal to a specialist: `/research ` → Librarian. Give every specialist a report contract in its instructions — *"report back … BY CALLING report_to_caller"* — so results flow back through the platform's reply path. ## Start work from plugin code — `host.session` In a tool, a middleware hook, or a session lifecycle event, `host.session` acts on **the current chat**: ```ts host.session.subagent.spawn({ targetAgentId?: string; task: string; isFireAndForget?: boolean }) : Promise<{ outcome: "created" | "forbidden" | "depthExceeded"; sessionId: string | null }> host.session.peer.send({ sessionId: string; text: string }) : Promise<{ isDelivered: boolean; isCanceled: boolean; cancelReason: string | null }> host.session.peer.get({ sessionId: string }): Promise<{ id: string; agentId: string; state: string }> host.session.conversation.read({ sessionId?, limit?, cursor? }): Promise<{ messages, nextCursor }> host.session.message.post({ text: string }): Promise<{ messageId: string }> ``` - `subagent.spawn` never blocks: it returns the child's session id and the child reports back through the normal path, waking the chat. `targetAgentId` omitted = a copy of the current agent. It follows the same rules as the `start_subagent` tool — reach and depth — and says so in `outcome` rather than throwing. - `peer.send` delivers into another visible session and wakes it. A target that isn't visible, is closed, or was vetoed by middleware comes back as `isDelivered: false` — data, not an error. - Neither discharges the current chat's own obligation to answer its human. - Permissions, derived from the calls: `agentApi:subagent.spawn` for `subagent.spawn`, `agentApi:peer.send` for `peer.send`, `agentApi:session.read.cross` for `peer.get` (and for `conversation.read` of another session), `agentApi:conversation.read` and `agentApi:message.post`. Written for these docs — a tool that fans a question out to a named specialist of your crew: ```ts globalThis.tools.ask_librarian = { description: "Hand a research question to the Librarian specialist. Returns immediately; the answer arrives later.", parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] }, handler: async (input: { question: string }) => { const { agents } = await host.agents!.list(); const librarian = agents.find((agent) => agent.key === "librarian"); if (!librarian) return { error: "the Librarian isn't installed on this account" }; const spawn = await host.session!.subagent.spawn({ targetAgentId: librarian.id, task: input.question }); return spawn.outcome === "created" ? { status: "started", sessionId: spawn.sessionId } : { status: spawn.outcome }; }, }; ``` ## Long missions — todos and timers Open todos keep an agent working: while any remain, the platform re-wakes the agent turn after turn. A timer wakes it later with a note. Plugins can set both: ```ts host.session.todo.create({ texts: string[] }): Promise<{ todos: { id: string; text: string }[] }> host.session.todo.list(): Promise<{ todos: { id: string; text: string }[] }> host.session.todo.update({ todoId: string; text: string }): Promise<{ id: string; text: string }> host.session.todo.complete({ todoId: string }): Promise // marks it done host.session.todo.delete({ todoId: string }): Promise // removes it ("never mind") host.session.notify.schedule({ message: string; delaySeconds: number }) : Promise<{ notifyId: string | null; fireAt: string | null; isCanceled: boolean; cancelReason: string | null }> ``` Permissions `agentApi:todo.read` / `agentApi:todo.write` / `agentApi:notify.write`, derived from the calls. A timer blanked by another plugin's `notifyScheduled` middleware comes back `{ notifyId: null, isCanceled: false }` — a legitimate no-op. And you can shape what agents set themselves with the `todoAdded`, `notifyScheduled` and `notifyDelivered` hooks ([Middleware](plugin-middleware.md)). ## Watching and shaping crew traffic `middleware.interAgentMessage` sees every message one agent's conversation sends into another's — peer messages, subagent reports (`isSubagentReport: true`), automatic reply-backs, and `peer.send` from plugins. It doesn't see the initial task text `start_subagent` hands a new subagent. Return new text, or veto. Written for these docs — keep a crew from leaking credentials to each other, and refuse reports that claim success without evidence: ```ts globalThis.middleware.interAgentMessage = async (context, tools) => { const text = context.text.replace(/\b(sk|pk|ghp)_[A-Za-z0-9_]{16,}\b/g, "[secret removed]"); if (context.isSubagentReport && /\bdone\b/i.test(text) && !/evidence|verified|test(s)? pass/i.test(text)) { host.log("warn", `report from agent ${context.senderAgentId} rejected: no evidence`); return tools.cancel("report claims done without evidence"); } return text; }; ``` Be careful with vetoes on reports: the sending subagent isn't told why, and its lead keeps waiting. Rewriting (for example appending *"[unverified — ask for evidence]"*) is usually kinder than cancelling. ## Safety rails - **Hop-depth fuse.** If a hook reacts to crew traffic by sending more crew traffic, each generation is a hop; past 4 hops from one original event, `host.session` is withheld for that generation. Loops die quietly. - **Your agents only.** `host.agents` never reaches agents your installation didn't create. - **Model-provider plugins can't do any of this** — they may not spawn, message, notify, write todos or create agents. ## Things to know before you design - One level of hierarchy: lead → specialists. No crews of crews. - Default subagent depth is 1: a specialist can't spawn its own subagents. - Parallelism is capped by the plan's concurrent runs; beyond it, work queues rather than fails. - A large crew needs a plan with room: seven agents plus the account's default agent needs Entrepreneur. # Create a 3D companion If you can make a 3D character, you have what you need. You don't need to code or use a terminal. There are two routes: - **[Just for you](#route-1-just-for-you):** upload your model and talk to her. She borrows ready-made motions, so you don't need any animation. - **[Share her with other people](#route-2-share-her-with-other-people):** add your model to our starter folder and upload it as a plugin. Anyone can install her once you list it. Start with route 1, even if you plan to share. It's the quickest way to see how your model behaves. Working with a painted character instead? See [Create a 2D companion](create-2d-companion.md). ## Get your model ready Your model needs to be a humanoid in a `.vrm` file. VRM is the best choice, because the file already says which bone is which and how the face moves. | You work in | How to get a VRM | | --- | --- | | **VRoid Studio** | Export as VRM. Everything is set up for you | | **Blender** | Install the free VRM add-on, assign the humanoid bones, then export as VRM | | **Unity** | Use the UniVRM package and export as VRM | Before you export, check these: | Check | Why it matters | | --- | --- | | **Keep it under 100 MB, and ideally well under 30 MB** | 100 MB is the limit, and people download the model to see her. 2048-pixel textures are plenty, and this is usually the biggest saving | | **Mouth shapes `aa`, `ih`, `ou`, `ee`, `oh`, and a blink** | They drive her lip-sync and blinking. Without them, her mouth and eyes stay still | | **Spring bones on hair, tails, ribbons and hoods** | These move on their own. Without them, those parts hang stiff | | **A body under the clothes** | We hide skin covered by clothes automatically, so it can't poke through. This needs a real body mesh under the garment | A `.glb` also works, but you'll need to match its bones to a humanoid when you upload. VRM gives you more: a `.glb` has no ready-made face setup and no spring bones. ## Route 1: Just for you 1. Open **Companions** in the console and choose to create your own. 2. Under **How it looks**, choose **Upload your own** and pick your `.vrm`. ![The Upload your own tab on the Create your own companion page](/docs-images/companion-upload-tab.png) 3. She appears in a small preview. Under **Standard motions**, choose **Feminine** or **Masculine**. This gives her a full set of motions, so you don't need your own. ![The review screen, with the model in a preview and the Feminine, Masculine and Only her own motions choices](/docs-images/companion-3d-review.png) 4. Press **Use this file**. 5. Choose a voice, and give her a name and personality. You can change these later. 6. Press **Create**, and talk to her. If the Create step says you've reached your plan's agent limit, choose **One of your agents** under **Who it talks as**. Your character then becomes the face of an agent you already have. **Adding your own motions** is optional. In the same screen, upload your clips (`.vrma`, `.glb` or `.fbx`) and choose what each one is for, such as **Idle** or **Gestures**. Your clips are used first, and the standard set fills the rest. Outfits she can change between are only available on route 2. ## Route 2: Share her with other people A plugin is just a folder that we read. Our starter folder already has 28 motions and everything set up, so you mostly drop your model in. 1. **Download the [3D starter folder](/companion/starter/companion-3d-starter.zip)** and unzip it. 2. **Copy your model** into `companion-models/my-look/` and name it exactly `model.vrm`. 3. **Name your pack.** Open `readme.md` in any text editor (Notepad or TextEdit are fine) and change `My Companion Pack` to your own name. It must be unique, so `Aiko by YourName` is a good pattern. 4. **Describe her.** Open `companion-characters/my-companion/character.json` and change the text inside the quotes. Leave the `look` line alone. | Line | What it is | | --- | --- | | `name` | her name in the gallery | | `vibe` | one short line on her card | | `soul` | her personality, in plain sentences | | `instructions` | how she should talk | Keep the quotes and commas as they are. To get a line break, write `\n`, and write `\"` for a quote inside the text. 5. **Zip the folder.** Right-click it and choose Compress (Mac) or Compress to ZIP (Windows). 6. **Upload it.** In the console, go to **Plugins → Created → Upload a plugin** and choose your zip. 7. **Try her.** Install the plugin, open **Companions**, find her in the gallery and press **Talk**. 8. **Share her.** On the plugin's page, open the **⋯** menu and choose **List in Browse**. Until you do, only your account can install her. To publish a change later, edit the folder and raise `version:` in `readme.md`, for example from `1.0` to `1.1`. Then zip it and upload it on the plugin's page. ### Make her your own These are all optional. Each one links to the exact settings, and you can ask an AI assistant to make the edit for you. [Our docs in plain text](/llms-full.txt) give it everything it needs. | To add | What you do | Read more | | --- | --- | --- | | **A picture in the gallery** | Add `thumbnail.png` to `my-look`, and name it in `model.json` | [Model](plugin-companion-packs.md#model--modeljson) | | **A room behind her** | Add a soft room picture and name it in `model.json`. It's blurred and cropped to fit | [Background](plugin-companion-packs.md#background) | | **Outfits she can change** | Keep each garment as its own mesh, or at least its own material, and list them | [Outfits](plugin-companion-packs.md#outfits) | | **Your own motions** | Replace a clip, keeping its file name, or add one to the list in `model.json` | [Model](plugin-companion-packs.md#model--modeljson) | | **Tail or ear movement** | Export a `.glb` animation of those bones | [Model](plugin-companion-packs.md#model--modeljson) | | **Exact camera crops** | Say what each Face, Upper body and Full body button shows | [Framing](plugin-companion-packs.md#framing) | | **A set voice** | Suggest a voice from a provider | [Character](plugin-companion-packs.md#character--characterjson) | **Skirts** are found automatically when they're a ring of bones under the hips, with at least 4 chains spreading around her. If yours isn't detected, see [Hair, cloth and what a garment covers](plugin-companion-packs.md#hair-cloth-and-what-a-garment-covers). ## Check her before you share her Most problems only show up in movement, or from angles you don't usually look at. - **Turn the camera** and look from the side and behind. Clipping and floating feet hide from the front. - **Play every motion**, and watch how each one starts and ends. - **Try every outfit**, then wave and lean in each one. - **Try Face, Upper body and Full body** in a narrow window as well as a wide one. - **Talk to her** and watch her mouth, blinks and eyes. ## If something looks wrong | What you see | Likely cause | What to do | | --- | --- | --- | | The mouth doesn't move when she talks | The VRM has no `aa`, `ih`, `ou`, `ee`, `oh` mouth shapes, or no voice is set | Add the shapes and export again, and check she has a voice | | Hair or a tail hangs stiff | No spring bones on it | Add them in your tool and export again | | Hair goes through her body, or sticks out | The spring settings need tuning | Change stiffness and gravity in small steps. For long hair, try stiffness 0.8 and gravity 0.15 | | Skin pokes through her clothes | The garment doesn't sit on a real body mesh, or it fits too tightly | Keep a body mesh under the clothes, and leave a small gap | | She stays in a T-pose | The file isn't a proper humanoid | Check the humanoid bone setup in your tool | | She bends strangely in one motion | That clip was made for a very different body or starting pose | Try another clip, or fix its first frame | | She takes a long time to appear | The file is large | Use 2048-pixel textures, and export again | ### When an upload is refused The message names what's wrong. These are the common ones: | Message | What it means | | --- | --- | | `'avatar' names file 'model.vrm', which isn't in the package` | Your model isn't in `companion-models/my-look/`, or isn't named exactly `model.vrm` | | `a look needs at least one idle, one talkIdle and one gesture motion` | You removed motions she needs. Add them back from the starter | | `every motion id must be unique` | The same clip is listed twice in `model.json` | | A message about JSON or an unexpected character | A quote or comma was removed from a file you edited. Undo your last change, or paste the file into an online JSON checker | ## Words you'll see | Word | Meaning | | --- | --- | | **Look** | Her model and motions | | **Character** | Her card in the gallery: name, personality and suggested voice | | **Plugin** or **pack** | The folder you upload, which can hold several looks and characters | | **Role** | What a motion is for, such as idle, talking or a gesture | | **`.vrma`** | An animation file for VRM humanoids | The full reference for every setting is [Companion packs](plugin-companion-packs.md). # Create a 2D companion A 2D companion is a Live2D Cubism model. If you can rig and animate in Cubism Editor, you have what you need. You don't need to code or use a terminal. **You need a rigged Live2D model.** A flat PNG won't work, because rigging is what makes it move. If you don't rig, you can commission a Live2D rigger, or make a [3D companion](create-3d-companion.md) with VRoid Studio instead. There are two routes: - **[Just for you](#route-1-just-for-you):** upload your model and talk to her. - **[Share her with other people](#route-2-share-her-with-other-people):** add your model to our starter folder and upload it as a plugin. Anyone can install her once you list it. Start with route 1, even if you plan to share. It's the quickest way to see how your model behaves. ## Get your model ready **Export for runtime from Cubism Editor,** then export your motions as `.motion3.json` files. Put everything in one folder, with the motions in a folder called `motions`: ``` Aiko/ Aiko.model3.json Aiko.moc3 Aiko.physics3.json Aiko.cdi3.json Aiko.2048/texture_00.png motions/idle.motion3.json motions/talk.motion3.json motions/nod.motion3.json ``` **Unlike 3D, a 2D model has no ready-made motions to borrow,** so you animate your own. You need at least an idle to upload it for yourself. To share it, you need three: an idle, a talking loop and one gesture. Then zip the folder: right-click it and choose Compress (Mac) or Compress to ZIP (Windows). The zip can be up to 100 MB. ### Use the standard parameter names We move your model by setting standard Cubism parameters. If you started from Cubism's standard parameter set, this already works. | Parameter | What it gives her | Without it | | --- | --- | --- | | `ParamAngleX`, `ParamAngleY`, `ParamAngleZ` | She turns and nods, and the background shifts slightly as she moves | Her head only moves in your own motions | | `ParamEyeBallX`, `ParamEyeBallY` | Her eyes look at the person | Her eyes stay still | | `ParamEyeLOpen`, `ParamEyeROpen` | She blinks | She doesn't blink | | `ParamMouthOpenY`, `ParamMouthForm` | Her mouth moves with her voice | Her mouth stays still | | `ParamBodyAngleX`, `ParamBodyAngleZ` | Her body turns and sways | Only a slight sway of the whole figure | | `ParamBreath` | Her chest moves as she breathes | No breathing from us | | A physics file | Hair and clothes follow her movement | They stay rigid | Using your own names for her face? You can [map them](plugin-companion-packs.md#face-map-smiling-eyes-and-closed-lips). **Two more things worth doing:** - **Add a hit area** with "Head" or "Face" in its name. It tells us exactly where her head is, for the Face view and for clicks. Without one, we measure it from what moves when `ParamAngleX` turns. - **Watch texture size.** A model must fit in 448 MB and four 4096-pixel textures on a desktop, and 192 MB and two textures on a phone. ## Route 1: Just for you 1. Open **Companions** in the console and choose to create your own. 2. Under **How it looks**, choose **Upload your own** and pick your `.zip`. ![The Upload your own tab on the Create your own companion page](/docs-images/companion-upload-tab.png) 3. She appears in a small preview. Under **Add from this model**, tick your motions. 4. Each ticked motion starts as a **Gesture**. Set what each one is for by ticking **Idle**, **While talking** and so on, and unticking **Gestures**. She needs at least one **Idle**. ![The review screen, with the model in a preview and a motion's name, description and role choices](/docs-images/companion-2d-review.png) 5. Replace each motion's second box with a short description of what it does, such as "A small, friendly nod". She reads it to choose a gesture. 6. Press **Use this file**. 7. Choose a voice, and give her a name and personality. You can change these later. 8. Press **Create**, and talk to her. If the Create step says you've reached your plan's agent limit, choose **One of your agents** under **Who it talks as**. Your character then becomes the face of an agent you already have. ## Route 2: Share her with other people A plugin is just a folder that we read. Our starter folder has everything set up, so you mostly drop your model in. 1. **Name three motions exactly.** In your model's `motions` folder, name them `idle.motion3.json`, `talk.motion3.json` and `nod.motion3.json`. The nod can be any small gesture. Then zip your model folder. 2. **Download the [2D starter folder](/companion/starter/companion-2d-starter.zip)** and unzip it. 3. **Copy your model's zip** into `companion-models/my-look/` and name it exactly `model.zip`. 4. **Name your pack.** Open `readme.md` in any text editor (Notepad or TextEdit are fine) and change `My Companion Pack` to your own name. It must be unique, so `Aiko by YourName` is a good pattern. 5. **Describe her.** Open `companion-characters/my-companion/character.json` and change the text inside the quotes. Leave the `look` line alone. | Line | What it is | | --- | --- | | `name` | her name in the gallery | | `vibe` | one short line on her card | | `soul` | her personality, in plain sentences | | `instructions` | how she should talk | Keep the quotes and commas as they are. To get a line break, write `\n`, and write `\"` for a quote inside the text. 6. **Zip the starter folder,** just like your model. 7. **Upload it.** In the console, go to **Plugins → Created → Upload a plugin** and choose your zip. 8. **Try her.** Install the plugin, open **Companions**, find her in the gallery and press **Talk**. 9. **Share her.** On the plugin's page, open the **⋯** menu and choose **List in Browse**. Until you do, only your account can install her. To publish a change later, edit the folder and raise `version:` in `readme.md`, for example from `1.0` to `1.1`. Then zip it and upload it on the plugin's page. ### Make her your own These are all optional. You can ask an AI assistant to make the edit for you. [Our docs in plain text](/llms-full.txt) give it everything it needs. | To add | What you do | Read more | | --- | --- | --- | | **More motions** | Add them to `model.json`, each with what it's for: listening, thinking, a reaction and so on | [Model](plugin-companion-packs.md#model--modeljson) | | **A picture in the gallery** | Add `thumbnail.png` to `my-look`, and name it in `model.json` | [Model](plugin-companion-packs.md#model--modeljson) | | **A room behind her** | Add a soft room picture and name it in `model.json`. It's blurred and cropped to fit, and she picks up its colours automatically | [Background](plugin-companion-packs.md#background) | | **Exact crops** | Say what each Face, Upper body and Full body button shows | [Framing](plugin-companion-packs.md#framing) | | **A set voice** | Suggest a voice from a provider | [Character](plugin-companion-packs.md#character--characterjson) | 2D companions can't have outfits, held items or walking, because the artwork is one painted piece. ## Check her before you share her - **Try Face, Upper body and Full body** in a narrow window as well as a wide one. - **Play every motion**, and watch how each one starts and ends. - **Talk to her** and watch her mouth, blinks and eyes. - **Click her head and body**, and check she reacts. ## If something looks wrong | What you see | Likely cause | What to do | | --- | --- | --- | | **Use this file** is greyed out | No motion is set to **Idle**, or a motion has no description | Tick **Idle** on one motion, and fill in the boxes | | The Face view is empty or cut off | Her figure doesn't fill the canvas, or sits off to one side | Add a hit area with "Head" in its name, or trim the canvas. You can also set the view in the companion's **Frame** mode | | The mouth doesn't move | No `ParamMouthOpenY`, or no voice is set | Rename the parameter or map it, and check she has a voice | | She doesn't blink | No `ParamEyeLOpen` or `ParamEyeROpen` | Rename them or map them | | Her head doesn't turn | No `ParamAngleX` | Rename it, and check it's connected in your rig | | Hair stays rigid | No physics file, or it isn't named in the `.model3.json` | Export physics again | | A motion moves the wrong things | It was made for another model | Export it again from this model | ### When an upload is refused The message names what's wrong. These are the common ones: | Message | What it means | | --- | --- | | `'avatar' names file 'model.zip', which isn't in the package` | Your model's zip isn't in `companion-models/my-look/`, or isn't named exactly `model.zip` | | `the avatar has no animation named 'motions/idle.motion3.json'` | A motion isn't in your model's zip, or is named differently. Names must match exactly | | `a look needs at least one idle, one talkIdle and one gesture motion` | You removed motions she needs | | A message about JSON or an unexpected character | A quote or comma was removed from a file you edited. Undo your last change, or paste the file into an online JSON checker | ## Words you'll see | Word | Meaning | | --- | --- | | **Look** | Her model and motions | | **Character** | Her card in the gallery: name, personality and suggested voice | | **Plugin** or **pack** | The folder you upload, which can hold several looks and characters | | **Role** | What a motion is for, such as idle, talking or a gesture | | **2.5D** | What the reference docs and console call a Live2D model | The full reference for every setting is [Companion packs](plugin-companion-packs.md). # Companion packs This is the full reference. To make your first companion, start with [Create a 3D companion](create-3d-companion.md) or [Create a 2D companion](create-2d-companion.md). Companion mode gives an agent a face and a voice: a character in the browser that listens, looks thoughtful while the agent works, and answers out loud with lip-sync and gestures timed to the words — while still being the same agent, with its tools, files and crew. A companion pack is a plugin that ships the **looks** and the **characters** for that, as data. No code runs. | Capability | Folder | What the user gets | | --- | --- | --- | | **Model** (a look) | `companion-models//` | an avatar — 3D (VRM, GLB) or 2.5D (a Live2D Cubism model) — **with its own motions** (idle, talking, listening, thinking, gestures, reactions, and for 3D, extra body parts like tails and ears) and its own facial expressions | | **Character** | `companion-characters//` | a ready-made face in the Companions gallery: one look + a suggested personality, voice, thinking sounds and behaviour profile | | **Item** | `companion-items//` | a prop any 3D companion can wear: a mug for the hand, a hat for the head. The agent picks it up with `[hold:…]`, the user ticks it in the wardrobe menu | Every look is drawn by the Unity renderer, the only one; a pack ships data and never a renderer. Voices come from voice providers — see [Voice and speech-to-text](plugin-voice-and-speech.md). A character only *suggests* voices. ## A complete pack ``` readme.md companion-models/sample-seed/model.json companion-models/sample-seed/seed-san.vrm companion-models/sample-seed/thumbnail.png companion-models/sample-seed/background.jpg companion-models/sample-seed/idle-neutral.vrma companion-models/sample-seed/talk-idle.vrma companion-models/sample-seed/listen.vrma companion-models/sample-seed/think.vrma companion-models/sample-seed/wave.vrma companion-models/sample-seed/laugh.vrma companion-characters/sample-seed-character/character.json companion-characters/sample-rose-character/character.json ``` *(`infrastructure/plugins/sample-companion-pack` — walkthrough: [Sample Companion Pack](example-sample-companion-pack.md))* An item pack looks the same, with `companion-items//item.json`, its `.glb` model and an optional thumbnail per item (`infrastructure/plugins/sample-companion-items`). **Keys are unique across the whole package.** A character can't reuse its model's key — use `fox-model` and `fox`. Every binary file must be referenced by one of the JSON files, or the publish is refused naming the file. ## Model — `model.json` A model is a look: the avatar plus everything that says how it moves and how its face is driven. The motions live **inside the look** — there is no separate motion-set capability. ```json { "name": "Sample Seed-san", "description": "VirtualCast's sample VRM avatar Seed-san, with a small set of its own motions.", "avatar": "seed-san.vrm", "thumbnail": "thumbnail.png", "motions": [ { "id": "file:idle-neutral.vrma", "roles": ["idle"], "name": "Idle", "description": "A long, unhurried neutral standing idle loop." }, { "id": "file:talk-idle.vrma", "roles": ["talkIdle"], "name": "Talk idle", "description": "Idle loop with light gestures while speaking." }, { "id": "file:wave.vrma", "roles": ["gesture"], "name": "Wave", "description": "Waves a hand — greeting or farewell.", "cues": ["waving"], "strokeSeconds": 0.9 } ] } ``` | Field | Rules | | --- | --- | | `name`, `description` | display text; name 1–100 characters | | `avatar` | required: `.vrm` or `.glb` (3D), or a `.zip` Live2D Cubism model (2.5D, see below), ≤ 100 MB. VRM gets the full treatment (facial expressions, visemes, spring-bone physics) | | `thumbnail` | optional PNG, JPEG or WebP, ≤ 2 MB — shown in the gallery and the look picker | | `outfits` | optional, 3D looks only — the clothes she can switch between, by showing and hiding parts of the model (below) | | `background` | optional PNG, JPEG or WebP, ≤ 2 MB — a picture shown blurred behind her on the Companion tab (below) | | `rig` | optional bone map for a plain GLB humanoid — see [Bone maps](#bone-maps-rig). Not allowed on a VRM | | `motions` | the look's motions (below). **At least one `idle`, one `talkIdle` and one `gesture`** or the publish is refused | | `face` | optional face map — which of the model's own morph targets or expressions drive each emotion, mouth shape, blink, smile, brows (raise, and `browLower` for a frown) and blush, plus an optional `jawBone` (a bone of the avatar). Every control must exist on the avatar | | `framing` | optional — what the **Face**, **Upper body** and **Full body** buttons show for this look, which one a new companion starts on, and for a 3D look its camera (below) | | `cloth` | optional, 3D looks only — the skirt or cape chains to simulate as cloth (below). Left out, the stage finds a skirt on its own; `[]` means no cloth | | `pacing` | optional, 3D looks only — whether she steps sideways across the floor on her own, how fast and how far (below). Needs a `walk` motion | | `isLooseChainPhysicsEnabled` | optional boolean, default `true` — for a model without spring bones, `false` keeps its loose bone chains on a gentle sway instead of physics (below) | The whole manifest, as the platform stores it, may be at most 64 KB. An unknown key anywhere in `model.json` refuses the publish. #### Framing By default the stage guesses each preset from where it thinks her head is. A look can say exactly what it wants instead: ```json "framing": { "defaultPreset": "fullBody", "face": { "bottom": 0.73, "top": 1.0 }, "upperBody": { "bottom": 0.45, "top": 1.0, "centerX": 0.5 } } ``` - `defaultPreset` (`face`, `upperBody` or `fullBody`) is where a new companion starts; without it, Upper body. - `face`, `upperBody`, `fullBody` are windows, each optional. A preset without a window keeps the stage's guess: the stage measures her head (a 2.5D look: from its `Head`/`Face` hit area, else from the parts that move as far as her eyes when `ParamAngleX` turns, else the top 13% of the canvas), then shows about 1.5 head heights below the head for Face and 4.5 for Upper body, and the whole model plus a 4% margin for Full body. If the head can't be found, every preset shows the whole model. - A window is `bottom` and `top`, as fractions of the model's height (0 = its bottom edge, 1 = its top edge), and an optional `centerX` (0 = left edge, 1 = right edge). Without `centerX` the crop is centred on her head. For a 3D look the model is its rest-pose box (hat and hair included), measured at its front; for a 2.5D look it is the canvas the artist drew in. - **A window guarantees height only.** At zoom 1 the stage's height shows exactly `bottom` to `top`, on any stage shape. The width is whatever the stage allows: a wide stage shows more space beside her, a narrow one crops her sides around `centerX`. `centerX` moves the crop, it cannot widen it. - Values may go a little past 0 and 1 (`"bottom": -0.04` leaves air under her feet), between -1 and 2, and `top` must be at least 0.02 above `bottom`. An unknown key or an out-of-range value refuses the publish with the reason. A user who uploads their own look gets the same thing from the **Use this view for Face** button in the companion's Frame mode. For a 3D look, `framing` can also carry a `camera` that says where the camera rests, how far a viewer can turn it and how it follows her: ```json "framing": { "camera": { "defaultYawDegrees": 20, "yawRange": [-45, 45], "isDriftEnabled": true, "followSeconds": 0.6 } } ``` | Field | Rules | | --- | --- | | `defaultYawDegrees` | -75 to 75, default 0 — the resting turn of the camera around her | | `yawRange` | optional `[minimum, maximum]`, both within -75 to 75, `defaultYawDegrees` inside it; default -75 to 75 | | `isDriftEnabled` | default `true` — the camera drifts a little while she idles | | `followSeconds` | 0.2 to 2, default 0.6 — how softly the camera follows her across the floor | A 2.5D look with a `camera` is refused. #### Pacing and the walk motion With `pacing` on, she now and then takes a step to the side on her own: while idle every 12 to 30 seconds, and sometimes between sentences of a long reply. She turns three-quarters toward the direction she walks (60 degrees), plays her `walk` loop on her legs while she travels, squares up to the viewer before the last step and stops to face the user when they start talking or typing. ```json "pacing": { "isEnabled": true, "speed": 0.35, "stepDistance": 0.25 }, "motions": [ { "id": "file:walk.vrma", "roles": ["walk"], "name": "Walk", "description": "An in-place walking loop for stepping across the floor." } ] ``` | Field | Rules | | --- | --- | | `isEnabled` | default `true` | | `speed` | body heights per second, 0.1 to 1, default 0.35 | | `stepDistance` | how far one step goes, in body heights, 0.05 to 0.6, default 0.25 | - **Distances are in body heights**, so a tall and a short model pace alike. - **The `walk` clip must be an in-place loop** (no travel in the clip; the stage moves her). The stage measures how far the left foot travels in the loop and plays it faster or slower (0.5x to 2x) to match `speed`; a clip it can't measure plays at 1x. - **Without a `walk` motion she never paces**, whatever `pacing` says: `pacing` alone does nothing. A user with reduced motion on never sees her pace either. - **Without a `pacing` block** the defaults above apply; she paces if the look has a `walk` motion. - **She only steps when there is floor to step on.** If the current framing (a close-up Face view, say) leaves less visible floor than one step, the step is skipped. - A character turns it off or tunes it with `behaviour.behaviours.pacing` (`{ "enabled": false }` or `{ "strength": 0.75 }`). - A `[pace:left]`, `[pace:right]` or `[pace:home]` tag in a reply makes her step on cue, and `[camera:30]` (a whole number of degrees within -75 to 75), `[camera:front]` or `[camera:reset]` turns the camera. On a 3D look the agent's briefing teaches the camera tags, and the pace tags when the look has a `walk` motion. A plugin that edits the reply can write them too. An invalid argument drops the tag. - Pacing is for 3D looks; a 2.5D look with `pacing` is refused. #### Outfits An outfit is a named set of the model's parts to show. She wears **one outfit at a time**, by design: "what is she wearing" is a single answer for the agent and the user. (Independent toggles such as "glasses on or off" are left out on purpose; make a second outfit instead.) ```json "outfits": [ { "key": "default", "name": "Casual", "meshes": ["Tshirts", "Bottoms", "Shoes"], "shapes": [{ "mesh": "Body", "shape": "Hip1", "weight": 1 }] }, { "key": "dress", "name": "Dress", "meshes": ["Dress", "Dress_Shoes"] }, { "key": "swim", "name": "Swimwear", "meshes": ["SwimB_L", "Bottoms_L002"], "materials": ["Mat_Swim"] } ] ``` | Field | Rules | | --- | --- | | `key` | required, `a-z0-9-`, 1–40, unique in the look — what `[outfit:key]` and the stored choice refer to | | `name` | required, 1–60 — shown in the wardrobe menu | | `meshes` | mesh node names the outfit **shows**, ≤ 50 parts per outfit | | `materials` | material names the outfit shows — for models that merge several garments into one mesh and split them only by material (the VRoid exports do) | | `shapes` | blend-shape weights the outfit sets, each `{ "mesh", "shape", "weight" }` with `weight` 0–1 — to shrink the body under the clothes; ≤ 64 per outfit, and they don't count as parts | - **The union rule.** A part that any outfit lists is hidden unless the worn outfit lists it; a part no outfit lists (body, face, hair, a tail) always shows. So an outfit only names its own clothes. - **Shapes follow the same rule.** A (mesh, shape) pair that any outfit names is 0 unless the worn outfit sets it; a pair no outfit names is never touched. The mesh must be one of the avatar's and the shape one of its shape keys. - **The first outfit is the default**, and what a companion wears until someone changes it. At most 20 outfits per look. - **Write mesh names the way three.js spells them** (the same rule as [bone maps](#bone-maps-rig)): `Bottoms_L.002` becomes `Bottoms_L002`. Material names are written as they are. The publish is refused when the avatar has no part of that name, and tells you the spelling when it differs. - A VRoid export draws the top, skirt and shoes as **one `Body` mesh** split by material: list the clothing materials in `materials` instead. VRoid has **no skin under its clothes**, so hiding a garment leaves a hole; give such an outfit its own mesh in the file, or keep the garments in all of them. - A material shared between a garment and the body hides both. That is the author's to avoid. - Outfits are for 3D looks. A Live2D look with `outfits` is refused. #### Hair, cloth and what a garment covers All of this is 3D only and needs no code, but the model has to carry the right things. What the stage does with what you leave out: | You supply | The stage does | Left out | | --- | --- | --- | | **Spring bones** in the VRM for hair, hoods, ribbons and tails | simulates them against a body rig (a ball for the head, capsules for neck, torso, arms, hands, hips, legs) fitted at load to the innermost layer of the mesh, which it treats as her skin, so garments don't widen it | the stage falls back to the loose-chain rule below | | **A body mesh under the clothes** (the innermost layer is the skin) | hides the skin and underwear that a garment covers, and the garment parts that poke through another one, from the geometry alone: no mesh names to list. The face is never hidden, and a thin border of skin stays next to every kept part so a pose shows a sliver of skin, never a hole | nothing covers anything: skin shows through, or the layers clip | | **Loose bone chains** (non-humanoid bones that skin the mesh) in a model **without** spring bones: a GLB, or a VRM that declares none | simulates the chains that hold at least 20 vertices and don't skin the face, up to 48 | the chains don't move; set `isLooseChainPhysicsEnabled: false` to keep a model's chains on a gentle sway instead of physics | | **A skirt as a ring or fan of bone chains** | finds it on its own: at least 4 chain roots under the hips (not on the legs, not above the waist) that spread over more than 180 degrees around her, grouped under one parent (a panel of at least 3 chains per parent also counts); the largest group becomes one closed-loop cloth that holds its shape and stays out of the legs | no skirt is found, and the skirt hangs rigid or follows the loose-chain rule above | | **`cloth`** in `model.json`, when detection picks the wrong thing or finds nothing | simulates exactly the cloths you list | a look with no `cloth` finds its skirt automatically | `cloth` is a list of up to 4 pieces: ```json "cloth": [ { "chains": ["Skirt_F1", "Skirt_F2", "Skirt_R1", "Skirt_B1", "Skirt_B2", "Skirt_L1"], "isClosedLoop": true, "stiffness": 0.5, "drag": 0.4, "gravity": 1 } ] ``` | Field | Rules | | --- | --- | | `chains` | required, 2 to 32 root bone names, ordered around the body, no bone used twice. Each must be a non-humanoid bone of the avatar, written the way three.js spells it (the same rule as [bone maps](#bone-maps-rig)); otherwise the publish names the bone | | `isClosedLoop` | default `true` — the last chain links back to the first (a skirt does, a cape doesn't) | | `stiffness` | 0 to 1, default 0.5 — higher holds its shape | | `drag` | 0 to 1, default 0.4 | | `gravity` | 0 to 2, default 1 | - `"cloth": []` on the look means no cloth at all. A skirt-less look needs nothing. - An **outfit** can carry its own `cloth` (same rules). A list, even an empty one, replaces the look's cloth while that outfit is worn; an outfit without `cloth` uses the look's, or the detected skirt. Yuzuki gives Casual, Loungewear and Swimwear `"cloth": []` so only Dress uses the detected skirt. - Cloth on a 2.5D look is refused. - An outfit change shows at once; the covered skin hides a moment later (the stage waits at most a quarter of a second for it, and remembers the result on that device). #### Background `background` is a picture that sits behind her on the Companion tab, blurred more the closer the framing (Face most, Full body least), so she stays the focus. It belongs to her stage, not the page, and it is not drawn in previews. Use a soft, out-of-focus room picture (1920×1080, a JPEG under 1 MB is plenty). It keeps its proportions: on a stage of another shape it is scaled to cover and cropped at the edges, never stretched. It works for 2.5D looks too. Each motion: | Field | Rules | | --- | --- | | `id` | required, unique in the look. `file:` — a clip file in this model's folder (`.vrma`, `.glb` or `.fbx`, ≤ 32 MB); or `avatar:` — an animation embedded in the avatar file (for a 2.5D look, the `.motion3.json` path inside the zip). `library:` ids are refused | | `roles` | required, 1–2 distinct roles — see below | | `name`, `description` | required — name 1–60 characters, description 1–300. The description is what the gesture picker reads, so describe the motion and its mood | | `rig` | a bone map for a body clip in a `.glb` without VRM humanoid data, or in an `.fbx` | | `cues` | up to 10 tone words that trigger this motion (see below) | | `strokeSeconds` | 0–30 — when in the clip the "beat" lands, so it can be timed to a word | | `isFullBody` | optional boolean | | `canPlayWhileSpeaking` | optional boolean, default `true` — `false` pauses her voice at a requested move's tag while the motion plays, then resumes; automatic gestures never pick it while she speaks | `isBodyAnimated` and `partBones` are worked out from the clip itself — writing them refuses the publish. **Roles:** | Role | Plays | | --- | --- | | `idle` | standing still, the default loop | | `talkIdle` | the loop while speaking | | `listen` | while the user is talking | | `think` | while the agent is working | | `gesture` | a movement chosen per sentence while speaking | | `reaction` | an emotional beat, e.g. a laugh on a `[laughing]` line | | `partsIdle` | an always-on loop for extra body parts (tail, ears, wings) | | `partsGesture` | an extra-parts movement chosen while speaking (a tail wag on a happy line) | | `walk` | an in-place walking loop the stage plays on the legs while she paces (3D only, see Pacing); never chosen by the agent | | `postureShift` | a shift of weight and a small turn, either side — the clip must **end in the shifted pose**; she holds it, then eases back. Used every 20–60 s, while she thinks and between sentences | | `leanIn` | a lean or turn toward the viewer that ends in that pose — used while the user talks to her | Posture roles are optional (a look with none just does not shift), never chosen by the agent, and a look's own posture clips replace the standard set's. `walk` is optional too: without it she does not pace. An extra-parts role needs a `.glb` clip (or an avatar animation) that moves at least one named bone the avatar has. A body clip on a humanoid avatar must map humanoid bones: a `.vrma` always does; a `.glb` or `.fbx` needs a `rig`. **Cues** tie motions to the tone of what the agent says. When the agent speaks aloud it can mark lines with stage directions such as `[happy]` or `[laughing]`; a motion whose `cues` include that word becomes the natural pick. Allowed words are the emotions (`happy`, `sad`, `excited`, `calm`, `curious`, `surprised`, `proud`, `grateful`, `confused`, … — about fifty), the sound effects (`laughing`, `chuckling`, `sighing`, `gasping`, `crying`, `yawning`, …) and the stage actions `waving` (her greeting), `stretching`, `yawning`, `humming` and `looking-around` — a motion cued with one of the last four is played when she is idle for a long while, so a look can bring its own idle habits. An unknown cue refuses the publish, naming it. ### 2.5D looks (Live2D) Set `avatar` to a `.zip` of a Live2D Cubism 5 model: one `.model3.json`, its `.moc3`, textures, motions, expressions and physics. Publish opens the archive and refuses a missing file, a hostile path or a broken `.moc3`. A 2.5D look is a painted character whose layers bend and slide, and it can only play its own motions — so list them as `avatar:.motion3.json` (the path inside the zip, leaving out the model's own folder if you zipped the folder) with roles `idle`, `talkIdle`, `listen`, `think`, `gesture`, `reaction`, `postureShift` and `leanIn`. Extra-parts roles, `walk` and `rig` do not apply, and a 2.5D look with `cloth`, `pacing` or a framing `camera` is refused. The `face` map names the model's own parameters (`ParamMouthOpenY`, `ParamEyeLSmile`, `ParamCheek`, …); anything it leaves out uses the standard Cubism parameters. Working example: `infrastructure/plugins/hello-live2d`. A 2.5D look takes a `background` like a 3D one, but **no outfits and no items**: the artwork is one painted piece. What the 2.5D look should supply, and what happens when it doesn't: - **A head hit area.** Name a hit area in the `.model3.json` with "head" or "face" in its name. The stage uses its drawable to find her head for framing, clicks and the backdrop shift. Without one, the stage measures the head when the model loads: the parts that move at least 60% as far as her eyes do when `ParamAngleX` turns. A model without `ParamAngleX` gets a guessed head (the top 13% of the canvas) and no backdrop shift. Declared `framing` windows replace the guess for the buttons. - **`ParamAngleX` and `ParamAngleY`.** They carry the head turn, nods and the shift of the picture against the backdrop. - The look is drawn on its own layer and lit with the backdrop's tint, rim light, soft shadow and glow; the artist's colours are not changed, and there are no per-model settings for it. - A model must fit the budget the stage keeps for it: on a desktop 448 MB and four 4096-pixel textures, on a phone 192 MB and two. ### Face map: smiling eyes and closed lips Besides emotions, mouth shapes, blink, smile and brows, a face map can drive: - `eyeSmile` (`left` and `right` lists, like `smile`) — the squint of a smile. It follows her happiness at full strength, even while she speaks, so her eyes keep smiling as she talks; the `smile` mouth dial is capped while she speaks so it doesn't fight the lip-sync. - Two more `visemes`: `pp` (closed lips, for "p", "b", "m") and `ss` (teeth together, for "s", "z"), next to `aa`, `ih`, `ou`, `ee` and `oh`. When the face map has any mouth shape, the VRM's own `aa`…`oh` expressions are not driven at the same time, so the two never fight. Auto-fill in the look editor suggests these names, and a pack author can start from them: | Model | Names | | --- | --- | | ARKit blendshapes (most avatar tools) | `eyeSmile` = `cheekSquintLeft/Right` + `eyeSquintLeft/Right`; `pp` = `mouthClose` 1, `mouthPressLeft/Right` 0.5; `ss` = `mouthStretchLeft/Right` 0.6, `jawOpen` 0.1 | | VRoid (`Fcl_*`) | `eyeSmile` = `Fcl_EYE_Joy_L/R`; visemes `Fcl_MTH_A/I/U/E/O`; `pp` = `Fcl_MTH_Close`; blink `Fcl_EYE_Close_L/R`; `smile` = `Fcl_MTH_Fun`; `browRaise` = `Fcl_BRW_Surprised`; `browLower` = `Fcl_BRW_Angry` | | Oculus / Ready Player Me | `viseme_aa/I/U/E/O`, `viseme_PP`, `viseme_SS` | ### Calm and lively talk loops A `talkIdle` motion can carry emotion cues (`"cues": ["calm"]`, `["happy"]`). A reply that is mostly calm (or sad) prefers a `talkIdle` cued with a calm word; a happy, excited reply prefers one cued with a happy word. A look with only one `talkIdle` just plays it. Gestures also run a little faster and bigger in a lively reply. **Looks are followed live.** A companion wearing your look points at it — nothing is copied. When a user's installation moves to a new version of your plugin, every companion wearing the look moves the new way, and open Companion tabs update. If a version drops a look, companions wearing it show "Model missing" with one-click replacements until a version brings it back. If the user uninstalls your plugin, they do too. ## Item — `item.json` An item is a small prop model with an instruction for where it sits. Any 3D companion with a humanoid look can wear any installed item, whatever pack its look came from. ```json { "name": "Mug", "description": "A warm mug of coffee.", "model": "mug.glb", "thumbnail": "thumbnail.png", "attach": { "bone": "rightHand", "position": [-0.03, 0, 0.03], "rotation": [0, 0, -90], "scale": 1 } } ``` | Field | Rules | | --- | --- | | `name` | required, 1–100 — also what `[hold:…]` calls it (see below) | | `description` | optional, ≤ 300 — what the agent reads about the item | | `model` | required, a `.glb` in the item's folder, **≤ 3 MB**, with at least one mesh. Every texture inside it must be **≤ 2048 px** on its longest side, embedded (no external URIs), and PNG, JPEG or WebP (no KTX2) | | `thumbnail` | optional PNG, JPEG or WebP, ≤ 2 MB — shown in the wardrobe menu | | `attach.bone` | required — one of the 15 humanoid bones: `hips`, `spine`, `head`, `leftUpperArm`, `leftLowerArm`, `leftHand`, `rightUpperArm`, `rightLowerArm`, `rightHand`, `leftUpperLeg`, `leftLowerLeg`, `leftFoot`, `rightUpperLeg`, `rightLowerLeg`, `rightFoot` | | `attach.position` | 3 numbers in metres, each within -2 to 2 — the item's origin, in the bone's own frame, **for the reference body** | | `attach.rotation` | 3 numbers in degrees (X, Y, Z order), each within -360 to 360 | | `attach.scale` | optional, 0.01–100, default 1 | - **The reference body is AvatarSample_A** (the VRoid Studio sample, freely downloadable from VRoid Hub). Author the `position`, `rotation` and `scale` against it (the bone frames are the VRM's *normalized* humanoid, arms in a T-pose, model facing +Z, so her right side is -X). The browser scales the item to each body by the length of the body part it sits on: `scale × (her segment ÷ the reference segment)`, and the position by the same factor. The reference segments, in metres: | Bone | Segment | Length | | --- | --- | --- | | `head` | head height | 0.240 | | `spine` | spine to neck | 0.330 | | `hips` | hip width | 0.308 | | `leftUpperArm`, `rightUpperArm` | upper arm | 0.240 | | `leftLowerArm`, `rightLowerArm` | forearm | 0.234 | | `leftHand`, `rightHand` | wrist to middle finger base | 0.057 | | `leftUpperLeg`, `rightUpperLeg` | thigh | 0.364 | | `leftLowerLeg`, `rightLowerLeg` | shin | 0.406 | | `leftFoot`, `rightFoot` | ankle to toes | 0.111 | - **Items are rigid props.** They move with the bone; there is no skinning and no animation inside an item. - **An item in a hand makes that hand curl around it.** - **One item per body part.** Wearing a second item on the same bone replaces the first. - **Worn budgets**, enforced together (a save or a `[hold:…]` that would break one is refused): at most **8 items** at a time, **12 MB** of item models in total, and **16 million texture pixels** in total. - Items are not drawn on 2.5D looks. ## Character — `character.json` ```json { "name": "Sample Seed", "vibe": "A test character with a plugin look that brings its own motions.", "look": "sample-seed", "soul": "You are Sample Seed, a cheerful test companion. You keep answers short and friendly.", "instructions": "You talk with the person out loud. Keep replies to a sentence or two and avoid lists.", "voice": { "description": "A bright, friendly young voice.", "suggestions": [ { "provider": "fish-audio", "voiceId": "8ef4a238714b45718ce04243307c57a7", "voiceName": "Sample voice" }, { "provider": "elevenlabs", "voiceId": "21m00Tcm4TlvDq8ikWAM", "voiceName": "Rachel", "models": ["eleven_v3"] } ] } } ``` A character appears in the **Companions** gallery as an equal of the built-in ones, tagged with your plugin's name. **Talk** creates a new agent from it; **Talk as…** puts the face on an existing agent (optionally adopting the suggested soul). Each character becomes at most one companion per account. The file is read **strictly**: an unknown field (a typo like `"sugestions"`) refuses the publish. The old top-level `model` and `motions` are gone — a character names its one `look`, and a look's motions live in its `model.json`. | Field | Rules | | --- | --- | | `name` | required, 1–100 | | `vibe` | required, 1–120 — the one line on the gallery card | | `soul` | required, 1–8,000 — the suggested persona for the agent | | `instructions` | required, 1–8,000 — the suggested operating instructions | | `look` | required — a model key from this package (`"look": ""`) or `{ "builtIn": "" }`. A character has exactly one look | | `fillers` | optional, ≤ 20 short lines she says while she thinks or is amused: `{ "key": "hmm", "text": "Hmm.", "purpose": "thinking" }` — keep thinking lines to a sound ("Hmm.", "Mm."): she says them before she knows the answer, so words like "interesting" give the game away — `key` is `a-z0-9-` (1–40, unique), `text` 1–120, `purpose` is `thinking` or `amused`. A companion's own lines win; then these; then the built-in lines. They update live with your plugin | | `behaviour` | optional, data only: `temperament` (`energy`, `eyeContact`, `gestureRate`, `blinkRate`, `expressiveness`, each 0.5–1.5, default 1), `behaviours` (per engine behaviour `{ "enabled": false }` or `{ "strength": 0.75 }`, 0.5–1.5), `wordReactions` (`phrases` → `smile`/`browRaise`/`glance`/`soften`, `{name}` is her own name) and `toolActivity` (`when` → `reading`/`glanceAside`/`aha`). Tools are named by stable identity: `builtin:`, `tool:` for a tool in this same package, `agent.delegated`, `agent.reported`. At most 30 rules, 20 entries per rule, phrases ≤ 40 characters. Default word lists are English, so add your own language's. An unknown key or reaction refuses the publish with its path. It updates live with your plugin. A top-level `temperament` is refused: it moved inside `behaviour` | | `thumbnail` | optional PNG/JPEG/WebP ≤ 2 MB, inside the character's folder | | `voice.description` | ≤ 300 characters, shown to the user | | `voice.suggestions` | ≤ 8, **one per provider**: `provider` (`elevenlabs`, `fish-audio`, `deepgram`, `openai-compatible`, or a plugin provider's `kindId`), `voiceId` (1–128), optional `voiceName` (≤ 100) and `models` (≤ 8 model ids, each ≤ 200) | **How the voice is picked:** the companion takes the **first** suggestion whose provider the user has set up. None matching means the user's default voice; with no voice set up at all, captions only, with a notice offering to add a provider. For ElevenLabs, fish.audio and Deepgram a voice id works on any of their models; for OpenAI-compatible and plugin providers a voice id belongs to certain models, so list them in `models`, preferred first — the suggestion then applies only when the user has one of them. Soul and instructions are suggestions: the personality always belongs to the agent. The agent's soul also steers which gestures the companion picks. ## How the agent changes her wardrobe The agent changes what she wears by putting tags in what she says aloud, the same way it writes `[happy]`: | Tag | Does | | --- | --- | | `[outfit:dress]` | puts on the outfit with that `key` | | `[hold:mug]` | picks up the item, in whatever hand or place its `attach.bone` says (taking off any item on that bone) | | `[hold:none]` | takes every item off | - A tag happens **where it is written**: `"[outfit:swim] Ready for the beach! [hold:mug]"` keeps the old outfit until the first word, switches, and adds the mug at the end. - **The change is permanent.** It is saved when the reply is saved, so a reload, another tab and the next reply all see it. The agent's briefing lists the outfits she has and the items installed, and what she wears now. - **Item tags** are the item's `key` (the folder name). When two installed item packs share a key, the tag is `key-pluginslug` (for example `coffee-cozy-props`); the long form always works, so older history keeps matching. Tags are case-insensitive. A tag that names nothing she has, or that would break a limit, is ignored. - A plugin can write these tags too — see [`assistantMessageOutbound`](plugin-middleware.md) — but the `companionPerformance` hook never sees them. - The picture follows the voice: the outfit and items change on screen as her voice reaches the tag. Replaying an old reply does not change her clothes again. ### When the user changes her wardrobe The agent is told when the user changes what she wears in the Companion tab. A run of clicks produces one platform notice, about 4 seconds after the last click, worded like "The user just changed what you are wearing in the Companion tab. Before: Casual, nothing extra. Now: Swimwear, with Mug (right hand)." — "Before" is what she wore when the run began, so clicking back to where she started produces no notice. The agent can react out loud if it fits the moment. Only changes made in the Companion tab are reported, never the agent's own tags. ## Preparing a VRM A VRM straight out of an authoring tool can be tens of megabytes. `infrastructure/scripts/dev/companion-model-shrink.mjs` compresses any VRM for the web while keeping its humanoid bones, expressions, **spring bones (hair and tail physics)** and **MToon toon shading**; it turns PNG textures into WebP and meshopt-compresses the geometry: ```bash npm i --no-save @gltf-transform/core@4 @gltf-transform/extensions@4 @gltf-transform/functions@4 meshoptimizer@1 sharp node infrastructure/scripts/dev/companion-model-shrink.mjs in.vrm out.vrm [maxTextureSize] ``` `maxTextureSize` (pixels, optional) caps the longest side of every texture; leave it out and no texture is resized (`2048` takes an 88 MB model with 4K textures down to about 6 MB). The script refuses a model whose expressions use material-colour or texture-transform binds, or whose first-person annotations or thumbnail point at meshes or images, and writes nothing in that case. ## Bone maps (`rig`) A body clip in an FBX file, or in a GLB without VRM humanoid data, needs a bone map from humanoid bone names to the file's node names: ```json "rig": { "version": 1, "bones": { "hips": "mixamorigHips", "spine": "mixamorigSpine", "head": "mixamorigHead", "leftUpperArm": "mixamorigLeftArm", "leftLowerArm": "mixamorigLeftForeArm", "leftHand": "mixamorigLeftHand", "rightUpperArm": "mixamorigRightArm", "rightLowerArm": "mixamorigRightForeArm", "rightHand": "mixamorigRightHand", "leftUpperLeg": "mixamorigLeftUpLeg", "leftLowerLeg": "mixamorigLeftLeg", "leftFoot": "mixamorigLeftFoot", "rightUpperLeg": "mixamorigRightUpLeg", "rightLowerLeg": "mixamorigRightLeg", "rightFoot": "mixamorigRightFoot" } } ``` `version` must be `1`; those 15 bones are required. **Write every bone name the way three.js spells it.** Browsers load these files with three.js, which rewrites node names: whitespace becomes `_`, and `[ ] . : /` are removed. Names in your JSON must already be in that form — the publish refuses a raw name and tells you the correct spelling: | In the source file | In your JSON | | --- | --- | | `Tail.001` | `Tail001` | | `mixamorig:Hips` | `mixamorigHips` | | `Ear L` | `Ear_L` | A file where two nodes would end up with the same name after that rewrite is refused. ## Limits | | | | --- | --- | | Package | 256 MB | | Avatar model (3D file or Live2D zip) | 100 MB | | Clip file | 32 MB | | Thumbnail, background, item thumbnail | 2 MB each (PNG, JPEG, WebP) | | Outfits per look | 20 (≤ 50 parts and ≤ 64 shapes per outfit) | | Item model | 3 MB `.glb`, textures ≤ 2048 px on the longest side | | Worn at once | 8 items, 12 MB of item models, 16 million texture pixels, one item per body part | | Motions per look | 100 (≤ 30 gestures, ≤ 10 extra-parts gestures) | | Filler lines per character | 20 | | Cues per motion | 10 | | Framing window | `bottom`/`top`/`centerX` within -1 to 2, `top` at least 0.02 above `bottom` | | Cloths per look (or outfit) | 4, each 2 to 32 chains | | Pacing | `speed` 0.1 to 1 body heights per second, `stepDistance` 0.05 to 0.6 body heights | | Camera | `defaultYawDegrees` and `yawRange` within -75 to 75, `followSeconds` 0.2 to 2 | | `model.json` once parsed | 64 KB | | Motion name / description | 60 / 300 characters | # Voice and speech-to-text providers In companion mode an agent speaks its replies out loud and can listen to the user's voice. AgentParley ships fish.audio, ElevenLabs, Deepgram and OpenAI-compatible voices; a plugin can add any other vendor — for speaking, for listening, or both. Users add it under **AI Providers → Add provider** like a native vendor, then pick its voices on a companion. These are [model providers](plugin-model-providers.md): the same `manifest.modelProvider`, the same `tools.fail(kind, message)`, the same "no agent powers" rule. Two handler groups: | Group | Handlers | Rule | | --- | --- | --- | | Voice (text-to-speech) | `speak` + `voices` | both or neither | | Speech-to-text | `transcribe` (+ optional `startLiveTranscription`, which needs `transcribe`) | on its own, or alongside anything else | The reference implementation is **OpenAI Voice** — see the [walkthrough](example-openai-voice.md). ```ts // infrastructure/plugins/openai-voice/index.ts globalThis.manifest = { egress: ["api.openai.com"], modelProvider: { kindId: "openai-voice", displayName: "OpenAI Voice" }, settings: [ { key: "apiKey", title: "API key", description: "An OpenAI API key with access to /v1/audio/speech.", type: "secret", required: true, }, ], }; ``` ## `speak` — say one group of sentences ```ts request: { voiceId: string; // the voice the user picked (one of yours, from `voices`) text: string; // one short group of sentences, ≤ ~300 characters, with the cues already taken out model: string; // the text-to-speech model id the user saved cues: { kind: "emotion" | "tone" | "effect" | "pause"; name: string; intensity: number; wordIndex: number }[]; spans: { name: "whisper" | "emphasis" | "soft" | "faster" | "slower" | "stress" | "forceful"; startWordIndex: number; endWordIndex: number }[]; } returns: { audioBase64: string; // raw PCM: signed 16-bit little-endian, 24 kHz, mono, no header words?: { text: string; start: number; end: number }[]; // optional per-word timing, seconds from the first sample } | ModelProviderFailure ``` - **Cues arrive parsed.** The agent writes bracketed stage directions such as `[happy]`, `[whispering]`, `[laughing]`, `[pause]`. You never parse them: `text` has them removed and `cues` lists each one with its `kind`, its lowercase `name` (the modifier removed), an `intensity` (0.35 for "slightly", 0.6 by default, 0.85 for "very", 1.0 for "extremely") and its `wordIndex` (how many words of `text` come before it; `words.length` when it is last). For `[slightly sad] I lost the match. [sighing] Next time. [whispering] Don't tell anyone.` you get: ```ts { text: "I lost the match. Next time. Don't tell anyone.", cues: [ { kind: "emotion", name: "sad", intensity: 0.35, wordIndex: 0 }, { kind: "effect", name: "sighing", intensity: 0.6, wordIndex: 4 }, { kind: "tone", name: "whispering", intensity: 0.6, wordIndex: 6 }, ], } ``` If your vendor understands cues, translate them into its own form; if not, ignore `cues` and speak `text`. - **Scoped tags arrive parsed too.** The agent can wrap words in `…` (also `emphasis`, `soft`, `faster`, `slower`, `stress`, `forceful`). `text` has the tags removed and `spans` says which words each covered: for `Go on. Bye.` you get `text: "Go on. Bye."` and `spans: [{ name: "whisper", startWordIndex: 0, endWordIndex: 2 }]` (the words from start up to, not including, end). Spans nest and never cross. Render them in your vendor's own form, or ignore them. - A bracket word that is not on the platform's cue list works only at the start of a sentence; elsewhere it stays in `text`. A plugin that can't speak such brackets should strip them, as OpenAI Voice does. - **Audio format is fixed**: raw 24 kHz mono 16-bit PCM, base64. Fetch binary from your vendor with `host.fetch({ …, responseEncoding: "base64" })`: ```ts // infrastructure/plugins/openai-voice/index.ts async speak(request, tools) { const apiKey = await host.settings.getSecret("apiKey"); const response = await host.fetch({ method: "POST", url: "https://api.openai.com/v1/audio/speech", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: "tts-1", voice: request.voiceId, input: request.text, response_format: "pcm", }), responseEncoding: "base64", }); if (response.status !== 200) return mapFailure(response.status, tools); return { audioBase64: response.body }; }, ``` - **Word timings make the face land on the word.** If your vendor returns per-word timestamps, pass them as `words`; lip-sync and gestures then line up exactly. Without them the platform estimates from the audio itself. At most 2,000 entries; each needs `0 ≤ start ≤ end` (invalid entries are dropped, the audio still plays). - **Buffered, not streamed.** The user hears nothing until your handler returns the whole group, so keep it quick: about **15 s** per call, and the response must fit the transport (~4 MB, about 30 s of audio). ### Telling the agent what your voice does — `voiceDirection` The platform already teaches every companion the cue words and that the face and body act on them. Add what is specific to your voice with `voiceDirection` in `manifest.modelProvider` (needs `speak` and `voices`): which cues it performs, which it ignores, how it handles pauses, any limit on stacking, or that it reads none. ```ts modelProvider: { kindId: "my-voice", voiceDirection: { default: "- Your voice performs [whispering] and [laughing]; it ignores emotion words.", models: { "my-voice-v2": "- This model also performs emotion words." }, }, }, ``` A plain string works too. Each text is at most 4,000 characters; `models` keys are model identifiers (matched ignoring case, at most 20), and `default` covers any other model. Without `voiceDirection`, the platform adds a generic line saying the voice comes from a plugin and performs what it can, with pauses anywhere and at most two cues together. The text is the same every turn, so keep it static. ## `voices` — list voices to pick from ```ts request: { search: string | null; // what the user typed in the picker mine: boolean; // only voices this account owns on the vendor (vs. the public library) cursor: string | null; // null for page 1; otherwise your own nextCursor from last time voiceIds: string[] | null; // look up specific ids (e.g. to show an already-picked voice's name) model: string; // the TTS model the voices are for } returns: { voices: { id: string; name: string; description?: string; languages?: string[]; sampleUrl?: string; coverImageUrl?: string }[]; nextCursor: string | null; // null = no more pages } | ModelProviderFailure ``` `nextCursor` is opaque to the platform — a page number, an offset, a vendor token. `sampleUrl` and `coverImageUrl` are loaded by the user's browser directly. About 10 s per call. There is no manual voice-id entry: a voice can only be picked from what `voices` returns. ```ts // infrastructure/plugins/openai-voice/index.ts voices(request) { const search = request.search?.trim().toLowerCase(); const voices = search ? VOICES.filter((voice) => voice.name.toLowerCase().includes(search)) : VOICES; return { voices, nextCursor: null }; }, ``` ## `transcribe` — turn the user's speech into text ```ts request: { audioBase64: string; // the whole recorded clip, base64 mediaType: string; // its MIME type — today always "audio/wav" (16 kHz, mono, 16-bit) model: string; // the speech-to-text model id the user saved language: string | null; // the user's language code, or null for auto-detect } returns: { text: string; durationSeconds?: number } | ModelProviderFailure ``` - **Recorded unless you also register `startLiveTranscription`.** The platform waits until the user stops talking, then calls you once with the whole clip. To show words while the user is still speaking, add the optional `startLiveTranscription` handler (below); `transcribe` stays as the fallback. - `durationSeconds` is what the user is billed for (rounded up); omit it and a flat 1 second is billed. - About **20 s** per call. - Vendors that want a file upload need `multipart/form-data`. The sandbox has no `Buffer` or `TextEncoder`, so build the body as bytes and send it with `bodyEncoding: "base64"` — OpenAI Voice shows how: ```ts // infrastructure/plugins/openai-voice/index.ts async transcribe(request, tools) { const apiKey = await host.settings.getSecret("apiKey"); const boundary = "agentparley-openai-voice-boundary"; const extension = extensionFor(request.mediaType); const preamble = asciiBytes( `--${boundary}\r\n` + `Content-Disposition: form-data; name="model"\r\n\r\n` + `whisper-1\r\n` + `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="audio.${extension}"\r\n` + `Content-Type: ${request.mediaType}\r\n\r\n`, ); const postamble = asciiBytes(`\r\n--${boundary}--\r\n`); const body = base64Encode([...preamble, ...base64Decode(request.audioBase64), ...postamble]); const response = await host.fetch({ method: "POST", url: "https://api.openai.com/v1/audio/transcriptions", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": `multipart/form-data; boundary=${boundary}` }, body, bodyEncoding: "base64", }); if (response.status !== 200) return mapFailure(response.status, tools); const parsed = JSON.parse(response.body); return { text: typeof parsed.text === "string" ? parsed.text : "" }; }, ``` ## `startLiveTranscription` — live words while the user talks (optional) The browser connects straight to your vendor; you only describe how. Platform calls this **once per turn, when the microphone opens**, and reuses the answer until its `expiresAt`. Usually you mint a short-lived browser token from your key and return it in `protocols` or the URL. ```ts request: { model: string; language?: string | null } returns: LiveTranscriptionSessionDescriptor | ModelProviderFailure // url (wss://), protocols?, expiresAt?, sampleRate (8000|16000|24000|48000), // audio: { frame: "binary" } | { frame: "json", template, field }, // start?, words: { when: { path, equals }, text, isFinal? }, endOfTurn?, error?, finalize?, close? ``` - `words.when` selects the vendor messages that carry transcript text and `words.text` is the dot-path to the text; `words.isFinal` (optional) is the path to a boolean saying the text is final. `endOfTurn.when` selects the vendor's "user stopped talking" message. Paths read own properties only and may not contain `__proto__`, `prototype` or `constructor`; at most 100 characters and 8 parts. - `audio: { frame: "json" }` sends each ~80 ms frame as a copy of `template` with the base64 PCM written at `field`. - **No secret value may appear anywhere in the descriptor** (secrets of 8 or more characters are checked) — Platform refuses it, logs the secret's name for you, and the turn falls back to recording. `url` must be `wss://` to a host on your egress list; `expiresAt`, if given, must be in the future; the JSON is at most 8 KB. The call gets 10 s. - Requires `transcribe` too. `infrastructure/plugins/hello-live-stt` is a working example against `test-utils/fake-live-stt.mjs`. ## Failures Return `tools.fail(kind, message)`: - `keyRejected`, `noCredit`, `modelNotFound`, `requestRejected` → the account's red provider banner. - `rateLimited`, `unavailable` → no banner; treated as a transient failure. A thrown error or timeout counts as `unavailable`. ## How a user sets it up 1. Install your plugin. 2. **AI Providers → Add provider → "*Your name* · from plugin"**, enter the key. 3. Add a **text-to-speech model** and/or a **speech-to-text model** under it, with the model identifier your vendor uses — it reaches you as `request.model`. 4. On a companion, open the voice picker, choose your provider, and pick a voice. For listening, choose your speech-to-text model as the account default or for that companion. The mic button appears once a speech-to-text model is available. Companion characters can suggest your voices by your `kindId` — see [Companion packs](plugin-companion-packs.md#character--characterjson). For plugin providers, include `model` in the suggestion. # Example: Caveman **Official plugin** · one `systemPrompt` middleware · no permissions · 15 lines Caveman makes agents answer dramatically shorter — fragments, no filler — while keeping code, commands, errors and URLs byte-exact. It's inspired by the open-source *caveman* prompt, and it shows how little a plugin needs to be. ## The package ``` caveman/ readme.md index.ts gallery/ the pictures shown on the plugin's Browse page tsconfig.json authoring only, left out of the upload ``` ```markdown --- name: Caveman description: An output-brevity skill — appends terseness guidance so the model produces short, fragment-based answers while keeping code, commands, and URLs byte-exact. categories: writing version: 1.0 gallery: - file: 01-cover.webp caption: Say more with less - file: 02-terse-answers.webp caption: Two questions, two short answers --- # Caveman … ## Permissions None. The middleware only rewrites the system prompt it is handed — no `host.*` calls, no network egress. ``` ```ts // plugins/caveman/index.ts (the file also opens with a comment crediting the original caveman prompt) const GUIDANCE = "\n\n## Caveman mode (output-brevity skill)\n" + "Rewrite your responses to be dramatically shorter while keeping technical accuracy.\n" + "- Cut filler, pleasantries, and explanatory padding.\n" + "- Use sentence fragments and minimal grammar.\n" + "- Keep code, commands, error messages, and URLs byte-for-byte exact; never abbreviate them.\n" + "- Keep the user's language (French input stays French).\n" + "Say the least that fully answers. Example: 'New ref each render. Wrap in useMemo.' not a paragraph."; globalThis.middleware.systemPrompt = async (context) => { return context.systemPrompt + GUIDANCE; }; ``` ## Walkthrough 1. **`GUIDANCE` is a constant.** Everything the plugin does is decided at the top level, so publish-time introspection sees it plainly. 2. **`globalThis.middleware.systemPrompt = …`** — assigning this one registry key is the whole declaration. At publish the platform reads it back and records one capability: *Middleware · systemPrompt*. 3. **The handler appends and returns.** It receives the system prompt this turn is about to use and returns the new one. It doesn't need `tools` (there's nothing to cancel on this hook) or any other context. 4. **No `host` calls → no permissions.** The Browse page's **What it can reach** is empty. That is a strong promise to an installer: this code can't reach the network, files, agents, or anything else. ## What happens at run time Every turn of every agent on the installing account: the platform composes the system prompt, runs the `systemPrompt` chain (Caveman, plus any other installed plugins' hooks, in order), and sends the result to the model. Caveman has 5 seconds; it needs microseconds. If it ever failed, the prompt would pass through unchanged. ## Variations - **My Dude** (also official) is the same shape with an instruction whose effect is exactly checkable — every reply ends with "my dude". Useful as a probe that middleware reaches the model. - **Ponytail** (official) appends a "smallest correct solution" ladder for coding agents. - Make it configurable with a setting — see the [Quickstart](plugin-quickstart.md). - Apply it only to some agents by checking `context.agentId` or `context.sessionKind`. # Example: Brave Search **Official plugin** · `web.search` provider · one secret setting · egress `api.search.brave.com` Brave Search lets an account run every agent's `web_search` tool on the Brave Search API with **its own** Brave key. It is the canonical example of calling a vendor from a plugin: a secret setting, a declared host, and `host.fetch`. ## The code ```ts // plugins/brave-search/index.ts globalThis.manifest = { egress: ["api.search.brave.com"], settings: [ { key: "apiKey", title: "Brave API key", description: "From https://api-dashboard.search.brave.com — the free tier works.", type: "secret", required: true, }, ], }; globalThis.web.search = async (input) => { const apiKey = await host.settings.getSecret("apiKey"); const count = Math.min(Math.max(input.maxResults, 1), 20); // Brave caps count at 20 const response = await host.fetch({ url: `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(input.query)}&count=${count}`, headers: { "X-Subscription-Token": apiKey, Accept: "application/json" }, }); if (response.status !== 200) throw new Error(`Brave search API returned ${response.status}`); const parsed = JSON.parse(response.body) as { web?: { results?: { title?: string; url?: string; description?: string }[] } }; const results = (parsed.web?.results ?? []) .filter((result) => result.title && result.url) .slice(0, input.maxResults) .map((result) => ({ title: result.title!, url: result.url!, snippet: result.description ?? "" })); return { results }; }; ``` ## Walkthrough **The manifest.** - `egress: ["api.search.brave.com"]` — the only host `host.fetch` may reach, on the default HTTPS port. Without it the publish fails, because the code calls `host.fetch`. The installer sees this host under **What it can reach**. - `settings` declares one `secret` field. In the console it's a write-only password field; the value is encrypted and never shown again. `required: true` means the plugin isn't usable until it's filled in. Secrets can't have a `default`. **The handler.** 1. `await host.settings.getSecret("apiKey")` — the key is fetched into the sandbox only now, only for this call. 2. `input.maxResults` is what the agent's `web_search` call asked for. Brave caps `count` at 20, so the plugin clamps it. (The platform caps results at 10 on the way back anyway.) 3. `host.fetch` makes the request. It resolves for any HTTP status, so the plugin checks `status` and **throws** on anything but 200 — the agent then sees a clear "provider failed" error. The platform won't fall back to its own engine behind the user's back. 4. It maps Brave's shape into `{ results: [{ title, url, snippet }] }` and drops rows without a title or URL. **Permissions.** None. `host.fetch` needs an egress entry, not a permission, and `host.settings` is always there. ## Using it 1. Install **Brave Search**, open its page, paste the Brave API key. 2. **Settings → Engines → Web search** → choose *Brave Search*. Every agent's `web_search` now goes through the user's own Brave account and quota. ## Going further - **Add `web.fetch`** in the same plugin; it becomes a second, separately selectable capability. **Wiki Web** does both: Wikipedia's opensearch for search, a reader service for fetch — see [Web providers](plugin-web-providers.md#webfetch). - **Surface bad keys clearly:** throw `new Error("Brave rejected the API key (401) — check the key in the plugin's settings")` on a 401; that sentence is what the model (and then the user) reads. # Example: Pi Compactor **Example plugin** · `compaction.summarize` · uses `host.session.llm` · no permissions When a long mission's conversation is compacted, the default summary is prose. Pi Compactor (after the Pi coding-agent's compaction pattern) writes a **structured** summary instead — Goal, Constraints, Progress, Key Decisions, Next Steps, Critical Context, and cumulative lists of files read and modified — and **refines** its own previous summary each time instead of starting over. Long missions keep their file lists and decisions intact across many compactions. ## The template ```ts // infrastructure/plugins/pi-compactor/index.ts const MAX_MESSAGE_CHARS = 2000; // Pi truncates bulky tool results before serializing. const SUMMARY_MARKER = "# Conversation summary"; // first line of our output — lets the next compaction detect it. const TEMPLATE = "## Goal\n" + "[What the user is trying to accomplish]\n\n" + "## Constraints & Preferences\n" + "- [Requirements/preferences the user stated]\n\n" + "## Progress\n" + "### Done\n" + "- [x] [Completed work]\n" + "### In Progress\n" + "- [ ] [Current work]\n" + "### Blocked\n" + "- [Issues, if any]\n\n" + "## Key Decisions\n" + "- **[Decision]**: [Rationale]\n\n" + "## Next Steps\n" + "1. [What should happen next]\n\n" + "## Critical Context\n" + "- [Facts/identifiers/data needed to continue]\n\n" + "\n[one file path per line, cumulative across the whole session]\n\n\n" + "\n[one file path per line, cumulative across the whole session]\n"; ``` ## The handler ```ts // infrastructure/plugins/pi-compactor/index.ts function serialize(messages: CompactionMessage[]): string { return messages .map((message) => { let content = message.content || ""; if (content.length > MAX_MESSAGE_CHARS) { content = content.slice(0, MAX_MESSAGE_CHARS) + "\n…[truncated " + (content.length - MAX_MESSAGE_CHARS) + " chars]"; } return "[" + message.role + "]\n" + content; }) .join("\n\n"); } globalThis.compaction.summarize = async (context) => { const messages = context.messages || []; if (messages.length === 0) { return ""; } const first = messages[0]; const hasPriorSummary = first.content.indexOf(SUMMARY_MARKER) === 0; const priorSummary = hasPriorSummary ? first.content : null; const transcript = serialize(hasPriorSummary ? messages.slice(1) : messages); const system = "You compact a long agent session into a single structured summary that preserves everything needed to " + "continue with NO loss of intent. Output EXACTLY the template below, filling every section from the " + "conversation (drop a placeholder bullet only when nothing applies). Keep file paths, identifiers, commands, " + "and decisions byte-exact. Accumulate / across the entire history. Your FIRST " + 'line MUST be "' + SUMMARY_MARKER + '". Output only the summary — no preamble.\n\n' + "TEMPLATE:\n" + SUMMARY_MARKER + "\n\n" + TEMPLATE + (priorSummary ? "\n\nPREVIOUS SUMMARY — refine and extend this; carry its file lists forward:\n" + priorSummary : ""); const result = await host.session!.llm!.complete({ messages: [ { role: "system", content: system }, { role: "user", content: "Conversation to summarize (oldest first):\n\n" + transcript }, ], params: { temperature: 0.2 }, }); return result.assistant.text || ""; }; ``` ## Walkthrough 1. **What it's given.** `context.messages` is the history since (and including) the last checkpoint, oldest first. The current request isn't there — the platform keeps it verbatim after the summary. 2. **Recognizing its own previous summary.** Every summary starts with `# Conversation summary`. On the next compaction the platform hands that summary back as `messages[0]`; the plugin spots the marker and feeds it to the model as *"refine and extend this"*. That's what keeps the file lists cumulative. 3. **Keeping the prompt bounded.** Each message is cut to 2,000 characters — tool results are the usual bulk. 4. **Calling a model without a key.** `host.session.llm.complete` runs the **chat's own agent model**. No setting, no egress, no permission — it's granted by context. The `!`s are safe because a compaction always runs inside a chat. Temperature 0.2 keeps it faithful. 5. **Returning.** One string. An empty return (the `messages.length === 0` case) or a summary more than half the compaction threshold makes the platform use its built-in summarizer and post a notice — the agent is never left uncompacted. ## Using it Install, then **Settings → Engines → Long conversations → Pi Compactor**. It applies to automatic compaction and to `/compact`; `/compact keep the deploy plan` arrives as `context.focus` (this plugin doesn't use it yet — adding `context.focus` to the system prompt is a one-line improvement). # Example: Discord **Official plugin** · channel (`send`, `typing`, `fetchAttachment`) + live connection (`open`, `receive`) · channel-scoped secret · wildcard egress · ~330 lines Discord bots receive messages only over the Discord Gateway — a WebSocket the bot keeps open indefinitely. This plugin connects a bot as an AgentParley channel: direct messages, @mentions and replies to the bot reach the agent; everything else in a server never even wakes the plugin; replies go back over Discord's REST API. The platform's LiveConnections service holds the socket. The plugin only describes the protocol. ## The manifest ```ts // plugins/discord/index.ts globalThis.manifest = { egress: ["discord.com", "gateway.discord.gg", "*.discord.gg", "cdn.discordapp.com", "media.discordapp.net"], settings: [ { key: "botToken", title: "Bot token", type: "secret", required: true, scope: "channel" }, { key: "readMessageContent", title: "Use the Message Content intent", type: "bool", default: true, scope: "channel", description: 'Needs "Message Content Intent" on in the Developer Portal. DMs and @mentions work without it.', }, { key: "respondToBots", title: "Answer other bots", type: "bool", default: false, scope: "channel" }, ], }; ``` - **Egress** covers the REST API, the gateway, Discord's resume hosts (`*.discord.gg` — any subdomain, not the apex) and the two attachment CDNs. - **Every setting is `scope: "channel"`.** Each connected Discord channel is its own bot with its own token, entered on **Channels → Connect a channel**. ## Connecting — `connection.open` ```ts // plugins/discord/index.ts (abridged) open: async (request) => { if (request.owner !== "channel") { return { links: [], fail: "Discord connections must be channel-owned." }; } const lastCloseCode = request.lastClose?.code ?? null; if (lastCloseCode !== null && lastCloseCode in FATAL_CLOSE_REASONS) { return { links: [], fail: FATAL_CLOSE_REASONS[lastCloseCode] }; // 4004 bad token, 4014 intents, … } const state = (request.state as DiscordState | null) ?? {}; const token = await host.settings.getSecret("botToken"); const respondToBots = host.settings.get("respondToBots") === true; const canResume = !!state.sessionId && !!state.resumeUrl && lastCloseCode !== 4007 && lastCloseCode !== 4009; let url: string; if (canResume) { url = `${state.resumeUrl}?v=10&encoding=json`; } else { const gatewayCheck = await discordFetch(token, "GET", "/gateway/bot"); if (gatewayCheck.status === 401) { return { links: [], fail: "Discord rejected the bot token." }; } // … refuse if Discord's daily session-start budget is nearly spent … url = GATEWAY_WS_URL; } return { state, links: [ { transport: "websocket", url, sequence: { path: "s" }, idleTimeoutMs: 120_000, filters: baseFilters(state.botId, respondToBots), }, ], resumable: true, }; }, ``` 1. **Owner check.** This plugin only serves channel-owned connections (it has no `installOwnedConnection`). 2. **Fatal closes stop for good.** `lastClose` tells `open` why the last socket died. For a rejected token or a missing intent it returns `fail` — nothing reconnects until the user fixes it, and the readme explains each code. 3. **Resume when possible.** `state` survives between calls (it's where the Gateway session id lives). After a deploy moves the socket, `open` resumes the Discord session instead of logging in again — `resumable: true` makes that allowed and gets the faster resume rate. 4. **One WebSocket link** with `sequence: { path: "s" }` (the service tracks Discord's sequence number on every frame), an idle timeout, and the initial filters. ## Filtering before waking ```ts // plugins/discord/index.ts function baseFilters(botId: string | undefined, respondToBots: boolean): FrameRule[] { const rules: FrameRule[] = [ { when: [{ path: "op", equals: 11 }], action: "drop" }, { when: [{ path: "t", equals: "RESUMED" }], action: "drop" }, ]; if (!botId) { return rules; } if (!respondToBots) { rules.push({ when: [{ path: "d.author.bot", equals: true }], action: "drop" }); } rules.push( { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.guild_id", exists: false }], action: "keep" }, { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.mentions[].id", equals: botId }], action: "keep" }, { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.referenced_message.author.id", equals: botId }], action: "keep" }, { when: [{ path: "t", equals: "READY" }], action: "keep" }, { when: [{ path: "op", equals: 0 }], action: "drop" }, ); return rules; } ``` Heartbeat acknowledgements, other bots, and every server message that isn't a DM, a mention or a reply to the bot are dropped by the service — they cost the account no plugin wakes. Until the bot's own id is known (before READY), only the first two rules apply; `receive` sends the full set once READY arrives. ## Receiving — `connection.receive` For each text frame (abridged): | Frame | The plugin returns | | --- | --- | | `op 10` HELLO | `send`: IDENTIFY (or RESUME with the stored session and `lastSequence`); `heartbeat`: `{ op: 1, d: null }` every `heartbeat_interval` ms with `sequenceAt: "d"` so the service fills in the sequence | | `op 1` heartbeat request | `send`: an immediate heartbeat | | `op 7` reconnect | `reconnect: { reason }` | | `op 9` invalid session | `reconnect` with `resetState` unless resumable | | `READY` | new `state` (session id, resume URL, bot id and name) and the full `filters` | | `MESSAGE_CREATE` | `messages`: one `ChannelInboundMessage` | The message mapping: ```ts // plugins/discord/index.ts return { conversationId: message.channel_id, messageId: message.id, sender: { id: message.author.id, displayName: displayName(message.author, message.member), handle: message.author.username }, text, // "<@botId>" replaced by "@botname" isGroup, // true in a server mentionsBot: isGroup ? mentionsBot : false, // the platform only wakes the agent in a group if mentioned attachments: attachments.length > 0 ? attachments : undefined, // refs are the CDN URLs }; ``` For server messages it also looks up the channel's name once (`#general`) and caches up to 50 names in `state`. ## Sending — `channel.send` ```ts // plugins/discord/index.ts function chunkNonce(deliveryId: string, index: number): string { return host.crypto.sha256({ data: `${deliveryId}:${index}`, outputEncoding: "hex" }).slice(0, 25); } ``` Replies are split into 2,000-character messages. Each chunk gets a **nonce derived from the platform's `deliveryId`** with `enforce_nonce`, so when the platform retries a send, Discord de-duplicates it. A 4xx other than 408/429 throws `[rejected] …` (don't retry); anything else throws a plain error (retry). `typing` posts Discord's typing indicator and swallows failures; `fetchAttachment` downloads a CDN URL as base64. ## What the user does The readme (shown in the console) walks through creating the bot in Discord's Developer Portal, turning on **Message Content Intent**, inviting it with the right permissions, then: install the plugin → **Channels → Connect a channel → Discord** → paste the token → pick the agent. ## Techniques to copy - Keep protocol state (session id, resume URL, small caches) in `state` — never secrets. - Put every "not for us" rule in `filters` so noise never costs a wake. - Let the service send heartbeats (`heartbeat` + `sequenceAt`) instead of waking on a timer. - Turn permanent failures into `fail` with a message the user can act on; everything else reconnects on its own. - Derive idempotency keys from `deliveryId`. # Example: Oh My OpenAgents **Example crew plugin** · 3 lifecycle events · 1 middleware · 5 commands · 3 skills · `host.agents` + `host.models` · multi-file TypeScript Inspired by the open-source *oh-my-openagent* project, this package turns an AgentParley account into a conducted team: a lead that decomposes work by category and delegates it in parallel to six specialists, each on the model that suits its job. It shows every crew technique at once. | Agent | Role | Model preference (first match among the account's models) | | --- | --- | --- | | Sisyphus · Orchestrator | the lead; decomposes, delegates, integrates, finishes on evidence | flagship reasoning (Opus, GPT-5, …) | | Prometheus · Planner | plans with acceptance criteria before building | strong reasoning | | Hephaestus · Deep Worker | scoped implementation end to end | GPT-class coding models first, then Claude | | Oracle · Architect | architecture review and root-cause debugging | the highest-reasoning models | | Librarian · Researcher | grounded research with exact citations | fast, cheap models able to synthesize | | Explore · Navigator | fast "where does X live" navigation | the cheapest, fastest models | | Argus · Visual Analyst | screenshots, diagrams, image analysis | strong multimodal models | It needs room for seven agents (plus the account's default agent) — an Entrepreneur plan. ## Package layout ``` oh-my-openagents/ readme.md index.ts registrations only src/ crew.ts the seven members: key, name, soul, instructions, model preferences reconcile.ts the self-healing provisioner models.ts picks each member's model from the account's models providers.ts per-model-family prompt corrections doctrine.ts the lead's doctrine and the specialists' report contract types.ts tsconfig.json authoring only, left out of the upload commands/ orchestrate.md plan.md deep.md debug.md research.md skills/ parallel-orchestration/skill.md parallel-orchestration/references/routing.md parallel-orchestration/references/verification.md root-cause-analysis/skill.md evidence-first-delivery/skill.md ``` Multiple source files, so `index.ts` is the entry; the `src/` modules are pulled in by relative imports. ## `index.ts` — everything the platform needs to see ```ts // infrastructure/plugins/oh-my-openagents/index.ts import { reconcileCrew } from "./src/reconcile"; import { ORCHESTRATION_DOCTRINE } from "./src/doctrine"; import { CREW } from "./src/crew"; globalThis.manifest = { agents: CREW.map((member) => ({ key: member.key, name: member.name })), }; globalThis.events.pluginInstalled = async () => { await reconcileCrew(); }; globalThis.events.sessionStarted = async () => { await reconcileCrew(); }; globalThis.events.pluginUpdated = async () => { await reconcileCrew(); }; globalThis.middleware.systemPrompt = async (context) => { const { agents } = await host.agents!.list(); const isOwnedCrewMember = agents.some((agent) => agent.id === context.agentId); return isOwnedCrewMember ? context.systemPrompt : context.systemPrompt + ORCHESTRATION_DOCTRINE; }; ``` At publish the platform discovers: 3 lifecycle-hook capabilities, 1 middleware, 5 commands, 3 skills. From the `host.agents.list/create/update/delete` and `host.models.list` calls (in `src/reconcile.ts`) it derives `agentApi:agents.read`, `agentApi:agents.write` and `agentApi:models.read`. The manifest's `agents` lets the install page say **Adds 7 agents** and check plan room first. No egress, no settings. ## 1. Declare each member once — `src/crew.ts` ```ts // infrastructure/plugins/oh-my-openagents/src/crew.ts (one member, abridged) { key: "sisyphus", category: "orchestration", name: "Sisyphus · Orchestrator", description: "The lead. Reads true intent, decomposes by category, delegates in parallel, integrates, and drives to a verified finish.", preferredModels: [/opus/i, /gpt-?5(?!.*(mini|nano))/i, /kimi|k2|k3/i, /glm/i, /sonnet/i], replyMode: "auto", soul: "You are Sisyphus, the tireless conductor. …", baseInstructions: "You CONDUCT. You almost never do the deep work yourself. The loop for every non-trivial request: …", }, ``` `key` is the stable identity used everywhere: in `manifest.agents`, in `host.agents.create`, and when reconciling. Names are for people and can change. ## 2. Pick a model per member — `src/models.ts` Each member lists model-name patterns in preference order. The first pattern matching one of **the account's** enabled models (`host.models.list()`) wins; no match falls back to the account default. The resolved model's family (Claude, GPT, Gemini, Kimi, GLM) then selects a short corrective overlay appended to the member's instructions — so the same protocol reads right on each vendor. ## 3. Heal the crew — `src/reconcile.ts` On install, on every update, and at the start of every interactive chat, `reconcileCrew()`: 1. lists the installation's own agents and the account's models; 2. provisions the lead first (as a **main** agent), so its id exists; 3. provisions each specialist as a **subagent** whose parent is the lead — `create` if its key is missing, otherwise `update` to re-assert name, persona, instructions, model, reply mode, parent, and `enabled: true`; 4. deletes any agent it owns whose key is no longer in `CREW`. Delete a specialist in the console and it's back on the next new chat. Upgrade the plugin with a new member and it appears after the update. See the full code in [Agent crews](plugin-agent-crews.md#self-healing-crews). Two details worth copying: - `create` sends a flat `parentAgentId`; `update` sends `parent: { parentAgentId }` so "no parent" (`null`) and "leave it" (omitted) are distinct. - Every call is written `host.agents!.x(…)` — never through a variable — so the permission scan sees it. And one to decide deliberately: because `update` re-asserts the whole definition, a user's edits to a crew member are reset on the next chat start. ## 4. Make the lead a conductor — doctrine, contract, skills, commands - **Doctrine (middleware).** Every turn, agents that are *not* crew members get a short orchestration doctrine: a routing table by category (planning → Prometheus, deep → Hephaestus, architecture → Oracle, research → Librarian, navigation → Explore, visual → Argus) and a five-step loop — read true intent, decompose by category, spawn independent units in parallel, integrate, finish on evidence. Crew members are skipped so a specialist never starts orchestrating. - **Report contract (instructions).** Every specialist's instructions end with: do one scoped thing fully, then report the result *with evidence* **by calling `report_to_caller`**. - **Skills (on demand).** The doctrine ends by pointing at `parallel-orchestration`, a multi-file skill with the full method, the routing table (`references/routing.md`) and a done-checklist (`references/verification.md`), loaded with `use_skill` only when the lead actually conducts. `root-cause-analysis` and `evidence-first-delivery` are for specialists. - **Commands.** `/orchestrate ` runs the whole loop; `/plan`, `/deep`, `/debug` and `/research` send a goal straight to one specialist: ```markdown --- description: Run the full crew orchestration loop on a goal — decompose, delegate in parallel, integrate, and finish on evidence. argument-hint: --- Run the full Oh My OpenAgents orchestration loop on the goal below. You are the conductor — decompose and delegate; do not try to carry every stone yourself. … Goal: $ARGUMENTS ``` ## What the user experiences They install it, see seven new agents under Sisyphus, and type `/orchestrate migrate the docs site to the new template` to Sisyphus. Sisyphus asks Prometheus for a plan, then starts Hephaestus and Librarian in parallel with `start_subagent`; the chat shows a live card per specialist. The platform holds Sisyphus's answer until both have called `report_to_caller`, then Sisyphus integrates the results and replies once. ## Ideas to build on it - Add `middleware.interAgentMessage` to enforce the report contract (e.g. flag reports that claim "done" without evidence) — see [Agent crews](plugin-agent-crews.md#watching-and-shaping-crew-traffic). - Mark specialists `isGroupOnlyCallable: true` so only Sisyphus and siblings can reach them. - Use `thinkingLevel: "high"` for Oracle and Prometheus. # Example: Sample Companion Pack **Example plugin** · 1 look with motions · 2 characters · no code, no permissions This pack ships everything companion mode can take from a plugin, with no `index.ts` at all: nothing runs in the sandbox, and the Browse page shows no permissions and nothing it can reach. ## Layout ``` readme.md companion-models/sample-seed/ model.json seed-san.vrm thumbnail.png background.jpg idle-neutral.vrma talk-idle.vrma listen.vrma think.vrma wave.vrma laugh.vrma companion-characters/sample-seed-character/character.json companion-characters/sample-rose-character/character.json ``` Three keys — `sample-seed`, `sample-seed-character`, `sample-rose-character` — all different, because keys are unique across the whole package (a character can't share its model's key). The readme uses `version: 1.3`. ## The look and its motions `model.json` lists the look's motions as `file:` clips in the model's own folder — every base role gets one body clip, plus one gesture and one reaction: | Motion | Role | Format | Notes | | --- | --- | --- | --- | | `file:idle-neutral.vrma` | `idle` | VRMA | required — every look needs at least one idle | | `file:talk-idle.vrma` | `talkIdle` | VRMA | required — the loop while speaking; `cues: ["calm"]`, so a calm reply prefers it | | `file:listen.vrma` | `listen` | VRMA | a nod while the user talks | | `file:think.vrma` | `think` | VRMA | hand to chin while the agent works | | `file:wave.vrma` | `gesture` | VRMA | required (at least one gesture) — `cues: ["waving"]`, `strokeSeconds: 0.9` | | `file:laugh.vrma` | `reaction` | VRMA | `cues: ["laughing", "chuckling"]` — plays on a `[laughing]` line | - **VRMA clips need nothing else** — no `rig`; they always drive the humanoid body. - **`cues` and `strokeSeconds`** let the companion land the wave on a greeting at the right beat. - **A companion wears the look by reference.** Publish a new version and, once a user's installation moves to it, every companion wearing the look moves the new way. ```json { "name": "Sample Seed-san", "description": "VirtualCast's sample VRM avatar Seed-san, with a small set of its own motions, for testing plugin looks end to end.", "avatar": "seed-san.vrm", "thumbnail": "thumbnail.png", "background": "background.jpg", "outfits": [ { "key": "plain", "name": "Plain", "meshes": ["wear"] }, { "key": "cyborg", "name": "Robot arm", "meshes": ["wear", "robo_arm"] } ], "motions": [ { "id": "file:idle-neutral.vrma", "roles": ["idle"], "name": "Idle", "description": "A long, unhurried neutral standing idle loop." } ] } ``` (The real file lists all six motions.) - **Outfits** name parts of the VRM by mesh. `robo_arm` is in only one outfit, so it is hidden in `plain` and shown in `cyborg`; `wear` is in both, so the clothes stay on either way, and the head, hair and tail (in no outfit) always show. The agent switches with `[outfit:cyborg]`; the Companion tab's wardrobe button does it by hand. - **`background.jpg`** is a soft, out-of-focus room picture (1920×1080) drawn blurred behind her. ## Beyond this sample: cloth, walking and 2.5D This pack is the smallest one. Two other real packs show the rest of `model.json`: - **Yuzuki** (`plugins/yuzuki`, published as `agentparley-yuzuki`) is a VRM with four outfits that set body shapes, an ARKit face map, tail and ear `partsGesture` clips, and a `walk` clip (`"roles": ["walk"]`) so she paces. Her Casual, Loungewear and Swimwear outfits carry `"cloth": []` (no skirt), so only Dress uses the skirt the stage detects. She declares no `framing`, `pacing` or `cloth` of her own: the defaults apply. - **Mira** (`infrastructure/plugins/hello-live2d`) is a 2.5D look in a `.zip` with `avatar:` motions and a `framing` block (`defaultPreset: "upperBody"`, a `face` window of `bottom: 0.58`, `top: 0.86`). It declares no `walk`, outfits or cloth, which a 2.5D look can't use. See [Companion packs](plugin-companion-packs.md) for every field and what the stage does when you leave one out. ## The item pack — Sample Companion Items A second fixture, `infrastructure/plugins/sample-companion-items`, ships two props as data (no code, no permissions): ``` readme.md companion-items/mug/ item.json mug.glb thumbnail.png companion-items/party-hat/ item.json party-hat.glb thumbnail.png ``` ```json { "name": "Mug", "description": "A warm mug of coffee, held in the right hand.", "model": "mug.glb", "thumbnail": "thumbnail.png", "attach": { "bone": "rightHand", "position": [-0.03, 0, 0.03], "rotation": [0, 0, -90], "scale": 1 } } ``` Install it next to the companion pack and the mug and the hat show up in every 3D companion's wardrobe menu; the agent can write `[hold:mug]`, `[hold:party-hat]` or `[hold:none]`. See [Companion packs](plugin-companion-packs.md#item--itemjson). ## Two characters, two patterns **Sample Seed** — the pack's own look, plus the built-in Rose look as a second option: ```json { "name": "Sample Seed", "vibe": "A test character with a plugin look that brings its own motions.", "look": "sample-seed", "soul": "You are Sample Seed, a cheerful test companion. You keep answers short and friendly.", "instructions": "You talk with the person out loud. Keep replies to a sentence or two and avoid lists.", "voice": { "description": "A bright, friendly young voice.", "suggestions": [ { "provider": "fish-audio", "voiceId": "8ef4a238714b45718ce04243307c57a7", "voiceName": "Sample voice" }, { "provider": "elevenlabs", "voiceId": "21m00Tcm4TlvDq8ikWAM", "voiceName": "Rachel", "models": ["eleven_v3"] } ] } } ``` **Sample Rose** — the built-in Rose look, only a new personality and voice: ```json { "name": "Sample Rose", "vibe": "A test character on the built-in Rose look, with a voice only Deepgram offers.", "look": { "builtIn": "rose" }, "soul": "You are Sample Rose, a calm and precise test companion who keeps people on track.", "instructions": "You talk with the person out loud. Be brief and clear.", "voice": { "description": "A calm, clear woman's voice.", "suggestions": [ { "provider": "deepgram", "voiceId": "aura-2-thalia-en", "voiceName": "Thalia" } ] } } ``` - **How she moves is data too.** A character may add a `behaviour` block next to `voice`, for example a calmer listener who reacts to her name and to the web-search tool: ```json "behaviour": { "temperament": { "energy": 0.9 }, "behaviours": { "backchannels": { "strength": 0.75 } }, "wordReactions": [ { "phrases": ["{name}", "seed"], "reaction": "browRaise" } ], "toolActivity": [ { "when": ["builtin:web_search"], "reaction": "reading" } ] } ``` - **Voice suggestions are ordered.** Sample Seed's companion uses fish.audio if the account has it, else ElevenLabs. An account with neither gets captions only, with a notice offering to add a voice provider — which is exactly what Sample Rose shows on an account without Deepgram. - **A character is a look + a suggested agent.** In the Companions gallery (tagged with the plugin's name), **Talk** creates a new agent with the suggested soul and instructions; **Talk as…** adds the face to an existing agent. - **The instructions are written for speech** — short, no lists — because a companion's replies are spoken. ## Publishing it A pack with binary files is published as a zip (**Publish from a .zip file instead**, or `POST /plugins/{id}/versions/zip`). Every binary must be referenced by one of the JSON files, or the publish is refused naming it. See [Companion packs](plugin-companion-packs.md). # Example: OpenAI Voice **Example plugin** · `modelProvider.speak` + `voices` + `transcribe` · one secret · egress `api.openai.com` This plugin adds OpenAI's text-to-speech voices and Whisper transcription as a provider for companion mode. It's the reference for the two hard parts of an audio provider: **binary audio through the sandbox**, which has no `Buffer`, and **mapping vendor errors** to the platform's failure kinds. ## Manifest ```ts // infrastructure/plugins/openai-voice/index.ts globalThis.manifest = { egress: ["api.openai.com"], modelProvider: { kindId: "openai-voice", displayName: "OpenAI Voice" }, settings: [ { key: "apiKey", title: "API key", description: "An OpenAI API key with access to /v1/audio/speech.", type: "secret", required: true, }, ], }; ``` `modelProvider.kindId` makes "OpenAI Voice · from plugin" appear under **AI Providers → Add provider**. The `apiKey` belongs to the provider the user adds there. ## Voices ```ts const VOICES: VoiceSummary[] = [ { id: "alloy", name: "Alloy" }, { id: "echo", name: "Echo" }, { id: "fable", name: "Fable" }, { id: "onyx", name: "Onyx" }, { id: "nova", name: "Nova" }, { id: "shimmer", name: "Shimmer" }, ]; // in globalThis.modelProvider: voices(request) { const search = request.search?.trim().toLowerCase(); const voices = search ? VOICES.filter((voice) => voice.name.toLowerCase().includes(search)) : VOICES; return { voices, nextCursor: null }; }, ``` A static list, filtered by the picker's search box; `nextCursor: null` means one page. A vendor with a large library would page with `cursor`, honour `mine`, and return `sampleUrl`s so users can preview. ## Speaking ```ts async speak(request, tools) { const apiKey = await host.settings.getSecret("apiKey"); const response = await host.fetch({ method: "POST", url: "https://api.openai.com/v1/audio/speech", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: "tts-1", voice: request.voiceId, input: stripCues(request.text), response_format: "pcm", }), responseEncoding: "base64", }); if (response.status !== 200) return mapFailure(response.status, tools); return { audioBase64: response.body }; }, ``` 1. **Leftover brackets are stripped.** The platform already takes the cues (`[happy]`, `[pause]`, `[laughing]`) out of `request.text` and hands them over in `cues`; this plugin ignores `cues` because OpenAI's TTS doesn't understand them. A bracket word that isn't on the platform's cue list can still stay in the text, so `stripCues` removes any letters-only bracket (leaving `[1]` and markdown links). 2. **The format matches exactly.** `response_format: "pcm"` returns 24 kHz, 16-bit little-endian mono — precisely what `speak` must return — so there's no conversion. 3. **Binary comes back as base64.** `responseEncoding: "base64"` makes `host.fetch` return the raw bytes base64 encoded, which is the `audioBase64` the platform wants. 4. **No `words`.** OpenAI doesn't return word timings, so the platform estimates them from the audio for lip-sync and gestures. (Note: this example always uses `tts-1`; a production plugin would send `request.model`, the TTS model the user added.) ## Listening `transcribe` receives the recorded clip (today a 16 kHz mono WAV) as base64 and must upload it as `multipart/form-data`. The sandbox has no `Buffer`/`TextEncoder`/`atob`, so the plugin builds the body as a byte array with a tiny base64 codec of its own, then sends it with `bodyEncoding: "base64"`: ```ts async transcribe(request, tools) { const apiKey = await host.settings.getSecret("apiKey"); const boundary = "agentparley-openai-voice-boundary"; const extension = extensionFor(request.mediaType); const preamble = asciiBytes( `--${boundary}\r\n` + `Content-Disposition: form-data; name="model"\r\n\r\n` + `whisper-1\r\n` + `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="audio.${extension}"\r\n` + `Content-Type: ${request.mediaType}\r\n\r\n`, ); const postamble = asciiBytes(`\r\n--${boundary}--\r\n`); const body = base64Encode([...preamble, ...base64Decode(request.audioBase64), ...postamble]); const response = await host.fetch({ method: "POST", url: "https://api.openai.com/v1/audio/transcriptions", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": `multipart/form-data; boundary=${boundary}` }, body, bodyEncoding: "base64", }); if (response.status !== 200) return mapFailure(response.status, tools); const parsed = JSON.parse(response.body); return { text: typeof parsed.text === "string" ? parsed.text : "" }; }, ``` Whisper chooses its decoder from the file name, so `extensionFor(mediaType)` maps the MIME type to an extension. The plugin doesn't return `durationSeconds`, so each transcription is billed as a flat 1 second; returning the vendor's duration would bill accurately. ## Failures ```ts function mapFailure(status: number, tools: ModelProviderTools): ModelProviderFailure { if (status === 401 || status === 403) return tools.fail("keyRejected", "OpenAI rejected the API key"); if (status === 402) return tools.fail("noCredit", "OpenAI account is out of credit"); if (status === 429) return tools.fail("rateLimited", "OpenAI rate limit exceeded"); if (status >= 500) return tools.fail("unavailable", `OpenAI returned ${status}`); return tools.fail("requestRejected", `OpenAI returned ${status}`); } ``` A bad key or empty balance shows the account's red provider banner, exactly like a native vendor; rate limits and outages are transient and show no banner. ## Using it 1. Install; **AI Providers → Add provider → OpenAI Voice · from plugin**; paste the key. 2. Add a text-to-speech model and a speech-to-text model under it. 3. On a companion, pick one of the six voices; set the speech-to-text model for listening. The mic button appears. Permissions: none. A provider plugin may not act as an agent, and this one doesn't need to. # Agent packs An agent pack is a plugin that installs a whole crew: a lead agent and its specialists, each on a model that suits its job, plus the doctrine that tells the lead how to delegate, slash commands that route work, and skills the crew loads when needed. You go from one blank agent to a working team in one click — and the pack keeps the team healthy afterwards. ## Before you install A pack's page in **Plugins → Browse** shows: - **What it can reach** — one **Creates an agent** line per crew member (and **Adds a schedule** for any schedule it brings), plus typically "Create or edit agents" and "List your models". Your plan must have room: agents per account are Free 2, Solo 5, Entrepreneur 25 (a seven-agent pack plus your default agent needs Entrepreneur). If it doesn't fit, the install dialog says **Adds N agents · you have room for M** and offers to install anyway with as many as fit. - **Capabilities** — for example 3 lifecycle events, 1 middleware, 5 commands, 3 skills. - The readme: who the agents are and how to drive them. ## What happens when you install 1. The pack creates its agents, marked as coming from the plugin. Specialists sit under the lead as its subagents. Each member is put on the best match among **your** enabled models (a pack can't bring its own). 2. Its doctrine is added each turn to the instructions of every agent outside the crew, and its commands and skills appear on your agents. 3. From then on, the pack re-checks the crew on each new chat and after each update: a member you deleted comes back; a member whose setup drifted is restored. If your plan didn't have room for every agent or schedule, upgrade and press **Rerun setup** on the installed plugin's page. ## Driving the crew Talk to the lead like any agent. It delegates to specialists with `start_subagent`; you'll see a live card per specialist in the chat (its current step, its cost, a Stop button), and the lead answers you once their reports are in. Packs usually add commands — for example `/orchestrate ` or `/research ` — that send a goal straight to the right specialist. ## Changing and removing it - **Edit freely.** Crew members are ordinary agents: change their model, persona or access. A pack that re-asserts its definition may restore some of those edits on the next chat — its readme should say so. - **Updates** that ask for more access (a new agent, a new permission) wait for your OK on the plugin's page. - **Uninstall** deletes the pack's agents and every chat they were in. ## Build one See [Agent crews](plugin-agent-crews.md) and the [Oh My OpenAgents walkthrough](example-oh-my-openagents.md). # The tunnel The tunnel connects one of your own computers to AgentParley: a home server, a VPS, a work machine behind NAT. Once it is connected, your agents can run commands and edit files on it ([SSH commands](tunnel-ssh.md)), and any models it runs can serve your agents as providers ([local models](tunnel-local-models.md), [Codex and Claude Code](tunnel-codex-claude.md)). It is a single program, `agentparley`, that you install on the computer. It **dials out** to AgentParley and keeps that connection open. AgentParley never connects in, so you open no port, change no firewall rule and need no public address. It works from behind NAT, a home router or a corporate proxy that allows outbound HTTPS. The daemon is open source. The code that runs on your machine is public, so you can read what you are installing before you run it. ## How it fits together ``` your computer AgentParley ┌──────────────────────────┐ ┌──────────────────────────────┐ │ agentparley daemon │ ── outbound ────▶ │ SSH egress service │ │ · runs commands │ gRPC over 443 │ holds the open connection │ │ · reads/writes files │ │ and forwards each operation │ │ · relays local models │ └──────────────▲───────────────┘ └──────────────────────────┘ │ │ login, token refresh │ one operation at a time ▼ │ AgentParley API ◀──────────────────────────── your agent's turn ``` - **The egress service** holds your daemon's connection. When an agent runs a command on your computer, its turn sends that one operation to the egress service, which passes it down your open connection and returns the result. - **Each operation is authorized on its own.** The turn gets a short-lived token for that single operation, scoped to your computer. The egress service checks it against AgentParley's published keys before forwarding anything. - **Your daemon proves who it is with a key that never leaves the computer.** At login it creates an Ed25519 key pair and AgentParley records the public key's fingerprint. Every token refresh is signed with the private key, so a copied refresh token alone is not enough to impersonate your computer. - **Removing the computer takes effect within seconds.** Delete it in the console, or run `agentparley logout` on it, and its connection is closed within about 15 seconds. ## Install Linux with systemd, x86-64 or ARM64: ```bash curl -fsSL https://get.agentparley.ai/tunnel/install.sh | sudo sh sudo -u agentparley login sudo systemctl start agentparley ``` The installer downloads the binary for your CPU, verifies its checksum, writes `/etc/agentparley/config.yaml`, creates `/var/lib/agentparley` (readable only by the run-as user), and installs and enables the systemd service. It creates the run-as account if it doesn't exist. `` is the OS account your agents' commands run as. By default it is the account that ran `sudo`; if you run the installer as root with no `sudo`, it stops and asks you to name one: `curl -fsSL https://get.agentparley.ai/tunnel/install.sh | sudo AGENTPARLEY_USER= sh`. Pick it deliberately: an agent can do on this computer exactly what that user can do, and nothing more. The daemon has no way to switch to another user. You can also start in the console: **Settings → Sandboxes → Connect a computer → Tunnel** shows the same command. ## Log in `agentparley login` prints a short code and a link: ``` Open https://app.agentparley.ai/device and enter this code: S2GK-5ZX3 Waiting for approval... ``` Open the link while signed in, check the computer name it shows, and approve. The code works for 15 minutes. The daemon then creates its identity key, swaps the one-time approval for a long-lived login, and the computer appears in your console as a connected computer. Start the service and it connects. You approve every new install in person on purpose: a stolen install command alone cannot add a computer to your account. **Computers nobody sits at.** A rented machine can log in from a one-time enrolment token file instead (`/run/agentparley/enrolment-token`). If the daemon finds no saved login and that file exists, it enrols with the token. AgentParley's own rented sandboxes use this. ## Let an agent use it A connected computer is available to an agent only once you allow it. On the computer's page, or in the agent's settings, choose which agents may use it and which computer is an agent's **primary** one (used when the agent doesn't name a host). Then the agent has the `ssh_*` tools for it. See [SSH commands](tunnel-ssh.md). ## Configuration Everything the daemon reads is in `/etc/agentparley/config.yaml`: ```yaml server: api: https://services.agentparley.ai/platform egress: services.agentparley.ai:443 run_as: deploy # must be the user the service runs as enabled: true # false refuses every operation read_only: false # true refuses every file write and delete allow_commands: [] # empty = any command (still subject to deny_commands) deny_commands: [] # deny always beats allow max_concurrent_operations: 8 auto_update: true update_server: https://get.agentparley.ai # where updates are checked enrolment_token_file: /run/agentparley/enrolment-token ``` The daemon reads this file when it starts, so restart the service after a change. `enabled`, `read_only`, `allow_commands` and `deny_commands` are your own lever on this computer. The daemon checks them before every operation, on the computer itself, whatever AgentParley decided. See [Limiting what agents can do](tunnel-ssh.md#limiting-what-agents-can-do). ## Updates Each time the service starts it checks `https://get.agentparley.ai` for a new release and installs it before it connects. An update is installed only if its checksum signature verifies against the release key built into the daemon. A failed check never stops the tunnel from starting on the version already installed. - `sudo agentparley update` updates now and restarts the service. - `auto_update: false` turns off the automatic check, for machines whose software only changes on purpose. ## Diagnose `agentparley doctor` checks everything and changes nothing: ``` ✓ version up to date ✓ config /etc/agentparley/config.yaml ✓ running as deploy ✓ logged in ✓ service enabled and active ✓ api reachable https://services.agentparley.ai/platform/health ✓ egress reachable services.agentparley.ai:443 ✓ clock within 1s of server time ✓ providers ``` Run it as the run-as user (`sudo -u agentparley doctor`) to include the login and local model checks. Every failed line says how to fix it. `journalctl -u agentparley` has the daemon's log. ## Remove it - `agentparley logout` revokes this install at AgentParley and deletes its login and key from the computer. The local wipe happens even when the computer can't reach AgentParley. - `curl -fsSL https://get.agentparley.ai/tunnel/uninstall.sh | sudo sh` removes the binary, service and folders. ## What it keeps on disk | Path | What | | --- | --- | | `/etc/agentparley/config.yaml` | your settings | | `/var/lib/agentparley/credentials.json` | the long-lived login and the identity key (owner-only) | | `/var/lib/agentparley/harnesses.json` | the local models `register` found | | `/var/lib/agentparley/cli/` | the daemon's own copies of Codex and Claude Code, and their logins, if you registered them | Each chat's shell state (working directory, exported variables) lives in memory-backed storage (`/dev/shm`), so a reboot clears it. It can hold secrets an agent exported, so it is never written to a real disk. # SSH commands Once a computer is [connected](tunnel.md) and an agent is allowed to use it, the agent gets the `ssh_*` tools. They behave as if the agent had SSH'd in as the computer's run-as user: a real login shell, the user's own `PATH` and profile, and nothing more than that user could do. The same tools work for both kinds of computer AgentParley supports: - **Tunnel** — the daemon dials out, so no port is opened (this section). - **Direct** — you run an ordinary `sshd` and give AgentParley a password or key, which it keeps in an encrypted vault. ## The tools | Tool | What it does | | --- | --- | | `list_ssh_hosts` | The computers this agent may use, with ids, names and which one is primary | | `ssh_run_command` | Run a command and wait for its output: 60 s by default, 120 s at most. With `async` the command starts detached and the call returns at once; its exit code and the end of its output come back to the chat as a message when it finishes | | `ssh_read_file` | Read a file, with line numbers and a content hash | | `ssh_write_file` | Create or overwrite a file | | `ssh_edit_file` | Replace lines, insert lines, or replace an exact piece of text. Line edits must quote the hash from the last read, so a file changed since then fails instead of being overwritten | | `ssh_list_files` | List a folder | | `ssh_delete_file` | Delete one file (no recursive delete) | Every call appears in the chat as a tool step with its arguments and result, so you can always see exactly what ran. ## What one command goes through ``` agent: ssh_run_command("cd /srv/app && git pull") │ ├─ AgentParley: host allowed for this agent? tool allowed? ├─ plugin middleware may inspect, rewrite or refuse it ├─ a one-operation token, scoped to this computer ▼ SSH egress ──▶ your daemon (its outbound connection) │ ├─ daemon: enabled? allowed by allow/deny_commands? ├─ daemon: restore this chat's shell, run as run_as, │ save the shell state ▼ exit code + output ──▶ sshCommandCompleted ──▶ the agent ``` ## Each chat has its own shell Every command runs as a fresh, non-interactive login shell, but each **chat** keeps its own working directory and exported environment on the computer. A `cd /srv/app` or an `export` in one call is still in effect in the next call from the same chat, and two chats never see each other's state. The first command in a chat says so in its result ("fresh shell"), so the agent knows nothing carried over. This state is held in memory-backed storage on the computer (`/dev/shm`) and is cleared by a reboot, never written to a real disk: an exported variable can be a secret. ## What commands can't do - **No terminal.** Anything that prompts (a `sudo` password, an editor, a pager, `-i` flags) waits until the timeout. Agents are told to use non-interactive forms: `sudo -n`, `--no-pager`, `DEBIAN_FRONTEND=noninteractive`. - **No privilege switch.** Commands run as the run-as user, full stop. `sudo` works only if you configured it for that user. - **At-least-once results.** If the connection drops mid-command the command may have run even though no result came back. - **Short commands only, unless async.** A command still running after its timeout (120 s at most) is stopped together with everything it started. Anything longer — a build, a scan, a download — runs with `async`: it starts detached, writes its output to a log file on the computer, and reports back to the chat when it is done. Reporting back needs `curl` or `wget` and outbound network access on the computer. ## Limiting what agents can do Four independent layers decide whether a command runs. Each can only narrow what the one before allowed. **1. Which agents can reach the computer.** In the console, a computer is reachable only by the agents you allowed on it. An agent that isn't allowed doesn't get the tools for it at all. **2. Which tools an agent has.** You can turn individual tools off for an agent, for example keep `ssh_read_file` and `ssh_list_files` but remove `ssh_run_command` and the write tools. **3. Plugin middleware.** A plugin with SSH hooks sees every remote operation before it is sent and after it ran: - `sshConnectionOpening` — once per computer per turn. Observe, log, run a setup command, or refuse. - `sshCommandSending` — every command and file operation. Refuse it, or rewrite the command, path or content. - `sshCommandCompleted` — every result. Redact or withhold output before the model sees it. A team can, for example, refuse `rm -rf` anywhere, force `--dry-run` on deploy scripts, or keep secrets out of command output. See [Middleware](plugin-middleware.md#sshcommandsending). **4. Your computer's own policy.** The daemon checks `/etc/agentparley/config.yaml` before every operation, on the computer, regardless of anything AgentParley decided: ```yaml enabled: true # false: refuse everything (a kill switch) read_only: false # true: refuse every file write, edit and delete allow_commands: # empty means any command - "systemctl status *" - "journalctl *" - "docker ps*" deny_commands: # deny always beats allow - "rm -rf *" - "*shutdown*" ``` Patterns match the **whole command line exactly as the agent wrote it**, never the shell wrapper the daemon adds around it. `*` matches any run of characters, including `/` and spaces, so `rm -rf /*` also matches `rm -rf /etc`. Everything else must match literally. A refused command returns an error to the agent; nothing runs. Because this layer lives on your computer, it holds even if your AgentParley account were compromised. ## Many commands at once A computer runs up to `max_concurrent_operations` (default 8) operations at the same time, across all chats and agents. An operation beyond that waits for a free slot, and the wait counts against its own timeout: a command that could not start before its timeout is never started late. # Local models A model server running on a [connected computer](tunnel.md) can power your agents. The server stays on `localhost`, nothing is exposed to the internet, and the prompts and answers travel only over the tunnel's own outbound connection. Two kinds of local server are supported: | Harness | Servers | Found at | | --- | --- | --- | | `ollama` | Ollama | `http://localhost:11434` | | `openai-compatible-local` | LM Studio, vLLM, llama.cpp, or anything that speaks the OpenAI chat API | `http://localhost:1234` (LM Studio), `:8000` (vLLM), `:8080` (llama.cpp) — the first that answers | (Codex and Claude Code can also run on the computer as models. They work differently; see [Codex and Claude Code](tunnel-codex-claude.md).) ## Register Run `register` once, as the daemon's run-as user, with the model server running: ```bash sudo -u agentparley register ``` It checks each harness and prints one line per harness: ``` – codex not found – claude not found ✓ ollama registered — 3 models ✓ openai-compatible-local registered — 1 model ``` Each registered harness becomes a **provider** in your console, named after it and the computer (for example "Ollama (local, via tunnel) on homelab"), with one model per model the server lists. Pick those models for an agent like any other model. You change nothing in the console: the server's address and its model list stay on your computer. Pulled a new model, or removed one? Run `register` again; it is safe to repeat. `agentparley unregister ollama` removes the provider. ## Context windows AgentParley needs each model's context window to know when to compact a long chat. `register` reads it from the server: - **Ollama:** the model's `context_length`, from `/api/show`. - **llama.cpp:** the context the server was actually started with (`n_ctx` from `/props`), which can be smaller than the model's trained context. - **vLLM:** `max_model_len` from `/v1/models`. When a server doesn't report one (LM Studio, for example), give it in the config, or the model is not registered: ```yaml harnesses: openai-compatible-local: url: http://localhost:1234 # optional; overrides discovery models: - id: qwen3-32b label: Qwen3 32B context_window_tokens: 32768 ollama: url: http://localhost:11434 # only needed for a non-default address ``` Give the server root as `url`; a trailing `/v1` is trimmed for you. ## How a request travels ``` agent turn ──▶ SSH egress ──▶ your daemon ──▶ POST localhost:/v1/chat/completions (messages + tool definitions) ◀── answer or tool calls ◀──────────┘ ``` Each model step is one request. The daemon sends the chat's messages and the agent's tools to your server's OpenAI-compatible chat endpoint, and returns the answer or the tool calls the model asked for. AgentParley runs the tools itself (SSH commands, web search, plugins, other agents) and sends the results back in the next step. Your local model therefore uses every tool your agent has, but the model server never runs a tool itself. Things to know: - **Use a model that supports tool calling.** Agents depend on tools; a model without tool calling can only chat. - **Text only.** Images are not sent to local models. - **One step can take up to 10 minutes.** Slow hardware with a big model is fine, within that limit. - **No per-token price.** You run the hardware, so AgentParley records token counts for these models at no cost. - Local model requests share the computer's operation limit (`max_concurrent_operations`, default 8) with SSH commands. Codex and Claude Code steps do not count against it; their own warm-session limit applies. ## Keeping a harness off To stop `register` from ever offering a harness on a computer, deny it in the config: ```yaml deny_harnesses: ["openai-compatible-local"] # deny always beats allow allow_harnesses: [] # empty = all harnesses considered ``` The daemon also checks this list on every request, so denying a harness that was already registered stops it at once. # Codex and Claude Code If you pay for ChatGPT or Claude, a [connected computer](tunnel.md) can use that plan to power your agents. The daemon runs the vendor's own coding CLI — the Codex CLI or Claude Code — as a model provider. Model usage counts against your own plan, under the vendor's terms; AgentParley adds no per-token cost. The CLI is only the model here. Your agent keeps its own persona, instructions and tools, and every tool call goes through AgentParley exactly as it does with any other model. ## Register ```bash sudo -u agentparley register codex sudo -u agentparley register claude ``` Each one does the same four things: 1. **Installs the daemon's own copy of the CLI.** It downloads the exact version named in a signed pin list from the vendor's official download address, and checks it against the list's SHA-256 before using it. Your own `codex` or `claude`, and its auto-updates, are never used or changed. 2. **Signs it in, once, with a login of its own.** Codex shows a device code to approve at OpenAI (`codex login --device-auth`). Claude Code opens a sign-in link and gives you a code to paste back (`claude setup-token`, which needs a Claude subscription). The login is stored in the daemon's own folder. 3. **Checks that it works.** It starts the CLI and confirms the model sees only AgentParley's tools. If anything else is visible it refuses to register and says what it found. 4. **Adds the provider** to your console, for example "Claude Code CLI (local, via tunnel) on homelab", with the models your plan offers (for Claude Code: Opus, Sonnet and Haiku). The daemon runs each CLI with a private configuration folder. Nothing is read from or written to your own `~/.codex` or `~/.claude`: no sessions, memories, settings or logins are shared, and nothing from your own coding work reaches an agent's chat. `agentparley unregister claude` (or `codex`) removes the provider and deletes the daemon's login and its copy of the CLI. ## Only AgentParley's tools Out of the box, both CLIs are coding agents with a shell, file editing and tools of their own. Through the tunnel none of that is available. The model sees exactly the tools your agent has, as real tools, and nothing else. **Codex** runs as `codex app-server`. Your agent's tools are given to it as its own tool set, and every built-in Codex tool is switched off: shell, file patching, web search, image tools and sub-agents. The few engine functions that remain (planning, and a code runner that the daemon's copy deliberately ships without) cannot touch your computer. **Claude Code** runs as a normal interactive session in a terminal the daemon owns, with every built-in tool turned off. A small mod that ships inside the daemon: - registers your agent's tools as real Claude Code tools; - replaces Claude Code's system prompt with your agent's; - passes every tool call to AgentParley over a private local socket, and the result back; - refuses any other tool or slash command. Claude Code is never driven by typing into it, and never in its pay-per-use print mode, so it uses your subscription. **Anything the CLI tries on its own is shown in the chat.** If a model reaches for a tool it doesn't have, the attempt is refused and appears in the chat's work block, for example *"The model tried its own tool "Bash". AgentParley blocked it."* ## How a turn runs AgentParley stays in charge of the turn, one model step at a time, exactly as with any other model: 1. AgentParley sends the step to your computer: the chat, the agent's tools, the model and thinking level. 2. The CLI calls the model on your plan. If the model asks for tools, the CLI pauses and the daemon returns the tool calls. 3. AgentParley runs them, with plugin middleware and your agent's tool settings applied as always: SSH commands on any computer, web search, plugins, other agents. It records each one in the chat. 4. The next step carries the results back, and the paused CLI carries on. Because the CLI waits between steps, everything that works with other models works here too: a tool that waits a long time for a human, the stop button, long turns that continue across many steps, and per-step usage records. ## Warm sessions Each chat keeps its CLI session open between steps (a Codex thread, or a Claude Code process), so the model's prompt cache is reused and a follow-up costs little. When the session no longer matches the chat — the computer restarted, the chat was idle for 30 minutes, a message was edited, the chat was compacted, or a turn was stopped — the next step rebuilds it from AgentParley's copy of the chat. Earlier turns go in as a record; the turn in progress is replayed with its real tool calls and results, without spending model calls. A Claude Code rebuild takes about a second or two, mostly starting the program, and does not depend on how long the chat is: only the last 64 steps of the open turn are replayed. Changing the model, thinking level or the agent's tools or system prompt also starts a fresh session. AgentParley's own chat history is always the source of truth. A message you send while a tool is running reaches the model with that tool's result, marked as coming from you. Size the warm sessions to your computer: ```yaml harnesses: codex: max_warm_sessions: 32 # default 32 warm_idle_minutes: 30 # default 30 claude: max_warm_sessions: 8 # default 8; each warm Claude Code session is a ~300 MB process ``` When the limit is reached, the least recently used idle chat is closed first. If nothing can be closed, the step waits up to 30 seconds for a slot, then AgentParley retries it with growing delays (15 seconds up to 5 minutes) and gives up after 30 minutes. ## Keeping the CLI current The pin list is signed with AgentParley's release key and refreshed by the daemon every few hours. A new CLI version is pinned only after automated checks have signed both CLIs in and confirmed the model still sees only AgentParley's tools. Warm sessions finish on the version they started with; new sessions start on the new one. ## Limits - **Text only.** Images are not sent to the CLI models. - **Plan limits apply.** If your ChatGPT or Claude plan is out of usage, the step fails with the vendor's message. - **One computer, one login per CLI.** Every agent that uses a computer's Codex or Claude Code provider shares the login registered there. - A tool result longer than 49,000 characters is cut, with a note saying how much was left out. Claude Code would otherwise replace it with a file the model cannot read.