Skip to content

ULIS — Unified LLM Interface Specification ​

Version 1.0.0 · Source: src/schema/ · Targets: Claude Code, OpenCode, Codex, Cursor, ForgeCode · CLI: @nejcm/ulis


1. Overview ​

ULIS is a CLI (ulis) that lets you define AI agent configurations once and compile them into the native format expected by each supported tool. You write canonical entity definitions in .ulis/ (project) or ~/.ulis/ (global), run ulis build (or ulis install to also deploy), and get ready-to-deploy configs under <source>/generated/.

.ulis/                        .ulis/generated/
├── agents/*.md       ─────►  ├── claude/   (agents/, commands/, rules/, skills/, settings.json, .claude.json)
├── skills/*/         ─────►  ├── opencode/ (opencode.json, agents/, commands/, rules/, skills/, settings.json)
│   SKILL.md                  ├── codex/    (config.toml, agents/*.toml, rules/, skills/, AGENTS.md)
├── mcp.yaml          ─────►  ├── cursor/   (agents/*.mdc, rules/, skills/, mcp.json, permissions.json)
│                             └── forgecode/ (AGENTS.md, .forge/agents, .forge/rules, .forge/skills, .forge/.mcp.json)
├── skills.yaml          (external skill installs)
├── extensions.yaml      (third-party CLI extension installs via npx/bunx)
├── permissions.yaml
└── config.yaml          ◄─── version + name + optional install + runner settings

.ulis/generated/<platform>/.ulis-provenance.json ◄─── remote sources for that platform output, if any

ulis build writes generated/<platform>/.ulis-provenance.json whenever a resolved preset carries a remote URL. The marker is { version: 1, remoteSources: string[] }; URLs are redacted, deduplicated, and sorted. The writer creates it immediately after clearing that platform directory and before writing any payload, so provenance lives and is destroyed with the output it describes. A local build writes no marker.

ulis install --skip-rebuild reads the selected platforms' markers. A malformed marker, an unsupported version, or any recorded remote source makes install refuse before writing a destination. Re-run ulis install --preset <url> to rebuild and review the source. A root-level generated/.ulis-provenance.json from the pre-release format also refuses; only a full ulis build without --target removes that opaque legacy flag.

ulis install deploys the generated tree to the per-platform destination (./.claude/, ./.forge/, etc.). Existing unmanaged destination agents and skills are left in place unless a generated entry has the same native name. Codex config.toml, Claude settings.json, and global .claude.json use base-first overlays: generated values overwrite matching paths and absent native values remain. Codex and ForgeCode TOML comments and ordering outside generated paths are preserved. Fresh native configs are copied byte-for-byte; installs that leave the native values unchanged retain the existing bytes. Other native configs preserve their allowlisted values or files, such as MCP server maps and ForgeCode .forge.toml. The ownership manifest records generated top-level MCP server names across all platforms, including raw fragments, and per-project MCP servers in Claude global ~/.claude.json raw fragments. Removed managed servers are pruned; unmanaged servers survive. --no-prune retains stale MCP servers and makes them unmanaged. Older manifests have no MCP ownership data, so pre-existing servers remain unmanaged until ULIS generates them again.

Why it exists: Claude Code, OpenCode, Codex, Cursor, and ForgeCode all have incompatible config formats. Without ULIS you maintain separate, drift-prone config trees. ULIS keeps one source of truth and compiles it.


2. Architecture ​

┌─────────────────────────────────────────────────────────┐
│  Source: .ulis/  (or ~/.ulis/)                          │
│  agents/  skills/  mcp.yaml  skills.yaml                │
└────────────────────────┬────────────────────────────────┘
                         │ gray-matter + Zod parse
                         ▼
┌─────────────────────────────────────────────────────────┐
│  Canonical bundle                                       │
│  ParsedAgent[]  ParsedSkill[]  McpConfig                │
└──────────┬──────────┬──────────┬──────────┬────────────┘
           │          │          │          │
    generateClaude  generateOpencode  generateCodex  generateCursor  generateForgecode
           │          │          │          │               │
           ▼          ▼          ▼          ▼               ▼
     generated/claude  opencode  codex    cursor        forgecode

Each generate* function:

  1. Reads the canonical bundle
  2. Maps canonical types (model aliases, tool groups, permission levels) to platform specifics
  3. Emits native files (YAML frontmatter, JSON, TOML, MDC)

Provider adapters own their own parsing-to-native behavior, generated file layout, and install semantics. Prefer keeping agent, skill, MCP, permission, and install handling inside the relevant platform implementation even when this duplicates some code across adapters. Platform config formats and install locations change independently, so localized duplication is acceptable when it keeps future platform updates isolated. Extract shared helpers only for stable, cross-platform mechanics that are clearly reusable, such as path utilities, environment placeholder rewriting, config merging, or policy comment formatting.

Markdown YAML frontmatter accepts merge keys and shared aliases, but rejects cycles, non-plain values, nesting beyond 100 levels, and walks exceeding 10,000 node visits. Repeated alias references count toward that budget.

Between parsing and generation the orchestrator runs validators (src/validators/):

  • validateCrossRefs(agents, skills, mcp) — agent → skill (warn), agent → mcp (error), agent → subagent allowlist (warn)
  • validateCollisions(agents, skills) — duplicate agent or skill names (error)

Errors abort the build (exit code 1, no files written). Warnings print and the build proceeds.

Parse and validation failures are reported as Diagnostics. A diagnostic includes the source label (base or preset:<name>), source-relative file, absolute file path, field path, target platform (claude, codex, cursor, opencode, forgecode, all, or none), optional line/column, and a suggested fix when ULIS can infer one. Platform override fields such as platforms.codex.* report that platform; wildcard or cross-platform fields report all; source-only config errors report none.

2.1 Build configuration ​

config.yaml holds CLI metadata (version, name). Local skills generated by ULIS are copied into each selected platform config alongside the rest of the generated platform output.

Platform adapter defaults are internal to ULIS. If you need platform-native output customization, place partial config files under raw/ (for example raw/opencode/opencode.json or raw/codex/config.toml). These are merged into the generated output — objects merge recursively, and raw arrays or scalars replace generated values at the same path. See Source Layout — Raw overrides for the full rules.

Capability mismatches are handled with best-effort + comments: if a target lacks native support for a field, the value is emitted as a comment in the generated file so reviewers can see it, and the build continues (no hard failure).

2.2 Presets ​

Presets are reusable ULIS source trees. They can be merged into a build before the selected base source (./.ulis/, ~/.ulis/, or --source), or installed by themselves with preset-only install. Each preset name resolves to a directory: ~/.ulis/presets/<name>/ is tried first, then bundled presets adjacent to the CLI package. User and bundled trees share the same on-disk layout as a normal source; optional preset.yaml carries display metadata only (see Field Reference — Preset metadata).

Parsed preset projects are merged in CLI order (comma-separated --preset values), then the base project is merged last so the base wins on duplicate entities and conflicting config keys. The same rules apply to build, install, and the TUI (including its validate action) when presets are selected. Discovery and labeling (user vs bundled) are implemented in src/presets.ts and src/utils/resolve-presets.ts. Optional fields for preset.yaml are documented under Preset metadata.

Preset-only install (ulis preset install <names...> and the TUI Presets screen action) parses and validates only the selected presets, merges them in the requested order, generates selected platform output in a temporary directory, installs that output to the chosen destination, then removes the temporary output. It does not require a project/global source and does not read or merge base source files. Preset skills.yaml and extensions.yaml entries run during preset-only install; extension runner selection is CLI flag first, then auto-detect (bunx if present, otherwise npx).

2.3 Install ownership ​

Each selected platform config root stores .ulis-manifest.json version 3. It contains validated relative paths for agents and local skill directories installed by ULIS, plus the individual root-relative files the install writes into the config root, used by OpenCode pruning. Optional mcpServers records top-level server names; optional mcpProjectServers records [projectPath, serverName] pairs for global Claude installs, matching projects[projectPath].mcpServers in ~/.claude.json. Project paths are exact native object keys, not filesystem removal targets. Older manifests remain readable and migrate: version 1 carries no root record and prunes no root entries. Version 2 recorded root names rather than files, so a recorded name that is a file is pruned like any other stale entry, while a recorded name that is a directory is pruned only when it is already empty — anything inside it was never recorded as ULIS's own. The manifest excludes external skills.yaml installs, extension output, and unmanaged preserved native config. MCP scopes absent from a legacy manifest remain unmanaged until generated again.

Before any selected destination is modified, ULIS reads and validates every selected platform manifest and derives the current managed set from generated output. Missing manifests trigger first-run adoption without pruning. MCP configs first merge without removing stale servers; MCP removal waits until all platform writes succeed. After platform files are installed, ULIS removes previous managed − current managed, then atomically writes the current manifest. If a platform install fails part-way, nothing is pruned and the manifest becomes the previous entries plus the current entries the copy actually wrote; current entries it never reached stay unrecorded, so an unmanaged file at one of those paths is not claimed. Ownership is path-based, so user edits to a tracked file do not prevent its removal. OpenCode agents are flat agents/<name>.md, so OpenCode merges each prompt with the agent.<name> entry in opencode.json by name. Manifests from older installs that recorded agents/core/... or agents/specialized/... are still read, and those entries are pruned like any other stale managed path; unmanaged files in those directories are left alone.

Pruning is enabled by default. --no-prune retains stale paths and MCP servers but replaces the manifest with the current set, making retained paths unmanaged. Empty or platform-disabled output is authoritative for selected platforms; unselected platform destinations and manifests remain untouched. Backups are taken before installation and therefore contain the prior manifest and any entries later pruned. A platform root that is itself a symlink is backed up as a real copy of the directory it points to, not as another link to the live tree.


3. Entity Model ​

3.1 Agent ​

An autonomous task executor. Defined in .ulis/agents/{name}.md with YAML frontmatter + a Markdown prompt body.

yaml
# .ulis/agents/builder.md
---
description: Implements features from specs
model: sonnet
tools:
  read: true
  write: true
  edit: true
  bash: true
contextHints:
  maxInputTokens: 80000
  priority: high
toolPolicy:
  requireConfirmation:
    - Write
security:
  blockedCommands:
    - git push --force
  rateLimit:
    perHour: 20
platforms:
  claude:
    permissionMode: default
  opencode:
    mode: subagent
---
You are a focused implementation agent. Read specs carefully before writing code.

Key fields:

FieldPurpose
modelCanonical alias: opus, sonnet, haiku, inherit. Mapped per-platform.
toolsPermission groups: read, write, edit, bash, search, browser, agent. Claude: an object that grants nothing becomes disallowedTools covering documented built-in tools and mcp__*. OpenCode: all-false denies "*"; browser controls playwright_* MCP tools, not arbitrary browser servers.
contextHintsAdvisory window hints. Emitted as comments (no native equivalent on any current target).
toolPolicyprefer/avoid → comments. requireConfirmation → native permission controls where supported.
securitypermissionLevel: readonly → Claude plan mode + OpenCode deny perms. blockedCommands → Claude PreToolUse hooks. rateLimit → OpenCode rate_limit_per_hour.
platformsPer-target overrides. Applied last; they win over derived values from canonical fields.

3.2 Skill ​

A composable, invocable capability. Defined as a directory .ulis/skills/{name}/SKILL.md. Both the prompt and associated files (scripts, templates) live in the same directory.

Skills reference: https://agentskills.io/home

yaml
# .ulis/skills/code-quality/SKILL.md
---
description: Run code quality checks on the current file
argumentHint: "[file-path]"
tools:
  read: true
  bash: true
isolation: fork
---
Run the following checks...

Skills become:

  • Claude: skill directories in generated/claude/skills/. Canonical fields become native frontmatter: argumentHint → argument-hint, allowModelInvocation: false → disable-model-invocation: true, userInvocable: false → user-invocable: false, isolation: fork → context: fork, tools → allowed-tools (skipped when the source already sets allowed-tools), hooks → nested Claude hook groups; effort and paths pass as-is. platforms.claude extras win.
  • OpenCode: skill directories in generated/opencode/skills/
  • Codex: skill directories in generated/codex/skills/
  • Cursor: skill directories in generated/cursor/skills/
  • ForgeCode: skill directories in generated/forgecode/.forge/skills/

Commands (.ulis/commands/*.md) are emitted for Claude and OpenCode only. platforms.claude / platforms.opencode take enabled (default true; false skips the command on that platform) and model; other keys pass through to that platform's frontmatter.

3.3 MCP Server ​

Defined once in .ulis/mcp.yaml (JSON is also accepted for backwards compatibility). Each server may declare a targets list to restrict it to specific platforms.

Semantics:

  • Omitted targets — server applies to every platform (the default).
  • Populated array (e.g. ["opencode"]) — server applies only to the listed platforms.
  • Empty array [] — server is disabled (applies to no platforms).
json
{
  "servers": {
    "github": {
      "type": "local",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
    },
    "context7": {
      "type": "remote",
      "url": "https://mcp.context7.com/mcp",
      "localFallback": {
        "command": "npx",
        "args": ["-y", "@context7/mcp-server"]
      }
    },
    "linear": {
      "type": "remote",
      "url": "https://mcp.linear.app/mcp",
      "targets": ["opencode"]
    }
  }
}

Codex emits native remote MCP url configuration, including supported HTTP headers. A remote URL takes precedence over localFallback; the fallback branch applies only when no URL is present. See the Codex config reference.

Environment variables use ${VAR} syntax everywhere. The build translates to platform-specific syntax (OpenCode local environment and remote headers use {env:VAR}; Cursor uses ${env:VAR} in command, args, env, url and headers). Codex interpolates nothing: a local server's KEY: ${KEY} becomes env_vars = ["KEY"], a remote header that is exactly ${VAR} becomes env_http_headers, and Authorization: Bearer ${VAR} becomes bearer_token_env_var; any other placeholder is written literally because Codex has no way to rename or embed a variable. MCP args also stay literal, including ${VAR} and fallback arguments. Pass secrets through same-name env_vars, or use a user-owned wrapper that reads its environment. Generators have no diagnostic channel, so ULIS does not warn for these literals. Codex launches stdio arguments unchanged.

3.4 Skill / Extension registry entries ​

Declarative installs are split into two files:

.ulis/skills.yaml — external skills installed via npx skills@latest add, keyed by platform or the "*" wildcard:

yaml
"*":
  skills:
    - name: mattpocock/skills/productivity/grill-me
    - name: vercel-labs/agent-skills
      args: ["--skill find-skills"]

claude:
  skills:
    - name: anthropics/skills
      args: ["--skill mcp-builder"]

opencode:
  skills:
    - name: some-opencode-skill

Key semantics:

FileKeyEffect during ulis install
skills.yaml"*"skills are installed for all platforms via npx skills@latest add -a <each-agent>
skills.yaml"<platform>"skills are installed for that platform only (-a <agent-name>)
extensions.yaml"*"each extensions entry runs once via the resolved runner (e.g. bunx <name> <args>)
extensions.yaml"<platform>"runs the entry only when that platform is part of the install target set

External skills are installed by npx skills@latest add, which writes into the selected project or global agent config directories. Local skills are generated by ULIS and copied into each platform's skill directory as part of the regular install pipeline.

Each skills entry supports:

FieldRequiredDescription
nameyesPackage name, owner/repo/skill, or full URL
argsnoAdditional CLI arguments forwarded verbatim to npx skills@latest add

.ulis/extensions.yaml — third-party CLI extensions invoked through a package runner. Useful for self-installing packages (e.g. bunx codex-supermemory@latest install) that wire themselves into a target tool's config files:

yaml
codex:
  extensions:
    - key: supermemory
      name: codex-supermemory@latest
      args: ["install"]

claude:
  extensions:
    - name: some-claude-helper@1.2.3
      args: ["setup", "--yes"]

Each extensions entry supports:

FieldRequiredDescription
nameyesPackage spec (e.g. codex-supermemory@latest); passed verbatim to the runner
argsnoArguments appended after name in the runner invocation
keynoFriendly identifier used in ulis install log lines

Runner resolution (precedence): --runner CLI flag → runner field in config.yaml → auto-detect (bunx if available on PATH, else npx).

Extensions run last in the install pipeline (build → files → skills → extensions) because most self-installing extensions mutate the very files ulis just deployed. Each entry runs every time ulis install runs (no caching in v1). Failures log a warning and the install continues.

3.5 Hook ​

Part of the AgentFrontmatterSchema (hooks field). Three event types:

  • PreToolUse — runs before a tool call (optionally filtered by matcher)
  • PostToolUse — runs after a tool call
  • Stop — runs when the session ends

Hooks are native to Claude Code only. On other targets they are silently dropped (the agent still works; hooks just don't fire).

Claude output always nests each entry as { matcher?, hooks: [{ type: command, command }] }, the native shape; an entry without matcher is emitted without one, not flattened.

security.blockedCommands synthesizes PreToolUse hook entries automatically for Claude: matcher Bash, a per-handler if: "Bash(<command>*)" permission rule (a PreToolUse matcher matches the tool name only), and a fixed command that prints to stderr and exits 2 — the only exit code that blocks the call. The blocked command is never interpolated into the shell command. Claude evaluates if best-effort; use permissions.yaml claude.deny for a hard block.


4. Capability Matrix ​

FeatureClaude CodeOpenCodeCodexCursorForgeCode
Native agents✓✓✓✓✓
Native skills/commands✓✓✓✓✓
Rule instruction discoverynativeglobal indexglobal indexnativeglobal index
Hooks (PreToolUse/PostToolUse/Stop)✓————
Subagent spawning✓✓comment——
Background execution✓——✓—
Git worktree isolation✓————
Local MCP servers✓✓✓✓✓
Remote MCP servers✓✓✓✓✓
Fine-grained tool permissions✓✓——tools list
contextHints enforcementcommentcommentcommentcommentcomment
toolPolicy.avoiddisallowedToolscommentcommentcommentcomment
toolPolicy.requireConfirmationpermissionModepermission.edit/bashcommentcommentcomment
security.permissionLevel: readonlyplan modedeny permscommentcommentcomment
security.blockedCommandsPreToolUse hookcommentcommentcommentcomment
security.rateLimitcommentrate_limit_per_hourcommentcommentcomment

Legend: ✓ native · comment = emitted as comment in output file · — = not emitted

"global index" means the injected rules index references ~/<home>/rules/ and loads only from a global install. OpenCode, Codex and ForgeCode do not discover AGENTS.md inside a project-scope config directory (./.opencode/, ./.codex/, ./.forge/), so a project install's rules index is not loaded; making it load needs scope-aware generation.

Cursor documents no per-subagent tools field or deny-all form. Its subagents inherit parent tools, including MCP; ULIS tool lists do not provide an enforced restriction. readonly restricts writes, not tool access. See Cursor subagents.

ForgeCode agents with omitted tools default to no tools, so an all-false canonical object is already restrictive. See ForgeCode agent tools.


5. Build Pipeline ​

ulis build                     # all targets
ulis build --target claude     # single target
ulis build --target claude,cursor
ulis install --yes             # build + deploy (project mode)
ulis install --global --yes    # build + deploy from ~/.ulis/

Internal flow (src/build.ts): sourceDir is resolved per invocation (see src/utils/resolve-source.ts).

typescript
const analysis = analyzeProject({ sourceDir, logger, presets });

for (const target of activeTargets) {
  const result = generate(target, analysis.project);
  writeResult(result, join(outputDir, target), target, logger);
}

generate() (src/generators/index.ts) dispatches through the GENERATORS map to generateClaude, generateOpencode, generateCodex, generateCursor, and generateForgecode. Each returns a pure GenerationResult; writeResult (src/generators/writer.ts) owns the filesystem side effects.

Parsing validates against Zod schemas and reports localized diagnostics for broken Markdown frontmatter, YAML/JSON config syntax, and schema failures. Semantic validators reuse the same diagnostic shape for cross-reference and collision failures.


6. Versioning ​

The ULIS specification version is 1.0.0. This is the specification version, not the npm package version.

Migration policy:

  • Patch (1.0.x): bug fixes, no schema changes.
  • Minor (1.x.0): additive schema changes (new optional fields). Old configs continue to parse.
  • Major (x.0.0): breaking schema changes. A migration guide will be provided.

7. Extending ULIS — Adding a New Adapter ​

  1. Create src/generators/platforms/{target}/index.ts:

    typescript
    export function generate{Target}(project: ProjectBundle): GenerationResult { /* ... */ }
  2. Register it in the GENERATORS map in src/generators/index.ts:

    typescript
    import { generateTarget } from "./platforms/target/index.js";
    // ...
    const GENERATORS = {
      // ...
      target: generateTarget,
    };
  3. Add "target" to PLATFORMS, PLATFORM_LABELS, PLATFORM_DESCRIPTIONS, and PLATFORM_DIRS in src/platforms.ts, and wire the install branch in src/install.ts.

  4. Add "target" to McpServerSchema.targets values if needed.

