Skip to main content

Extension Standards (RFC)

Status​

This page began as a December 2024 proposal. The extensions repository adopted a different layout from the one it proposed (a packages/ext-{name} folder per pack, MIT licensing, CI and a marketplace for community repositories); what follows is the standard the repository and the runtime follow. The contribution process itself lives in the extensions repository's CONTRIBUTING.md.

Summary​

An extension pack is an npm package whose factory returns an ExtensionPack: a name, a version and a list of descriptors (tools, guardrails, workflows and the other extension kinds). ExtensionManager loads the packs named in AgentOSConfig.extensionManifest and registers their descriptors.

Terminology​

  • Extension: one descriptor in a pack (a tool, a guardrail, a workflow, a channel adapter, and so on).
  • Extension pack: a package of related extensions distributed together.
  • Registry: registry.json, the index of the curated packs, shipped by the root package @framers/agentos-extensions.

Repository Structure​

The curated packs live in framerslab/agentos-extensions, one workspace package per pack under a category folder:

agentos-extensions/
├── registry/curated/
│ ├── research/web-search/ # @framers/agentos-ext-web-search
│ ├── channels/telegram/ # @framers/agentos-ext-telegram
│ └── ... # auth, cloud, media, memory, safety, voice, ...
├── templates/ # basic-tool, guardrail, multi-tool
├── registry.json # generated from the packs by the build; untracked, shipped by the root package
└── CONTRIBUTING.md

Everything in the repository is licensed under Apache-2.0.

Extension Package Structure​

registry/curated/<category>/<name>/
├── src/
│ ├── index.ts # exports createExtensionPack(context)
│ └── tools/ # one file per tool
├── test/
├── README.md # with an example
├── manifest.json # describes the pack
├── package.json # name @framers/agentos-ext-<name>, license Apache-2.0
└── tsconfig.json

A new pack starts as a copy of templates/basic-tool. The pack's package.json sets "publishConfig": { "access": "public" } and carries no LICENSE file of its own: the repository's LICENSE applies.

Package Naming​

  • npm name: @framers/agentos-ext-{name}
  • Folder: registry/curated/{category}/{name}
  • Manifest id (the template's convention): com.framers.ext.{name}

Manifest​

manifest.json describes the pack for the registry: its id, name, version, description, categories, the extensions it contains and their entry files, and its configuration properties (a property marked "secret": true holds a credential). The template's manifest is the reference shape.

Extension Implementation​

A tool​

A tool implements ITool: id, name, displayName, description, inputSchema and execute(args, context), with optional outputSchema, category, version, hasSideEffects and requiredCapabilities. The tool's configuration comes from the pack factory, not from the execution context, which carries the GMI, persona and user identifiers, the session data and an abort signal.

ToolExecutor checks a call's arguments against inputSchema before execute runs, and a successful result's output against outputSchema, with Ajv, which reads a schema as JSON Schema draft-07:

  • It reports every error, coerces a value to the type its schema names where it can ("5" to 5, the output itself included), and fills in a default the schema gives.
  • It checks the standard formats (date-time, email, uri and the others of ajv-formats). It passes over a keyword it does not know, as JSON Schema says to, and a format it does not know, which it names in the log when it compiles the schema.
  • A schema compiles on the first call that reads it, once for as long as its object lives. Two tools may each hold a copy of one schema under a shared $id, and a tool registered again may bring a new copy.
  • A schema that does not compile, such as one whose type is no JSON Schema type, whose $schema names a draft other than draft-07, that takes the draft-07 meta-schema's own $id or that is marked $async, fails the call with an error that names the schema's fault and details.code SYS_CONFIGURATION_ERROR. For an input schema the tool does not run. For an output schema the tool has run: the executor's result keeps its output, as it does for an output that fails its schema, and ToolOrchestrator, which the runtime calls tools through, answers the failed call with output: null, the error and its details.
// src/tools/webSearch.ts
import type { ITool, ToolExecutionContext, ToolExecutionResult, JSONSchemaObject } from '@framers/agentos';

export interface WebSearchConfig {
provider: 'serper' | 'brave';
apiKey?: string;
}

export class WebSearchTool implements ITool<{ query: string; maxResults?: number }> {
readonly id = 'com.framers.ext.search.webSearch';
readonly name = 'webSearch';
readonly displayName = 'Web Search';
readonly description = 'Search the web for information using the configured search API';
readonly hasSideEffects = false;

readonly inputSchema: JSONSchemaObject = {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
maxResults: { type: 'integer', minimum: 1, maximum: 10, default: 5 },
},
required: ['query'],
};

constructor(private config: WebSearchConfig) {}

setApiKey(apiKey: string): void {
this.config = { ...this.config, apiKey };
}

async execute(
input: { query: string; maxResults?: number },
context: ToolExecutionContext,
): Promise<ToolExecutionResult> {
if (!this.config.apiKey) {
return { success: false, error: 'Search API key not configured' };
}
try {
const results = await this.performSearch(input.query, input.maxResults ?? 5, context.signal);
return { success: true, output: { results } };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Search failed' };
}
}

private async performSearch(query: string, maxResults: number, signal?: AbortSignal) {
// Provider-specific implementation
return [] as Array<{ title: string; snippet: string; url: string }>;
}
}

