Generated: 2026-04-01 Sources: 42 resources analyzed (source code, official docs, PRs, issues, TypeScript protocol) Depth: deep Codex CLI Version: v0.117.0+ (plugin system introduced March 26, 2026)
- Familiarity with JSON manifest formats
- Understanding of Codex CLI basics (installation, config.toml)
- Basic knowledge of MCP (Model Context Protocol) concepts
- Understanding of plugin/extension architectures
- Plugins live in
.codex-plugin/with a requiredplugin.jsonmanifest - Three component types: skills (SKILL.md files), MCP servers (.mcp.json), apps (.app.json)
- All paths must be relative, starting with
./, no..traversal allowed - Plugin names must be kebab-case, ASCII alphanumeric plus hyphens/underscores only
- Marketplace distribution via
.agents/plugins/marketplace.jsonat repo or home level - Default prompts limited to 3 entries, 128 characters each
- Interface section is optional - entirely omitted if all fields are empty
- Plugin hooks are NOT supported in plugin.json despite some docs suggesting otherwise
A Codex plugin is a directory containing a .codex-plugin/plugin.json manifest and optional component directories:
my-plugin/
.codex-plugin/
plugin.json # Required manifest (only file in .codex-plugin/)
skills/
skill-name/
SKILL.md # Skill instructions
.mcp.json # MCP server configuration
.app.json # App connector mappings
assets/
icon.png # Visual assets (logo, screenshots, etc.)
Critical rule: Only plugin.json belongs inside .codex-plugin/. All other files go at the plugin root.
The manifest path constant is .codex-plugin/plugin.json (defined as PLUGIN_MANIFEST_PATH in codex-utils-plugins).
CLI startup
-> PluginsManager::plugins_for_config()
-> For each configured plugin:
-> load_plugin_manifest(plugin_root)
-> Read {plugin_root}/.codex-plugin/plugin.json
-> Deserialize as RawPluginManifest (serde_json)
-> Resolve paths (skills, mcpServers, apps)
-> Resolve interface fields (assets, prompts)
-> Return PluginManifest or None
-> Load skills from resolved skills path
-> Load MCP servers from resolved mcpServers path
-> Load app connectors from resolved apps path
-> Build PluginLoadOutcome with all plugins
{
"name": "string (required, validated)",
"description": "string | null (optional)",
"skills": "./relative-path (optional)",
"mcpServers": "./relative-path (optional)",
"apps": "./relative-path (optional)",
"interface": { /* optional PluginManifestInterface */ }
}Note on field naming: The raw deserialization uses camelCase (#[serde(rename_all = "camelCase")]). The path fields skills, mcpServers, and apps are top-level string fields, NOT nested under a paths object. The internal Rust struct PluginManifestPaths groups them after parsing, but the JSON is flat.
- Required: Yes, but if empty/whitespace, falls back to the plugin directory name
- Validation: Must match
validate_plugin_segment()rules when used as PluginId:- Cannot be empty
- Only ASCII alphanumeric characters, hyphens (
-), and underscores (_) - No path separators (
/,\) - No dot sequences (
.,..)
- Convention: Kebab-case (e.g.,
my-first-plugin) - Purpose: Used as both the plugin identifier and skill namespace prefix
// Name fallback logic (from manifest.rs):
let name = plugin_root
.file_name()
.and_then(|entry| entry.to_str())
.filter(|_| raw_name.trim().is_empty())
.unwrap_or(&raw_name)
.to_string();When the manifest name is empty or whitespace-only, the directory name is used instead.
- Optional: Defaults to None
- Sanitization for capability summary: Newlines and irregular whitespace collapsed to single spaces
- Truncation:
MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN(1024 characters) for the capability summary - Original preserved: The manifest description is kept as-is; only the
PluginCapabilitySummarydescription is sanitized
All three path fields follow identical validation rules defined in resolve_manifest_path():
- Must start with
./- Paths without this prefix are silently ignored with a warning - Must not be just
./- Empty relative part after stripping prefix is rejected - Must not contain
..- Parent directory traversal is blocked - Must stay within plugin root - Only
Component::Normalpath components allowed - Must resolve to absolute path - Final path is
plugin_root.join(normalized) - Empty string = None - An empty string is treated as absent
// Validation logic (from manifest.rs):
fn resolve_manifest_path(plugin_root: &Path, field: &'static str, path: Option<&str>) -> Option<AbsolutePathBuf> {
let path = path?;
if path.is_empty() { return None; }
let Some(relative_path) = path.strip_prefix("./") else {
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
return None;
};
if relative_path.is_empty() {
tracing::warn!("ignoring {field}: path must not be `./`");
return None;
}
let mut normalized = PathBuf::new();
for component in Path::new(relative_path).components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: path must stay within the plugin root");
return None;
}
}
}
AbsolutePathBuf::try_from(plugin_root.join(normalized)).ok()
}Default paths when manifest fields are absent:
- Skills:
./skills/(directory containing skill subdirectories) - MCP Servers:
./.mcp.json(JSON file at plugin root) - Apps:
./.app.json(JSON file at plugin root)
The interface object provides display metadata for the plugin marketplace/directory. It is entirely optional - if present but all fields are empty/default, the interface is omitted (set to None).
{
"interface": {
"displayName": "string | null",
"shortDescription": "string | null",
"longDescription": "string | null",
"developerName": "string | null",
"category": "string | null",
"capabilities": ["array of strings"],
"websiteUrl": "string | null",
"privacyPolicyUrl": "string | null",
"termsOfServiceUrl": "string | null",
"defaultPrompt": "string | [array] | null",
"brandColor": "string | null",
"composerIcon": "./relative-path | null",
"logo": "./relative-path | null",
"screenshots": ["./screenshot-1.png", "./screenshot-2.png"]
}
}Field aliases: The raw deserialization accepts both camelCase and URL-suffix variants:
websiteUrlorwebsiteURLprivacyPolicyUrlorprivacyPolicyURLtermsOfServiceUrlortermsOfServiceURL
Interface omission logic: The interface is set to None if ALL of these are true:
displayNameis NoneshortDescriptionis NonelongDescriptionis NonedeveloperNameis Nonecategoryis Nonecapabilitiesis emptywebsiteUrlis NoneprivacyPolicyUrlis NonetermsOfServiceUrlis NonedefaultPromptis None (after resolution)brandColoris NonecomposerIconis Nonelogois Nonescreenshotsis empty
The defaultPrompt field supports three input shapes:
- Single string:
"defaultPrompt": "Summarize my inbox" - Array of strings:
"defaultPrompt": ["Prompt 1", "Prompt 2", "Prompt 3"] - Invalid type: Any other JSON type is rejected with a warning
Constants:
MAX_DEFAULT_PROMPT_COUNT: 3 (maximum number of prompts)MAX_DEFAULT_PROMPT_LEN: 128 (maximum characters per prompt after normalization)
Processing rules:
- Whitespace is normalized:
split_whitespace().collect::<Vec<_>>().join(" ") - Leading/trailing whitespace is stripped (via the split_whitespace normalization)
- Empty prompts (after normalization) are rejected: "prompt must not be empty"
- Prompts exceeding 128 characters (by
chars().count()) are rejected - Non-string entries in an array (e.g., numbers, objects) are skipped with a warning
- Only the first 3 valid entries are kept; excess entries trigger a warning
- If a single string input is valid, it becomes a one-element array
- If all entries are invalid, the field resolves to
None
Example of normalization behavior (from tests):
// Input:
"defaultPrompt": [
" Summarize my inbox ", // -> "Summarize my inbox" (valid)
123, // -> skipped (not a string)
"xxx...129chars", // -> skipped (too long)
" ", // -> skipped (empty after normalization)
"Draft the reply ", // -> "Draft the reply" (valid)
"Find my next action", // -> valid
"Archive old mail" // -> skipped (max 3 reached)
]
// Output: ["Summarize my inbox", "Draft the reply", "Find my next action"]These use the same resolve_manifest_path() validation as component paths:
- Must start with
./ - No
..traversal - Must stay within plugin root
- Resolved to absolute paths
- Invalid paths are silently ignored (the field becomes
Noneor is omitted from the screenshots array)
TypeScript protocol type (after resolution):
composerIcon: AbsolutePathBuf | null;
logo: AbsolutePathBuf | null;
screenshots: Array<AbsolutePathBuf>; // never null, may be emptyPlugins are identified by a compound key: {plugin_name}@{marketplace_name}
pub struct PluginId {
pub plugin_name: String,
pub marketplace_name: String,
}Parsing: The @ separator is split on the rightmost occurrence. Both segments must pass validate_plugin_segment().
Segment validation (validate_plugin_segment()):
- Cannot be empty
- Only ASCII alphanumeric characters (
a-z,A-Z,0-9), hyphens (-), and underscores (_) - No other characters (no dots, slashes, spaces, etc.)
Error format: "invalid plugin key 'sample'; expected <plugin>@<marketplace>"
Skills within a plugin are namespaced with the plugin name:
- Pattern:
{plugin_name}:{skill_name} - Example:
sample:sample-searchfor skillsample-searchin pluginsample
The namespace is resolved by walking up directory ancestors from the skill file path until a .codex-plugin/plugin.json is found, then extracting the name field.
Plugins are discovered from:
- User config (
~/.codex/config.toml): Plugin entries with explicit paths - Repo marketplace (
$REPO_ROOT/.agents/plugins/marketplace.json) - Home marketplace (
~/.agents/plugins/marketplace.json) - OpenAI curated marketplace: Synced via git (preferred) or HTTP fallback
Important: Project-level .codex/config.toml plugin settings are ignored. Only user-level config is processed.
Installed plugins are cached at:
~/.codex/plugins/cache/{marketplace_name}/{plugin_name}/{version}/
- Local plugins: Version is
"local" - Curated plugins: Version is the git SHA
- Version priority:
"local"always preferred; otherwise lexicographically last
A loaded plugin (LoadedPlugin) contains:
config_name: The key used in config (e.g.,sample@marketplace)manifest_name: Name from plugin.json (may differ from config_name's plugin segment)manifest_description: Raw description from plugin.jsonroot: Absolute path to plugin rootenabled: Whether the plugin is activeskill_roots: Directories containing skillsdisabled_skill_paths: Skills explicitly disabled by confighas_enabled_skills: Whether any skills remain enabledmcp_servers: MCP server configurations loaded from the pluginapps: App connector IDserror: Optional error message if loading partially failed
A plugin is considered active only if enabled == true AND error.is_none().
Located at .agents/plugins/marketplace.json:
{
"name": "marketplace-name",
"interface": {
"displayName": "Human-Readable Name"
},
"plugins": [
{
"name": "plugin-name",
"source": {
"source": "local",
"path": "./plugins/plugin-name"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL",
"products": ["CODEX", "CHATGPT", "ATLAS"]
},
"category": "Productivity"
}
]
}Installation policy (PluginInstallPolicy):
"NOT_AVAILABLE"- Plugin cannot be installed"AVAILABLE"- Plugin can be installed by user (default)"INSTALLED_BY_DEFAULT"- Plugin is pre-installed
Authentication policy (PluginAuthPolicy):
"ON_INSTALL"- Auth required during installation (default)"ON_USE"- Auth deferred until first use
Product filtering (products array):
- If absent/missing: Plugin available to ALL products (permissive default)
- If present but empty
[]: Plugin available to NO products (explicit denial) - If present with values: Plugin only available to listed products
- Known products:
"CODEX","CHATGPT","ATLAS"
Source paths in marketplace.json follow the same rules as plugin manifest paths:
- Must start with
./ - No
..traversal - Resolved relative to marketplace root (NOT
.agents/plugins/)
Legacy top-level installPolicy and authPolicy fields on plugin entries are silently ignored. Only the nested policy object is used.
Marketplace files are found at .agents/plugins/marketplace.json relative to:
- Codex home directory (
~/.codex/or equivalent) - Git repository roots discovered from provided working directories
- The OpenAI curated repository (
openai-curated)
When multiple marketplace roots point to the same git repository, only one marketplace entry is created (deduplication).
When the same plugin name appears multiple times within a single marketplace, the first entry wins. Later duplicates are silently ignored.
When the same plugin name appears across different marketplaces, both entries are preserved but resolution prioritizes the repository marketplace over the home marketplace.
- Validate source directory exists
- Read manifest, extract plugin name
- Confirm manifest name matches marketplace plugin name
- Atomically copy files to cache using staging directory
- Update config.toml with plugin entry
- Set
enabled = trueautomatically
Installation uses a staging pattern:
- Create temporary staging directory
- Copy plugin files to staging
- Back up existing version (if any)
- Atomic rename of staging to final path
- Rollback on failure (restore backup)
- Remove plugin from cache directory
- Remove plugin entry from config.toml
- Idempotent - calling twice succeeds
When plugins are mentioned (via @plugin-name), the system:
- Collects mentioned plugins from user input
- Filters MCP servers belonging to those plugins
- Locates enabled app connectors for those plugins
- Renders plugin instructions as
DeveloperInstructions - Injects as
ResponseItemobjects into the conversation
Plugin instructions are rendered as markdown sections:
- General
## Pluginssection listing all available plugins - Per-plugin sections listing capabilities (skills, MCP servers, apps)
- Skills use
plugin_name:prefix convention - Wrapped in XML tags (
PLUGINS_INSTRUCTIONS_OPEN_TAG/CLOSE_TAG)
The entire plugin system is behind a feature flag:
- Set in config:
[features] plugins = true - When disabled: All plugin operations return empty/default results
- Feature check is the first gate in most plugin operations
On CLI startup:
- Sync OpenAI curated plugins repository (git preferred, HTTP fallback)
- Git sync:
ls-remoteto check SHA, shallow clone (depth=1) if outdated - HTTP fallback: GitHub API for branch info, download zipball
- Validate marketplace manifest after extraction
- Remote plugin sync (additive only): reconcile local state with remote
- Marker file prevents duplicate startup syncs
- Wait timeout: 5 seconds for prerequisites; 30 seconds for git/HTTP operations
- SHA file stored at
.tmp/plugins.sha - If local SHA matches remote, sync is skipped
- Temporary directories auto-cleaned after 10 minutes
Despite documentation suggesting hooks.json as a plugin companion surface, the runtime does NOT execute plugin-local hooks. Only global ~/.codex/hooks.json is loaded. The manifest parser does not process a hooks field. (GitHub issue #16430)
On sync failure, ~/.codex/.tmp/plugins-clone-* directories may be orphaned. (GitHub issue #16004)
If the interface section is present in JSON but all fields are default/empty, the parsed manifest will have interface: None. This means validating the presence of interface fields must account for this normalization.
When name is empty or whitespace, the plugin directory name becomes the plugin name. This means two different manifest states can produce the same effective name.
The resolve_manifest_path() function normalizes paths by iterating components, meaning ./foo/../bar would be rejected (contains ..) even though it mathematically resolves within the plugin root. This is intentional for security.
The JSON uses camelCase (mcpServers, displayName, etc.) via serde's rename_all = "camelCase". The raw deserialization struct maps directly to JSON field names.
The app-server-protocol defines these types for the plugin system:
// Plugin summary in marketplace listings
type PluginSummary = {
id: string;
name: string;
source: PluginSource;
installed: boolean;
enabled: boolean;
installPolicy: PluginInstallPolicy;
authPolicy: PluginAuthPolicy;
interface: PluginInterface | null;
};
// Plugin source (currently only local)
type PluginSource = { type: "local"; path: AbsolutePathBuf };
// Plugin detail (full view)
type PluginDetail = {
marketplaceName: string;
marketplacePath: AbsolutePathBuf;
summary: PluginSummary;
description: string | null;
skills: Array<SkillSummary>;
apps: Array<AppSummary>;
mcpServers: Array<string>;
};
// Marketplace entry
type PluginMarketplaceEntry = {
name: string;
path: AbsolutePathBuf;
interface: MarketplaceInterface | null;
plugins: Array<PluginSummary>;
};
// Policies
type PluginInstallPolicy = "NOT_AVAILABLE" | "AVAILABLE" | "INSTALLED_BY_DEFAULT";
type PluginAuthPolicy = "ON_INSTALL" | "ON_USE";| Rule | Severity | Description |
|---|---|---|
name required |
Error | Must be non-empty (or directory name fallback) |
name characters |
Error | ASCII alphanumeric, hyphens, underscores only |
name no path separators |
Error | No /, \, or .. |
name kebab-case |
Warning | Convention, not enforced by parser |
| JSON parse valid | Error | Must be valid JSON |
| Unknown fields | Info | Serde default allows extra fields silently |
| Rule | Severity | Description |
|---|---|---|
Must start with ./ |
Warning | Invalid paths silently ignored |
Must not be just ./ |
Warning | Empty relative path rejected |
No .. traversal |
Warning | Path must not contain parent dir |
| Stay within plugin root | Warning | Only Normal path components |
| Must resolve to absolute | Warning | Plugin root must be absolute |
| Empty string = absent | Info | Empty strings treated as None |
| Rule | Severity | Description |
|---|---|---|
| All-empty interface | Info | Entire interface is omitted |
defaultPrompt max 3 |
Warning | Excess entries ignored |
defaultPrompt max 128 chars |
Warning | Over-length entries ignored |
defaultPrompt non-empty |
Warning | Empty-after-normalization rejected |
defaultPrompt type check |
Warning | Non-string/array types rejected |
defaultPrompt entry type |
Warning | Non-string entries in array skipped |
| Asset paths same as component paths | Warning | Same ./ and traversal rules |
capabilities array required |
Info | Defaults to empty array |
screenshots array required |
Info | Defaults to empty array |
Note: severity differs from Path Rules above. Plugin component paths (skills, mcpServers, apps) silently ignore invalid entries (Warning - plugin still installs). Marketplace source paths must be valid for install to proceed (Error - installation blocked).
| Rule | Severity | Description |
|---|---|---|
Plugin source path ./ prefix |
Error | Required for local sources |
No .. in source paths |
Error | Traversal prevention |
policy.installation valid enum |
Warning | Must be known value |
policy.authentication valid enum |
Warning | Must be known value |
Empty products: [] blocks all |
Warning | Likely unintentional |
Missing products allows all |
Info | Permissive default |
| Duplicate plugin names | Warning | First entry wins |
| Legacy policy fields ignored | Info | installPolicy/authPolicy top-level |
| Manifest name matches plugin name | Error | Must match during installation |
| Rule | Severity | Description |
|---|---|---|
Format name@marketplace |
Error | Required separator |
| Both segments non-empty | Error | No empty plugin or marketplace name |
| Segment characters | Error | ASCII alphanumeric, hyphens, underscores |
| Pitfall | Why It Happens | How to Avoid |
|---|---|---|
Paths without ./ prefix silently ignored |
Developers use bare relative paths like skills/ |
Always prefix with ./ |
.. in paths silently rejected |
Trying to reference files outside plugin root | Keep all files within plugin directory |
| Hooks in plugin.json not executed | Documentation suggests it works, runtime ignores it | Use global ~/.codex/hooks.json instead |
Empty products: [] blocks everything |
Confusion between missing and empty arrays | Omit products field to allow all products |
| Default prompts over 128 chars silently dropped | No clear error at authoring time | Keep prompts concise, check char count |
| Plugin name mismatch blocks installation | Manifest name must match marketplace plugin name | Keep names synchronized |
| Project config plugin settings ignored | Only user-level config is processed | Configure plugins in ~/.codex/config.toml |
| Legacy policy fields have no effect | Old installPolicy/authPolicy at top level |
Use nested policy object |
| Interface with all nulls becomes None | All-empty interface is normalized away | Ensure at least one interface field is set |
- Use kebab-case names - Convention followed by all official plugins (Source: developers.openai.com/codex/plugins/build)
- Always prefix paths with
./- The only accepted relative path format (Source: manifest.rsresolve_manifest_path) - Keep assets in
./assets/- Organized convention for logos, icons, screenshots (Source: official build docs) - Include
displayNamein interface - Prioritized for rendering plugin labels (Source: PR #15606) - Keep descriptions under 1024 chars - Capability summary truncates at this limit (Source: manager_tests.rs)
- Keep default prompts under 128 chars - Hard limit enforced during parsing (Source: manifest.rs constants)
- Limit default prompts to 3 - Hard maximum, excess silently dropped (Source: manifest.rs constants)
- Set explicit
policy.products- Avoid accidentally blocking or allowing unwanted products (Source: marketplace_tests.rs) - Test with
codex /plugins- Verify plugin appears in the directory after installation (Source: official plugin docs) - Restart Codex after changes - Plugin cache requires restart to pick up modifications (Source: official build docs)
| Resource | Type | Why Recommended |
|---|---|---|
| manifest.rs | Source | Definitive validation rules |
| Build Plugins | Docs | Official development guide |
| Plugin Overview | Docs | User-facing plugin documentation |
| marketplace.rs | Source | Marketplace discovery and validation |
| manager.rs | Source | Plugin lifecycle management |
| store.rs | Source | Cache storage and installation |
| plugin_id.rs | Source | PluginId parsing and validation |
| v0.117.0 Release | Release | Plugin system introduction |
| PR #14993 | PR | Product-aware policies design |
| Issue #16430 | Issue | Plugin hooks limitation |
This guide was synthesized from 42 sources including Rust source code, TypeScript protocol definitions, official OpenAI documentation, GitHub PRs, and issues. See resources/codex-plugin-manifest-sources.json for full source list with quality scores.