Author new tools the AI sidebar can use — the manifest, the capability api, and the write → validate → test → pack → publish workflow.
An AI plugin adds tools the Connect+ AI sidebar assistant can use — call an
online API, transform data, (soon) read or act on the current page. A plugin is a
small folder of JavaScript; the assistant loads its tools on demand and runs them
in an isolated worker that can only reach what the plugin declared and the user
approved.
This guide covers the manifest, the capability api, and the author workflow
(write → validate → test → pack → publish). The types and validation come from the
@arw/ai-plugin package, which mirrors the runtime exactly.
{ "id": "catfacts", // lowercase, digits, hyphens (2–64) "name": "Cat Facts", "description": "Fetches a random cat fact.", "version": "1.0.0", "entry": "index.js", // relative; .js / .mjs / .cjs, no ".." "permissions": { "network": ["catfact.ninja"], // exact hostnames api.fetch may reach "page": "read", // "read" | "act" (page access — coming soon) "secrets": ["API_TOKEN"] // UPPER_SNAKE names the user fills in }, "tools": [ { "name": "random_fact", // bare identifier: a–z, 0–9, _ "description": "Get a random cat fact.", "parameters": { // JSON-Schema object; omit for a no-arg tool "type": "object", "properties": { "maxLength": { "type": "number" } } } } ]}
Everything in permissions is a request, not a grant: the user sees exactly
what a plugin asks for and approves it per profile. Declaring a host or secret grants
nothing until they do.
Export one async function per tool named in the manifest. Each receives
(args, api) and returns a string (or any JSON-serialisable value, which is
stringified for the assistant).
export async function random_fact(args, api) { const max = args.maxLength ?? 140; const res = await api.fetch('https://catfact.ninja/fact?max_length=' + max); return res.json().fact;}
With TypeScript, import the types for full typing:
import type { Tool } from '@arw/ai-plugin';export const random_fact: Tool<{ maxLength?: number }> = async (args, api) => { const res = await api.fetch(`https://catfact.ninja/fact?max_length=${args.maxLength ?? 140}`); return (res.json() as { fact: string }).fact;};
HTTP to a host in permissions.network (http/https only). Anything else throws. Returns { status, headers, text(), json() }. Runs in the main process, bound to the profile session (its cookies/proxy/UA).
api.secret(name)
The value the user entered for name (must be in permissions.secrets); undeclared throws. Never logged, never shown to the assistant.
api.page.* (read/act on the current tab) is coming and will require the page
permission plus a per-site grant.
The plugin runs in a worker with no access to Node, the filesystem, the session,
other plugins, or any host you didn't declare — the api is the whole surface.
Locally: AI sidebar → Permissions → Plugins → Install from folder… → pick
your folder. Enable it, fill in any secrets, and ask the assistant to use it.
From the store (coming): pack + sign your folder into a signed .aip and
publish it; users install with one click (Add to Connect). The desktop
re-verifies the signature on install — the store is only distribution.
A .aip is signed. Signed by the ARW root key → first-party; signed by your
author key → signed. The app re-verifies every package on install and refuses a
tampered, unsigned, or key-rotated one.