For capability mismatches, use buildPolicyCommentBlock(agent.frontmatter, "md" | "toml" | "mdc") from src/utils/policy-comments.ts to emit unsupported fields as comments.


8. Examples ​

Source: .ulis/agents/ — canonical agent definitions Generated: .ulis/generated/claude/agents/, .ulis/generated/opencode/opencode.json, etc.

Run ulis build --source example from a checkout of this repo (or bun run dev) to see a full end-to-end example.

Field reference: REFERENCE.md — auto-generated from Zod schemas.


9. Tooling ​

End-user CLI (see CLI.md for the full surface):

CommandPurpose
ulis init [--global]Scaffold .ulis/ (or ~/.ulis/)
ulis build [--target ...]Generate configs under <source>/generated/
ulis install [--global] [--yes]Build and deploy to platform config dirs
ulis tuiInteractive dashboard for source workflows

Repo dev scripts:

ScriptPurpose
bun run buildBundle dist/cli.js + Bun-only dist/tui.js; regenerate published schemas
bun run devulis build --source example
bun run testRun test suite
bun run linttsc --noEmit
bun run formatFormat with oxfmt
bun run gen:schemasRegenerate dist/schemas/*.schema.json and schemas/*.schema.json
bun run gen:referenceRegenerate docs/REFERENCE.md
bun run cleanDelete dist/ and schemas/

Released under the ISC License.