The pack factory​

createExtensionPack(context) receives an ExtensionPackContext: the manifest entry, its options, a logger, getSecret(id) and the shared services. ExtensionManager calls the module's createExtensionPack export, or its default export.

// src/index.ts
import type { ExtensionPack, ExtensionPackContext } from '@framers/agentos';
import { WebSearchTool } from './tools/webSearch';

export function createExtensionPack(context: ExtensionPackContext): ExtensionPack {
const options = (context.options ?? {}) as { provider?: 'serper' | 'brave' };
const tool = new WebSearchTool({ provider: options.provider ?? 'serper' });

return {
name: '@framers/agentos-ext-search',
version: '1.0.0',
descriptors: [
{
id: 'webSearch',
kind: 'tool',
payload: tool,
priority: 10,
enableByDefault: true,
requiredSecrets: [{ id: 'serper.apiKey' }],
metadata: { category: 'research' },
onActivate: async (ctx) => {
const apiKey = ctx.getSecret?.('serper.apiKey');
if (apiKey) tool.setApiKey(apiKey);
},
},
],
};
}

export default createExtensionPack;

getSecret(id) reads AgentOSConfig.extensionSecrets[id] first, then the environment variable the secret catalog maps to that id.

Loading a pack​

import { AgentOS } from '@framers/agentos';

const agentos = await AgentOS.create({
extensionManifest: {
packs: [
{ package: '@framers/agentos-ext-search', options: { provider: 'serper' } },
// or { module: './local-pack.js' }, or { factory: () => createExtensionPack({}) }
],
},
extensionSecrets: { 'serper.apiKey': process.env.SERPER_API_KEY! },
});

const tool = await agentos.getToolOrchestrator().getTool('webSearch');

Testing​

Each pack keeps its own tests and runs them with pnpm test in its folder. The repository's CI builds every pack, runs each pack's tests, and runs the pack guard: it packs every package the next release would publish, installs the tarball into an empty project and imports it with network access refused, so a pack must construct with inert inputs and return its descriptors. An integration test loads the pack through extensionManifest as above and calls the tool through the tool orchestrator.

Documentation​

Each pack has a README with installation, configuration (its options and secrets) and an example.

Publishing​

A merge to master publishes, in the same run, every publishable package whose shipped files it changed: the packs, libraries and templates that scripts/package-roles.json names, less those marked private: true, and after them the root package @framers/agentos-extensions when its catalog or its own shipped files changed. The release plan reads npm and the release tags and decides each version from the merged pull request's title and description. A change to a package's shipped files publishes its next patch, whatever the Conventional Commits type, and a change to the root package's catalog alone publishes the root's next patch. A breaking mark (! before the colon of the title, or a line of the description beginning BREAKING CHANGE: or BREAKING-CHANGE:) on a pull request that changed a package's shipped files publishes that package's next minor below 1.0.0; at 1.0.0 or above it refuses the run until the pull request writes the next major into the package's package.json, and a written version below that major is refused the same way. Any other version written into package.json above every published one publishes as written. The release job publishes the tarballs CI built and tested, with npm provenance, tags each version at the merge commit and writes its GitHub release. A pull request carries no changeset file, and no version pull request follows the merge. The release guide gives every rule.

Security​

  • A pack runs in the host process with the host's permissions: AgentOS does not sandbox a pack's code, restrict its file system or network access, or limit its resources. Install packs you trust.
  • Never hardcode credentials: declare them as requiredSecrets and read them with getSecret(). A descriptor whose required secret (one not marked optional) is missing is skipped with a warning.
  • Mark credential properties in manifest.json with "secret": true.

References​